Skip to main content

kellnr_settings/
oauth2.rs

1use clap_serde_derive::ClapSerde;
2use serde::{Deserialize, Serialize};
3
4fn default_scopes() -> Vec<String> {
5    vec![
6        "openid".to_string(),
7        "profile".to_string(),
8        "email".to_string(),
9    ]
10}
11
12fn default_button_text() -> String {
13    "Login with SSO".to_string()
14}
15
16/// `OAuth2`/`OpenID` Connect authentication configuration
17#[derive(Debug, Deserialize, Serialize, Eq, PartialEq, Clone, ClapSerde)]
18#[serde(default)]
19pub struct OAuth2 {
20    /// Enable `OAuth2`/OIDC authentication
21    #[default(false)]
22    #[arg(id = "oauth2-enabled", long = "oauth2-enabled")]
23    pub enabled: bool,
24
25    /// OIDC issuer URL (discovery URL, e.g., `<https://authentik.example.com/application/o/kellnr/>`)
26    #[default(None)]
27    #[arg(id = "oauth2-issuer-url", long = "oauth2-issuer-url")]
28    pub issuer_url: Option<String>,
29
30    /// `OAuth2` client ID
31    #[default(None)]
32    #[arg(id = "oauth2-client-id", long = "oauth2-client-id")]
33    pub client_id: Option<String>,
34
35    /// `OAuth2` client secret (prefer setting via `KELLNR_OAUTH2__CLIENT_SECRET` env var)
36    #[default(None)]
37    #[serde(skip_serializing)]
38    #[arg(id = "oauth2-client-secret", long = "oauth2-client-secret")]
39    pub client_secret: Option<String>,
40
41    /// `OAuth2` scopes to request (default: `["openid", "profile", "email"]`)
42    #[default(default_scopes())]
43    #[arg(id = "oauth2-scopes", long = "oauth2-scopes")]
44    pub scopes: Vec<String>,
45
46    /// Automatically create local user accounts for new `OAuth2` users
47    #[default(true)]
48    #[arg(
49        id = "oauth2-auto-provision-users",
50        long = "oauth2-auto-provision-users"
51    )]
52    pub auto_provision_users: bool,
53
54    /// Claim name to check for admin group membership (e.g., "groups")
55    #[default(None)]
56    #[arg(id = "oauth2-admin-group-claim", long = "oauth2-admin-group-claim")]
57    pub admin_group_claim: Option<String>,
58
59    /// Value in the admin group claim that grants admin privileges (e.g., "kellnr-admins")
60    #[default(None)]
61    #[arg(id = "oauth2-admin-group-value", long = "oauth2-admin-group-value")]
62    pub admin_group_value: Option<String>,
63
64    /// Claim name to check for read-only group membership (e.g., "groups")
65    #[default(None)]
66    #[arg(
67        id = "oauth2-read-only-group-claim",
68        long = "oauth2-read-only-group-claim"
69    )]
70    pub read_only_group_claim: Option<String>,
71
72    /// Value in the read-only group claim that grants read-only access (e.g., "kellnr-readonly")
73    #[default(None)]
74    #[arg(
75        id = "oauth2-read-only-group-value",
76        long = "oauth2-read-only-group-value"
77    )]
78    pub read_only_group_value: Option<String>,
79
80    /// Text displayed on the `OAuth2` login button
81    #[default(default_button_text())]
82    #[arg(id = "oauth2-button-text", long = "oauth2-button-text")]
83    pub button_text: String,
84}
85
86impl OAuth2 {
87    /// Validate the `OAuth2` configuration
88    /// Returns an error message if the configuration is invalid
89    pub fn validate(&self) -> Result<(), String> {
90        if !self.enabled {
91            return Ok(());
92        }
93
94        if self.issuer_url.is_none() {
95            return Err("OAuth2 is enabled but issuer_url is not set".to_string());
96        }
97
98        if self.client_id.is_none() {
99            return Err("OAuth2 is enabled but client_id is not set".to_string());
100        }
101
102        // client_secret may be optional for public clients using PKCE,
103        // but we require it for confidential clients (default use case)
104        if self.client_secret.is_none() {
105            return Err("OAuth2 is enabled but client_secret is not set. \
106                Set it via KELLNR_OAUTH2__CLIENT_SECRET environment variable"
107                .to_string());
108        }
109
110        if self.scopes.is_empty() {
111            return Err("OAuth2 scopes cannot be empty".to_string());
112        }
113
114        if !self.scopes.contains(&"openid".to_string()) {
115            return Err("OAuth2 scopes must contain 'openid' for OIDC".to_string());
116        }
117
118        Ok(())
119    }
120}
121
122#[cfg(test)]
123mod tests {
124    use super::*;
125
126    #[test]
127    fn test_default_oauth2() {
128        let oauth2 = OAuth2::default();
129        assert!(!oauth2.enabled);
130        assert!(oauth2.issuer_url.is_none());
131        assert!(oauth2.client_id.is_none());
132        assert!(oauth2.client_secret.is_none());
133        assert_eq!(oauth2.scopes, vec!["openid", "profile", "email"]);
134        assert!(oauth2.auto_provision_users);
135        assert_eq!(oauth2.button_text, "Login with SSO");
136    }
137
138    #[test]
139    fn test_validate_disabled() {
140        let oauth2 = OAuth2::default();
141        assert!(oauth2.validate().is_ok());
142    }
143
144    #[test]
145    fn test_validate_enabled_missing_issuer() {
146        let oauth2 = OAuth2 {
147            enabled: true,
148            ..Default::default()
149        };
150        let result = oauth2.validate();
151        assert!(result.is_err());
152        assert!(result.unwrap_err().contains("issuer_url"));
153    }
154
155    #[test]
156    fn test_validate_enabled_missing_client_id() {
157        let oauth2 = OAuth2 {
158            enabled: true,
159            issuer_url: Some("https://example.com".to_string()),
160            ..Default::default()
161        };
162        let result = oauth2.validate();
163        assert!(result.is_err());
164        assert!(result.unwrap_err().contains("client_id"));
165    }
166
167    #[test]
168    fn test_validate_enabled_missing_client_secret() {
169        let oauth2 = OAuth2 {
170            enabled: true,
171            issuer_url: Some("https://example.com".to_string()),
172            client_id: Some("client-id".to_string()),
173            ..Default::default()
174        };
175        let result = oauth2.validate();
176        assert!(result.is_err());
177        assert!(result.unwrap_err().contains("client_secret"));
178    }
179
180    #[test]
181    fn test_validate_enabled_valid() {
182        let oauth2 = OAuth2 {
183            enabled: true,
184            issuer_url: Some("https://example.com".to_string()),
185            client_id: Some("client-id".to_string()),
186            client_secret: Some("client-secret".to_string()),
187            ..Default::default()
188        };
189        assert!(oauth2.validate().is_ok());
190    }
191
192    #[test]
193    fn test_validate_empty_scopes() {
194        let oauth2 = OAuth2 {
195            enabled: true,
196            issuer_url: Some("https://example.com".to_string()),
197            client_id: Some("client-id".to_string()),
198            client_secret: Some("client-secret".to_string()),
199            scopes: vec![],
200            ..Default::default()
201        };
202        let result = oauth2.validate();
203        assert!(result.is_err());
204        assert!(result.unwrap_err().contains("empty"));
205    }
206
207    #[test]
208    fn test_validate_missing_openid_scope() {
209        let oauth2 = OAuth2 {
210            enabled: true,
211            issuer_url: Some("https://example.com".to_string()),
212            client_id: Some("client-id".to_string()),
213            client_secret: Some("client-secret".to_string()),
214            scopes: vec!["profile".to_string(), "email".to_string()],
215            ..Default::default()
216        };
217        let result = oauth2.validate();
218        assert!(result.is_err());
219        assert!(result.unwrap_err().contains("openid"));
220    }
221}