use crate::errors::{AuthError, Result};
use crate::security::secure_jwt::SecureJwtValidator;
use crate::server::{DpopManager, MutualTlsManager, PARManager, PrivateKeyJwtManager};
use chrono::{DateTime, Duration, Utc};
use jsonwebtoken::{Algorithm, DecodingKey, EncodingKey, Header, Validation};
use serde::{Deserialize, Serialize};
use serde_json::{Value, json};
use std::collections::HashMap;
use std::sync::Arc;
use tokio::sync::RwLock;
use uuid::Uuid;
#[derive(Debug, Clone)]
pub struct FapiManager {
dpop_manager: Arc<DpopManager>,
mtls_manager: Arc<MutualTlsManager>,
par_manager: Arc<PARManager>,
private_key_jwt_manager: Arc<PrivateKeyJwtManager>,
jwt_validator: Arc<SecureJwtValidator>,
config: FapiConfig,
sessions: Arc<RwLock<HashMap<String, FapiSession>>>,
}
#[derive(Clone)]
pub struct FapiConfig {
pub issuer: String,
pub request_signing_algorithm: Algorithm,
pub response_signing_algorithm: Algorithm,
pub private_key: EncodingKey,
pub public_key: DecodingKey,
pub max_request_age: i64,
pub require_dpop: bool,
pub require_mtls: bool,
pub require_par: bool,
pub enable_jarm: bool,
pub enhanced_audit: bool,
pub is_degraded: bool,
}
impl std::fmt::Debug for FapiConfig {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("FapiConfig")
.field("issuer", &self.issuer)
.field("request_signing_algorithm", &self.request_signing_algorithm)
.field(
"response_signing_algorithm",
&self.response_signing_algorithm,
)
.field("private_key", &"<EncodingKey>")
.field("public_key", &"<DecodingKey>")
.field("max_request_age", &self.max_request_age)
.field("require_dpop", &self.require_dpop)
.field("require_mtls", &self.require_mtls)
.field("require_par", &self.require_par)
.field("enable_jarm", &self.enable_jarm)
.field("enhanced_audit", &self.enhanced_audit)
.field("is_degraded", &self.is_degraded)
.finish()
}
}
impl FapiConfig {
pub fn builder(
issuer: impl Into<String>,
private_key: EncodingKey,
public_key: DecodingKey,
) -> FapiConfigBuilder {
FapiConfigBuilder {
issuer: issuer.into(),
request_signing_algorithm: Algorithm::PS256,
response_signing_algorithm: Algorithm::PS256,
private_key,
public_key,
max_request_age: 60,
require_dpop: true,
require_mtls: true,
require_par: true,
enable_jarm: true,
enhanced_audit: true,
is_degraded: false,
}
}
#[deprecated(since = "0.5.0", note = "use FapiConfig::from_env() instead")]
pub fn load_from_env() -> Self {
Self::from_env()
}
pub fn from_env() -> Self {
Self::default()
}
}
pub struct FapiConfigBuilder {
issuer: String,
request_signing_algorithm: Algorithm,
response_signing_algorithm: Algorithm,
private_key: EncodingKey,
public_key: DecodingKey,
max_request_age: i64,
require_dpop: bool,
require_mtls: bool,
require_par: bool,
enable_jarm: bool,
enhanced_audit: bool,
is_degraded: bool,
}
impl FapiConfigBuilder {
pub fn request_signing_algorithm(mut self, alg: Algorithm) -> Self {
self.request_signing_algorithm = alg;
self
}
pub fn response_signing_algorithm(mut self, alg: Algorithm) -> Self {
self.response_signing_algorithm = alg;
self
}
pub fn max_request_age(mut self, age: i64) -> Self {
self.max_request_age = age;
self
}
pub fn require_dpop(mut self, require: bool) -> Self {
self.require_dpop = require;
self
}
pub fn require_mtls(mut self, require: bool) -> Self {
self.require_mtls = require;
self
}
pub fn require_par(mut self, require: bool) -> Self {
self.require_par = require;
self
}
pub fn enable_jarm(mut self, enable: bool) -> Self {
self.enable_jarm = enable;
self
}
pub fn enhanced_audit(mut self, enable: bool) -> Self {
self.enhanced_audit = enable;
self
}
pub fn degraded(mut self) -> Self {
self.is_degraded = true;
self
}
pub fn build(self) -> FapiConfig {
FapiConfig {
issuer: self.issuer,
request_signing_algorithm: self.request_signing_algorithm,
response_signing_algorithm: self.response_signing_algorithm,
private_key: self.private_key,
public_key: self.public_key,
max_request_age: self.max_request_age,
require_dpop: self.require_dpop,
require_mtls: self.require_mtls,
require_par: self.require_par,
enable_jarm: self.enable_jarm,
enhanced_audit: self.enhanced_audit,
is_degraded: self.is_degraded,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct FapiSession {
pub session_id: String,
pub client_id: String,
pub user_id: String,
pub created_at: DateTime<Utc>,
pub expires_at: DateTime<Utc>,
pub dpop_proof: Option<String>,
pub cert_thumbprint: Option<String>,
pub request_jti: Option<String>,
pub scopes: Vec<String>,
pub metadata: HashMap<String, Value>,
}
impl FapiSession {
pub fn builder(
session_id: impl Into<String>,
client_id: impl Into<String>,
user_id: impl Into<String>,
expires_in: Duration,
) -> FapiSessionBuilder {
let now = Utc::now();
FapiSessionBuilder {
session_id: session_id.into(),
client_id: client_id.into(),
user_id: user_id.into(),
created_at: now,
expires_at: now + expires_in,
dpop_proof: None,
cert_thumbprint: None,
request_jti: None,
scopes: Vec::new(),
metadata: HashMap::new(),
}
}
}
pub struct FapiSessionBuilder {
session_id: String,
client_id: String,
user_id: String,
created_at: DateTime<Utc>,
expires_at: DateTime<Utc>,
dpop_proof: Option<String>,
cert_thumbprint: Option<String>,
request_jti: Option<String>,
scopes: Vec<String>,
metadata: HashMap<String, Value>,
}
impl FapiSessionBuilder {
pub fn dpop_proof(mut self, proof: impl Into<String>) -> Self {
self.dpop_proof = Some(proof.into());
self
}
pub fn cert_thumbprint(mut self, thumbprint: impl Into<String>) -> Self {
self.cert_thumbprint = Some(thumbprint.into());
self
}
pub fn request_jti(mut self, jti: impl Into<String>) -> Self {
self.request_jti = Some(jti.into());
self
}
pub fn add_scope(mut self, scope: impl Into<String>) -> Self {
self.scopes.push(scope.into());
self
}
pub fn add_scopes<I, S>(mut self, scopes: I) -> Self
where
I: IntoIterator<Item = S>,
S: Into<String>,
{
self.scopes.extend(scopes.into_iter().map(Into::into));
self
}
pub fn add_metadata(mut self, key: impl Into<String>, value: Value) -> Self {
self.metadata.insert(key.into(), value);
self
}
pub fn build(self) -> FapiSession {
FapiSession {
session_id: self.session_id,
client_id: self.client_id,
user_id: self.user_id,
created_at: self.created_at,
expires_at: self.expires_at,
dpop_proof: self.dpop_proof,
cert_thumbprint: self.cert_thumbprint,
request_jti: self.request_jti,
scopes: self.scopes,
metadata: self.metadata,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct FapiRequestObject {
pub iss: String,
pub aud: String,
pub iat: i64,
pub exp: i64,
pub nbf: Option<i64>,
pub jti: String,
pub response_type: String,
pub client_id: String,
pub redirect_uri: String,
pub scope: String,
pub state: Option<String>,
pub nonce: Option<String>,
pub code_challenge: Option<String>,
pub code_challenge_method: Option<String>,
#[serde(flatten)]
pub additional_claims: HashMap<String, Value>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct FapiAuthorizationResponse {
pub iss: String,
pub aud: String,
pub iat: i64,
pub exp: i64,
pub code: Option<String>,
pub state: Option<String>,
pub error: Option<String>,
pub error_description: Option<String>,
#[serde(flatten)]
pub additional_params: HashMap<String, Value>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct FapiTokenResponse {
pub access_token: String,
pub token_type: String,
pub expires_in: i64,
pub refresh_token: Option<String>,
pub scope: Option<String>,
pub id_token: Option<String>,
pub cnf: Option<Value>,
}
impl FapiManager {
pub fn new(
config: FapiConfig,
dpop_manager: Arc<DpopManager>,
mtls_manager: Arc<MutualTlsManager>,
par_manager: Arc<PARManager>,
private_key_jwt_manager: Arc<PrivateKeyJwtManager>,
jwt_validator: Arc<SecureJwtValidator>,
) -> Self {
Self {
dpop_manager,
mtls_manager,
par_manager,
private_key_jwt_manager,
jwt_validator,
config,
sessions: Arc::new(RwLock::new(HashMap::new())),
}
}
pub async fn validate_authorization_request(
&self,
request_object: &str,
client_cert: Option<&str>,
dpop_proof: Option<&str>,
request_uri: Option<&str>,
) -> Result<FapiRequestObject> {
let claims = if let Some(uri) = request_uri {
if self.config.require_par {
let par_request = self.par_manager.consume_request(uri).await.map_err(|e| {
AuthError::InvalidRequest(format!("PAR request validation failed: {}", e))
})?;
tracing::info!(
"FAPI PAR request consumed successfully for client: {}",
par_request.client_id
);
let nonce = par_request.additional_params.get("nonce").cloned();
FapiRequestObject {
iss: par_request.client_id.clone(),
aud: self.config.issuer.clone(),
iat: Utc::now().timestamp(),
exp: Utc::now().timestamp() + 300, nbf: Some(Utc::now().timestamp()),
jti: uuid::Uuid::new_v4().to_string(),
response_type: par_request.response_type,
client_id: par_request.client_id,
redirect_uri: par_request.redirect_uri,
scope: par_request.scope.unwrap_or_default(),
state: par_request.state,
nonce,
code_challenge: par_request.code_challenge,
code_challenge_method: par_request.code_challenge_method,
additional_claims: par_request
.additional_params
.into_iter()
.map(|(k, v)| (k, serde_json::Value::String(v)))
.collect(),
}
} else {
return Err(AuthError::InvalidRequest(
"request_uri provided but PAR not required".to_string(),
));
}
} else {
if self.config.require_par {
return Err(AuthError::InvalidRequest(
"PAR is required but no request_uri provided".to_string(),
));
}
self.validate_request_object(request_object).await?
};
if self.config.require_mtls {
let cert = client_cert.ok_or_else(|| {
AuthError::auth_method("mtls", "mTLS certificate required for FAPI 2.0")
})?;
let cert_bytes = cert.as_bytes(); self.mtls_manager
.validate_client_certificate(cert_bytes, &claims.client_id)
.await?;
}
if self.config.require_dpop {
let proof = dpop_proof.ok_or_else(|| {
AuthError::auth_method("dpop", "DPoP proof required for FAPI 2.0")
})?;
self.dpop_manager
.validate_dpop_proof(
proof,
"POST",
&format!("{}/authorize", self.config.issuer),
None,
None,
)
.await?;
}
self.validate_request_claims(&claims).await?;
Ok(claims)
}
async fn validate_request_object(&self, request_object: &str) -> Result<FapiRequestObject> {
let decoding_key = &self.config.public_key;
match self
.jwt_validator
.validate_token(request_object, decoding_key)
{
Ok(secure_claims) => {
let header = jsonwebtoken::decode_header(request_object).map_err(|e| {
AuthError::token(format!("Invalid request object header: {}", e))
})?;
if !matches!(
header.alg,
Algorithm::RS256 | Algorithm::PS256 | Algorithm::ES256
) {
return Err(AuthError::token(
"Request object must use RS256, PS256, or ES256".to_string(),
));
}
let mut validation = Validation::new(header.alg);
validation.set_audience(&[&self.config.issuer]);
validation.validate_exp = true;
validation.validate_nbf = true;
let token_data = jsonwebtoken::decode::<FapiRequestObject>(
request_object,
&self.config.public_key,
&validation,
)
.map_err(|e| {
AuthError::token(format!("Request object validation failed: {}", e))
})?;
let fapi_claims = token_data.claims;
if secure_claims.sub != fapi_claims.client_id {
return Err(AuthError::token(
"Subject mismatch between secure validation and FAPI claims".to_string(),
));
}
if secure_claims.iss != fapi_claims.iss {
return Err(AuthError::token(
"Issuer mismatch between secure validation and FAPI claims".to_string(),
));
}
if secure_claims.exp != fapi_claims.exp {
return Err(AuthError::token(
"Expiry mismatch between secure validation and FAPI claims".to_string(),
));
}
let now = Utc::now().timestamp();
if now - fapi_claims.iat > self.config.max_request_age {
return Err(AuthError::token("Request object too old".to_string()));
}
if fapi_claims.client_id.is_empty() {
return Err(AuthError::token(
"client_id required in request object".to_string(),
));
}
if fapi_claims.redirect_uri.is_empty() {
return Err(AuthError::token(
"redirect_uri required in request object".to_string(),
));
}
if fapi_claims.response_type.is_empty() {
return Err(AuthError::token(
"response_type required in request object".to_string(),
));
}
tracing::info!(
"FAPI request object validated successfully with SecureJwtValidator for client: {}",
fapi_claims.client_id
);
Ok(fapi_claims)
}
Err(e) => {
tracing::error!("SecureJwtValidator failed for FAPI request object: {}", e);
Err(AuthError::token(format!(
"Enhanced JWT validation failed: {}",
e
)))
}
}
}
async fn validate_request_claims(&self, claims: &FapiRequestObject) -> Result<()> {
if !matches!(claims.response_type.as_str(), "code" | "code id_token") {
return Err(AuthError::InvalidRequest(
"FAPI 2.0 requires code or code id_token response type".to_string(),
));
}
if claims.code_challenge.is_none() {
return Err(AuthError::InvalidRequest(
"PKCE required for FAPI 2.0".to_string(),
));
}
if let Some(method) = &claims.code_challenge_method {
if method != "S256" {
return Err(AuthError::InvalidRequest(
"FAPI 2.0 requires S256 code challenge method".to_string(),
));
}
} else {
return Err(AuthError::InvalidRequest(
"code_challenge_method required for FAPI 2.0".to_string(),
));
}
Ok(())
}
pub async fn authenticate_client_jwt(&self, client_assertion: &str) -> Result<String> {
let auth_result = self
.private_key_jwt_manager
.authenticate_client(client_assertion)
.await
.map_err(|e| {
AuthError::auth_method(
"private_key_jwt",
format!("Private key JWT authentication failed: {}", e),
)
})?;
match auth_result.authenticated {
true => {
tracing::info!(
"FAPI client authenticated successfully using private key JWT: {}",
auth_result.client_id
);
Ok(auth_result.client_id)
}
false => {
let error_msg = auth_result.errors.join("; ");
tracing::error!("FAPI private key JWT authentication failed: {}", error_msg);
Err(AuthError::auth_method(
"private_key_jwt",
format!("Authentication failed: {}", error_msg),
))
}
}
}
pub async fn validate_token_request(
&self,
client_assertion: Option<&str>,
client_cert: Option<&str>,
dpop_proof: Option<&str>,
authorization_code: &str,
) -> Result<String> {
let client_id = if let Some(assertion) = client_assertion {
self.authenticate_client_jwt(assertion).await?
} else if self.config.require_mtls {
if let Some(cert) = client_cert {
let cert_bytes = cert.as_bytes();
let client_id = self.extract_client_id_from_certificate(cert_bytes).await?;
self.mtls_manager
.validate_client_certificate(cert_bytes, &client_id)
.await?;
client_id.to_string()
} else {
return Err(AuthError::auth_method(
"mtls",
"Client certificate required for FAPI 2.0 token request",
));
}
} else {
return Err(AuthError::auth_method(
"fapi",
"FAPI 2.0 requires either private_key_jwt or mTLS client authentication",
));
};
if self.config.require_dpop {
let proof = dpop_proof.ok_or_else(|| {
AuthError::auth_method("dpop", "DPoP proof required for FAPI 2.0 token request")
})?;
self.dpop_manager
.validate_dpop_proof(
proof,
"POST",
&format!("{}/token", self.config.issuer),
Some(authorization_code), None,
)
.await?;
}
Ok(client_id)
}
pub async fn generate_authorization_response(
&self,
client_id: &str,
code: Option<&str>,
state: Option<&str>,
error: Option<&str>,
error_description: Option<&str>,
) -> Result<String> {
if !self.config.enable_jarm {
return Err(AuthError::Configuration {
message: "JARM not enabled".to_string(),
help: Some("Enable JARM in your configuration to use this feature".to_string()),
docs_url: Some("https://docs.auth-framework.com/fapi#jarm".to_string()),
source: None,
suggested_fix: Some("Set enable_jarm to true in your FAPIConfig".to_string()),
});
}
let now = Utc::now();
let exp = now + Duration::minutes(5);
let response = FapiAuthorizationResponse {
iss: self.config.issuer.clone(),
aud: client_id.to_string(),
iat: now.timestamp(),
exp: exp.timestamp(),
code: code.map(|c| c.to_string()),
state: state.map(|s| s.to_string()),
error: error.map(|e| e.to_string()),
error_description: error_description.map(|d| d.to_string()),
additional_params: HashMap::new(),
};
let header = Header::new(self.config.response_signing_algorithm);
let token =
jsonwebtoken::encode(&header, &response, &self.config.private_key).map_err(|e| {
AuthError::TokenGeneration(format!("Failed to sign JARM response: {}", e))
})?;
Ok(token)
}
pub async fn generate_token_response(
&self,
client_id: &str,
user_id: &str,
scopes: Vec<String>,
cert_thumbprint: Option<String>,
dpop_jkt: Option<String>,
) -> Result<FapiTokenResponse> {
let access_token = self
.generate_access_token(client_id, user_id, &scopes, &cert_thumbprint, &dpop_jkt)
.await?;
let refresh_token = self.generate_refresh_token(client_id, user_id).await?;
let mut cnf = json!({});
if let Some(thumbprint) = cert_thumbprint {
cnf["x5t#S256"] = Value::String(thumbprint);
}
if let Some(jkt) = dpop_jkt {
cnf["jkt"] = Value::String(jkt);
}
let response = FapiTokenResponse {
access_token,
token_type: "DPoP".to_string(), expires_in: 3600, refresh_token: Some(refresh_token),
scope: Some(scopes.join(" ")),
id_token: None, cnf: if cnf.as_object().map_or(true, |o| o.is_empty()) {
None
} else {
Some(cnf)
},
};
Ok(response)
}
async fn generate_access_token(
&self,
client_id: &str,
user_id: &str,
scopes: &[String],
cert_thumbprint: &Option<String>,
dpop_jkt: &Option<String>,
) -> Result<String> {
let now = Utc::now();
let exp = now + Duration::hours(1);
let mut claims = json!({
"iss": self.config.issuer,
"aud": client_id,
"sub": user_id,
"iat": now.timestamp(),
"exp": exp.timestamp(),
"scope": scopes.join(" "),
"jti": Uuid::new_v4().to_string(),
});
let mut cnf = json!({});
if let Some(thumbprint) = cert_thumbprint {
cnf["x5t#S256"] = Value::String(thumbprint.clone());
}
if let Some(jkt) = dpop_jkt {
cnf["jkt"] = Value::String(jkt.clone());
}
if !cnf.as_object().map_or(true, |o| o.is_empty()) {
claims["cnf"] = cnf;
}
let header = Header::new(Algorithm::RS256);
let token =
jsonwebtoken::encode(&header, &claims, &self.config.private_key).map_err(|e| {
AuthError::TokenGeneration(format!("Failed to generate access token: {}", e))
})?;
Ok(token)
}
async fn generate_refresh_token(&self, client_id: &str, user_id: &str) -> Result<String> {
let now = Utc::now();
let exp = now + Duration::days(30);
let claims = json!({
"iss": self.config.issuer,
"aud": client_id,
"sub": user_id,
"iat": now.timestamp(),
"exp": exp.timestamp(),
"typ": "refresh_token",
"jti": Uuid::new_v4().to_string(),
});
let header = Header::new(Algorithm::RS256);
let token =
jsonwebtoken::encode(&header, &claims, &self.config.private_key).map_err(|e| {
AuthError::TokenGeneration(format!("Failed to generate refresh token: {}", e))
})?;
Ok(token)
}
pub async fn create_session(
&self,
client_id: &str,
user_id: &str,
scopes: Vec<String>,
dpop_proof: Option<String>,
cert_thumbprint: Option<String>,
request_jti: Option<String>,
) -> Result<String> {
let session_id = Uuid::new_v4().to_string();
let now = Utc::now();
let expires_at = now + Duration::hours(24);
let session = FapiSession {
session_id: session_id.clone(),
client_id: client_id.to_string(),
user_id: user_id.to_string(),
created_at: now,
expires_at,
dpop_proof,
cert_thumbprint,
request_jti,
scopes,
metadata: HashMap::new(),
};
let mut sessions = self.sessions.write().await;
sessions.insert(session_id.clone(), session);
Ok(session_id)
}
pub async fn get_session(&self, session_id: &str) -> Result<Option<FapiSession>> {
let sessions = self.sessions.read().await;
Ok(sessions.get(session_id).cloned())
}
pub async fn validate_session(&self, session_id: &str) -> Result<FapiSession> {
let session = self
.get_session(session_id)
.await?
.ok_or_else(|| AuthError::validation("Session not found".to_string()))?;
if Utc::now() > session.expires_at {
return Err(AuthError::validation("Session expired".to_string()));
}
Ok(session)
}
pub async fn remove_session(&self, session_id: &str) -> Result<()> {
let mut sessions = self.sessions.write().await;
sessions.remove(session_id);
Ok(())
}
pub async fn audit_log(&self, event: &str, details: &Value) -> Result<()> {
if self.config.enhanced_audit {
let timestamp = chrono::Utc::now().to_rfc3339();
let audit_entry = format!("[{}] FAPI AUDIT: {} - {}", timestamp, event, details);
tracing::info!("{}", audit_entry);
}
Ok(())
}
async fn extract_client_id_from_certificate(&self, cert_bytes: &[u8]) -> Result<String> {
let cert_str = String::from_utf8_lossy(cert_bytes);
if let Some(cn_start) = cert_str.find("CN=") {
let cn_section = &cert_str[cn_start + 3..];
if let Some(cn_end) = cn_section.find(',').or_else(|| cn_section.find('\n')) {
let client_id = cn_section[..cn_end].trim().to_string();
if !client_id.is_empty() {
tracing::info!("Extracted client ID from certificate CN: {}", client_id);
return Ok(client_id);
}
}
}
if let Some(san_start) = cert_str.find("DNS:") {
let san_section = &cert_str[san_start + 4..];
if let Some(san_end) = san_section.find(',').or_else(|| san_section.find('\n')) {
let client_id = san_section[..san_end].trim().to_string();
if !client_id.is_empty() && client_id.contains("client") {
tracing::info!("Extracted client ID from certificate SAN: {}", client_id);
return Ok(client_id);
}
}
}
use sha2::{Digest, Sha256};
let cert_hash = format!("cert_client_{}", hex::encode(Sha256::digest(cert_bytes)));
tracing::info!(
"Generated hash-based client ID from certificate: {}",
cert_hash
);
Ok(cert_hash)
}
pub fn check_compliance(&self) -> Vec<FapiComplianceViolation> {
let mut violations = Vec::new();
if self.config.is_degraded {
violations.push(FapiComplianceViolation {
requirement: "crypto-keys".to_string(),
severity: FapiViolationSeverity::Critical,
message: "RSA key pair not properly configured; all FAPI operations will fail"
.to_string(),
});
}
if !self.config.require_par {
violations.push(FapiComplianceViolation {
requirement: "par".to_string(),
severity: FapiViolationSeverity::Critical,
message: "FAPI 2.0 Security Profile requires Pushed Authorization Requests"
.to_string(),
});
}
if !self.config.require_dpop {
violations.push(FapiComplianceViolation {
requirement: "sender-constraint".to_string(),
severity: FapiViolationSeverity::Warning,
message: "DPoP is recommended for sender-constrained tokens".to_string(),
});
}
if !self.config.require_mtls {
violations.push(FapiComplianceViolation {
requirement: "sender-constraint".to_string(),
severity: FapiViolationSeverity::Warning,
message: "mTLS is recommended for client authentication and token binding"
.to_string(),
});
}
if !self.config.require_dpop && !self.config.require_mtls {
violations.push(FapiComplianceViolation {
requirement: "sender-constraint".to_string(),
severity: FapiViolationSeverity::Critical,
message:
"FAPI 2.0 requires at least one sender-constraining mechanism (DPoP or mTLS)"
.to_string(),
});
}
if !self.config.enable_jarm {
violations.push(FapiComplianceViolation {
requirement: "jarm".to_string(),
severity: FapiViolationSeverity::Warning,
message: "JARM is recommended for authorization response integrity".to_string(),
});
}
if !self.config.enhanced_audit {
violations.push(FapiComplianceViolation {
requirement: "audit".to_string(),
severity: FapiViolationSeverity::Warning,
message: "Enhanced audit logging is recommended for FAPI compliance".to_string(),
});
}
if !matches!(
self.config.request_signing_algorithm,
Algorithm::RS256 | Algorithm::PS256 | Algorithm::ES256
) {
violations.push(FapiComplianceViolation {
requirement: "algorithm".to_string(),
severity: FapiViolationSeverity::Critical,
message: "Request signing must use RS256, PS256, or ES256".to_string(),
});
}
if self.config.max_request_age > 600 {
violations.push(FapiComplianceViolation {
requirement: "request-lifetime".to_string(),
severity: FapiViolationSeverity::Warning,
message: "Max request age exceeds recommended 10 minutes".to_string(),
});
}
violations
}
pub fn is_compliant(&self) -> bool {
!self
.check_compliance()
.iter()
.any(|v| matches!(v.severity, FapiViolationSeverity::Critical))
}
pub async fn validate_sender_constrained_token(
&self,
access_token: &str,
dpop_proof: Option<&str>,
client_cert: Option<&str>,
http_method: &str,
http_uri: &str,
) -> Result<serde_json::Value> {
let token_data = self
.jwt_validator
.validate_token(access_token, &self.config.public_key)?;
let header = jsonwebtoken::decode_header(access_token)
.map_err(|e| AuthError::token(format!("Invalid token header: {}", e)))?;
let mut validation = Validation::new(header.alg);
validation.set_audience(&[&self.config.issuer]);
validation.validate_exp = true;
let raw_claims = jsonwebtoken::decode::<serde_json::Value>(
access_token,
&self.config.public_key,
&validation,
)
.map_err(|e| AuthError::token(format!("Token decode failed: {}", e)))?;
let cnf = raw_claims.claims.get("cnf").ok_or_else(|| {
AuthError::token("Token is not sender-constrained (missing cnf claim)".to_string())
})?;
if let Some(expected_jkt) = cnf.get("jkt").and_then(|v| v.as_str()) {
let proof = dpop_proof.ok_or_else(|| {
AuthError::token(
"Token is DPoP-bound but no DPoP proof header provided".to_string(),
)
})?;
self.dpop_manager
.validate_dpop_proof(proof, http_method, http_uri, None, Some(expected_jkt))
.await?;
}
if let Some(expected_thumbprint) = cnf.get("x5t#S256").and_then(|v| v.as_str()) {
let cert = client_cert.ok_or_else(|| {
AuthError::token(
"Token is certificate-bound but no client certificate provided".to_string(),
)
})?;
use base64::Engine;
use sha2::{Digest, Sha256};
let presented_thumbprint = base64::engine::general_purpose::URL_SAFE_NO_PAD
.encode(Sha256::digest(cert.as_bytes()));
if !bool::from(subtle::ConstantTimeEq::ct_eq(
presented_thumbprint.as_bytes(),
expected_thumbprint.as_bytes(),
)) {
return Err(AuthError::token(
"Certificate thumbprint does not match token binding".to_string(),
));
}
}
Ok(serde_json::json!({
"sub": token_data.sub,
"iss": token_data.iss,
"exp": token_data.exp,
"scope": token_data.scope,
}))
}
pub fn validate_authorization_details(
&self,
authorization_details: &[AuthorizationDetail],
) -> Result<()> {
if authorization_details.is_empty() {
return Err(AuthError::InvalidRequest(
"authorization_details must not be empty".to_string(),
));
}
for (i, detail) in authorization_details.iter().enumerate() {
if detail.r#type.is_empty() {
return Err(AuthError::InvalidRequest(format!(
"authorization_details[{}]: type is required",
i
)));
}
for location in &detail.locations {
if !location.starts_with("https://") {
return Err(AuthError::InvalidRequest(format!(
"authorization_details[{}]: location must use HTTPS: {}",
i, location
)));
}
}
for action in &detail.actions {
if action.is_empty() {
return Err(AuthError::InvalidRequest(format!(
"authorization_details[{}]: empty action not allowed",
i
)));
}
}
}
Ok(())
}
}
#[derive(Debug, Clone)]
pub struct FapiComplianceViolation {
pub requirement: String,
pub severity: FapiViolationSeverity,
pub message: String,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum FapiViolationSeverity {
Critical,
Warning,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AuthorizationDetail {
pub r#type: String,
#[serde(default)]
pub locations: Vec<String>,
#[serde(default)]
pub actions: Vec<String>,
#[serde(default)]
pub datatypes: Vec<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub identifier: Option<String>,
#[serde(flatten)]
pub additional_fields: HashMap<String, Value>,
}
impl Default for FapiConfig {
fn default() -> Self {
let issuer =
std::env::var("FAPI_ISSUER").unwrap_or_else(|_| "https://auth.example.com".to_string());
let mut is_degraded = false;
let private_key = if let Ok(key_path) = std::env::var("FAPI_PRIVATE_KEY_PATH") {
std::fs::read(&key_path)
.map_err(|e| tracing::warn!("Failed to load private key from {}: {}", key_path, e))
.and_then(|bytes| {
EncodingKey::from_rsa_pem(&bytes)
.map_err(|e| tracing::warn!("Invalid RSA key format: {}", e))
})
.unwrap_or_else(|_| {
tracing::error!(
"SECURITY CRITICAL: FAPI_PRIVATE_KEY_PATH is set but the key could not \
be loaded. FAPI REQUIRES an RSA private key for request/response signing. \
Using an ephemeral HMAC placeholder — ALL FAPI OPERATIONS WILL BE REJECTED \
until a valid RSA key is provided. Set FAPI_PRIVATE_KEY_PATH to a valid \
PEM-encoded RSA private key file."
);
is_degraded = true;
use ring::rand::{SecureRandom, SystemRandom};
let rng = SystemRandom::new();
let mut bytes = [0u8; 32];
rng.fill(&mut bytes).expect("AuthFramework fatal: system CSPRNG unavailable — the operating system cannot provide cryptographic randomness");
EncodingKey::from_secret(&bytes)
})
} else {
tracing::error!(
"SECURITY CRITICAL: FAPI_PRIVATE_KEY_PATH not set. FAPI REQUIRES an RSA \
private key for request and response signing. Using an ephemeral HMAC \
placeholder — ALL FAPI OPERATIONS WILL BE REJECTED until a valid RSA key \
is provided. Set FAPI_PRIVATE_KEY_PATH to a PEM-encoded RSA private key file."
);
is_degraded = true;
use ring::rand::{SecureRandom, SystemRandom};
let rng = SystemRandom::new();
let mut bytes = [0u8; 32];
rng.fill(&mut bytes).expect("AuthFramework fatal: system CSPRNG unavailable — the operating system cannot provide cryptographic randomness");
EncodingKey::from_secret(&bytes)
};
let public_key = if let Ok(key_path) = std::env::var("FAPI_PUBLIC_KEY_PATH") {
std::fs::read(&key_path)
.map_err(|e| tracing::warn!("Failed to load public key from {}: {}", key_path, e))
.and_then(|bytes| {
DecodingKey::from_rsa_pem(&bytes)
.map_err(|e| tracing::warn!("Invalid RSA public key format: {}", e))
})
.unwrap_or_else(|_| {
tracing::error!(
"SECURITY CRITICAL: FAPI_PUBLIC_KEY_PATH is set but the key could not \
be loaded. FAPI verification will not work correctly."
);
is_degraded = true;
use ring::rand::{SecureRandom, SystemRandom};
let rng = SystemRandom::new();
let mut secret = [0u8; 32];
rng.fill(&mut secret).expect("AuthFramework fatal: system CSPRNG unavailable — the operating system cannot provide cryptographic randomness");
DecodingKey::from_secret(&secret)
})
} else {
tracing::error!(
"SECURITY CRITICAL: FAPI_PUBLIC_KEY_PATH not set. FAPI verification will \
not work correctly. Set FAPI_PUBLIC_KEY_PATH to a PEM-encoded RSA public key file."
);
is_degraded = true;
use ring::rand::{SecureRandom, SystemRandom};
let rng = SystemRandom::new();
let mut secret = [0u8; 32];
rng.fill(&mut secret).expect("AuthFramework fatal: system CSPRNG unavailable — the operating system cannot provide cryptographic randomness");
DecodingKey::from_secret(&secret)
};
Self {
issuer,
request_signing_algorithm: Algorithm::RS256,
response_signing_algorithm: Algorithm::RS256,
private_key,
public_key,
max_request_age: 300, require_dpop: true,
require_mtls: true,
require_par: true,
enable_jarm: true,
enhanced_audit: true,
is_degraded,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_fapi_config_builder() {
use ring::rand::{SecureRandom, SystemRandom};
let rng = SystemRandom::new();
let mut bytes = [0u8; 32];
rng.fill(&mut bytes).unwrap();
let private_key = EncodingKey::from_secret(&bytes);
let public_key = DecodingKey::from_secret(&bytes);
let config = FapiConfig::builder("https://fapi.example.com", private_key, public_key)
.request_signing_algorithm(Algorithm::ES256)
.max_request_age(120)
.require_dpop(false)
.degraded()
.build();
assert_eq!(config.issuer, "https://fapi.example.com");
assert_eq!(config.request_signing_algorithm, Algorithm::ES256);
assert_eq!(config.max_request_age, 120);
assert!(!config.require_dpop);
assert!(config.require_mtls); assert!(config.is_degraded);
}
#[test]
fn test_fapi_session_builder() {
let session = FapiSession::builder("sess_123", "client_456", "user_789", Duration::try_hours(1).unwrap())
.dpop_proof("proof_abc")
.add_scope("openid")
.add_scopes(vec!["profile", "email"])
.add_metadata("custom_flag", json!(true))
.build();
assert_eq!(session.session_id, "sess_123");
assert_eq!(session.client_id, "client_456");
assert_eq!(session.user_id, "user_789");
assert_eq!(session.dpop_proof.as_deref(), Some("proof_abc"));
assert_eq!(session.scopes, vec!["openid", "profile", "email"]);
assert_eq!(session.metadata["custom_flag"], true);
}
#[tokio::test]
async fn test_fapi_manager_creation() {
let config = FapiConfig::default();
assert_eq!(config.issuer, "https://auth.example.com"); assert!(config.require_dpop);
assert!(config.require_mtls);
assert!(config.require_par);
assert!(config.enable_jarm);
assert!(config.enhanced_audit);
}
#[tokio::test]
async fn test_fapi_request_validation() {
let config = FapiConfig::default();
let request_object = r#"{"iss":"client_id","aud":"https://example.com","exp":9999999999,"nbf":1000000000,"iat":1000000000,"jti":"unique_id"}"#;
let validation_result = validate_fapi_request_object(request_object, &config);
assert!(
validation_result.is_ok(),
"FAPI request object validation failed"
);
assert!(!request_object.is_empty());
}
fn validate_fapi_request_object(
request_object: &str,
_config: &FapiConfig,
) -> Result<(), String> {
let parsed: serde_json::Value = serde_json::from_str(request_object)
.map_err(|_| "Invalid JSON structure in request object")?;
let required_claims = ["iss", "aud", "exp", "iat", "jti"];
for claim in &required_claims {
if parsed.get(claim).is_none() {
return Err(format!("Missing required FAPI claim: {}", claim));
}
}
if let Some(exp) = parsed.get("exp").and_then(|v| v.as_i64()) {
let now = chrono::Utc::now().timestamp();
if exp <= now {
return Err("Request object has expired".to_string());
}
}
Ok(())
}
#[tokio::test]
async fn test_fapi_response_generation() {
let config = FapiConfig::default();
let auth_response = serde_json::json!({
"code": "auth_code_123",
"state": "client_state",
"iss": config.issuer,
"aud": "client_id",
"exp": 9999999999i64
});
assert!(auth_response["code"].is_string());
}
#[tokio::test]
async fn test_fapi_token_generation() {
let config = FapiConfig::default();
let scopes = ["accounts".to_string(), "payments".to_string()];
let client_id = "fapi_client_123";
let user_id = "user_456";
let cert_thumbprint = Some("sha256_cert_thumbprint".to_string());
assert!(config.require_dpop);
assert!(config.require_mtls);
assert!(!scopes.is_empty());
assert!(!client_id.is_empty());
assert!(!user_id.is_empty());
assert!(cert_thumbprint.is_some());
}
#[tokio::test]
async fn test_fapi_session_management() {
let config = FapiConfig::default();
let session_data = serde_json::json!({
"client_id": "fapi_client",
"user_id": "fapi_user",
"scopes": ["accounts", "payments"],
"mtls_cert": "client_certificate",
"dpop_key": "client_dpop_key"
});
assert!(session_data["mtls_cert"].is_string());
assert!(session_data["dpop_key"].is_string());
assert!(config.enhanced_audit);
}
}