Skip to main content

teamcraft/
oidc.rs

1//! OpenID Connect login (authorization code flow with PKCE), tested against
2//! Authentik but provider agnostic.
3//!
4//! Flow:
5//! 1. `/auth/oidc/login` stores `state`, `nonce` and a PKCE verifier in the
6//!    `oidc_states` table and redirects to the provider.
7//! 2. The provider redirects back to `/auth/oidc/callback?code=…&state=…`.
8//! 3. The code is exchanged for tokens at the token endpoint (back channel,
9//!    authenticated with the client secret, over TLS).
10//! 4. Claims are taken from the ID token and the userinfo endpoint. Because
11//!    the ID token is received directly from the token endpoint over TLS,
12//!    TLS server validation substitutes signature validation (OpenID Connect
13//!    Core 1.0, section 3.1.3.7, step 6). `iss`, `aud`, `exp` and `nonce`
14//!    are still verified.
15//! 5. Groups are mapped to a TeamCraft role (see [`role_for_groups`]).
16
17use 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    /// The provider's signing keys, for back-channel logout tokens.
46    #[serde(default)]
47    jwks_uri: Option<String>,
48}
49
50/// One public key of the provider (RFC 7517), as far as RS256 and ES256 need.
51#[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
76/// The event a back-channel logout token carries (OpenID Connect
77/// Back-Channel Logout 1.0, section 2.4).
78pub const BACKCHANNEL_LOGOUT_EVENT: &str = "http://schemas.openid.net/event/backchannel-logout";
79
80/// How old (`iat`) a logout token may be.
81const LOGOUT_TOKEN_MAX_AGE_SECS: i64 = 300;
82
83/// How long fetched signing keys are used before they are fetched again; an
84/// unknown key id fetches them again after [`JWKS_REFRESH`].
85const 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    /// The provider's signing keys, cached for [`JWKS_TTL`]; `refresh`
118    /// fetches them again (key rotation) unless they are younger than
119    /// [`JWKS_REFRESH`].
120    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    /// Checks a back-channel logout token (signature with the provider's
148    /// keys, issuer, audience, age, logout event) and returns its subject.
149    /// A token that does not pass is a bad request (400, as the
150    /// specification asks).
151    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            // a key the cache does not know yet: the provider may have rotated
159            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    /// Fetches (and caches) the provider metadata. Done lazily so TeamCraft
170    /// starts even when the identity provider is temporarily unavailable.
171    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    /// Starts a login and returns the URL to redirect the browser to.
203    pub async fn start_login(&self, pool: &PgPool, return_to: &str) -> AppResult<String> {
204        self.start(pool, return_to, None).await
205    }
206
207    /// Starts a fresh sign-in of the user of an existing web session (the
208    /// hash of its token) to confirm a review decision: the provider is
209    /// asked to authenticate again (`prompt=login`, `max_age=0`), and the
210    /// callback marks that session instead of opening a new one.
211    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    /// Completes a login. Returns the identity, the local page to return to
270    /// and – for a fresh sign-in started by [`Self::start_reauth`] – the hash
271    /// of the session to mark.
272    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            // RFC 6749, 5.2: `invalid_grant` is this login attempt – a code
313            // that was already used or expired, or a PKCE verifier that does
314            // not match (an intercepted code). The user can simply try again;
315            // it is no server fault. Every other error (`invalid_client`, …)
316            // is the configuration and stays an internal error.
317            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        // Merge userinfo claims (groups are sometimes only available there).
356        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    // 256 bits, hex encoded inside the token helper; strip the empty prefix.
401    auth::generate_token("")
402}
403
404fn normalize_issuer(issuer: &str) -> &str {
405    issuer.trim().trim_end_matches('/')
406}
407
408/// Decodes the payload of a compact JWS without verifying the signature.
409fn 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/// Why a back-channel logout token was refused.
422#[derive(Debug, PartialEq, Eq)]
423pub(crate) enum LogoutTokenError {
424    Malformed,
425    /// No key of the provider has the token's key id.
426    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
453/// Whether `signature` over `message` is valid with `key` for `alg` – only
454/// RS256 and ES256 (P-256); anything else (`none` included) is refused.
455fn 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            // SEC 1 uncompressed point: 0x04 ‖ x ‖ y
481            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
492/// Checks a back-channel logout token (OpenID Connect Back-Channel Logout
493/// 1.0, 2.6): a JWS signed with one of `keys` (RS256 or ES256), issued by
494/// `issuer` for `client_id`, at most [`LOGOUT_TOKEN_MAX_AGE_SECS`] old, with
495/// the logout event, a subject and no nonce. Returns the subject.
496pub(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
566/// For a fresh sign-in (`max_age=0`): the provider must report when the user
567/// authenticated (`auth_time`, OIDC Core 3.1.2.1), and that must be within
568/// the last few minutes (clock skew included).
569pub(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
622/// Extracts the group names from a claim that is either a list or a string.
623fn 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
638/// Maps IdP groups to a role. `None` means the user may not log in.
639pub 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;