1use std::time::{Duration, Instant};
18
19use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine};
20use serde::Deserialize;
21use serde_json::Value;
22use sha2::{Digest, Sha256};
23use sqlx::PgPool;
24use tokio::sync::RwLock;
25
26use crate::{
27 auth,
28 config::OidcConfig,
29 domain::Role,
30 error::{AppError, AppResult},
31 services::users::OidcIdentity,
32 util,
33};
34
35const DISCOVERY_TTL: Duration = Duration::from_secs(3600);
36const CLOCK_SKEW_SECS: i64 = 120;
37
38#[derive(Clone, Debug, Deserialize)]
39struct Discovery {
40 issuer: String,
41 authorization_endpoint: String,
42 token_endpoint: String,
43 #[serde(default)]
44 userinfo_endpoint: Option<String>,
45 #[serde(default)]
47 jwks_uri: Option<String>,
48}
49
50#[derive(Clone, Debug, Default, Deserialize)]
52pub(crate) struct Jwk {
53 #[serde(default)]
54 pub kty: String,
55 #[serde(default)]
56 pub kid: Option<String>,
57 #[serde(default)]
58 pub alg: Option<String>,
59 #[serde(default)]
60 pub n: Option<String>,
61 #[serde(default)]
62 pub e: Option<String>,
63 #[serde(default)]
64 pub crv: Option<String>,
65 #[serde(default)]
66 pub x: Option<String>,
67 #[serde(default)]
68 pub y: Option<String>,
69}
70
71#[derive(Debug, Deserialize)]
72struct JwkSet {
73 keys: Vec<Jwk>,
74}
75
76pub const BACKCHANNEL_LOGOUT_EVENT: &str = "http://schemas.openid.net/event/backchannel-logout";
79
80const LOGOUT_TOKEN_MAX_AGE_SECS: i64 = 300;
82
83const JWKS_TTL: Duration = Duration::from_secs(3600);
86const JWKS_REFRESH: Duration = Duration::from_secs(60);
87
88#[derive(Debug, Deserialize)]
89struct TokenResponse {
90 id_token: Option<String>,
91 access_token: Option<String>,
92}
93
94pub struct OidcClient {
95 config: OidcConfig,
96 redirect_uri: String,
97 http: reqwest::Client,
98 discovery: RwLock<Option<(Instant, Discovery)>>,
99 jwks: RwLock<Option<(Instant, Vec<Jwk>)>>,
100}
101
102impl OidcClient {
103 pub fn new(config: OidcConfig, public_url: &str) -> anyhow::Result<OidcClient> {
104 let http = reqwest::Client::builder()
105 .timeout(Duration::from_secs(10))
106 .user_agent(concat!("TeamCraft/", env!("CARGO_PKG_VERSION")))
107 .build()?;
108 Ok(OidcClient {
109 redirect_uri: config.redirect_uri(public_url),
110 config,
111 http,
112 discovery: RwLock::new(None),
113 jwks: RwLock::new(None),
114 })
115 }
116
117 async fn signing_keys(&self, refresh: bool) -> AppResult<Vec<Jwk>> {
121 if let Some((fetched, keys)) = self.jwks.read().await.as_ref() {
122 let max = if refresh { JWKS_REFRESH } else { JWKS_TTL };
123 if fetched.elapsed() < max {
124 return Ok(keys.clone());
125 }
126 }
127 let discovery = self.discovery().await?;
128 let Some(uri) = discovery.jwks_uri else {
129 return Err(AppError::Other(anyhow::anyhow!(
130 "the identity provider publishes no jwks_uri; back-channel logout needs it"
131 )));
132 };
133 let set: JwkSet = self
134 .http
135 .get(&uri)
136 .send()
137 .await
138 .and_then(|r| r.error_for_status())
139 .map_err(|e| idp_error("fetching the signing keys", e))?
140 .json()
141 .await
142 .map_err(|e| idp_error("parsing the signing keys", e))?;
143 *self.jwks.write().await = Some((Instant::now(), set.keys.clone()));
144 Ok(set.keys)
145 }
146
147 pub async fn verify_logout_token(&self, token: &str) -> AppResult<String> {
152 let discovery = self.discovery().await?;
153 let now = chrono::Utc::now().timestamp();
154 let check = |keys: &[Jwk]| {
155 verify_logout_token(token, keys, &discovery.issuer, &self.config.client_id, now)
156 };
157 let result = match check(&self.signing_keys(false).await?) {
158 Err(LogoutTokenError::UnknownKey) => check(&self.signing_keys(true).await?),
160 other => other,
161 };
162 result.map_err(|e| AppError::BadRequest(format!("Invalid logout token: {e}.")))
163 }
164
165 pub fn display_name(&self) -> &str {
166 &self.config.display_name
167 }
168
169 async fn discovery(&self) -> AppResult<Discovery> {
172 if let Some((fetched, d)) = self.discovery.read().await.as_ref() {
173 if fetched.elapsed() < DISCOVERY_TTL {
174 return Ok(d.clone());
175 }
176 }
177 let url = format!(
178 "{}/.well-known/openid-configuration",
179 self.config.issuer_url.trim_end_matches('/')
180 );
181 let discovery: Discovery = self
182 .http
183 .get(&url)
184 .send()
185 .await
186 .and_then(|r| r.error_for_status())
187 .map_err(|e| idp_error("fetching the OIDC discovery document", e))?
188 .json()
189 .await
190 .map_err(|e| idp_error("parsing the OIDC discovery document", e))?;
191 if normalize_issuer(&discovery.issuer) != normalize_issuer(&self.config.issuer_url) {
192 return Err(AppError::Other(anyhow::anyhow!(
193 "OIDC issuer mismatch: configured '{}', provider reports '{}'",
194 self.config.issuer_url,
195 discovery.issuer
196 )));
197 }
198 *self.discovery.write().await = Some((Instant::now(), discovery.clone()));
199 Ok(discovery)
200 }
201
202 pub async fn start_login(&self, pool: &PgPool, return_to: &str) -> AppResult<String> {
204 self.start(pool, return_to, None).await
205 }
206
207 pub async fn start_reauth(
212 &self,
213 pool: &PgPool,
214 return_to: &str,
215 session_hash: &str,
216 ) -> AppResult<String> {
217 self.start(pool, return_to, Some(session_hash)).await
218 }
219
220 async fn start(
221 &self,
222 pool: &PgPool,
223 return_to: &str,
224 reauth_session: Option<&str>,
225 ) -> AppResult<String> {
226 let discovery = self.discovery().await?;
227 let state = random_urlsafe();
228 let nonce = random_urlsafe();
229 let verifier = random_urlsafe();
230 let challenge = URL_SAFE_NO_PAD.encode(Sha256::digest(verifier.as_bytes()));
231 sqlx::query(
232 "INSERT INTO oidc_states (state, nonce, pkce_verifier, return_to, reauth_session)
233 VALUES ($1, $2, $3, $4, $5)",
234 )
235 .bind(&state)
236 .bind(&nonce)
237 .bind(&verifier)
238 .bind(util::safe_redirect_target(Some(return_to)))
239 .bind(reauth_session)
240 .execute(pool)
241 .await?;
242 let mut params = vec![
243 ("response_type", "code"),
244 ("client_id", self.config.client_id.as_str()),
245 ("redirect_uri", self.redirect_uri.as_str()),
246 ("scope", self.config.scopes.as_str()),
247 ("state", state.as_str()),
248 ("nonce", nonce.as_str()),
249 ("code_challenge", challenge.as_str()),
250 ("code_challenge_method", "S256"),
251 ];
252 if reauth_session.is_some() {
253 params.push(("prompt", "login"));
254 params.push(("max_age", "0"));
255 }
256 let query = params
257 .iter()
258 .map(|(k, v)| format!("{k}={}", util::pct_encode(v)))
259 .collect::<Vec<_>>()
260 .join("&");
261 let sep = if discovery.authorization_endpoint.contains('?') {
262 '&'
263 } else {
264 '?'
265 };
266 Ok(format!("{}{sep}{query}", discovery.authorization_endpoint))
267 }
268
269 pub async fn finish_login(
273 &self,
274 pool: &PgPool,
275 code: &str,
276 state: &str,
277 ) -> AppResult<(OidcIdentity, String, Option<String>)> {
278 #[derive(sqlx::FromRow)]
279 struct Pending {
280 nonce: String,
281 pkce_verifier: String,
282 return_to: String,
283 reauth_session: Option<String>,
284 }
285 let pending = sqlx::query_as::<_, Pending>(
286 "DELETE FROM oidc_states WHERE state = $1 AND created_at > now() - interval '15 minutes'
287 RETURNING nonce, pkce_verifier, return_to, reauth_session",
288 )
289 .bind(state)
290 .fetch_optional(pool)
291 .await?
292 .ok_or_else(|| {
293 AppError::BadRequest("The login attempt expired or is invalid. Please try again.".into())
294 })?;
295
296 let discovery = self.discovery().await?;
297 let response = self
298 .http
299 .post(&discovery.token_endpoint)
300 .form(&[
301 ("grant_type", "authorization_code"),
302 ("code", code),
303 ("redirect_uri", self.redirect_uri.as_str()),
304 ("client_id", self.config.client_id.as_str()),
305 ("client_secret", self.config.client_secret.as_str()),
306 ("code_verifier", pending.pkce_verifier.as_str()),
307 ])
308 .send()
309 .await
310 .map_err(|e| idp_error("exchanging the authorization code", e))?;
311 if response.status().is_client_error() {
312 let status = response.status();
318 let error = response
319 .json::<Value>()
320 .await
321 .ok()
322 .and_then(|v| v["error"].as_str().map(str::to_string))
323 .unwrap_or_default();
324 if error == "invalid_grant" {
325 tracing::warn!(%status, "the identity provider refused the authorization code");
326 return Err(AppError::BadRequest(
327 "Single sign-on failed: the identity provider refused the login code. \
328 Please try again."
329 .into(),
330 ));
331 }
332 return Err(AppError::Other(anyhow::anyhow!(
333 "single sign-on failed while exchanging the authorization code: {status} {error}"
334 )));
335 }
336 let tokens: TokenResponse = response
337 .error_for_status()
338 .map_err(|e| idp_error("exchanging the authorization code", e))?
339 .json()
340 .await
341 .map_err(|e| idp_error("parsing the token response", e))?;
342
343 let id_token = tokens.id_token.ok_or_else(|| {
344 AppError::Other(anyhow::anyhow!("token response contains no id_token"))
345 })?;
346 let mut claims = decode_jwt_claims(&id_token)?;
347 validate_id_token_claims(
348 &claims,
349 &discovery.issuer,
350 &self.config.client_id,
351 &pending.nonce,
352 chrono::Utc::now().timestamp(),
353 )?;
354
355 if let (Some(endpoint), Some(access_token)) =
357 (&discovery.userinfo_endpoint, &tokens.access_token)
358 {
359 match self.userinfo(endpoint, access_token).await {
360 Ok(Value::Object(info)) if info.get("sub") == claims.get("sub") => {
361 if let Value::Object(map) = &mut claims {
362 for (k, v) in info {
363 map.entry(k).or_insert(v);
364 }
365 }
366 }
367 Ok(_) => tracing::warn!("ignoring userinfo response with a different subject"),
368 Err(e) => {
369 tracing::warn!(error = %e, "userinfo request failed; using ID token claims only")
370 }
371 }
372 }
373
374 if pending.reauth_session.is_some() {
375 fresh_sign_in(&claims, chrono::Utc::now().timestamp())?;
376 }
377 let identity = identity_from_claims(&claims, &self.config)?;
378 Ok((identity, pending.return_to, pending.reauth_session))
379 }
380
381 async fn userinfo(&self, endpoint: &str, access_token: &str) -> AppResult<Value> {
382 self.http
383 .get(endpoint)
384 .bearer_auth(access_token)
385 .send()
386 .await
387 .and_then(|r| r.error_for_status())
388 .map_err(|e| idp_error("calling the userinfo endpoint", e))?
389 .json()
390 .await
391 .map_err(|e| idp_error("parsing the userinfo response", e))
392 }
393}
394
395fn idp_error(what: &str, err: reqwest::Error) -> AppError {
396 AppError::Other(anyhow::anyhow!("single sign-on failed while {what}: {err}"))
397}
398
399fn random_urlsafe() -> String {
400 auth::generate_token("")
402}
403
404fn normalize_issuer(issuer: &str) -> &str {
405 issuer.trim().trim_end_matches('/')
406}
407
408fn decode_jwt_claims(jwt: &str) -> AppResult<Value> {
410 let payload = jwt
411 .split('.')
412 .nth(1)
413 .ok_or_else(|| AppError::Other(anyhow::anyhow!("malformed id_token")))?;
414 let bytes = URL_SAFE_NO_PAD
415 .decode(payload.trim_end_matches('='))
416 .map_err(|e| AppError::Other(anyhow::anyhow!("id_token is not base64url: {e}")))?;
417 serde_json::from_slice(&bytes)
418 .map_err(|e| AppError::Other(anyhow::anyhow!("id_token payload is not JSON: {e}")))
419}
420
421#[derive(Debug, PartialEq, Eq)]
423pub(crate) enum LogoutTokenError {
424 Malformed,
425 UnknownKey,
427 BadSignature,
428 Claim(&'static str),
429}
430
431impl std::fmt::Display for LogoutTokenError {
432 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
433 match self {
434 LogoutTokenError::Malformed => f.write_str("malformed token"),
435 LogoutTokenError::UnknownKey => f.write_str("unknown signing key"),
436 LogoutTokenError::BadSignature => f.write_str("bad signature"),
437 LogoutTokenError::Claim(what) => f.write_str(what),
438 }
439 }
440}
441
442fn b64_json(part: &str) -> Result<Value, LogoutTokenError> {
443 let bytes = URL_SAFE_NO_PAD
444 .decode(part.trim_end_matches('='))
445 .map_err(|_| LogoutTokenError::Malformed)?;
446 serde_json::from_slice(&bytes).map_err(|_| LogoutTokenError::Malformed)
447}
448
449fn b64_bytes(part: Option<&str>) -> Option<Vec<u8>> {
450 URL_SAFE_NO_PAD.decode(part?.trim_end_matches('=')).ok()
451}
452
453fn signature_ok(key: &Jwk, alg: &str, message: &[u8], sig: &[u8]) -> bool {
456 use ring::signature as rs;
457 if key.alg.as_deref().is_some_and(|a| a != alg) {
458 return false;
459 }
460 match (alg, key.kty.as_str()) {
461 ("RS256", "RSA") => {
462 let n = b64_bytes(key.n.as_deref());
463 let e = b64_bytes(key.e.as_deref());
464 let (Some(n), Some(e)) = (n, e) else {
465 return false;
466 };
467 rs::RsaPublicKeyComponents { n, e }
468 .verify(&rs::RSA_PKCS1_2048_8192_SHA256, message, sig)
469 .is_ok()
470 }
471 ("ES256", "EC") if key.crv.as_deref() == Some("P-256") => {
472 let x = b64_bytes(key.x.as_deref());
473 let y = b64_bytes(key.y.as_deref());
474 let (Some(x), Some(y)) = (x, y) else {
475 return false;
476 };
477 if x.len() != 32 || y.len() != 32 {
478 return false;
479 }
480 let mut point = vec![4u8];
482 point.extend_from_slice(&x);
483 point.extend_from_slice(&y);
484 rs::UnparsedPublicKey::new(&rs::ECDSA_P256_SHA256_FIXED, point)
485 .verify(message, sig)
486 .is_ok()
487 }
488 _ => false,
489 }
490}
491
492pub(crate) fn verify_logout_token(
497 token: &str,
498 keys: &[Jwk],
499 issuer: &str,
500 client_id: &str,
501 now: i64,
502) -> Result<String, LogoutTokenError> {
503 let parts: Vec<&str> = token.trim().split('.').collect();
504 let [header, payload, signature] = parts.as_slice() else {
505 return Err(LogoutTokenError::Malformed);
506 };
507 let header = b64_json(header)?;
508 let alg = header.get("alg").and_then(Value::as_str).unwrap_or("");
509 let kid = header.get("kid").and_then(Value::as_str);
510 let sig = b64_bytes(Some(*signature)).ok_or(LogoutTokenError::Malformed)?;
511 let message = format!("{}.{}", parts[0], parts[1]);
512 let candidates: Vec<&Jwk> = keys
513 .iter()
514 .filter(|k| kid.is_none() || k.kid.as_deref() == kid)
515 .collect();
516 if candidates.is_empty() {
517 return Err(LogoutTokenError::UnknownKey);
518 }
519 let signed = candidates
520 .iter()
521 .any(|k| signature_ok(k, alg, message.as_bytes(), &sig));
522 if !signed {
523 return Err(LogoutTokenError::BadSignature);
524 }
525 let claims = b64_json(payload)?;
526 let iss = claims.get("iss").and_then(Value::as_str).unwrap_or("");
527 if normalize_issuer(iss) != normalize_issuer(issuer) {
528 return Err(LogoutTokenError::Claim("unexpected issuer"));
529 }
530 let aud_ok = match claims.get("aud") {
531 Some(Value::String(a)) => a == client_id,
532 Some(Value::Array(list)) => list.iter().any(|a| a.as_str() == Some(client_id)),
533 _ => false,
534 };
535 if !aud_ok {
536 return Err(LogoutTokenError::Claim("issued for another application"));
537 }
538 let iat = claims.get("iat").and_then(Value::as_i64);
539 match iat {
540 Some(at) if at <= now + CLOCK_SKEW_SECS && now - at <= LOGOUT_TOKEN_MAX_AGE_SECS => {}
541 _ => return Err(LogoutTokenError::Claim("too old or from the future")),
542 }
543 if let Some(exp) = claims.get("exp").and_then(Value::as_i64) {
544 if exp + CLOCK_SKEW_SECS < now {
545 return Err(LogoutTokenError::Claim("expired"));
546 }
547 }
548 let event = claims
549 .get("events")
550 .and_then(Value::as_object)
551 .is_some_and(|e| e.contains_key(BACKCHANNEL_LOGOUT_EVENT));
552 if !event {
553 return Err(LogoutTokenError::Claim("no logout event"));
554 }
555 if claims.get("nonce").is_some() {
556 return Err(LogoutTokenError::Claim("a logout token carries no nonce"));
557 }
558 match claims.get("sub").and_then(Value::as_str).map(str::trim) {
559 Some(sub) if !sub.is_empty() => Ok(sub.to_string()),
560 _ => Err(LogoutTokenError::Claim(
561 "no subject (only sid is not supported)",
562 )),
563 }
564}
565
566pub(crate) fn fresh_sign_in(claims: &Value, now: i64) -> AppResult<()> {
570 const FRESH_SECONDS: i64 = 300;
571 match claims.get("auth_time").and_then(Value::as_i64) {
572 Some(at) if now - at <= FRESH_SECONDS && at <= now + 60 => Ok(()),
573 _ => Err(AppError::BadRequest(
574 "The identity provider did not confirm a fresh sign-in. Please try again.".into(),
575 )),
576 }
577}
578
579fn validate_id_token_claims(
580 claims: &Value,
581 issuer: &str,
582 client_id: &str,
583 nonce: &str,
584 now: i64,
585) -> AppResult<()> {
586 let fail = |msg: &str| {
587 AppError::BadRequest(format!("Single sign-on failed: {msg}. Please try again."))
588 };
589 let iss = claims
590 .get("iss")
591 .and_then(Value::as_str)
592 .unwrap_or_default();
593 if normalize_issuer(iss) != normalize_issuer(issuer) {
594 return Err(fail("unexpected token issuer"));
595 }
596 let aud_ok = match claims.get("aud") {
597 Some(Value::String(a)) => a == client_id,
598 Some(Value::Array(list)) => list.iter().any(|a| a.as_str() == Some(client_id)),
599 _ => false,
600 };
601 if !aud_ok {
602 return Err(fail("token was issued for another application"));
603 }
604 let exp = claims.get("exp").and_then(Value::as_i64).unwrap_or(0);
605 if exp + CLOCK_SKEW_SECS < now {
606 return Err(fail("token expired"));
607 }
608 if claims.get("nonce").and_then(Value::as_str) != Some(nonce) {
609 return Err(fail("nonce mismatch"));
610 }
611 if claims
612 .get("sub")
613 .and_then(Value::as_str)
614 .unwrap_or_default()
615 .is_empty()
616 {
617 return Err(fail("token has no subject"));
618 }
619 Ok(())
620}
621
622fn groups_from_claims(claims: &Value, claim: &str) -> Vec<String> {
624 match claims.get(claim) {
625 Some(Value::Array(list)) => list
626 .iter()
627 .filter_map(|g| g.as_str().map(str::to_string))
628 .collect(),
629 Some(Value::String(s)) => s
630 .split([',', ' '])
631 .filter(|g| !g.is_empty())
632 .map(str::to_string)
633 .collect(),
634 _ => Vec::new(),
635 }
636}
637
638pub fn role_for_groups(groups: &[String], config: &OidcConfig) -> Option<Role> {
640 let member = |g: &str| groups.iter().any(|x| x.eq_ignore_ascii_case(g));
641 if member(&config.admin_group) {
642 Some(Role::Admin)
643 } else if member(&config.editor_group) {
644 Some(Role::Editor)
645 } else {
646 match &config.viewer_group {
647 Some(g) if !member(g) => None,
648 _ => Some(Role::Viewer),
649 }
650 }
651}
652
653fn identity_from_claims(claims: &Value, config: &OidcConfig) -> AppResult<OidcIdentity> {
654 let get = |k: &str| {
655 claims
656 .get(k)
657 .and_then(Value::as_str)
658 .map(str::trim)
659 .filter(|s| !s.is_empty())
660 .map(str::to_string)
661 };
662 let subject = get("sub")
663 .ok_or_else(|| AppError::BadRequest("Single sign-on failed: no subject.".into()))?;
664 let username_hint = get(&config.username_claim)
665 .or_else(|| get("preferred_username"))
666 .or_else(|| get("email"))
667 .unwrap_or_else(|| subject.clone());
668 let display_name = get("name").unwrap_or_else(|| username_hint.clone());
669 let groups = groups_from_claims(claims, &config.groups_claim);
670 let role = role_for_groups(&groups, config).ok_or_else(|| {
671 AppError::Forbidden(format!(
672 "Your account is not a member of a TeamCraft group. Ask IT to add you to '{}', '{}' or '{}'.",
673 config.viewer_group.as_deref().unwrap_or("-"),
674 config.editor_group,
675 config.admin_group
676 ))
677 })?;
678 Ok(OidcIdentity {
679 subject,
680 username_hint,
681 display_name,
682 email: get("email"),
683 role,
684 groups,
685 claims: crate::services::membership_conditions::claim_values(claims),
686 })
687}
688
689#[cfg(test)]
690mod tests;