Skip to main content

auth_oidc/
config.rs

1use platform_core::{AppContext, AppError, AppResult};
2use serde::Deserialize;
3use serde_json::Value;
4
5pub const CONFIG_PREFIX: &str = "auth-oidc";
6
7const DEFAULT_CONSOLE_CLIENT_ID: &str = "lenso-console";
8
9#[derive(Debug, Clone, Deserialize, PartialEq, Eq)]
10pub struct OidcConfig {
11    #[serde(default)]
12    pub enabled: bool,
13    #[serde(default)]
14    pub issuer: Option<String>,
15    #[serde(default)]
16    pub console_client_id: Option<String>,
17    #[serde(default)]
18    pub console_redirect_uris: Vec<String>,
19    #[serde(default)]
20    pub jwks: Option<Value>,
21    #[serde(default)]
22    pub id_token_private_key_pem: Option<String>,
23    #[serde(default)]
24    pub id_token_key_id: Option<String>,
25}
26
27#[derive(Debug, Clone, PartialEq, Eq)]
28pub struct ResolvedOidcConfig {
29    pub console_client_id: String,
30    pub console_redirect_uris: Vec<String>,
31    pub issuer: String,
32    pub jwks: Value,
33    pub id_token_private_key_pem: String,
34    pub id_token_key_id: Option<String>,
35}
36
37impl OidcConfig {
38    pub fn from_context(ctx: &AppContext) -> AppResult<Self> {
39        ctx.config.module_local_config(CONFIG_PREFIX)
40    }
41
42    pub fn resolve(&self) -> AppResult<Option<ResolvedOidcConfig>> {
43        if !self.enabled {
44            return Ok(None);
45        }
46
47        let issuer = self
48            .issuer
49            .as_deref()
50            .map(str::trim)
51            .filter(|value| !value.is_empty())
52            .ok_or_else(|| config_error("issuer", "issuer is required"))?
53            .trim_end_matches('/')
54            .to_owned();
55        let jwks = self
56            .jwks
57            .clone()
58            .filter(|value| value.get("keys").and_then(Value::as_array).is_some())
59            .ok_or_else(|| config_error("jwks", "jwks must contain a keys array"))?;
60        let id_token_private_key_pem = self
61            .id_token_private_key_pem
62            .as_deref()
63            .map(str::trim)
64            .filter(|value| !value.is_empty())
65            .ok_or_else(|| {
66                config_error(
67                    "id_token_private_key_pem",
68                    "id_token_private_key_pem is required",
69                )
70            })?
71            .to_owned();
72        let id_token_key_id = self
73            .id_token_key_id
74            .as_deref()
75            .map(str::trim)
76            .filter(|value| !value.is_empty())
77            .map(ToOwned::to_owned);
78        let console_redirect_uris = self
79            .console_redirect_uris
80            .iter()
81            .map(|uri| uri.trim())
82            .filter(|uri| !uri.is_empty())
83            .map(ToOwned::to_owned)
84            .collect::<Vec<_>>();
85        if console_redirect_uris.is_empty()
86            || console_redirect_uris.iter().any(|uri| uri.contains('#'))
87        {
88            return Err(config_error(
89                "console_redirect_uris",
90                "console_redirect_uris must contain at least one redirect URI without a fragment",
91            ));
92        }
93
94        Ok(Some(ResolvedOidcConfig {
95            console_client_id: self
96                .console_client_id
97                .clone()
98                .unwrap_or_else(|| DEFAULT_CONSOLE_CLIENT_ID.to_owned()),
99            console_redirect_uris,
100            issuer,
101            jwks,
102            id_token_private_key_pem,
103            id_token_key_id,
104        }))
105    }
106}
107
108impl Default for OidcConfig {
109    fn default() -> Self {
110        Self {
111            enabled: false,
112            issuer: None,
113            console_client_id: None,
114            console_redirect_uris: Vec::new(),
115            jwks: None,
116            id_token_private_key_pem: None,
117            id_token_key_id: None,
118        }
119    }
120}
121
122fn config_error(field: &str, reason: &str) -> AppError {
123    AppError::validation(
124        "Invalid OIDC provider configuration",
125        vec![platform_core::error::ErrorDetail {
126            field: Some(field.to_owned()),
127            reason: reason.to_owned(),
128        }],
129    )
130}
131
132#[cfg(test)]
133mod tests {
134    use super::*;
135    use serde_json::json;
136
137    #[test]
138    fn resolve_returns_none_when_disabled() {
139        assert!(OidcConfig::default().resolve().unwrap().is_none());
140    }
141
142    #[test]
143    fn resolve_requires_issuer_jwks_and_redirect_uris() {
144        let config = OidcConfig {
145            enabled: true,
146            ..OidcConfig::default()
147        };
148
149        assert!(config.resolve().is_err());
150
151        let config = OidcConfig {
152            enabled: true,
153            issuer: Some("https://example.com/".to_owned()),
154            console_redirect_uris: vec!["https://console.example.com/callback".to_owned()],
155            jwks: Some(json!({"keys": []})),
156            id_token_private_key_pem: Some("test-private-key".to_owned()),
157            ..OidcConfig::default()
158        };
159        let oidc = config.resolve().expect("valid config").unwrap();
160
161        assert_eq!(oidc.issuer, "https://example.com");
162        assert_eq!(oidc.console_client_id, "lenso-console");
163        assert_eq!(
164            oidc.console_redirect_uris,
165            vec!["https://console.example.com/callback".to_owned()]
166        );
167    }
168}