Skip to main content

rskit_auth/oidc/
config.rs

1use std::time::Duration;
2
3use jsonwebtoken::Algorithm;
4use reqwest::Url;
5
6use super::error::OidcError;
7
8/// `OpenID` Connect client type.
9#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
10#[non_exhaustive]
11pub enum OidcClientType {
12    /// Public/native/browser clients. PKCE is mandatory.
13    #[default]
14    Public,
15    /// Confidential server-side clients. PKCE is still recommended.
16    Confidential,
17}
18
19/// OIDC validation and authorization configuration.
20#[derive(Debug, Clone)]
21pub struct OidcConfig {
22    /// Issuer URL (e.g. `https://accounts.google.com`).
23    pub issuer: String,
24    /// Client ID registered with the provider.
25    pub client_id: String,
26    /// Exact redirect URI registered with the provider.
27    pub redirect_uri: String,
28    /// OIDC client type.
29    pub client_type: OidcClientType,
30    /// Accepted audience claim values.
31    pub audience: Vec<String>,
32    /// Accepted ID-token signing algorithms.
33    pub allowed_algorithms: Vec<Algorithm>,
34    /// Clock-skew tolerance for `exp` and `nbf`.
35    pub clock_skew: Duration,
36}
37
38impl OidcConfig {
39    /// Create a new OIDC configuration.
40    #[must_use]
41    pub fn new(
42        issuer: impl Into<String>,
43        client_id: impl Into<String>,
44        redirect_uri: impl Into<String>,
45        client_type: OidcClientType,
46    ) -> Self {
47        let client_id = client_id.into();
48        Self {
49            issuer: issuer.into(),
50            audience: vec![client_id.clone()],
51            client_id,
52            redirect_uri: redirect_uri.into(),
53            client_type,
54            allowed_algorithms: vec![Algorithm::RS256, Algorithm::ES256, Algorithm::EdDSA],
55            clock_skew: Duration::from_secs(30),
56        }
57    }
58
59    /// Override accepted audiences.
60    #[must_use]
61    pub fn with_audience(mut self, audience: Vec<String>) -> Self {
62        self.audience = audience;
63        self
64    }
65
66    /// Override allowed algorithms.
67    #[must_use]
68    pub fn with_allowed_algorithms(mut self, algorithms: Vec<Algorithm>) -> Self {
69        self.allowed_algorithms = algorithms;
70        self
71    }
72
73    /// Override clock-skew tolerance.
74    #[must_use]
75    pub const fn with_clock_skew(mut self, clock_skew: Duration) -> Self {
76        self.clock_skew = clock_skew;
77        self
78    }
79
80    pub(super) fn validate(&self) -> Result<(), OidcError> {
81        if self.clock_skew.as_secs() > 60 {
82            return Err(OidcError::Configuration(
83                "clock skew tolerance must be 60 seconds or less".into(),
84            ));
85        }
86        let issuer = Url::parse(&self.issuer)
87            .map_err(|error| OidcError::Configuration(format!("invalid issuer URL: {error}")))?;
88        let redirect_uri = Url::parse(&self.redirect_uri)
89            .map_err(|error| OidcError::Configuration(format!("invalid redirect URI: {error}")))?;
90
91        if issuer.scheme() != "https" {
92            return Err(OidcError::Configuration(
93                "OIDC issuer must use HTTPS".into(),
94            ));
95        }
96        if !is_allowed_redirect_uri(&redirect_uri) {
97            return Err(OidcError::Configuration(
98                "redirect URI must be HTTPS or a localhost development callback".into(),
99            ));
100        }
101        if self.audience.is_empty() {
102            return Err(OidcError::Configuration(
103                "OIDC audience must not be empty".into(),
104            ));
105        }
106        if self.allowed_algorithms.is_empty() {
107            return Err(OidcError::Configuration(
108                "OIDC allowed algorithms must not be empty".into(),
109            ));
110        }
111        if self
112            .allowed_algorithms
113            .iter()
114            .any(|algorithm| !is_approved_oidc_algorithm(*algorithm))
115        {
116            return Err(OidcError::Configuration(
117                "OIDC allowed algorithms must be asymmetric RS256, ES256, or EdDSA".into(),
118            ));
119        }
120        Ok(())
121    }
122}
123
124const fn is_approved_oidc_algorithm(algorithm: Algorithm) -> bool {
125    matches!(
126        algorithm,
127        Algorithm::RS256 | Algorithm::ES256 | Algorithm::EdDSA
128    )
129}
130
131fn is_allowed_redirect_uri(url: &Url) -> bool {
132    if url.scheme() == "https" {
133        return true;
134    }
135    if url.scheme() == "http"
136        && let Some(host) = url.host_str()
137    {
138        return matches!(host, "localhost" | "127.0.0.1" | "::1");
139    }
140    false
141}
142
143#[cfg(test)]
144mod tests {
145    use std::time::Duration;
146
147    use jsonwebtoken::Algorithm;
148
149    use super::{OidcClientType, OidcConfig};
150
151    #[test]
152    fn oidc_config_rejects_empty_or_symmetric_algorithm_policy() {
153        let empty = OidcConfig::new(
154            "https://issuer.example",
155            "client",
156            "https://app.example/callback",
157            OidcClientType::Public,
158        )
159        .with_allowed_algorithms(Vec::new());
160        assert!(empty.validate().is_err());
161
162        let symmetric = OidcConfig::new(
163            "https://issuer.example",
164            "client",
165            "https://app.example/callback",
166            OidcClientType::Public,
167        )
168        .with_allowed_algorithms(vec![Algorithm::HS256]);
169        assert!(symmetric.validate().is_err());
170    }
171
172    #[test]
173    fn oidc_config_allows_localhost_development_redirects() {
174        let config = OidcConfig::new(
175            "https://issuer.example",
176            "client",
177            "http://localhost:3000/callback",
178            OidcClientType::Public,
179        );
180        assert!(config.validate().is_ok());
181    }
182
183    #[test]
184    fn oidc_config_rejects_invalid_boundaries() {
185        let base = || {
186            OidcConfig::new(
187                "https://issuer.example",
188                "client",
189                "https://app.example/callback",
190                OidcClientType::Confidential,
191            )
192        };
193
194        assert!(
195            OidcConfig::new(
196                "not a url",
197                "client",
198                "https://app.example/callback",
199                OidcClientType::Public,
200            )
201            .validate()
202            .is_err()
203        );
204        assert!(
205            OidcConfig::new(
206                "http://issuer.example",
207                "client",
208                "https://app.example/callback",
209                OidcClientType::Public,
210            )
211            .validate()
212            .is_err()
213        );
214        assert!(
215            OidcConfig::new(
216                "https://issuer.example",
217                "client",
218                "not a redirect",
219                OidcClientType::Public,
220            )
221            .validate()
222            .is_err()
223        );
224        assert!(
225            OidcConfig::new(
226                "https://issuer.example",
227                "client",
228                "http://app.example/callback",
229                OidcClientType::Public,
230            )
231            .validate()
232            .is_err()
233        );
234        assert!(base().with_audience(Vec::new()).validate().is_err());
235        assert!(
236            base()
237                .with_clock_skew(Duration::from_secs(61))
238                .validate()
239                .is_err()
240        );
241        assert!(base().validate().is_ok());
242    }
243}