1use provcfg::{ClapArgs, Configurable};
2use serde::{Deserialize, Serialize};
3use utoipa::ToSchema;
4
5fn default_scopes() -> Vec<String> {
6 vec![
7 "openid".to_string(),
8 "profile".to_string(),
9 "email".to_string(),
10 ]
11}
12
13fn default_button_text() -> String {
14 "Log in with SSO".to_string()
15}
16
17#[derive(Debug, Deserialize, Serialize, Eq, PartialEq, Clone, Configurable, ClapArgs, ToSchema)]
19#[serde(default)]
20#[configurable(clap_prefix = "oauth2")]
21#[allow(clippy::struct_excessive_bools)]
22pub struct OAuth2 {
23 pub enabled: bool,
25
26 pub issuer_url: Option<String>,
28
29 pub client_id: Option<String>,
31
32 #[serde(skip_serializing)]
34 #[configurable(secret)]
35 pub client_secret: Option<String>,
36
37 #[configurable(env_list)]
39 #[arg(value_delimiter = ',')]
40 pub additional_audiences: Vec<String>,
41
42 #[configurable(env_list)]
44 #[arg(value_delimiter = ',')]
45 pub scopes: Vec<String>,
46
47 pub auto_provision_users: bool,
49
50 pub admin_group_claim: Option<String>,
53
54 pub admin_group_value: Option<String>,
56
57 pub read_only_group_claim: Option<String>,
60
61 pub read_only_group_value: Option<String>,
63
64 pub button_text: String,
66
67 pub enforced: bool,
69
70 pub auto_redirect: bool,
72}
73
74impl Default for OAuth2 {
75 fn default() -> Self {
76 Self {
77 enabled: false,
78 issuer_url: None,
79 client_id: None,
80 client_secret: None,
81 additional_audiences: Vec::new(),
82 scopes: default_scopes(),
83 auto_provision_users: true,
84 admin_group_claim: None,
85 admin_group_value: None,
86 read_only_group_claim: None,
87 read_only_group_value: None,
88 button_text: default_button_text(),
89 enforced: false,
90 auto_redirect: false,
91 }
92 }
93}
94
95impl OAuth2 {
96 pub fn validate(&self) -> Result<(), String> {
99 if self.enforced && !self.enabled {
100 return Err("OAuth2 enforced is set but OAuth2 is not enabled; \
101 this would disable all login. Set oauth2.enabled = true"
102 .to_string());
103 }
104
105 if self.auto_redirect && !self.enforced {
106 return Err("OAuth2 auto_redirect is set but OAuth2 is not enforced; \
107 set oauth2.enforced = true to redirect straight to SSO"
108 .to_string());
109 }
110
111 if !self.enabled {
112 return Ok(());
113 }
114
115 if is_unset(self.issuer_url.as_ref()) {
116 return Err("OAuth2 is enabled but issuer_url is not set".to_string());
117 }
118
119 if is_unset(self.client_id.as_ref()) {
120 return Err("OAuth2 is enabled but client_id is not set".to_string());
121 }
122
123 if self.client_secret.is_none() {
128 return Err("OAuth2 is enabled but client_secret is not set. \
129 Set it via KELLNR_OAUTH2__CLIENT_SECRET environment variable"
130 .to_string());
131 }
132
133 if self.scopes.is_empty() {
134 return Err("OAuth2 scopes cannot be empty".to_string());
135 }
136
137 if !self.scopes.contains(&"openid".to_string()) {
138 return Err("OAuth2 scopes must contain 'openid' for OIDC".to_string());
139 }
140
141 Ok(())
142 }
143
144 pub fn admin_claim(&self) -> Option<ClaimMatch<'_>> {
147 ClaimMatch::new(
148 self.admin_group_claim.as_deref(),
149 self.admin_group_value.as_deref(),
150 )
151 }
152
153 pub fn read_only_claim(&self) -> Option<ClaimMatch<'_>> {
156 ClaimMatch::new(
157 self.read_only_group_claim.as_deref(),
158 self.read_only_group_value.as_deref(),
159 )
160 }
161
162 pub fn warnings(&self) -> Vec<String> {
165 let mut warnings = Vec::new();
166 if !self.enabled {
167 return warnings;
168 }
169 if self.admin_group_claim.is_some() != self.admin_group_value.is_some() {
170 warnings.push(
171 "OAuth2 admin_group_claim and admin_group_value must be set together, \
172 admin status is not synced from the provider"
173 .to_string(),
174 );
175 }
176 if self.read_only_group_claim.is_some() != self.read_only_group_value.is_some() {
177 warnings.push(
178 "OAuth2 read_only_group_claim and read_only_group_value must be set \
179 together, read-only status is not synced from the provider"
180 .to_string(),
181 );
182 }
183 warnings
184 }
185}
186
187#[derive(Debug, Clone, Copy, PartialEq, Eq)]
189pub struct ClaimMatch<'a> {
190 pub claim: &'a str,
192 pub value: &'a str,
194}
195
196impl<'a> ClaimMatch<'a> {
197 fn new(claim: Option<&'a str>, value: Option<&'a str>) -> Option<Self> {
198 Some(Self {
199 claim: claim?,
200 value: value?,
201 })
202 }
203}
204
205fn is_unset(value: Option<&String>) -> bool {
207 value.is_none_or(|v| v.trim().is_empty())
208}
209
210#[cfg(test)]
211mod tests {
212 use super::*;
213
214 #[test]
215 fn test_default_oauth2() {
216 let oauth2 = OAuth2::default();
217 assert!(!oauth2.enabled);
218 assert!(oauth2.issuer_url.is_none());
219 assert!(oauth2.client_id.is_none());
220 assert!(oauth2.client_secret.is_none());
221 assert!(oauth2.additional_audiences.is_empty());
222 assert_eq!(oauth2.scopes, vec!["openid", "profile", "email"]);
223 assert!(oauth2.auto_provision_users);
224 assert_eq!(oauth2.button_text, "Log in with SSO");
225 assert!(!oauth2.enforced);
226 assert!(!oauth2.auto_redirect);
227 }
228
229 #[test]
230 fn test_validate_disabled() {
231 let oauth2 = OAuth2::default();
232 assert!(oauth2.validate().is_ok());
233 }
234
235 #[test]
236 fn test_validate_enabled_missing_issuer() {
237 let oauth2 = OAuth2 {
238 enabled: true,
239 ..Default::default()
240 };
241 let result = oauth2.validate();
242 assert!(result.is_err());
243 assert!(result.unwrap_err().contains("issuer_url"));
244 }
245
246 #[test]
247 fn test_validate_enabled_missing_client_id() {
248 let oauth2 = OAuth2 {
249 enabled: true,
250 issuer_url: Some("https://example.com".to_string()),
251 ..Default::default()
252 };
253 let result = oauth2.validate();
254 assert!(result.is_err());
255 assert!(result.unwrap_err().contains("client_id"));
256 }
257
258 #[test]
259 fn test_validate_enabled_missing_client_secret() {
260 let oauth2 = OAuth2 {
261 enabled: true,
262 issuer_url: Some("https://example.com".to_string()),
263 client_id: Some("client-id".to_string()),
264 ..Default::default()
265 };
266 let result = oauth2.validate();
267 assert!(result.is_err());
268 assert!(result.unwrap_err().contains("client_secret"));
269 }
270
271 #[test]
272 fn test_validate_enabled_valid() {
273 let oauth2 = OAuth2 {
274 enabled: true,
275 issuer_url: Some("https://example.com".to_string()),
276 client_id: Some("client-id".to_string()),
277 client_secret: Some("client-secret".to_string()),
278 ..Default::default()
279 };
280 assert!(oauth2.validate().is_ok());
281 }
282
283 fn enabled() -> OAuth2 {
284 OAuth2 {
285 enabled: true,
286 issuer_url: Some("https://example.com".to_string()),
287 client_id: Some("client-id".to_string()),
288 client_secret: Some("client-secret".to_string()),
289 ..Default::default()
290 }
291 }
292
293 #[test]
294 fn test_validate_empty_required_values() {
295 for oauth2 in [
296 OAuth2 {
297 issuer_url: Some(String::new()),
298 ..enabled()
299 },
300 OAuth2 {
301 client_id: Some(" ".to_string()),
302 ..enabled()
303 },
304 ] {
305 assert!(oauth2.validate().unwrap_err().contains("not set"));
306 }
307 }
308
309 #[test]
310 fn test_validate_accepts_empty_client_secret() {
311 let oauth2 = OAuth2 {
312 client_secret: Some(String::new()),
313 ..enabled()
314 };
315 assert!(oauth2.validate().is_ok());
316 }
317
318 #[test]
319 fn test_warnings_for_half_configured_group_claims() {
320 for oauth2 in [
321 OAuth2 {
322 admin_group_claim: Some("groups".to_string()),
323 ..enabled()
324 },
325 OAuth2 {
326 admin_group_value: Some("admins".to_string()),
327 ..enabled()
328 },
329 OAuth2 {
330 read_only_group_claim: Some("groups".to_string()),
331 ..enabled()
332 },
333 OAuth2 {
334 read_only_group_value: Some("readers".to_string()),
335 ..enabled()
336 },
337 ] {
338 assert!(oauth2.validate().is_ok());
339 let warnings = oauth2.warnings();
340 assert_eq!(1, warnings.len());
341 assert!(warnings[0].contains("set together"));
342 }
343 }
344
345 #[test]
346 fn claim_matches_need_claim_and_value() {
347 let oauth2 = OAuth2 {
348 admin_group_claim: Some("groups".to_string()),
349 admin_group_value: Some("admins".to_string()),
350 read_only_group_claim: Some("roles".to_string()),
351 ..OAuth2::default()
352 };
353 assert_eq!(
354 oauth2.admin_claim(),
355 Some(ClaimMatch {
356 claim: "groups",
357 value: "admins"
358 })
359 );
360 assert_eq!(oauth2.read_only_claim(), None);
361
362 let oauth2 = OAuth2 {
363 read_only_group_value: Some("readers".to_string()),
364 ..OAuth2::default()
365 };
366 assert_eq!(oauth2.admin_claim(), None);
367 assert_eq!(oauth2.read_only_claim(), None);
368 }
369
370 #[test]
371 fn test_validate_complete_group_claims() {
372 let oauth2 = OAuth2 {
373 admin_group_claim: Some("groups".to_string()),
374 admin_group_value: Some("admins".to_string()),
375 read_only_group_claim: Some("groups".to_string()),
376 read_only_group_value: Some("readers".to_string()),
377 ..enabled()
378 };
379 assert!(oauth2.validate().is_ok());
380 assert!(oauth2.warnings().is_empty());
381 }
382
383 #[test]
384 fn test_validate_empty_scopes() {
385 let oauth2 = OAuth2 {
386 enabled: true,
387 issuer_url: Some("https://example.com".to_string()),
388 client_id: Some("client-id".to_string()),
389 client_secret: Some("client-secret".to_string()),
390 scopes: vec![],
391 ..Default::default()
392 };
393 let result = oauth2.validate();
394 assert!(result.is_err());
395 assert!(result.unwrap_err().contains("empty"));
396 }
397
398 #[test]
399 fn test_validate_enforced_without_enabled() {
400 let oauth2 = OAuth2 {
401 enforced: true,
402 ..Default::default()
403 };
404 let result = oauth2.validate();
405 assert!(result.is_err());
406 assert!(result.unwrap_err().contains("enforced"));
407 }
408
409 #[test]
410 fn test_validate_enforced_with_valid_enabled() {
411 let oauth2 = OAuth2 {
412 enabled: true,
413 enforced: true,
414 issuer_url: Some("https://example.com".to_string()),
415 client_id: Some("client-id".to_string()),
416 client_secret: Some("client-secret".to_string()),
417 ..Default::default()
418 };
419 assert!(oauth2.validate().is_ok());
420 }
421
422 #[test]
423 fn test_validate_auto_redirect_without_enforced() {
424 let oauth2 = OAuth2 {
425 enabled: true,
426 auto_redirect: true,
427 issuer_url: Some("https://example.com".to_string()),
428 client_id: Some("client-id".to_string()),
429 client_secret: Some("client-secret".to_string()),
430 ..Default::default()
431 };
432 let result = oauth2.validate();
433 assert!(result.is_err());
434 assert!(result.unwrap_err().contains("auto_redirect"));
435 }
436
437 #[test]
438 fn test_validate_auto_redirect_with_enforced() {
439 let oauth2 = OAuth2 {
440 enabled: true,
441 enforced: true,
442 auto_redirect: true,
443 issuer_url: Some("https://example.com".to_string()),
444 client_id: Some("client-id".to_string()),
445 client_secret: Some("client-secret".to_string()),
446 ..Default::default()
447 };
448 assert!(oauth2.validate().is_ok());
449 }
450
451 #[test]
452 fn test_validate_missing_openid_scope() {
453 let oauth2 = OAuth2 {
454 enabled: true,
455 issuer_url: Some("https://example.com".to_string()),
456 client_id: Some("client-id".to_string()),
457 client_secret: Some("client-secret".to_string()),
458 scopes: vec!["profile".to_string(), "email".to_string()],
459 ..Default::default()
460 };
461 let result = oauth2.validate();
462 assert!(result.is_err());
463 assert!(result.unwrap_err().contains("openid"));
464 }
465}