1use std::{sync::Arc, time::Duration};
5
6use data_encoding::{BASE64, BASE64URL_NOPAD};
7use serde::Deserialize;
8use sha2::{Digest, Sha256};
9
10use super::ProviderIdentity;
11use crate::Text;
12
13const REQUEST_TIMEOUT: Duration = Duration::from_secs(10);
15
16const MAX_RESPONSE_BYTES: u64 = 256 * 1024;
19
20const CLOCK_SKEW_SECS: i64 = 300;
23
24const SCOPES: &str = "openid email profile";
26
27pub(super) const RESERVED_PARAMS: [&str; 8] = [
29 "response_type",
30 "client_id",
31 "redirect_uri",
32 "scope",
33 "state",
34 "nonce",
35 "code_challenge",
36 "code_challenge_method",
37];
38
39pub struct OidcProvider {
55 pub(super) key: String,
56 pub(super) label: Text,
57 issuer: String,
58 issuers: Vec<String>,
61 client_id: String,
62 client_secret: String,
63 scopes: Vec<String>,
65 pub(super) params: Vec<(String, String)>,
67 endpoints: Arc<tokio::sync::OnceCell<Endpoints>>,
68}
69
70impl OidcProvider {
71 #[must_use]
75 pub fn google(client_id: impl Into<String>, client_secret: impl Into<String>) -> Self {
76 let provider = Self::new(
77 "google",
78 "Google",
79 "https://accounts.google.com",
80 client_id,
81 client_secret,
82 );
83 let endpoints = Endpoints {
85 authorization: "https://accounts.google.com/o/oauth2/v2/auth".to_string(),
86 token: "https://oauth2.googleapis.com/token".to_string(),
87 };
88 Self {
89 issuers: vec![
91 "https://accounts.google.com".to_string(),
92 "accounts.google.com".to_string(),
93 ],
94 endpoints: Arc::new(tokio::sync::OnceCell::new_with(Some(endpoints))),
95 ..provider
96 }
97 }
98
99 #[must_use]
107 pub fn new(
108 key: impl Into<String>,
109 label: impl Into<Text>,
110 issuer: impl Into<String>,
111 client_id: impl Into<String>,
112 client_secret: impl Into<String>,
113 ) -> Self {
114 let issuer = issuer.into();
115 Self {
116 key: key.into(),
117 label: label.into(),
118 issuers: vec![issuer.clone()],
119 issuer,
120 client_id: client_id.into(),
121 client_secret: client_secret.into(),
122 scopes: Vec::new(),
123 params: Vec::new(),
124 endpoints: Arc::new(tokio::sync::OnceCell::new()),
125 }
126 }
127
128 #[must_use]
130 pub fn scope(mut self, scope: impl Into<String>) -> Self {
131 self.scopes.push(scope.into());
132 self
133 }
134
135 #[must_use]
141 pub fn param(mut self, name: impl Into<String>, value: impl Into<String>) -> Self {
142 self.params.push((name.into(), value.into()));
143 self
144 }
145
146 pub(super) fn key_is_valid(&self) -> bool {
148 !self.key.is_empty()
149 && self
150 .key
151 .bytes()
152 .all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit() || byte == b'-')
153 }
154
155 pub(super) fn issuer_is_valid(&self) -> bool {
157 secure_url(&self.issuer)
158 }
159
160 pub(super) async fn authorization_url(
162 &self,
163 redirect_uri: &str,
164 pending: &Pending,
165 ) -> Result<String, Failure> {
166 let endpoints = self.endpoints().await?;
167 let scope = std::iter::once(SCOPES)
168 .chain(self.scopes.iter().map(String::as_str))
169 .collect::<Vec<_>>()
170 .join(" ");
171 let mut query = form_urlencoded::Serializer::new(String::new());
172 query
173 .append_pair("response_type", "code")
174 .append_pair("client_id", &self.client_id)
175 .append_pair("redirect_uri", redirect_uri)
176 .append_pair("scope", &scope)
177 .append_pair("state", &pending.state)
178 .append_pair("nonce", &pending.nonce)
179 .append_pair("code_challenge", &code_challenge(&pending.verifier))
180 .append_pair("code_challenge_method", "S256");
181 for (name, value) in &self.params {
182 query.append_pair(name, value);
183 }
184 let query = query.finish();
185 let separator = if endpoints.authorization.contains('?') {
186 '&'
187 } else {
188 '?'
189 };
190 Ok(format!("{}{separator}{query}", endpoints.authorization))
191 }
192
193 pub(super) async fn redeem(
196 &self,
197 code: &str,
198 redirect_uri: &str,
199 pending: &Pending,
200 ) -> Result<ProviderIdentity, Failure> {
201 let endpoints = self.endpoints().await?;
202 let form = [
203 ("grant_type".to_string(), "authorization_code".to_string()),
204 ("code".to_string(), code.to_string()),
205 ("redirect_uri".to_string(), redirect_uri.to_string()),
206 ("code_verifier".to_string(), pending.verifier.clone()),
207 ];
208 let encode =
210 |value: &str| form_urlencoded::byte_serialize(value.as_bytes()).collect::<String>();
211 let credentials = format!(
212 "{}:{}",
213 encode(&self.client_id),
214 encode(&self.client_secret)
215 );
216 let authorization = format!("Basic {}", BASE64.encode(credentials.as_bytes()));
217 let token = endpoints.token.clone();
218 let answer: TokenResponse = request(move |agent| {
219 agent
220 .post(&token)
221 .header("authorization", &authorization)
222 .header("accept", "application/json")
223 .send_form(
224 form.iter()
225 .map(|(key, value)| (key.as_str(), value.as_str())),
226 )
227 })
228 .await?;
229 let now = jiff::Timestamp::now().as_second();
230 identity(self, &answer.id_token, &pending.nonce, now).ok_or(Failure::Refused)
231 }
232
233 async fn endpoints(&self) -> Result<&Endpoints, Failure> {
236 self.endpoints
237 .get_or_try_init(|| discover(self.issuer.clone()))
238 .await
239 }
240}
241
242impl std::fmt::Debug for OidcProvider {
244 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
245 f.debug_struct("OidcProvider")
246 .field("key", &self.key)
247 .field("issuer", &self.issuer)
248 .field("client_id", &self.client_id)
249 .field("client_secret", &"<redacted>")
250 .finish_non_exhaustive()
251 }
252}
253
254#[derive(Debug)]
256struct Endpoints {
257 authorization: String,
258 token: String,
259}
260
261#[derive(Debug, Clone, serde::Serialize, Deserialize)]
264pub(super) struct Pending {
265 pub(super) panel: String,
267 pub(super) provider: String,
269 pub(super) state: String,
271 pub(super) nonce: String,
273 pub(super) verifier: String,
275 pub(super) next: String,
277 pub(super) started: i64,
280}
281
282impl Pending {
283 pub(super) fn new(panel: String, provider: String, next: String) -> Self {
285 Self {
286 panel,
287 provider,
288 state: secret(),
289 nonce: secret(),
290 verifier: secret(),
291 next,
292 started: jiff::Timestamp::now().as_second(),
293 }
294 }
295}
296
297#[derive(Debug, Clone, Copy, PartialEq, Eq)]
299pub(crate) enum Failure {
300 Unavailable,
302 Refused,
304}
305
306fn secret() -> String {
308 let mut bytes = [0; 32];
309 getrandom::fill(&mut bytes).expect("the sign-in randomness source failed");
310 BASE64URL_NOPAD.encode(&bytes)
311}
312
313pub(super) fn code_challenge(verifier: &str) -> String {
315 BASE64URL_NOPAD.encode(&Sha256::digest(verifier.as_bytes()))
316}
317
318pub(super) fn secure_url(url: &str) -> bool {
320 let Ok(parsed) = url::Url::parse(url) else {
321 return false;
322 };
323 match parsed.scheme() {
324 "https" => parsed.host().is_some(),
325 "http" => match parsed.host() {
326 Some(url::Host::Domain(domain)) => domain == "localhost",
327 Some(url::Host::Ipv4(address)) => address.is_loopback(),
328 Some(url::Host::Ipv6(address)) => address.is_loopback(),
329 None => false,
330 },
331 _ => false,
332 }
333}
334
335async fn discover(issuer: String) -> Result<Endpoints, Failure> {
338 #[derive(Deserialize)]
339 struct Discovery {
340 issuer: String,
341 authorization_endpoint: String,
342 token_endpoint: String,
343 }
344 let url = format!(
345 "{}/.well-known/openid-configuration",
346 issuer.trim_end_matches('/')
347 );
348 let document: Discovery = request(move |agent| agent.get(&url).call()).await?;
349 let endpoints_secure =
352 secure_url(&document.authorization_endpoint) && secure_url(&document.token_endpoint);
353 if document.issuer != issuer || !endpoints_secure {
354 tracing::error!(
355 %issuer,
356 discovered = %document.issuer,
357 endpoints_secure,
358 "an OpenID Connect discovery document failed its checks"
359 );
360 return Err(Failure::Refused);
361 }
362 Ok(Endpoints {
363 authorization: document.authorization_endpoint,
364 token: document.token_endpoint,
365 })
366}
367
368#[derive(Deserialize)]
370struct TokenResponse {
371 id_token: String,
372}
373
374async fn request<T, F>(send: F) -> Result<T, Failure>
378where
379 T: serde::de::DeserializeOwned + Send + 'static,
380 F: FnOnce(&ureq::Agent) -> Result<http::Response<ureq::Body>, ureq::Error> + Send + 'static,
381{
382 let answer = tokio::task::spawn_blocking(move || {
383 let agent: ureq::Agent = ureq::Agent::config_builder()
384 .timeout_global(Some(REQUEST_TIMEOUT))
385 .http_status_as_error(false)
386 .max_redirects(0)
387 .build()
388 .into();
389 let mut response = send(&agent).map_err(|error| {
390 tracing::warn!(%error, "an OpenID Connect provider could not be reached");
391 Failure::Unavailable
392 })?;
393 let status = response.status();
394 if status.is_server_error() {
395 tracing::warn!(%status, "an OpenID Connect provider answered with a server error");
396 return Err(Failure::Unavailable);
397 }
398 if !status.is_success() {
399 tracing::warn!(%status, "an OpenID Connect provider refused a request");
400 return Err(Failure::Refused);
401 }
402 response
403 .body_mut()
404 .with_config()
405 .limit(MAX_RESPONSE_BYTES)
406 .read_to_string()
407 .map_err(|_| Failure::Unavailable)
408 })
409 .await
410 .map_err(|_| Failure::Unavailable)??;
411 serde_json::from_str(&answer).map_err(|error| {
412 tracing::warn!(%error, "an OpenID Connect provider's answer does not parse");
413 Failure::Refused
414 })
415}
416
417pub(super) fn identity(
423 provider: &OidcProvider,
424 id_token: &str,
425 nonce: &str,
426 now: i64,
427) -> Option<ProviderIdentity> {
428 #[derive(Deserialize)]
429 #[serde(untagged)]
430 enum Audience {
431 One(String),
432 Many(Vec<String>),
433 }
434 #[derive(Deserialize)]
435 #[serde(untagged)]
436 enum Flag {
437 Bool(bool),
438 Text(String),
440 }
441 #[derive(Deserialize)]
442 struct Claims {
443 iss: String,
444 sub: String,
445 aud: Audience,
446 azp: Option<String>,
447 exp: i64,
448 iat: i64,
449 nonce: Option<String>,
450 email: Option<String>,
451 email_verified: Option<Flag>,
452 name: Option<String>,
453 }
454 let mut parts = id_token.split('.');
455 let (Some(_header), Some(payload), Some(_signature), None) =
456 (parts.next(), parts.next(), parts.next(), parts.next())
457 else {
458 return None;
459 };
460 let payload = BASE64URL_NOPAD
461 .decode(payload.trim_end_matches('=').as_bytes())
462 .ok()?;
463 let all: serde_json::Map<String, serde_json::Value> = serde_json::from_slice(&payload).ok()?;
464 let claims: Claims = serde_json::from_value(serde_json::Value::Object(all.clone())).ok()?;
465 let audience = match claims.aud {
466 Audience::One(one) => vec![one],
467 Audience::Many(many) => many,
468 };
469 let issued_here = provider.issuers.contains(&claims.iss);
470 let addressed_here = audience.contains(&provider.client_id)
471 && (audience.len() == 1 || claims.azp.is_some())
472 && claims
473 .azp
474 .as_ref()
475 .is_none_or(|azp| *azp == provider.client_id);
476 let live = claims.exp > now - CLOCK_SKEW_SECS && claims.iat < now + CLOCK_SKEW_SECS;
477 let ours = claims.nonce.as_deref().is_some_and(|claimed| {
478 subtle::ConstantTimeEq::ct_eq(claimed.as_bytes(), nonce.as_bytes()).into()
479 });
480 if !(issued_here && addressed_here && live && ours) || claims.sub.is_empty() {
481 return None;
482 }
483 let email_verified = match claims.email_verified {
484 Some(Flag::Bool(verified)) => verified,
485 Some(Flag::Text(text)) => text == "true",
486 None => false,
487 };
488 Some(ProviderIdentity {
489 provider: provider.key.clone(),
490 issuer: provider.issuer.clone(),
491 subject: claims.sub,
492 email: claims.email.filter(|email| !email.is_empty()),
493 email_verified,
494 name: claims.name.filter(|name| !name.is_empty()),
495 claims: all,
496 })
497}