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}