mockforge_http/auth/
oidc.rs

1//! OpenID Connect (OIDC) simulation support
2//!
3//! This module provides OIDC-compliant endpoints for simulating identity providers,
4//! including discovery documents and JSON Web Key Set (JWKS) endpoints.
5
6use axum::{extract::State, http::StatusCode, response::Json};
7use chrono::{Duration, Utc};
8use jsonwebtoken::{Algorithm, EncodingKey, Header};
9use serde::{Deserialize, Serialize};
10use serde_json::json;
11use std::collections::HashMap;
12use std::sync::Arc;
13use tokio::sync::RwLock;
14
15use crate::auth::state::AuthState;
16use mockforge_core::Error;
17
18/// OIDC configuration
19#[derive(Debug, Clone, Serialize, Deserialize)]
20pub struct OidcConfig {
21    /// Whether OIDC is enabled
22    pub enabled: bool,
23    /// Issuer identifier (e.g., "https://mockforge.example.com")
24    pub issuer: String,
25    /// JWKS configuration
26    pub jwks: JwksConfig,
27    /// Default claims to include in tokens
28    pub claims: ClaimsConfig,
29    /// Multi-tenant configuration
30    pub multi_tenant: Option<MultiTenantConfig>,
31}
32
33/// JWKS (JSON Web Key Set) configuration
34#[derive(Debug, Clone, Serialize, Deserialize)]
35pub struct JwksConfig {
36    /// List of signing keys
37    pub keys: Vec<JwkKey>,
38}
39
40/// JSON Web Key configuration
41#[derive(Debug, Clone, Serialize, Deserialize)]
42pub struct JwkKey {
43    /// Key ID
44    pub kid: String,
45    /// Algorithm (RS256, ES256, HS256, etc.)
46    pub alg: String,
47    /// Public key (PEM format for RSA/ECDSA, base64 for HMAC)
48    pub public_key: String,
49    /// Private key (PEM format for RSA/ECDSA, base64 for HMAC) - optional, used for signing
50    #[serde(skip_serializing)]
51    pub private_key: Option<String>,
52    /// Key type (RSA, EC, oct)
53    pub kty: String,
54    /// Key use (sig, enc)
55    #[serde(default = "default_key_use")]
56    pub use_: String,
57}
58
59fn default_key_use() -> String {
60    "sig".to_string()
61}
62
63/// Claims configuration
64#[derive(Debug, Clone, Serialize, Deserialize)]
65pub struct ClaimsConfig {
66    /// Default claims to include in all tokens
67    pub default: Vec<String>,
68    /// Custom claim templates
69    #[serde(default)]
70    pub custom: HashMap<String, serde_json::Value>,
71}
72
73/// Multi-tenant configuration
74#[derive(Debug, Clone, Serialize, Deserialize)]
75pub struct MultiTenantConfig {
76    /// Whether multi-tenant mode is enabled
77    pub enabled: bool,
78    /// Claim name for organization ID
79    pub org_id_claim: String,
80    /// Claim name for tenant ID
81    pub tenant_id_claim: Option<String>,
82}
83
84impl Default for OidcConfig {
85    fn default() -> Self {
86        Self {
87            enabled: false,
88            issuer: "https://mockforge.example.com".to_string(),
89            jwks: JwksConfig { keys: vec![] },
90            claims: ClaimsConfig {
91                default: vec!["sub".to_string(), "iss".to_string(), "exp".to_string()],
92                custom: HashMap::new(),
93            },
94            multi_tenant: None,
95        }
96    }
97}
98
99/// OIDC discovery document response
100#[derive(Debug, Serialize)]
101pub struct OidcDiscoveryDocument {
102    /// Issuer identifier
103    pub issuer: String,
104    /// Authorization endpoint
105    pub authorization_endpoint: String,
106    /// Token endpoint
107    pub token_endpoint: String,
108    /// UserInfo endpoint
109    pub userinfo_endpoint: String,
110    /// JWKS URI
111    pub jwks_uri: String,
112    /// Supported response types
113    pub response_types_supported: Vec<String>,
114    /// Supported subject types
115    pub subject_types_supported: Vec<String>,
116    /// Supported ID token signing algorithms
117    pub id_token_signing_alg_values_supported: Vec<String>,
118    /// Supported scopes
119    pub scopes_supported: Vec<String>,
120    /// Supported claims
121    pub claims_supported: Vec<String>,
122    /// Supported grant types
123    pub grant_types_supported: Vec<String>,
124}
125
126/// JWKS response
127#[derive(Debug, Serialize)]
128pub struct JwksResponse {
129    /// Array of JSON Web Keys
130    pub keys: Vec<JwkPublicKey>,
131}
132
133/// Public JSON Web Key (for JWKS endpoint - no private key)
134#[derive(Debug, Serialize)]
135pub struct JwkPublicKey {
136    /// Key ID
137    pub kid: String,
138    /// Key type
139    pub kty: String,
140    /// Algorithm
141    pub alg: String,
142    /// Key use
143    #[serde(rename = "use")]
144    pub use_: String,
145    /// RSA modulus (for RSA keys)
146    #[serde(skip_serializing_if = "Option::is_none")]
147    pub n: Option<String>,
148    /// RSA exponent (for RSA keys)
149    #[serde(skip_serializing_if = "Option::is_none")]
150    pub e: Option<String>,
151    /// EC curve (for EC keys)
152    #[serde(skip_serializing_if = "Option::is_none")]
153    pub crv: Option<String>,
154    /// EC X coordinate (for EC keys)
155    #[serde(skip_serializing_if = "Option::is_none")]
156    pub x: Option<String>,
157    /// EC Y coordinate (for EC keys)
158    #[serde(skip_serializing_if = "Option::is_none")]
159    pub y: Option<String>,
160}
161
162/// OIDC state stored in AuthState
163#[derive(Clone)]
164pub struct OidcState {
165    /// OIDC configuration
166    pub config: OidcConfig,
167    /// Active signing keys (indexed by kid)
168    pub signing_keys: Arc<RwLock<HashMap<String, EncodingKey>>>,
169}
170
171impl OidcState {
172    /// Create new OIDC state from configuration
173    pub fn new(config: OidcConfig) -> Result<Self, Error> {
174        let mut signing_keys = HashMap::new();
175
176        // Load signing keys
177        for key in &config.jwks.keys {
178            if let Some(ref private_key) = key.private_key {
179                let encoding_key = match key.alg.as_str() {
180                    "RS256" | "RS384" | "RS512" => {
181                        EncodingKey::from_rsa_pem(private_key.as_bytes()).map_err(|e| {
182                            Error::generic(format!("Failed to load RSA key {}: {}", key.kid, e))
183                        })?
184                    }
185                    "ES256" | "ES384" | "ES512" => EncodingKey::from_ec_pem(private_key.as_bytes())
186                        .map_err(|e| {
187                            Error::generic(format!("Failed to load EC key {}: {}", key.kid, e))
188                        })?,
189                    "HS256" | "HS384" | "HS512" => EncodingKey::from_secret(private_key.as_bytes()),
190                    _ => {
191                        return Err(Error::generic(format!("Unsupported algorithm: {}", key.alg)));
192                    }
193                };
194                signing_keys.insert(key.kid.clone(), encoding_key);
195            }
196        }
197
198        Ok(Self {
199            config,
200            signing_keys: Arc::new(RwLock::new(signing_keys)),
201        })
202    }
203
204    /// Create OIDC state with default configuration for mock server
205    ///
206    /// This creates a basic OIDC configuration suitable for testing and development.
207    /// For production use, load configuration from config files or environment variables.
208    pub fn default_mock() -> Result<Self, Error> {
209        use std::env;
210
211        // Get issuer from environment or use default
212        let issuer = env::var("MOCKFORGE_OIDC_ISSUER").unwrap_or_else(|_| {
213            env::var("MOCKFORGE_BASE_URL")
214                .unwrap_or_else(|_| "https://mockforge.example.com".to_string())
215        });
216
217        // Create default HS256 key for signing (suitable for development/testing)
218        let default_secret = env::var("MOCKFORGE_OIDC_SECRET")
219            .unwrap_or_else(|_| "mockforge-default-secret-key-change-in-production".to_string());
220
221        let default_key = JwkKey {
222            kid: "default".to_string(),
223            alg: "HS256".to_string(),
224            public_key: default_secret.clone(),
225            private_key: Some(default_secret),
226            kty: "oct".to_string(),
227            use_: "sig".to_string(),
228        };
229
230        let config = OidcConfig {
231            enabled: true,
232            issuer,
233            jwks: JwksConfig {
234                keys: vec![default_key],
235            },
236            claims: ClaimsConfig {
237                default: vec!["sub".to_string(), "iss".to_string(), "exp".to_string()],
238                custom: HashMap::new(),
239            },
240            multi_tenant: None,
241        };
242
243        Self::new(config)
244    }
245}
246
247/// Helper function to load OIDC state from configuration
248///
249/// Attempts to load OIDC configuration from:
250/// 1. Environment variables (MOCKFORGE_OIDC_CONFIG, MOCKFORGE_OIDC_ISSUER, etc.)
251/// 2. Config file (if available)
252/// 3. Default mock configuration
253///
254/// Returns None if OIDC is not configured or disabled.
255pub fn load_oidc_state() -> Option<OidcState> {
256    use std::env;
257
258    // Check if OIDC is explicitly disabled
259    if let Ok(disabled) = env::var("MOCKFORGE_OIDC_ENABLED") {
260        if disabled == "false" || disabled == "0" {
261            return None;
262        }
263    }
264
265    // Try to load from environment variable (JSON config)
266    if let Ok(config_json) = env::var("MOCKFORGE_OIDC_CONFIG") {
267        if let Ok(config) = serde_json::from_str::<OidcConfig>(&config_json) {
268            if config.enabled {
269                return OidcState::new(config).ok();
270            }
271            return None;
272        }
273    }
274
275    // Try to load from config file (future enhancement)
276    // For now, use default mock configuration if OIDC is not explicitly disabled
277    OidcState::default_mock().ok()
278}
279
280/// Get OIDC discovery document
281pub async fn get_oidc_discovery() -> Json<OidcDiscoveryDocument> {
282    // Get base URL from environment variable or use default
283    // In production, this would be loaded from configuration
284    let base_url = std::env::var("MOCKFORGE_BASE_URL")
285        .unwrap_or_else(|_| "https://mockforge.example.com".to_string());
286
287    let discovery = OidcDiscoveryDocument {
288        issuer: base_url.clone(),
289        authorization_endpoint: format!("{}/oauth2/authorize", base_url),
290        token_endpoint: format!("{}/oauth2/token", base_url),
291        userinfo_endpoint: format!("{}/oauth2/userinfo", base_url),
292        jwks_uri: format!("{}/.well-known/jwks.json", base_url),
293        response_types_supported: vec![
294            "code".to_string(),
295            "id_token".to_string(),
296            "token id_token".to_string(),
297        ],
298        subject_types_supported: vec!["public".to_string()],
299        id_token_signing_alg_values_supported: vec![
300            "RS256".to_string(),
301            "ES256".to_string(),
302            "HS256".to_string(),
303        ],
304        scopes_supported: vec![
305            "openid".to_string(),
306            "profile".to_string(),
307            "email".to_string(),
308            "address".to_string(),
309            "phone".to_string(),
310        ],
311        claims_supported: vec![
312            "sub".to_string(),
313            "iss".to_string(),
314            "aud".to_string(),
315            "exp".to_string(),
316            "iat".to_string(),
317            "auth_time".to_string(),
318            "nonce".to_string(),
319            "email".to_string(),
320            "email_verified".to_string(),
321            "name".to_string(),
322            "given_name".to_string(),
323            "family_name".to_string(),
324        ],
325        grant_types_supported: vec![
326            "authorization_code".to_string(),
327            "implicit".to_string(),
328            "refresh_token".to_string(),
329            "client_credentials".to_string(),
330        ],
331    };
332
333    Json(discovery)
334}
335
336/// Get JWKS (JSON Web Key Set)
337pub async fn get_jwks() -> Json<JwksResponse> {
338    // Return empty JWKS by default
339    // Use get_jwks_from_state() when OIDC state is available from request context
340    let jwks = JwksResponse { keys: vec![] };
341
342    Json(jwks)
343}
344
345/// Get JWKS from OIDC state
346pub fn get_jwks_from_state(oidc_state: &OidcState) -> Result<JwksResponse, Error> {
347    use crate::auth::jwks_converter::convert_jwk_key_simple;
348
349    let mut public_keys = Vec::new();
350
351    for key in &oidc_state.config.jwks.keys {
352        match convert_jwk_key_simple(key) {
353            Ok(jwk) => public_keys.push(jwk),
354            Err(e) => {
355                tracing::warn!("Failed to convert key {} to JWK format: {}", key.kid, e);
356                // Continue with other keys
357            }
358        }
359    }
360
361    Ok(JwksResponse { keys: public_keys })
362}
363
364/// Generate a signed JWT token with configurable claims
365///
366/// # Arguments
367/// * `claims` - Map of claim names to values
368/// * `kid` - Optional key ID for the signing key
369/// * `algorithm` - Signing algorithm (RS256, ES256, HS256, etc.)
370/// * `encoding_key` - Encoding key for signing
371/// * `expires_in_seconds` - Optional expiration time in seconds from now
372/// * `issuer` - Optional issuer claim
373/// * `audience` - Optional audience claim
374pub fn generate_signed_jwt(
375    mut claims: HashMap<String, serde_json::Value>,
376    kid: Option<String>,
377    algorithm: Algorithm,
378    encoding_key: &EncodingKey,
379    expires_in_seconds: Option<i64>,
380    issuer: Option<String>,
381    audience: Option<String>,
382) -> Result<String, Error> {
383    use chrono::Utc;
384
385    let mut header = Header::new(algorithm);
386    if let Some(kid) = kid {
387        header.kid = Some(kid);
388    }
389
390    // Add standard claims
391    let now = Utc::now();
392    claims.insert("iat".to_string(), json!(now.timestamp()));
393
394    if let Some(exp_seconds) = expires_in_seconds {
395        let exp = now + chrono::Duration::seconds(exp_seconds);
396        claims.insert("exp".to_string(), json!(exp.timestamp()));
397    }
398
399    if let Some(iss) = issuer {
400        claims.insert("iss".to_string(), json!(iss));
401    }
402
403    if let Some(aud) = audience {
404        claims.insert("aud".to_string(), json!(aud));
405    }
406
407    let token = jsonwebtoken::encode(&header, &claims, encoding_key)
408        .map_err(|e| Error::generic(format!("Failed to sign JWT: {}", e)))?;
409
410    Ok(token)
411}
412
413/// Tenant context for multi-tenant token generation
414#[derive(Debug, Clone)]
415pub struct TenantContext {
416    /// Organization ID
417    pub org_id: Option<String>,
418    /// Tenant ID
419    pub tenant_id: Option<String>,
420}
421
422/// Generate a signed JWT token with default claims from OIDC config
423pub fn generate_oidc_token(
424    oidc_state: &OidcState,
425    subject: String,
426    additional_claims: Option<HashMap<String, serde_json::Value>>,
427    expires_in_seconds: Option<i64>,
428    tenant_context: Option<TenantContext>,
429) -> Result<String, Error> {
430    use chrono::Utc;
431    use jsonwebtoken::Algorithm;
432
433    // Start with default claims
434    let mut claims = HashMap::new();
435    claims.insert("sub".to_string(), json!(subject));
436    claims.insert("iss".to_string(), json!(oidc_state.config.issuer.clone()));
437
438    // Add default claims from config
439    for claim_name in &oidc_state.config.claims.default {
440        if !claims.contains_key(claim_name) {
441            // Add standard claim if not already present
442            match claim_name.as_str() {
443                "sub" | "iss" => {} // Already added
444                "exp" => {
445                    let exp_seconds = expires_in_seconds.unwrap_or(3600);
446                    let exp = Utc::now() + chrono::Duration::seconds(exp_seconds);
447                    claims.insert("exp".to_string(), json!(exp.timestamp()));
448                }
449                "iat" => {
450                    claims.insert("iat".to_string(), json!(Utc::now().timestamp()));
451                }
452                _ => {
453                    // Use custom claim value if available
454                    if let Some(value) = oidc_state.config.claims.custom.get(claim_name) {
455                        claims.insert(claim_name.clone(), value.clone());
456                    }
457                }
458            }
459        }
460    }
461
462    // Add custom claims from config
463    for (key, value) in &oidc_state.config.claims.custom {
464        if !claims.contains_key(key) {
465            claims.insert(key.clone(), value.clone());
466        }
467    }
468
469    // Add multi-tenant claims if enabled
470    if let Some(ref mt_config) = oidc_state.config.multi_tenant {
471        if mt_config.enabled {
472            // Get org_id and tenant_id from tenant context or use defaults
473            let org_id = tenant_context
474                .as_ref()
475                .and_then(|ctx| ctx.org_id.clone())
476                .unwrap_or_else(|| "org-default".to_string());
477            let tenant_id = tenant_context
478                .as_ref()
479                .and_then(|ctx| ctx.tenant_id.clone())
480                .or_else(|| Some("tenant-default".to_string()));
481
482            claims.insert(mt_config.org_id_claim.clone(), json!(org_id));
483            if let Some(ref tenant_claim) = mt_config.tenant_id_claim {
484                if let Some(tid) = tenant_id {
485                    claims.insert(tenant_claim.clone(), json!(tid));
486                }
487            }
488        }
489    }
490
491    // Merge additional claims (override defaults)
492    if let Some(additional) = additional_claims {
493        for (key, value) in additional {
494            claims.insert(key, value);
495        }
496    }
497
498    // Get signing key (use first available key for now)
499    let signing_keys = oidc_state.signing_keys.blocking_read();
500    let (kid, encoding_key) = signing_keys
501        .iter()
502        .next()
503        .ok_or_else(|| Error::generic("No signing keys available".to_string()))?;
504
505    // Determine algorithm from key configuration
506    // Default to HS256 if algorithm not specified in key config
507    let algorithm = oidc_state
508        .config
509        .jwks
510        .keys
511        .iter()
512        .find(|k| k.kid == *kid)
513        .and_then(|k| match k.alg.as_str() {
514            "RS256" => Some(Algorithm::RS256),
515            "RS384" => Some(Algorithm::RS384),
516            "RS512" => Some(Algorithm::RS512),
517            "ES256" => Some(Algorithm::ES256),
518            "ES384" => Some(Algorithm::ES384),
519            "HS256" => Some(Algorithm::HS256),
520            "HS384" => Some(Algorithm::HS384),
521            "HS512" => Some(Algorithm::HS512),
522            _ => None,
523        })
524        .unwrap_or(Algorithm::HS256);
525
526    generate_signed_jwt(
527        claims,
528        Some(kid.clone()),
529        algorithm,
530        encoding_key,
531        expires_in_seconds,
532        Some(oidc_state.config.issuer.clone()),
533        None,
534    )
535}
536
537/// Create OIDC router with well-known endpoints
538pub fn oidc_router() -> axum::Router {
539    use axum::{routing::get, Router};
540
541    Router::new()
542        .route("/.well-known/openid-configuration", get(get_oidc_discovery))
543        .route("/.well-known/jwks.json", get(get_jwks))
544}