rskit_auth/oidc/
config.rs1use std::time::Duration;
2
3use jsonwebtoken::Algorithm;
4use reqwest::Url;
5
6use super::error::OidcError;
7
8#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
10#[non_exhaustive]
11pub enum OidcClientType {
12 #[default]
14 Public,
15 Confidential,
17}
18
19#[derive(Debug, Clone)]
21pub struct OidcConfig {
22 pub issuer: String,
24 pub client_id: String,
26 pub redirect_uri: String,
28 pub client_type: OidcClientType,
30 pub audience: Vec<String>,
32 pub allowed_algorithms: Vec<Algorithm>,
34 pub clock_skew: Duration,
36}
37
38impl OidcConfig {
39 #[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 #[must_use]
61 pub fn with_audience(mut self, audience: Vec<String>) -> Self {
62 self.audience = audience;
63 self
64 }
65
66 #[must_use]
68 pub fn with_allowed_algorithms(mut self, algorithms: Vec<Algorithm>) -> Self {
69 self.allowed_algorithms = algorithms;
70 self
71 }
72
73 #[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}