feat: Add OpenID Connect (OIDC) authentication support with new OIDC service, routes, and configuration.
This commit is contained in:
666
Cargo.lock
generated
666
Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
@@ -5,10 +5,11 @@ edition = "2024"
|
|||||||
default-run = "api"
|
default-run = "api"
|
||||||
|
|
||||||
[features]
|
[features]
|
||||||
default = ["sqlite", "auth-axum-login"]
|
default = ["sqlite", "auth-axum-login", "auth-oidc"]
|
||||||
sqlite = ["infra/sqlite", "tower-sessions-sqlx-store/sqlite"]
|
sqlite = ["infra/sqlite", "tower-sessions-sqlx-store/sqlite"]
|
||||||
postgres = ["infra/postgres", "tower-sessions-sqlx-store/postgres"]
|
postgres = ["infra/postgres", "tower-sessions-sqlx-store/postgres"]
|
||||||
auth-axum-login = ["infra/auth-axum-login"]
|
auth-axum-login = ["infra/auth-axum-login"]
|
||||||
|
auth-oidc = ["infra/auth-oidc"]
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
k-core = { git = "https://git.gabrielkaszewski.dev/GKaszewski/k-core", features = [
|
k-core = { git = "https://git.gabrielkaszewski.dev/GKaszewski/k-core", features = [
|
||||||
@@ -58,3 +59,4 @@ tracing-subscriber = { version = "0.3.22", features = ["env-filter"] }
|
|||||||
|
|
||||||
dotenvy = "0.15.7"
|
dotenvy = "0.15.7"
|
||||||
config = "0.15.19"
|
config = "0.15.19"
|
||||||
|
tower-sessions = "0.14.0"
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ use std::env;
|
|||||||
|
|
||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
|
|
||||||
|
//todo: replace with newtypes
|
||||||
#[derive(Debug, Clone, Deserialize)]
|
#[derive(Debug, Clone, Deserialize)]
|
||||||
pub struct Config {
|
pub struct Config {
|
||||||
pub database_url: String,
|
pub database_url: String,
|
||||||
@@ -26,6 +27,11 @@ pub struct Config {
|
|||||||
|
|
||||||
#[serde(default = "default_db_min_connections")]
|
#[serde(default = "default_db_min_connections")]
|
||||||
pub db_min_connections: u32,
|
pub db_min_connections: u32,
|
||||||
|
|
||||||
|
pub oidc_issuer: Option<String>,
|
||||||
|
pub oidc_client_id: Option<String>,
|
||||||
|
pub oidc_client_secret: Option<String>,
|
||||||
|
pub oidc_redirect_url: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
fn default_secure_cookie() -> bool {
|
fn default_secure_cookie() -> bool {
|
||||||
@@ -98,6 +104,11 @@ impl Config {
|
|||||||
.and_then(|s| s.parse().ok())
|
.and_then(|s| s.parse().ok())
|
||||||
.unwrap_or(1);
|
.unwrap_or(1);
|
||||||
|
|
||||||
|
let oidc_issuer = env::var("OIDC_ISSUER").ok();
|
||||||
|
let oidc_client_id = env::var("OIDC_CLIENT_ID").ok();
|
||||||
|
let oidc_client_secret = env::var("OIDC_CLIENT_SECRET").ok();
|
||||||
|
let oidc_redirect_url = env::var("OIDC_REDIRECT_URL").ok();
|
||||||
|
|
||||||
Self {
|
Self {
|
||||||
host,
|
host,
|
||||||
port,
|
port,
|
||||||
@@ -107,6 +118,10 @@ impl Config {
|
|||||||
secure_cookie,
|
secure_cookie,
|
||||||
db_max_connections,
|
db_max_connections,
|
||||||
db_min_connections,
|
db_min_connections,
|
||||||
|
oidc_issuer,
|
||||||
|
oidc_client_id,
|
||||||
|
oidc_client_secret,
|
||||||
|
oidc_redirect_url,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -49,7 +49,7 @@ async fn main() -> anyhow::Result<()> {
|
|||||||
let user_repo = build_user_repository(&db_pool).await?;
|
let user_repo = build_user_repository(&db_pool).await?;
|
||||||
let user_service = UserService::new(user_repo.clone());
|
let user_service = UserService::new(user_repo.clone());
|
||||||
|
|
||||||
let state = AppState::new(user_service, config.clone());
|
let state = AppState::new(user_service, config.clone()).await?;
|
||||||
|
|
||||||
let session_store = build_session_store(&db_pool)
|
let session_store = build_session_store(&db_pool)
|
||||||
.await
|
.await
|
||||||
|
|||||||
@@ -12,13 +12,21 @@ use crate::{
|
|||||||
state::AppState,
|
state::AppState,
|
||||||
};
|
};
|
||||||
use domain::{DomainError, Email};
|
use domain::{DomainError, Email};
|
||||||
|
use tower_sessions::Session;
|
||||||
|
|
||||||
pub fn router() -> Router<AppState> {
|
pub fn router() -> Router<AppState> {
|
||||||
Router::new()
|
let r = Router::new()
|
||||||
.route("/login", post(login))
|
.route("/login", post(login))
|
||||||
.route("/register", post(register))
|
.route("/register", post(register))
|
||||||
.route("/logout", post(logout))
|
.route("/logout", post(logout))
|
||||||
.route("/me", post(me))
|
.route("/me", post(me));
|
||||||
|
|
||||||
|
#[cfg(feature = "auth-oidc")]
|
||||||
|
let r = r
|
||||||
|
.route("/login/oidc", axum::routing::get(oidc_login))
|
||||||
|
.route("/auth/callback", axum::routing::get(oidc_callback));
|
||||||
|
|
||||||
|
r
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn login(
|
async fn login(
|
||||||
@@ -115,3 +123,104 @@ async fn me(auth_session: crate::auth::AuthSession) -> Result<impl IntoResponse,
|
|||||||
created_at: user.0.created_at,
|
created_at: user.0.created_at,
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(feature = "auth-oidc")]
|
||||||
|
async fn oidc_login(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
session: Session,
|
||||||
|
) -> Result<impl IntoResponse, ApiError> {
|
||||||
|
let service = state
|
||||||
|
.oidc_service
|
||||||
|
.as_ref()
|
||||||
|
.ok_or(ApiError::Internal("OIDC not configured".into()))?;
|
||||||
|
|
||||||
|
let (url, csrf, nonce, pkce) = service.get_authorization_url();
|
||||||
|
|
||||||
|
session
|
||||||
|
.insert("oidc_csrf", csrf)
|
||||||
|
.await
|
||||||
|
.map_err(|_| ApiError::Internal("Session error".into()))?;
|
||||||
|
session
|
||||||
|
.insert("oidc_nonce", nonce)
|
||||||
|
.await
|
||||||
|
.map_err(|_| ApiError::Internal("Session error".into()))?;
|
||||||
|
session
|
||||||
|
.insert("oidc_pkce", pkce)
|
||||||
|
.await
|
||||||
|
.map_err(|_| ApiError::Internal("Session error".into()))?;
|
||||||
|
|
||||||
|
Ok(axum::response::Redirect::to(&url))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(feature = "auth-oidc")]
|
||||||
|
#[derive(serde::Deserialize)]
|
||||||
|
struct CallbackParams {
|
||||||
|
code: String,
|
||||||
|
state: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(feature = "auth-oidc")]
|
||||||
|
async fn oidc_callback(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
session: Session,
|
||||||
|
mut auth_session: crate::auth::AuthSession,
|
||||||
|
axum::extract::Query(params): axum::extract::Query<CallbackParams>,
|
||||||
|
) -> Result<impl IntoResponse, ApiError> {
|
||||||
|
let service = state
|
||||||
|
.oidc_service
|
||||||
|
.as_ref()
|
||||||
|
.ok_or(ApiError::Internal("OIDC not configured".into()))?;
|
||||||
|
|
||||||
|
let stored_csrf: String = session
|
||||||
|
.get("oidc_csrf")
|
||||||
|
.await
|
||||||
|
.map_err(|_| ApiError::Internal("Session error".into()))?
|
||||||
|
.ok_or(ApiError::Validation("Missing CSRF token".into()))?;
|
||||||
|
|
||||||
|
if params.state != stored_csrf {
|
||||||
|
return Err(ApiError::Validation("Invalid CSRF token".into()));
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Retrieve secrets
|
||||||
|
let stored_pkce: String = session
|
||||||
|
.get("oidc_pkce")
|
||||||
|
.await
|
||||||
|
.map_err(|_| ApiError::Internal("Session error".into()))?
|
||||||
|
.ok_or(ApiError::Validation("Missing PKCE".into()))?;
|
||||||
|
let stored_nonce: String = session
|
||||||
|
.get("oidc_nonce")
|
||||||
|
.await
|
||||||
|
.map_err(|_| ApiError::Internal("Session error".into()))?
|
||||||
|
.ok_or(ApiError::Validation("Missing Nonce".into()))?;
|
||||||
|
|
||||||
|
let oidc_user = service
|
||||||
|
.resolve_callback(params.code, stored_nonce, stored_pkce)
|
||||||
|
.await
|
||||||
|
.map_err(|e| ApiError::Internal(e.to_string()))?;
|
||||||
|
|
||||||
|
let user = state
|
||||||
|
.user_service
|
||||||
|
.find_or_create(&oidc_user.subject, &oidc_user.email)
|
||||||
|
.await
|
||||||
|
.map_err(|e| ApiError::Internal(e.to_string()))?;
|
||||||
|
|
||||||
|
auth_session
|
||||||
|
.login(&crate::auth::AuthUser(user))
|
||||||
|
.await
|
||||||
|
.map_err(|_| ApiError::Internal("Login failed".into()))?;
|
||||||
|
|
||||||
|
let _: Option<String> = session
|
||||||
|
.remove("oidc_csrf")
|
||||||
|
.await
|
||||||
|
.map_err(|_| ApiError::Internal("Session error".into()))?;
|
||||||
|
let _: Option<String> = session
|
||||||
|
.remove("oidc_pkce")
|
||||||
|
.await
|
||||||
|
.map_err(|_| ApiError::Internal("Session error".into()))?;
|
||||||
|
let _: Option<String> = session
|
||||||
|
.remove("oidc_nonce")
|
||||||
|
.await
|
||||||
|
.map_err(|_| ApiError::Internal("Session error".into()))?;
|
||||||
|
|
||||||
|
Ok(axum::response::Redirect::to("/"))
|
||||||
|
}
|
||||||
|
|||||||
@@ -3,6 +3,8 @@
|
|||||||
//! Holds shared state for the application.
|
//! Holds shared state for the application.
|
||||||
|
|
||||||
use axum::extract::FromRef;
|
use axum::extract::FromRef;
|
||||||
|
#[cfg(feature = "auth-oidc")]
|
||||||
|
use infra::auth::oidc::OidcService;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
||||||
use crate::config::Config;
|
use crate::config::Config;
|
||||||
@@ -11,15 +13,36 @@ use domain::UserService;
|
|||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
pub struct AppState {
|
pub struct AppState {
|
||||||
pub user_service: Arc<UserService>,
|
pub user_service: Arc<UserService>,
|
||||||
|
#[cfg(feature = "auth-oidc")]
|
||||||
|
pub oidc_service: Option<Arc<OidcService>>,
|
||||||
|
|
||||||
pub config: Arc<Config>,
|
pub config: Arc<Config>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl AppState {
|
impl AppState {
|
||||||
pub fn new(user_service: UserService, config: Config) -> Self {
|
pub async fn new(user_service: UserService, config: Config) -> anyhow::Result<Self> {
|
||||||
Self {
|
#[cfg(feature = "auth-oidc")]
|
||||||
|
let oidc_service = if let (Some(issuer), Some(id), Some(secret), Some(redirect)) = (
|
||||||
|
&config.oidc_issuer,
|
||||||
|
&config.oidc_client_id,
|
||||||
|
&config.oidc_client_secret,
|
||||||
|
&config.oidc_redirect_url,
|
||||||
|
) {
|
||||||
|
tracing::info!("Initializing OIDC service with issuer: {}", issuer);
|
||||||
|
Some(Arc::new(
|
||||||
|
OidcService::new(issuer.clone(), id.clone(), secret.clone(), redirect.clone())
|
||||||
|
.await?,
|
||||||
|
))
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
|
||||||
|
Ok(Self {
|
||||||
user_service: Arc::new(user_service),
|
user_service: Arc::new(user_service),
|
||||||
|
#[cfg(feature = "auth-oidc")]
|
||||||
|
oidc_service,
|
||||||
config: Arc::new(config),
|
config: Arc::new(config),
|
||||||
}
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ postgres = [
|
|||||||
]
|
]
|
||||||
broker-nats = ["dep:futures-util", "k-core/broker-nats"]
|
broker-nats = ["dep:futures-util", "k-core/broker-nats"]
|
||||||
auth-axum-login = ["dep:axum-login", "dep:password-auth"]
|
auth-axum-login = ["dep:axum-login", "dep:password-auth"]
|
||||||
|
auth-oidc = ["dep:openidconnect", "dep:url"]
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
k-core = { git = "https://git.gabrielkaszewski.dev/GKaszewski/k-core", features = [
|
k-core = { git = "https://git.gabrielkaszewski.dev/GKaszewski/k-core", features = [
|
||||||
@@ -47,3 +48,6 @@ tower-sessions = "0.14"
|
|||||||
# Auth dependencies (optional)
|
# Auth dependencies (optional)
|
||||||
axum-login = { version = "0.18", optional = true }
|
axum-login = { version = "0.18", optional = true }
|
||||||
password-auth = { version = "1.0", optional = true }
|
password-auth = { version = "1.0", optional = true }
|
||||||
|
openidconnect = { version = "4.0.1", optional = true }
|
||||||
|
url = { version = "2.5.8", optional = true }
|
||||||
|
# reqwest = { version = "0.13.1", features = ["blocking", "json"], optional = true }
|
||||||
|
|||||||
@@ -115,3 +115,6 @@ pub mod backend {
|
|||||||
Ok(auth_layer)
|
Ok(auth_layer)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(feature = "auth-oidc")]
|
||||||
|
pub mod oidc;
|
||||||
|
|||||||
145
infra/src/auth/oidc.rs
Normal file
145
infra/src/auth/oidc.rs
Normal file
@@ -0,0 +1,145 @@
|
|||||||
|
use anyhow::anyhow;
|
||||||
|
use openidconnect::{
|
||||||
|
AccessTokenHash, AuthorizationCode, Client, ClientId, ClientSecret, CsrfToken,
|
||||||
|
EmptyAdditionalClaims, EndpointMaybeSet, EndpointNotSet, EndpointSet, IssuerUrl, Nonce,
|
||||||
|
OAuth2TokenResponse, PkceCodeChallenge, PkceCodeVerifier, RedirectUrl, Scope,
|
||||||
|
StandardErrorResponse, TokenResponse,
|
||||||
|
core::{
|
||||||
|
CoreAuthDisplay, CoreAuthPrompt, CoreAuthenticationFlow, CoreClient, CoreErrorResponseType,
|
||||||
|
CoreGenderClaim, CoreJsonWebKey, CoreJweContentEncryptionAlgorithm, CoreProviderMetadata,
|
||||||
|
CoreRevocableToken, CoreRevocationErrorResponse, CoreTokenIntrospectionResponse,
|
||||||
|
CoreTokenResponse,
|
||||||
|
},
|
||||||
|
reqwest,
|
||||||
|
};
|
||||||
|
|
||||||
|
pub type OidcClient = Client<
|
||||||
|
EmptyAdditionalClaims,
|
||||||
|
CoreAuthDisplay,
|
||||||
|
CoreGenderClaim,
|
||||||
|
CoreJweContentEncryptionAlgorithm,
|
||||||
|
CoreJsonWebKey,
|
||||||
|
CoreAuthPrompt,
|
||||||
|
StandardErrorResponse<CoreErrorResponseType>,
|
||||||
|
CoreTokenResponse,
|
||||||
|
CoreTokenIntrospectionResponse,
|
||||||
|
CoreRevocableToken,
|
||||||
|
CoreRevocationErrorResponse,
|
||||||
|
EndpointSet, // HasAuthUrl (Required and guaranteed by discovery)
|
||||||
|
EndpointNotSet, // HasDeviceAuthUrl
|
||||||
|
EndpointNotSet, // HasIntrospectionUrl
|
||||||
|
EndpointNotSet, // HasRevocationUrl
|
||||||
|
EndpointMaybeSet, // HasTokenUrl (Discovered, might be missing)
|
||||||
|
EndpointMaybeSet, // HasUserInfoUrl (Discovered, might be missing)
|
||||||
|
>;
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub struct OidcService {
|
||||||
|
client: OidcClient,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub struct OidcUser {
|
||||||
|
pub subject: String,
|
||||||
|
pub email: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl OidcService {
|
||||||
|
//todo: replace Strings with newtypes
|
||||||
|
pub async fn new(
|
||||||
|
issuer: String,
|
||||||
|
client_id: String,
|
||||||
|
client_secret: String,
|
||||||
|
redirect_url: String,
|
||||||
|
) -> anyhow::Result<Self> {
|
||||||
|
let http_client = reqwest::ClientBuilder::new()
|
||||||
|
.redirect(reqwest::redirect::Policy::none())
|
||||||
|
.build()?;
|
||||||
|
|
||||||
|
let provider_metadata =
|
||||||
|
CoreProviderMetadata::discover_async(IssuerUrl::new(issuer)?, &http_client).await?;
|
||||||
|
|
||||||
|
let client = CoreClient::from_provider_metadata(
|
||||||
|
provider_metadata,
|
||||||
|
ClientId::new(client_id),
|
||||||
|
Some(ClientSecret::new(client_secret)),
|
||||||
|
)
|
||||||
|
.set_redirect_uri(RedirectUrl::new(redirect_url)?);
|
||||||
|
|
||||||
|
Ok(Self { client })
|
||||||
|
}
|
||||||
|
|
||||||
|
// todo: replace this tuple with newtype
|
||||||
|
pub fn get_authorization_url(&self) -> (String, String, String, String) {
|
||||||
|
let (pkce_challenge, pkce_verifier) = PkceCodeChallenge::new_random_sha256();
|
||||||
|
|
||||||
|
let (auth_url, csrf_token, nonce) = self
|
||||||
|
.client
|
||||||
|
.authorize_url(
|
||||||
|
CoreAuthenticationFlow::AuthorizationCode,
|
||||||
|
CsrfToken::new_random,
|
||||||
|
Nonce::new_random,
|
||||||
|
)
|
||||||
|
.add_scope(Scope::new("profile".to_string()))
|
||||||
|
.add_scope(Scope::new("email".to_string()))
|
||||||
|
.set_pkce_challenge(pkce_challenge)
|
||||||
|
.url();
|
||||||
|
|
||||||
|
(
|
||||||
|
auth_url.to_string(),
|
||||||
|
csrf_token.secret().to_string(),
|
||||||
|
nonce.secret().to_string(),
|
||||||
|
pkce_verifier.secret().to_string(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
//todo: replace strings with newtype
|
||||||
|
pub async fn resolve_callback(
|
||||||
|
&self,
|
||||||
|
code: String,
|
||||||
|
nonce: String,
|
||||||
|
pkce_verifier: String,
|
||||||
|
) -> anyhow::Result<OidcUser> {
|
||||||
|
let http_client = reqwest::ClientBuilder::new()
|
||||||
|
.redirect(reqwest::redirect::Policy::none())
|
||||||
|
.build()?;
|
||||||
|
|
||||||
|
let pkce_verifier = PkceCodeVerifier::new(pkce_verifier);
|
||||||
|
let nonce = Nonce::new(nonce);
|
||||||
|
|
||||||
|
let token_response = self
|
||||||
|
.client
|
||||||
|
.exchange_code(AuthorizationCode::new(code))?
|
||||||
|
.set_pkce_verifier(pkce_verifier)
|
||||||
|
.request_async(&http_client)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
let id_token = token_response
|
||||||
|
.id_token()
|
||||||
|
.ok_or_else(|| anyhow!("Server did not return an ID token"))?;
|
||||||
|
|
||||||
|
let id_token_verifier = self.client.id_token_verifier();
|
||||||
|
let claims = id_token.claims(&id_token_verifier, &nonce)?;
|
||||||
|
|
||||||
|
if let Some(expected_access_token_hash) = claims.access_token_hash() {
|
||||||
|
let actual_access_token_hash = AccessTokenHash::from_token(
|
||||||
|
token_response.access_token(),
|
||||||
|
id_token.signing_alg()?,
|
||||||
|
id_token.signing_key(&id_token_verifier)?,
|
||||||
|
)?;
|
||||||
|
|
||||||
|
if actual_access_token_hash != *expected_access_token_hash {
|
||||||
|
return Err(anyhow!("Invalid access token"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(OidcUser {
|
||||||
|
subject: claims.subject().to_string(),
|
||||||
|
email: claims
|
||||||
|
.email()
|
||||||
|
.map(|email| email.as_str())
|
||||||
|
.unwrap_or("<not provided>")
|
||||||
|
.to_string(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user