Skip to main content

kellnr_settings/
oauth2.rs

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/// `OAuth2`/`OpenID` Connect authentication configuration
18#[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    /// Enable `OAuth2`/OIDC authentication
24    pub enabled: bool,
25
26    /// OIDC issuer URL (discovery URL, e.g., `<https://authentik.example.com/application/o/kellnr/>`)
27    pub issuer_url: Option<String>,
28
29    /// `OAuth2` client ID
30    pub client_id: Option<String>,
31
32    /// `OAuth2` client secret (prefer setting via `KELLNR_OAUTH2__CLIENT_SECRET` env var)
33    #[serde(skip_serializing)]
34    #[configurable(secret)]
35    pub client_secret: Option<String>,
36
37    /// Additional audiences accepted in the ID token besides the client ID
38    #[configurable(env_list)]
39    #[arg(value_delimiter = ',')]
40    pub additional_audiences: Vec<String>,
41
42    /// `OAuth2` scopes to request (default: `["openid", "profile", "email"]`)
43    #[configurable(env_list)]
44    #[arg(value_delimiter = ',')]
45    pub scopes: Vec<String>,
46
47    /// Automatically create local user accounts for new `OAuth2` users
48    pub auto_provision_users: bool,
49
50    /// Claim name to check for admin group membership (e.g., "groups"),
51    /// requires `admin_group_value`
52    pub admin_group_claim: Option<String>,
53
54    /// Value in the admin group claim that grants admin privileges (e.g., "kellnr-admins")
55    pub admin_group_value: Option<String>,
56
57    /// Claim name to check for read-only group membership (e.g., "groups"),
58    /// requires `read_only_group_value`
59    pub read_only_group_claim: Option<String>,
60
61    /// Value in the read-only group claim that grants read-only access (e.g., "kellnr-readonly")
62    pub read_only_group_value: Option<String>,
63
64    /// Text displayed on the `OAuth2` login button
65    pub button_text: String,
66
67    /// Enforce SSO-only login, disabling local password login/change/reset and user creation (requires `enabled`)
68    pub enforced: bool,
69
70    /// Skip the login page and redirect straight to the SSO provider (requires `enforced`)
71    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    /// Validate the `OAuth2` configuration
97    /// Returns an error message if the configuration is invalid
98    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        // client_secret may be optional for public clients using PKCE,
124        // but we require it for confidential clients (default use case).
125        // An empty secret is accepted, as some providers ignore it for
126        // public clients.
127        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    /// The claim that grants admin rights, if both `admin_group_claim` and
145    /// `admin_group_value` are set.
146    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    /// The claim that makes a user read-only, if both
154    /// `read_only_group_claim` and `read_only_group_value` are set.
155    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    /// Configuration that is accepted but most likely a mistake, for
163    /// example a group claim without its value, which grants nothing.
164    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/// A value that an ID token claim has to contain to grant a privilege.
188#[derive(Debug, Clone, Copy, PartialEq, Eq)]
189pub struct ClaimMatch<'a> {
190    /// Name of the claim, e.g. `groups`
191    pub claim: &'a str,
192    /// Value the claim has to contain, e.g. `kellnr-admins`
193    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
205/// Whether a required setting is missing or empty.
206fn 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}