adapter-auth: JWT, OIDC, password hashing
This commit is contained in:
27
crates/adapters/auth/Cargo.toml
Normal file
27
crates/adapters/auth/Cargo.toml
Normal file
@@ -0,0 +1,27 @@
|
||||
[package]
|
||||
name = "adapter-auth"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
|
||||
[features]
|
||||
default = ["jwt"]
|
||||
jwt = ["dep:jsonwebtoken"]
|
||||
oidc = ["dep:openidconnect", "dep:reqwest", "dep:url"]
|
||||
|
||||
[dependencies]
|
||||
domain = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
tracing = { workspace = true }
|
||||
serde = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
|
||||
# JWT deps
|
||||
jsonwebtoken = { workspace = true, optional = true }
|
||||
|
||||
# OIDC deps (optional)
|
||||
openidconnect = { version = "4", optional = true }
|
||||
reqwest = { workspace = true, optional = true }
|
||||
url = { workspace = true, optional = true }
|
||||
|
||||
# Password hashing
|
||||
password-auth = "1"
|
||||
350
crates/adapters/auth/src/jwt.rs
Normal file
350
crates/adapters/auth/src/jwt.rs
Normal file
@@ -0,0 +1,350 @@
|
||||
//! JWT token generation and validation (HS256).
|
||||
//!
|
||||
//! This does NOT implement a domain port — it is used directly by the
|
||||
//! presentation layer's auth extractors.
|
||||
|
||||
use domain::User;
|
||||
use jsonwebtoken::{Algorithm, DecodingKey, EncodingKey, Header, Validation, decode, encode};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
/// Minimum secret length for production (256 bits = 32 bytes).
|
||||
const MIN_SECRET_LENGTH: usize = 32;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Config
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// JWT configuration.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct JwtConfig {
|
||||
/// Secret key for HS256 signing/verification.
|
||||
pub secret: String,
|
||||
/// Expected issuer (for validation).
|
||||
pub issuer: Option<String>,
|
||||
/// Expected audience (for validation).
|
||||
pub audience: Option<String>,
|
||||
/// Access token expiry in hours (default: 24).
|
||||
pub expiry_hours: u64,
|
||||
/// Refresh token expiry in days (default: 30).
|
||||
pub refresh_expiry_days: u64,
|
||||
}
|
||||
|
||||
impl JwtConfig {
|
||||
/// Create a new JWT config with validation.
|
||||
///
|
||||
/// In production mode, this rejects secrets shorter than
|
||||
/// [`MIN_SECRET_LENGTH`] bytes.
|
||||
pub fn new(
|
||||
secret: String,
|
||||
issuer: Option<String>,
|
||||
audience: Option<String>,
|
||||
expiry_hours: Option<u64>,
|
||||
refresh_expiry_days: Option<u64>,
|
||||
is_production: bool,
|
||||
) -> Result<Self, JwtError> {
|
||||
if is_production && secret.len() < MIN_SECRET_LENGTH {
|
||||
return Err(JwtError::WeakSecret {
|
||||
min_length: MIN_SECRET_LENGTH,
|
||||
actual_length: secret.len(),
|
||||
});
|
||||
}
|
||||
|
||||
Ok(Self {
|
||||
secret,
|
||||
issuer,
|
||||
audience,
|
||||
expiry_hours: expiry_hours.unwrap_or(24),
|
||||
refresh_expiry_days: refresh_expiry_days.unwrap_or(30),
|
||||
})
|
||||
}
|
||||
|
||||
/// Create config without validation (for testing).
|
||||
pub fn new_unchecked(secret: String) -> Self {
|
||||
Self {
|
||||
secret,
|
||||
issuer: None,
|
||||
audience: None,
|
||||
expiry_hours: 24,
|
||||
refresh_expiry_days: 30,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Claims
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
fn default_token_type() -> String {
|
||||
"access".to_string()
|
||||
}
|
||||
|
||||
/// JWT claims structure.
|
||||
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||
pub struct JwtClaims {
|
||||
/// Subject — the user's unique identifier (user ID as string).
|
||||
pub sub: String,
|
||||
/// User's email address.
|
||||
pub email: String,
|
||||
/// Expiry timestamp (seconds since UNIX epoch).
|
||||
pub exp: usize,
|
||||
/// Issued-at timestamp (seconds since UNIX epoch).
|
||||
pub iat: usize,
|
||||
/// Issuer.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub iss: Option<String>,
|
||||
/// Audience.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub aud: Option<String>,
|
||||
/// Token type: `"access"` or `"refresh"`. Defaults to `"access"` for
|
||||
/// backward compatibility.
|
||||
#[serde(default = "default_token_type")]
|
||||
pub token_type: String,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Errors
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// JWT-related errors.
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum JwtError {
|
||||
#[error("JWT secret too weak: minimum {min_length} bytes, got {actual_length}")]
|
||||
WeakSecret {
|
||||
min_length: usize,
|
||||
actual_length: usize,
|
||||
},
|
||||
|
||||
#[error("Token creation failed: {0}")]
|
||||
CreationFailed(#[from] jsonwebtoken::errors::Error),
|
||||
|
||||
#[error("Token validation failed: {0}")]
|
||||
ValidationFailed(String),
|
||||
|
||||
#[error("Token expired")]
|
||||
Expired,
|
||||
|
||||
#[error("Invalid token format")]
|
||||
InvalidFormat,
|
||||
|
||||
#[error("Missing configuration")]
|
||||
MissingConfig,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Validator / generator
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// JWT token validator and generator.
|
||||
#[derive(Clone)]
|
||||
pub struct JwtValidator {
|
||||
config: JwtConfig,
|
||||
encoding_key: EncodingKey,
|
||||
decoding_key: DecodingKey,
|
||||
validation: Validation,
|
||||
}
|
||||
|
||||
impl JwtValidator {
|
||||
/// Create a new JWT validator with the given configuration.
|
||||
pub fn new(config: JwtConfig) -> Self {
|
||||
let encoding_key = EncodingKey::from_secret(config.secret.as_bytes());
|
||||
let decoding_key = DecodingKey::from_secret(config.secret.as_bytes());
|
||||
|
||||
let mut validation = Validation::new(Algorithm::HS256);
|
||||
|
||||
if let Some(ref issuer) = config.issuer {
|
||||
validation.set_issuer(&[issuer]);
|
||||
}
|
||||
if let Some(ref audience) = config.audience {
|
||||
validation.set_audience(&[audience]);
|
||||
}
|
||||
|
||||
Self {
|
||||
config,
|
||||
encoding_key,
|
||||
decoding_key,
|
||||
validation,
|
||||
}
|
||||
}
|
||||
|
||||
/// Create an access JWT token for the given user.
|
||||
pub fn create_token(&self, user: &User) -> Result<String, JwtError> {
|
||||
let now = now_secs();
|
||||
let expiry = now + (self.config.expiry_hours as usize * 3600);
|
||||
|
||||
let claims = JwtClaims {
|
||||
sub: user.id().to_string(),
|
||||
email: user.email().as_ref().to_string(),
|
||||
exp: expiry,
|
||||
iat: now,
|
||||
iss: self.config.issuer.clone(),
|
||||
aud: self.config.audience.clone(),
|
||||
token_type: "access".to_string(),
|
||||
};
|
||||
|
||||
encode(&Header::new(Algorithm::HS256), &claims, &self.encoding_key)
|
||||
.map_err(JwtError::CreationFailed)
|
||||
}
|
||||
|
||||
/// Create a refresh JWT token for the given user (longer-lived).
|
||||
pub fn create_refresh_token(&self, user: &User) -> Result<String, JwtError> {
|
||||
let now = now_secs();
|
||||
let expiry = now + (self.config.refresh_expiry_days as usize * 86400);
|
||||
|
||||
let claims = JwtClaims {
|
||||
sub: user.id().to_string(),
|
||||
email: user.email().as_ref().to_string(),
|
||||
exp: expiry,
|
||||
iat: now,
|
||||
iss: self.config.issuer.clone(),
|
||||
aud: self.config.audience.clone(),
|
||||
token_type: "refresh".to_string(),
|
||||
};
|
||||
|
||||
encode(&Header::new(Algorithm::HS256), &claims, &self.encoding_key)
|
||||
.map_err(JwtError::CreationFailed)
|
||||
}
|
||||
|
||||
/// Validate a JWT token and return the claims.
|
||||
pub fn validate_token(&self, token: &str) -> Result<JwtClaims, JwtError> {
|
||||
let token_data =
|
||||
decode::<JwtClaims>(token, &self.decoding_key, &self.validation).map_err(|e| {
|
||||
match e.kind() {
|
||||
jsonwebtoken::errors::ErrorKind::ExpiredSignature => JwtError::Expired,
|
||||
jsonwebtoken::errors::ErrorKind::InvalidToken => JwtError::InvalidFormat,
|
||||
_ => JwtError::ValidationFailed(e.to_string()),
|
||||
}
|
||||
})?;
|
||||
|
||||
Ok(token_data.claims)
|
||||
}
|
||||
|
||||
/// Validate an access token — rejects refresh tokens.
|
||||
pub fn validate_access_token(&self, token: &str) -> Result<JwtClaims, JwtError> {
|
||||
let claims = self.validate_token(token)?;
|
||||
if claims.token_type != "access" {
|
||||
return Err(JwtError::ValidationFailed(
|
||||
"Not an access token".to_string(),
|
||||
));
|
||||
}
|
||||
Ok(claims)
|
||||
}
|
||||
|
||||
/// Validate a refresh token — rejects access tokens.
|
||||
pub fn validate_refresh_token(&self, token: &str) -> Result<JwtClaims, JwtError> {
|
||||
let claims = self.validate_token(token)?;
|
||||
if claims.token_type != "refresh" {
|
||||
return Err(JwtError::ValidationFailed(
|
||||
"Not a refresh token".to_string(),
|
||||
));
|
||||
}
|
||||
Ok(claims)
|
||||
}
|
||||
|
||||
/// Get the user ID (subject) from a token without full validation.
|
||||
///
|
||||
/// Useful for logging/debugging — should not be trusted for auth decisions.
|
||||
pub fn decode_unverified(&self, token: &str) -> Result<JwtClaims, JwtError> {
|
||||
let mut insecure = Validation::new(Algorithm::HS256);
|
||||
insecure.insecure_disable_signature_validation();
|
||||
insecure.validate_exp = false;
|
||||
insecure.validate_aud = false;
|
||||
|
||||
let token_data = decode::<JwtClaims>(token, &self.decoding_key, &insecure)
|
||||
.map_err(|_| JwtError::InvalidFormat)?;
|
||||
Ok(token_data.claims)
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for JwtValidator {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.debug_struct("JwtValidator")
|
||||
.field("issuer", &self.config.issuer)
|
||||
.field("audience", &self.config.audience)
|
||||
.field("expiry_hours", &self.config.expiry_hours)
|
||||
.finish_non_exhaustive()
|
||||
}
|
||||
}
|
||||
|
||||
fn now_secs() -> usize {
|
||||
SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.expect("Time went backwards")
|
||||
.as_secs() as usize
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use domain::Email;
|
||||
|
||||
fn test_user() -> User {
|
||||
let email = Email::new("test@example.com").unwrap();
|
||||
User::new("test-subject", email)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn create_and_validate_token() {
|
||||
let config = JwtConfig::new_unchecked("test-secret-key-that-is-long-enough".to_string());
|
||||
let validator = JwtValidator::new(config);
|
||||
let user = test_user();
|
||||
|
||||
let token = validator.create_token(&user).expect("create token");
|
||||
let claims = validator.validate_token(&token).expect("validate token");
|
||||
|
||||
assert_eq!(claims.sub, user.id().to_string());
|
||||
assert_eq!(claims.email, "test@example.com");
|
||||
assert_eq!(claims.token_type, "access");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn refresh_token_round_trip() {
|
||||
let config = JwtConfig::new_unchecked("test-secret-key-that-is-long-enough".to_string());
|
||||
let validator = JwtValidator::new(config);
|
||||
let user = test_user();
|
||||
|
||||
let token = validator.create_refresh_token(&user).unwrap();
|
||||
let claims = validator.validate_refresh_token(&token).unwrap();
|
||||
assert_eq!(claims.token_type, "refresh");
|
||||
|
||||
// Access-only validation rejects it
|
||||
assert!(validator.validate_access_token(&token).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn weak_secret_rejected_in_production() {
|
||||
let result = JwtConfig::new("short".to_string(), None, None, None, None, true);
|
||||
assert!(matches!(result, Err(JwtError::WeakSecret { .. })));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn weak_secret_allowed_in_development() {
|
||||
let result = JwtConfig::new("short".to_string(), None, None, None, None, false);
|
||||
assert!(result.is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_token_rejected() {
|
||||
let config = JwtConfig::new_unchecked("test-secret-key-that-is-long-enough".to_string());
|
||||
let validator = JwtValidator::new(config);
|
||||
assert!(validator.validate_token("invalid.token.here").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wrong_secret_rejected() {
|
||||
let v1 = JwtValidator::new(JwtConfig::new_unchecked(
|
||||
"secret-one-that-is-long-enough".to_string(),
|
||||
));
|
||||
let v2 = JwtValidator::new(JwtConfig::new_unchecked(
|
||||
"secret-two-that-is-long-enough".to_string(),
|
||||
));
|
||||
|
||||
let user = test_user();
|
||||
let token = v1.create_token(&user).unwrap();
|
||||
assert!(v2.validate_token(&token).is_err());
|
||||
}
|
||||
}
|
||||
17
crates/adapters/auth/src/lib.rs
Normal file
17
crates/adapters/auth/src/lib.rs
Normal file
@@ -0,0 +1,17 @@
|
||||
//! Auth adapter crate — JWT, OIDC, and password hashing.
|
||||
|
||||
pub mod password;
|
||||
|
||||
#[cfg(feature = "jwt")]
|
||||
pub mod jwt;
|
||||
|
||||
#[cfg(feature = "oidc")]
|
||||
pub mod oidc;
|
||||
|
||||
pub use password::PasswordAuthService;
|
||||
|
||||
#[cfg(feature = "jwt")]
|
||||
pub use jwt::{JwtClaims, JwtConfig, JwtError, JwtValidator};
|
||||
|
||||
#[cfg(feature = "oidc")]
|
||||
pub use oidc::{OidcService, OidcState, OidcUser};
|
||||
277
crates/adapters/auth/src/oidc.rs
Normal file
277
crates/adapters/auth/src/oidc.rs
Normal file
@@ -0,0 +1,277 @@
|
||||
//! OIDC (OpenID Connect) authorization flow adapter.
|
||||
|
||||
use domain::{
|
||||
AuthorizationCode, AuthorizationUrlData, ClientId, ClientSecret, CsrfToken, IssuerUrl,
|
||||
OidcNonce, PkceVerifier, RedirectUrl, ResourceId,
|
||||
};
|
||||
use openidconnect::{
|
||||
AccessTokenHash, Client, EmptyAdditionalClaims, EndpointMaybeSet, EndpointNotSet, EndpointSet,
|
||||
OAuth2TokenResponse, PkceCodeChallenge, Scope, StandardErrorResponse, TokenResponse,
|
||||
UserInfoClaims,
|
||||
core::{
|
||||
CoreAuthDisplay, CoreAuthPrompt, CoreAuthenticationFlow, CoreClient, CoreErrorResponseType,
|
||||
CoreGenderClaim, CoreJsonWebKey, CoreJweContentEncryptionAlgorithm, CoreProviderMetadata,
|
||||
CoreRevocableToken, CoreRevocationErrorResponse, CoreTokenIntrospectionResponse,
|
||||
CoreTokenResponse,
|
||||
},
|
||||
reqwest,
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Type aliases
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
pub type OidcClient = Client<
|
||||
EmptyAdditionalClaims,
|
||||
CoreAuthDisplay,
|
||||
CoreGenderClaim,
|
||||
CoreJweContentEncryptionAlgorithm,
|
||||
CoreJsonWebKey,
|
||||
CoreAuthPrompt,
|
||||
StandardErrorResponse<CoreErrorResponseType>,
|
||||
CoreTokenResponse,
|
||||
CoreTokenIntrospectionResponse,
|
||||
CoreRevocableToken,
|
||||
CoreRevocationErrorResponse,
|
||||
EndpointSet, // HasAuthUrl
|
||||
EndpointNotSet, // HasDeviceAuthUrl
|
||||
EndpointNotSet, // HasIntrospectionUrl
|
||||
EndpointNotSet, // HasRevocationUrl
|
||||
EndpointMaybeSet, // HasTokenUrl
|
||||
EndpointMaybeSet, // HasUserInfoUrl
|
||||
>;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Errors
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// OIDC-specific errors.
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum OidcError {
|
||||
#[error("OIDC discovery failed: {0}")]
|
||||
Discovery(String),
|
||||
|
||||
#[error("Token exchange failed: {0}")]
|
||||
TokenExchange(String),
|
||||
|
||||
#[error("ID token verification failed: {0}")]
|
||||
IdTokenVerification(String),
|
||||
|
||||
#[error("Missing ID token in response")]
|
||||
MissingIdToken,
|
||||
|
||||
#[error("Invalid access token hash")]
|
||||
InvalidAccessTokenHash,
|
||||
|
||||
#[error("User has no email address")]
|
||||
MissingEmail,
|
||||
|
||||
#[error("HTTP client error: {0}")]
|
||||
Http(String),
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// State / types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Serializable OIDC state stored in an encrypted cookie during the auth flow.
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct OidcState {
|
||||
pub csrf_token: CsrfToken,
|
||||
pub nonce: OidcNonce,
|
||||
pub pkce_verifier: PkceVerifier,
|
||||
}
|
||||
|
||||
/// Resolved OIDC user info.
|
||||
#[derive(Debug)]
|
||||
pub struct OidcUser {
|
||||
pub subject: String,
|
||||
pub email: String,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Service
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// OIDC authorization flow service.
|
||||
#[derive(Clone)]
|
||||
pub struct OidcService {
|
||||
client: OidcClient,
|
||||
http_client: reqwest::Client,
|
||||
resource_id: Option<ResourceId>,
|
||||
}
|
||||
|
||||
impl OidcService {
|
||||
/// Create a new OIDC service — performs provider discovery.
|
||||
pub async fn new(
|
||||
issuer: IssuerUrl,
|
||||
client_id: ClientId,
|
||||
client_secret: Option<ClientSecret>,
|
||||
redirect_url: RedirectUrl,
|
||||
resource_id: Option<ResourceId>,
|
||||
) -> Result<Self, OidcError> {
|
||||
tracing::debug!("OIDC setup: client_id={client_id}, redirect={redirect_url}");
|
||||
tracing::debug!(
|
||||
"OIDC setup: secret={}",
|
||||
if client_secret.is_some() {
|
||||
"SET"
|
||||
} else {
|
||||
"NONE"
|
||||
}
|
||||
);
|
||||
|
||||
let http_client = reqwest::ClientBuilder::new()
|
||||
.redirect(reqwest::redirect::Policy::none())
|
||||
.build()
|
||||
.map_err(|e| OidcError::Http(e.to_string()))?;
|
||||
|
||||
let provider_metadata = CoreProviderMetadata::discover_async(
|
||||
openidconnect::IssuerUrl::new(issuer.as_ref().to_string())
|
||||
.map_err(|e| OidcError::Discovery(e.to_string()))?,
|
||||
&http_client,
|
||||
)
|
||||
.await
|
||||
.map_err(|e| OidcError::Discovery(e.to_string()))?;
|
||||
|
||||
let oidc_client_id = openidconnect::ClientId::new(client_id.as_ref().to_string());
|
||||
let oidc_client_secret = client_secret
|
||||
.as_ref()
|
||||
.filter(|s| !s.is_empty())
|
||||
.map(|s| openidconnect::ClientSecret::new(s.as_ref().to_string()));
|
||||
let oidc_redirect_url =
|
||||
openidconnect::RedirectUrl::new(redirect_url.as_ref().to_string())
|
||||
.map_err(|e| OidcError::Discovery(e.to_string()))?;
|
||||
|
||||
let client = CoreClient::from_provider_metadata(
|
||||
provider_metadata,
|
||||
oidc_client_id,
|
||||
oidc_client_secret,
|
||||
)
|
||||
.set_redirect_uri(oidc_redirect_url);
|
||||
|
||||
Ok(Self {
|
||||
client,
|
||||
http_client,
|
||||
resource_id,
|
||||
})
|
||||
}
|
||||
|
||||
/// Build the authorization URL and associated state for OIDC login.
|
||||
///
|
||||
/// Returns `(AuthorizationUrlData, OidcState)` — the state should be
|
||||
/// serialized and stored in an encrypted cookie for the duration of the
|
||||
/// flow.
|
||||
pub fn get_authorization_url(&self) -> (AuthorizationUrlData, OidcState) {
|
||||
let (pkce_challenge, pkce_verifier) = PkceCodeChallenge::new_random_sha256();
|
||||
|
||||
let (auth_url, csrf_token, nonce) = self
|
||||
.client
|
||||
.authorize_url(
|
||||
CoreAuthenticationFlow::AuthorizationCode,
|
||||
openidconnect::CsrfToken::new_random,
|
||||
openidconnect::Nonce::new_random,
|
||||
)
|
||||
.add_scope(Scope::new("profile".to_string()))
|
||||
.add_scope(Scope::new("email".to_string()))
|
||||
.set_pkce_challenge(pkce_challenge)
|
||||
.url();
|
||||
|
||||
let oidc_state = OidcState {
|
||||
csrf_token: CsrfToken::new(csrf_token.secret().to_string()),
|
||||
nonce: OidcNonce::new(nonce.secret().to_string()),
|
||||
pkce_verifier: PkceVerifier::new(pkce_verifier.secret().to_string()),
|
||||
};
|
||||
|
||||
let auth_data = AuthorizationUrlData {
|
||||
url: auth_url.into(),
|
||||
csrf_token: oidc_state.csrf_token.clone(),
|
||||
nonce: oidc_state.nonce.clone(),
|
||||
pkce_verifier: oidc_state.pkce_verifier.clone(),
|
||||
};
|
||||
|
||||
(auth_data, oidc_state)
|
||||
}
|
||||
|
||||
/// Resolve the OIDC callback — exchange code for tokens, verify ID token,
|
||||
/// and return the authenticated user.
|
||||
pub async fn resolve_callback(
|
||||
&self,
|
||||
code: AuthorizationCode,
|
||||
nonce: OidcNonce,
|
||||
pkce_verifier: PkceVerifier,
|
||||
) -> Result<OidcUser, OidcError> {
|
||||
let oidc_pkce_verifier =
|
||||
openidconnect::PkceCodeVerifier::new(pkce_verifier.as_ref().to_string());
|
||||
let oidc_nonce = openidconnect::Nonce::new(nonce.as_ref().to_string());
|
||||
|
||||
let token_response = self
|
||||
.client
|
||||
.exchange_code(openidconnect::AuthorizationCode::new(
|
||||
code.as_ref().to_string(),
|
||||
))
|
||||
.map_err(|e| OidcError::TokenExchange(e.to_string()))?
|
||||
.set_pkce_verifier(oidc_pkce_verifier)
|
||||
.request_async(&self.http_client)
|
||||
.await
|
||||
.map_err(|e| OidcError::TokenExchange(e.to_string()))?;
|
||||
|
||||
let id_token = token_response
|
||||
.id_token()
|
||||
.ok_or(OidcError::MissingIdToken)?;
|
||||
|
||||
let mut id_token_verifier = self.client.id_token_verifier().clone();
|
||||
|
||||
if let Some(resource_id) = &self.resource_id {
|
||||
let trusted = resource_id.as_ref().to_string();
|
||||
id_token_verifier =
|
||||
id_token_verifier.set_other_audience_verifier_fn(move |aud| aud.as_str() == trusted);
|
||||
}
|
||||
|
||||
let claims = id_token
|
||||
.claims(&id_token_verifier, &oidc_nonce)
|
||||
.map_err(|e| OidcError::IdTokenVerification(e.to_string()))?;
|
||||
|
||||
// Verify access token hash if present
|
||||
if let Some(expected_hash) = claims.access_token_hash() {
|
||||
let actual_hash = AccessTokenHash::from_token(
|
||||
token_response.access_token(),
|
||||
id_token
|
||||
.signing_alg()
|
||||
.map_err(|e| OidcError::IdTokenVerification(e.to_string()))?,
|
||||
id_token
|
||||
.signing_key(&id_token_verifier)
|
||||
.map_err(|e| OidcError::IdTokenVerification(e.to_string()))?,
|
||||
)
|
||||
.map_err(|e| OidcError::IdTokenVerification(e.to_string()))?;
|
||||
|
||||
if actual_hash != *expected_hash {
|
||||
return Err(OidcError::InvalidAccessTokenHash);
|
||||
}
|
||||
}
|
||||
|
||||
// Get email from ID token or fall back to UserInfo endpoint
|
||||
let email = if let Some(email) = claims.email() {
|
||||
Some(email.as_str().to_string())
|
||||
} else {
|
||||
tracing::debug!("Email missing in ID token, fetching UserInfo");
|
||||
|
||||
let user_info: UserInfoClaims<EmptyAdditionalClaims, CoreGenderClaim> = self
|
||||
.client
|
||||
.user_info(token_response.access_token().clone(), None)
|
||||
.map_err(|e| OidcError::TokenExchange(e.to_string()))?
|
||||
.request_async(&self.http_client)
|
||||
.await
|
||||
.map_err(|e| OidcError::TokenExchange(e.to_string()))?;
|
||||
|
||||
user_info.email().map(|e| e.as_str().to_string())
|
||||
};
|
||||
|
||||
let email = email.ok_or(OidcError::MissingEmail)?;
|
||||
|
||||
Ok(OidcUser {
|
||||
subject: claims.subject().to_string(),
|
||||
email,
|
||||
})
|
||||
}
|
||||
}
|
||||
37
crates/adapters/auth/src/password.rs
Normal file
37
crates/adapters/auth/src/password.rs
Normal file
@@ -0,0 +1,37 @@
|
||||
//! Password hashing adapter using the `password-auth` crate.
|
||||
|
||||
use domain::errors::DomainResult;
|
||||
use domain::ports::AuthService;
|
||||
|
||||
/// Concrete `AuthService` implementation backed by `password-auth`
|
||||
/// (Argon2id by default).
|
||||
pub struct PasswordAuthService;
|
||||
|
||||
impl AuthService for PasswordAuthService {
|
||||
fn hash_password(&self, password: &str) -> DomainResult<String> {
|
||||
Ok(password_auth::generate_hash(password))
|
||||
}
|
||||
|
||||
fn verify_password(&self, password: &str, hash: &str) -> DomainResult<bool> {
|
||||
Ok(password_auth::verify_password(password, hash).is_ok())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn hash_and_verify_round_trip() {
|
||||
let svc = PasswordAuthService;
|
||||
let hash = svc.hash_password("supersecret").unwrap();
|
||||
assert!(svc.verify_password("supersecret", &hash).unwrap());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wrong_password_rejected() {
|
||||
let svc = PasswordAuthService;
|
||||
let hash = svc.hash_password("correct").unwrap();
|
||||
assert!(!svc.verify_password("wrong", &hash).unwrap());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user