Skip to main content

notedthat_server/
oidc.rs

1//! OIDC bearer-token verification: discovery, a cached JWKS, and the
2//! [`TokenVerifier`] the [`notedthat_core::Authenticator`] consults for any
3//! bearer that is not the service token (D53).
4//!
5//! Only signed JWT access tokens are accepted — `RS256`/`RS384`/`RS512` and
6//! `ES256`/`ES384` against the issuer's published keys. There is no
7//! introspection call, so a provider that mints opaque access tokens by
8//! default (Authelia, Zitadel) has to be told to mint JWTs; the configuration
9//! guide says how for each supported provider.
10
11use async_trait::async_trait;
12use jsonwebtoken::jwk::{AlgorithmParameters, Jwk, JwkSet};
13use jsonwebtoken::{Algorithm, DecodingKey, Validation, decode, decode_header};
14use notedthat_core::{TokenRejected, TokenVerifier, UserIdentity};
15use serde::Deserialize;
16use std::collections::BTreeSet;
17use std::sync::RwLock;
18use std::time::{Duration, Instant};
19
20/// The algorithms a token may be signed with.
21///
22/// Asymmetric only. An `HS*` token is refused before any key is looked at:
23/// the deployment holds no shared secret with the issuer, and a JWKS never
24/// publishes one, so accepting the family would only be a footgun.
25const ACCEPTED_ALGORITHMS: [Algorithm; 5] = [
26    Algorithm::RS256,
27    Algorithm::RS384,
28    Algorithm::RS512,
29    Algorithm::ES256,
30    Algorithm::ES384,
31];
32
33/// How much clock skew between issuer and server to forgive on `exp`/`nbf`.
34const LEEWAY: Duration = Duration::from_secs(60);
35
36/// The shortest interval between two JWKS fetches triggered by tokens.
37///
38/// An unknown `kid` refetches, so without this a flood of tokens carrying a
39/// bogus `kid` would be a flood of requests to the issuer.
40const MIN_REFETCH_INTERVAL: Duration = Duration::from_secs(30);
41
42/// After this long a cached key set is refreshed before the next verification
43/// uses it, so a key rotation that keeps the old `kid` is still picked up.
44const MAX_KEY_AGE: Duration = Duration::from_hours(1);
45
46/// What `NOTEDTHAT_OIDC_*` configures.
47#[derive(Debug, Clone, PartialEq, Eq)]
48pub struct OidcSettings {
49    /// The issuer URL, exactly as the provider spells its `iss` claim.
50    pub issuer: String,
51    /// The audiences a token may carry; one of them must match.
52    pub audiences: Vec<String>,
53    /// The claim `user:` rules and logs identify a caller by.
54    pub username_claim: String,
55    /// The claim `group:` rules are matched against.
56    pub groups_claim: String,
57    /// Timeout for the discovery and JWKS requests.
58    pub http_timeout: Duration,
59    /// This deployment's public URL, when it publishes RFC 9728 metadata.
60    pub resource: Option<String>,
61    /// A PEM bundle of CA certificates to trust for the issuer, on top of the
62    /// built-in roots — a self-hosted provider is usually behind an internal CA.
63    pub ca_cert: Option<std::path::PathBuf>,
64}
65
66impl OidcSettings {
67    /// The default username claim.
68    pub const DEFAULT_USERNAME_CLAIM: &'static str = "preferred_username";
69    /// The default groups claim.
70    pub const DEFAULT_GROUPS_CLAIM: &'static str = "groups";
71    /// The default HTTP timeout, in milliseconds.
72    pub const DEFAULT_HTTP_TIMEOUT_MS: u64 = 5_000;
73
74    /// The URL the discovery document is fetched from.
75    pub fn discovery_url(&self) -> String {
76        format!(
77            "{}/.well-known/openid-configuration",
78            self.issuer.trim_end_matches('/')
79        )
80    }
81}
82
83/// The parts of the discovery document this verifier reads.
84#[derive(Debug, Deserialize)]
85struct DiscoveryDocument {
86    issuer: String,
87    jwks_uri: String,
88}
89
90/// One published key, ready to verify with.
91struct CachedKey {
92    kid: Option<String>,
93    key: DecodingKey,
94}
95
96struct KeyCache {
97    keys: Vec<CachedKey>,
98    /// When the keys were fetched; `None` means "stale, refetch when asked".
99    fetched_at: Option<Instant>,
100}
101
102/// Verifies bearer tokens against an OIDC issuer's published keys.
103pub struct OidcVerifier {
104    settings: OidcSettings,
105    http: reqwest::Client,
106    jwks_uri: String,
107    cache: RwLock<KeyCache>,
108    /// Serialises refetches so concurrent unknown-`kid` tokens cost one request.
109    refetch: tokio::sync::Mutex<()>,
110}
111
112impl std::fmt::Debug for OidcVerifier {
113    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
114        f.debug_struct("OidcVerifier")
115            .field("issuer", &self.settings.issuer)
116            .field("jwks_uri", &self.jwks_uri)
117            .finish_non_exhaustive()
118    }
119}
120
121impl OidcVerifier {
122    /// Discover the issuer and fetch its keys.
123    ///
124    /// Runs at startup and fails fast (D39): a verifier that cannot reach its
125    /// issuer would refuse every identity token, which is a misconfiguration
126    /// better reported before the listener binds than after.
127    ///
128    /// # Errors
129    ///
130    /// When the discovery document or the JWKS cannot be fetched or parsed, or
131    /// when the document's `issuer` is not the configured one.
132    pub async fn discover(settings: OidcSettings) -> anyhow::Result<Self> {
133        let mut builder = reqwest::Client::builder().timeout(settings.http_timeout);
134        if let Some(path) = &settings.ca_cert {
135            let bundle = std::fs::read(path).map_err(|error| {
136                anyhow::anyhow!("NOTEDTHAT_OIDC_CA_CERT {}: {error}", path.display())
137            })?;
138            let certificates = reqwest::Certificate::from_pem_bundle(&bundle).map_err(|error| {
139                anyhow::anyhow!(
140                    "NOTEDTHAT_OIDC_CA_CERT {} is not a PEM certificate bundle: {error}",
141                    path.display()
142                )
143            })?;
144            if certificates.is_empty() {
145                anyhow::bail!(
146                    "NOTEDTHAT_OIDC_CA_CERT {} contains no certificates",
147                    path.display()
148                );
149            }
150            for certificate in certificates {
151                builder = builder.add_root_certificate(certificate);
152            }
153        }
154        let http = builder
155            .build()
156            .map_err(|error| anyhow::anyhow!("could not build the OIDC HTTP client: {error}"))?;
157
158        let discovery_url = settings.discovery_url();
159        let document: DiscoveryDocument = http
160            .get(&discovery_url)
161            .send()
162            .await
163            .and_then(reqwest::Response::error_for_status)
164            .map_err(|error| anyhow::Error::new(error).context(format!("GET {discovery_url}")))?
165            .json()
166            .await
167            .map_err(|error| {
168                anyhow::Error::new(error)
169                    .context(format!("{discovery_url} is not a discovery document"))
170            })?;
171
172        // The document's own issuer is what every token's `iss` will carry.
173        // A mismatch means every token would be rejected, and the fix is to
174        // spell NOTEDTHAT_OIDC_ISSUER the way the provider does.
175        if document.issuer != settings.issuer {
176            anyhow::bail!(
177                "{discovery_url} reports issuer `{}` but NOTEDTHAT_OIDC_ISSUER is `{}`; \
178                 they must match exactly, trailing slash included",
179                document.issuer,
180                settings.issuer
181            );
182        }
183
184        let jwks = fetch_jwks(&http, &document.jwks_uri).await?;
185        tracing::info!(
186            issuer = %settings.issuer,
187            jwks_uri = %document.jwks_uri,
188            keys = jwks.keys.len(),
189            "OIDC issuer discovered"
190        );
191        Ok(Self::assemble(settings, http, document.jwks_uri, &jwks))
192    }
193
194    /// A verifier over an already-fetched key set, for tests without an issuer.
195    ///
196    /// `jwks_uri` is where refetches go; `None` means an unknown `kid` is
197    /// simply an unknown `kid`.
198    #[cfg(any(test, feature = "test-support"))]
199    pub fn from_jwks(settings: OidcSettings, jwks: &JwkSet, jwks_uri: Option<String>) -> Self {
200        let http = reqwest::Client::builder()
201            .timeout(settings.http_timeout)
202            .build()
203            .expect("reqwest client");
204        Self::assemble(
205            settings,
206            http,
207            jwks_uri.unwrap_or_else(|| "unset:".to_string()),
208            jwks,
209        )
210    }
211
212    fn assemble(
213        settings: OidcSettings,
214        http: reqwest::Client,
215        jwks_uri: String,
216        jwks: &JwkSet,
217    ) -> Self {
218        Self {
219            settings,
220            http,
221            jwks_uri,
222            cache: RwLock::new(KeyCache {
223                keys: cache_keys(jwks),
224                fetched_at: Some(Instant::now()),
225            }),
226            refetch: tokio::sync::Mutex::new(()),
227        }
228    }
229
230    /// The settings this verifier was built from.
231    pub fn settings(&self) -> &OidcSettings {
232        &self.settings
233    }
234
235    /// Whether the cache holds a key usable for `kid`.
236    fn has_key(&self, kid: Option<&str>) -> bool {
237        self.lookup(kid).is_some()
238    }
239
240    /// The decoding key for `kid`; with no `kid`, the only key if there is one.
241    fn lookup(&self, kid: Option<&str>) -> Option<DecodingKey> {
242        let cache = self
243            .cache
244            .read()
245            .unwrap_or_else(std::sync::PoisonError::into_inner);
246        match kid {
247            Some(kid) => cache
248                .keys
249                .iter()
250                .find(|key| key.kid.as_deref() == Some(kid)),
251            None if cache.keys.len() == 1 => cache.keys.first(),
252            None => None,
253        }
254        .map(|key| key.key.clone())
255    }
256
257    fn cache_age(&self) -> Duration {
258        self.cache
259            .read()
260            .unwrap_or_else(std::sync::PoisonError::into_inner)
261            .fetched_at
262            .map_or(Duration::MAX, |fetched_at| fetched_at.elapsed())
263    }
264
265    /// Forget when the keys were fetched, so the next token refreshes them.
266    #[cfg(test)]
267    fn mark_stale(&self) {
268        self.cache
269            .write()
270            .unwrap_or_else(std::sync::PoisonError::into_inner)
271            .fetched_at = None;
272    }
273
274    /// Refetch the key set, unless one was fetched too recently.
275    ///
276    /// A failed refetch keeps the previous keys: a transient issuer outage
277    /// should not turn every already-verifiable token into a refusal.
278    async fn refresh(&self) {
279        let _serialised = self.refetch.lock().await;
280        if self.cache_age() < MIN_REFETCH_INTERVAL {
281            return;
282        }
283        match fetch_jwks(&self.http, &self.jwks_uri).await {
284            Ok(jwks) => {
285                let mut cache = self
286                    .cache
287                    .write()
288                    .unwrap_or_else(std::sync::PoisonError::into_inner);
289                cache.keys = cache_keys(&jwks);
290                cache.fetched_at = Some(Instant::now());
291                tracing::debug!(keys = cache.keys.len(), "OIDC key set refreshed");
292            }
293            Err(error) => {
294                // Move the timestamp anyway, so a broken issuer is asked again
295                // at the rate-limited interval and not on every token.
296                self.cache
297                    .write()
298                    .unwrap_or_else(std::sync::PoisonError::into_inner)
299                    .fetched_at = Some(Instant::now());
300                tracing::warn!(error = %format!("{error:#}"), "OIDC key set refresh failed; keeping the previous keys");
301            }
302        }
303    }
304}
305
306#[async_trait]
307impl TokenVerifier for OidcVerifier {
308    async fn verify(&self, token: &str) -> Result<UserIdentity, TokenRejected> {
309        let header = decode_header(token).map_err(|_| TokenRejected::new("not a JWT"))?;
310        if !ACCEPTED_ALGORITHMS.contains(&header.alg) {
311            return Err(TokenRejected::new(format!(
312                "algorithm {:?} is not accepted",
313                header.alg
314            )));
315        }
316
317        let kid = header.kid.as_deref();
318        if self.cache_age() > MAX_KEY_AGE || !self.has_key(kid) {
319            self.refresh().await;
320        }
321        let key = self.lookup(kid).ok_or_else(|| {
322            TokenRejected::new(match kid {
323                Some(_) => "signed with a key the issuer does not publish",
324                None => "no `kid`, and the issuer publishes more than one key",
325            })
326        })?;
327
328        let mut validation = Validation::new(header.alg);
329        validation.set_issuer(&[&self.settings.issuer]);
330        validation.set_audience(&self.settings.audiences);
331        validation.set_required_spec_claims(&["exp", "iss", "aud"]);
332        validation.leeway = LEEWAY.as_secs();
333        validation.validate_nbf = true;
334
335        let claims = decode::<serde_json::Map<String, serde_json::Value>>(token, &key, &validation)
336            .map_err(|error| TokenRejected::new(describe(&error)))?
337            .claims;
338
339        let subject = claims
340            .get(&self.settings.username_claim)
341            .or_else(|| claims.get("sub"))
342            .and_then(serde_json::Value::as_str)
343            .filter(|subject| !subject.is_empty())
344            .ok_or_else(|| TokenRejected::new("no username claim and no `sub`"))?
345            .to_string();
346        let groups = claims
347            .get(&self.settings.groups_claim)
348            .map(groups_from_claim)
349            .unwrap_or_default();
350
351        Ok(UserIdentity { subject, groups })
352    }
353}
354
355/// An operator-facing reason for a validation failure. Never the token.
356fn describe(error: &jsonwebtoken::errors::Error) -> String {
357    use jsonwebtoken::errors::ErrorKind;
358    match error.kind() {
359        ErrorKind::ExpiredSignature => "expired".to_string(),
360        ErrorKind::ImmatureSignature => "not yet valid (`nbf`)".to_string(),
361        ErrorKind::InvalidIssuer => "issuer mismatch".to_string(),
362        ErrorKind::InvalidAudience => "audience mismatch".to_string(),
363        ErrorKind::InvalidSignature => "signature does not verify".to_string(),
364        ErrorKind::MissingRequiredClaim(claim) => format!("missing `{claim}`"),
365        ErrorKind::InvalidAlgorithm => "key and algorithm disagree".to_string(),
366        other => format!("invalid: {other:?}"),
367    }
368}
369
370/// The group names a claim value carries.
371///
372/// Providers disagree on the shape. Authentik and Authelia publish an array of
373/// strings; Zitadel's `urn:zitadel:iam:org:project:roles` is an object keyed
374/// by role name (its documentation also shows that object wrapped in an
375/// array); a single string is taken as one group. Anything else contributes
376/// nothing rather than failing the token — a caller with unreadable groups is
377/// a caller in no groups.
378fn groups_from_claim(value: &serde_json::Value) -> BTreeSet<String> {
379    use serde_json::Value;
380    let mut groups = BTreeSet::new();
381    match value {
382        Value::String(group) => {
383            groups.insert(group.clone());
384        }
385        Value::Array(items) => {
386            for item in items {
387                match item {
388                    Value::String(group) => {
389                        groups.insert(group.clone());
390                    }
391                    Value::Object(roles) => groups.extend(roles.keys().cloned()),
392                    _ => {}
393                }
394            }
395        }
396        Value::Object(roles) => groups.extend(roles.keys().cloned()),
397        _ => {}
398    }
399    groups
400}
401
402async fn fetch_jwks(http: &reqwest::Client, jwks_uri: &str) -> anyhow::Result<JwkSet> {
403    http.get(jwks_uri)
404        .send()
405        .await
406        .and_then(reqwest::Response::error_for_status)
407        .map_err(|error| anyhow::Error::new(error).context(format!("GET {jwks_uri}")))?
408        .json()
409        .await
410        .map_err(|error| anyhow::Error::new(error).context(format!("{jwks_uri} is not a JWK set")))
411}
412
413/// The usable keys in a set. Keys of a kind this verifier cannot sign-check
414/// with (symmetric, unknown) are skipped rather than failing the whole set,
415/// and so is a key of a usable kind whose parameters do not decode — with a
416/// warning naming its `kid`, since a token it signed will then be refused as
417/// signed with a key the issuer does not publish.
418fn cache_keys(jwks: &JwkSet) -> Vec<CachedKey> {
419    jwks.keys
420        .iter()
421        .filter(|jwk| {
422            matches!(
423                jwk.algorithm,
424                AlgorithmParameters::RSA(_) | AlgorithmParameters::EllipticCurve(_)
425            )
426        })
427        .filter_map(|jwk: &Jwk| {
428            let kid = jwk.common.key_id.clone();
429            match DecodingKey::from_jwk(jwk) {
430                Ok(key) => Some(CachedKey { kid, key }),
431                Err(error) => {
432                    tracing::warn!(
433                        kid = kid.as_deref().unwrap_or("<none>"),
434                        error = %error,
435                        "OIDC key set publishes a key this server cannot use; skipping it"
436                    );
437                    None
438                }
439            }
440        })
441        .collect()
442}
443
444/// The checked-in RS256 key pair the OIDC tests sign with.
445#[cfg(any(test, feature = "test-support"))]
446pub mod test_support {
447    use super::{JwkSet, OidcSettings};
448    use jsonwebtoken::{Algorithm, EncodingKey, Header, encode};
449    use std::time::Duration;
450
451    /// The `kid` the fixture key is published under.
452    pub const KID: &str = "test-2026";
453    const PRIVATE_KEY_PEM: &str = include_str!("oidc/testdata/rs256-private.pem");
454    /// The JWKS document publishing the fixture key, verbatim.
455    pub const JWKS_JSON: &str = include_str!("oidc/testdata/jwks.json");
456
457    /// The fixture key set.
458    pub fn jwks() -> JwkSet {
459        serde_json::from_str(JWKS_JSON).expect("fixture JWKS parses")
460    }
461
462    /// Settings for `issuer`, accepting audience `notedthat`, with the defaults.
463    pub fn settings(issuer: &str) -> OidcSettings {
464        OidcSettings {
465            issuer: issuer.to_string(),
466            audiences: vec!["notedthat".to_string()],
467            username_claim: OidcSettings::DEFAULT_USERNAME_CLAIM.to_string(),
468            groups_claim: OidcSettings::DEFAULT_GROUPS_CLAIM.to_string(),
469            http_timeout: Duration::from_millis(OidcSettings::DEFAULT_HTTP_TIMEOUT_MS),
470            resource: None,
471            ca_cert: None,
472        }
473    }
474
475    /// Sign `claims` with the fixture key under [`KID`].
476    pub fn mint(claims: &serde_json::Value) -> String {
477        mint_with(Algorithm::RS256, Some(KID), claims)
478    }
479
480    /// Sign `claims` with the fixture key, choosing algorithm and `kid`.
481    pub fn mint_with(alg: Algorithm, kid: Option<&str>, claims: &serde_json::Value) -> String {
482        let mut header = Header::new(alg);
483        header.kid = kid.map(str::to_string);
484        let key = match alg {
485            Algorithm::HS256 | Algorithm::HS384 | Algorithm::HS512 => {
486                EncodingKey::from_secret(b"not-a-secret-anyone-shares")
487            }
488            _ => EncodingKey::from_rsa_pem(PRIVATE_KEY_PEM.as_bytes()).expect("fixture key"),
489        };
490        encode(&header, claims, &key).expect("token encodes")
491    }
492
493    /// Seconds since the Unix epoch, for `exp`/`iat`/`nbf` claims.
494    pub fn now() -> u64 {
495        std::time::SystemTime::now()
496            .duration_since(std::time::UNIX_EPOCH)
497            .expect("after the epoch")
498            .as_secs()
499    }
500
501    /// A well-formed set of claims for `subject` in `groups`, valid for an hour.
502    pub fn claims(issuer: &str, subject: &str, groups: &[&str]) -> serde_json::Value {
503        serde_json::json!({
504            "iss": issuer,
505            "sub": format!("{subject}-opaque-id"),
506            "aud": "notedthat",
507            "exp": now() + 3600,
508            "iat": now(),
509            "preferred_username": subject,
510            "groups": groups,
511        })
512    }
513}
514
515#[cfg(test)]
516mod tests {
517    use super::test_support::{JWKS_JSON, KID, claims, jwks, mint, mint_with, now, settings};
518    use super::*;
519    use jsonwebtoken::Algorithm;
520    use wiremock::matchers::{method, path};
521    use wiremock::{Mock, MockServer, ResponseTemplate};
522
523    const ISSUER: &str = "https://auth.example.com";
524
525    fn verifier() -> OidcVerifier {
526        OidcVerifier::from_jwks(settings(ISSUER), &jwks(), None)
527    }
528
529    async fn issuer_serving(jwks_body: &str) -> MockServer {
530        let server = MockServer::start().await;
531        let issuer = server.uri();
532        Mock::given(method("GET"))
533            .and(path("/.well-known/openid-configuration"))
534            .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
535                "issuer": issuer,
536                "jwks_uri": format!("{issuer}/jwks"),
537            })))
538            .mount(&server)
539            .await;
540        Mock::given(method("GET"))
541            .and(path("/jwks"))
542            .respond_with(ResponseTemplate::new(200).set_body_string(jwks_body))
543            .mount(&server)
544            .await;
545        server
546    }
547
548    #[tokio::test]
549    async fn a_token_signed_by_a_published_key_yields_subject_and_groups() {
550        // Given
551        let token = mint(&claims(ISSUER, "alice", &["editors", "staff"]));
552
553        // When
554        let identity = verifier().verify(&token).await.expect("verifies");
555
556        // Then
557        assert_eq!(identity.subject, "alice");
558        assert_eq!(
559            identity.groups,
560            ["editors", "staff"]
561                .map(str::to_string)
562                .into_iter()
563                .collect()
564        );
565    }
566
567    #[tokio::test]
568    async fn an_expired_token_is_rejected() {
569        // Given — expired well beyond the leeway.
570        let mut claims = claims(ISSUER, "alice", &[]);
571        claims["exp"] = serde_json::json!(now() - 3600);
572
573        // When / Then
574        let rejected = verifier()
575            .verify(&mint(&claims))
576            .await
577            .expect_err("expired");
578        assert_eq!(rejected.reason, "expired");
579    }
580
581    #[tokio::test]
582    async fn a_token_for_another_audience_is_rejected() {
583        let mut claims = claims(ISSUER, "alice", &[]);
584        claims["aud"] = serde_json::json!("someone-else");
585        let rejected = verifier().verify(&mint(&claims)).await.expect_err("aud");
586        assert_eq!(rejected.reason, "audience mismatch");
587    }
588
589    #[tokio::test]
590    async fn an_audience_array_containing_ours_is_accepted() {
591        // Given — Zitadel lists the project id and every client id.
592        let mut claims = claims(ISSUER, "alice", &[]);
593        claims["aud"] = serde_json::json!(["123456", "notedthat"]);
594
595        // When / Then
596        assert!(verifier().verify(&mint(&claims)).await.is_ok());
597    }
598
599    #[tokio::test]
600    async fn a_token_from_another_issuer_is_rejected() {
601        let rejected = verifier()
602            .verify(&mint(&claims("https://evil.example.com", "alice", &[])))
603            .await
604            .expect_err("iss");
605        assert_eq!(rejected.reason, "issuer mismatch");
606    }
607
608    #[tokio::test]
609    async fn hs256_is_rejected_before_any_key_is_consulted() {
610        let token = mint_with(Algorithm::HS256, Some(KID), &claims(ISSUER, "alice", &[]));
611        let rejected = verifier().verify(&token).await.expect_err("HS256");
612        assert!(rejected.reason.contains("HS256"), "{}", rejected.reason);
613    }
614
615    #[tokio::test]
616    async fn a_token_that_is_not_a_jwt_is_rejected() {
617        let rejected = verifier()
618            .verify("sk-live-not-a-jwt")
619            .await
620            .expect_err("shape");
621        assert_eq!(rejected.reason, "not a JWT");
622    }
623
624    #[tokio::test]
625    async fn a_token_signed_with_a_different_key_is_rejected() {
626        // Given — a verifier that publishes no key at all.
627        let empty = OidcVerifier::from_jwks(settings(ISSUER), &JwkSet { keys: Vec::new() }, None);
628
629        // When / Then
630        let rejected = empty
631            .verify(&mint(&claims(ISSUER, "alice", &[])))
632            .await
633            .expect_err("no key");
634        assert!(
635            rejected.reason.contains("does not publish"),
636            "{}",
637            rejected.reason
638        );
639    }
640
641    #[tokio::test]
642    async fn a_key_that_does_not_decode_is_skipped_not_fatal() {
643        // Given — a key set publishing, before the fixture key, an RSA key
644        // whose modulus is not base64url and so cannot become a DecodingKey.
645        let mut published: JwkSet = serde_json::from_str(JWKS_JSON).expect("fixture");
646        let mut broken: Jwk = published.keys[0].clone();
647        broken.common.key_id = Some("broken".into());
648        if let AlgorithmParameters::RSA(params) = &mut broken.algorithm {
649            params.n = "not base64url!".into();
650        }
651        published.keys.insert(0, broken);
652        let verifier = OidcVerifier::from_jwks(settings(ISSUER), &published, None);
653
654        // When / Then — the fixture key is still usable, and a token naming
655        // the broken kid is refused as unpublished rather than panicking.
656        assert!(
657            verifier
658                .verify(&mint(&claims(ISSUER, "alice", &[])))
659                .await
660                .is_ok()
661        );
662        let rejected = verifier
663            .verify(&mint_with(
664                Algorithm::RS256,
665                Some("broken"),
666                &claims(ISSUER, "alice", &[]),
667            ))
668            .await
669            .expect_err("broken kid");
670        assert!(
671            rejected.reason.contains("does not publish"),
672            "{}",
673            rejected.reason
674        );
675    }
676
677    #[tokio::test]
678    async fn a_token_without_a_kid_uses_the_only_key() {
679        let token = mint_with(Algorithm::RS256, None, &claims(ISSUER, "alice", &[]));
680        assert!(verifier().verify(&token).await.is_ok());
681    }
682
683    #[tokio::test]
684    async fn username_claim_falls_back_to_sub() {
685        // Given
686        let mut claims = claims(ISSUER, "alice", &[]);
687        claims.as_object_mut().unwrap().remove("preferred_username");
688
689        // When / Then
690        let identity = verifier().verify(&mint(&claims)).await.expect("verifies");
691        assert_eq!(identity.subject, "alice-opaque-id");
692    }
693
694    #[tokio::test]
695    async fn a_token_with_neither_username_nor_sub_is_rejected() {
696        let mut claims = claims(ISSUER, "alice", &[]);
697        let object = claims.as_object_mut().unwrap();
698        object.remove("preferred_username");
699        object.remove("sub");
700        assert!(verifier().verify(&mint(&claims)).await.is_err());
701    }
702
703    #[tokio::test]
704    async fn the_groups_claim_name_is_configurable_and_zitadel_roles_become_groups() {
705        // Given — Zitadel's shape under its claim name.
706        let mut settings = settings(ISSUER);
707        settings.groups_claim = "urn:zitadel:iam:org:project:roles".to_string();
708        let verifier = OidcVerifier::from_jwks(settings, &jwks(), None);
709        let mut claims = claims(ISSUER, "alice", &["ignored-under-the-default-name"]);
710        claims["urn:zitadel:iam:org:project:roles"] = serde_json::json!({
711            "admin": { "123": "example.zitadel.cloud" },
712            "editor": { "123": "example.zitadel.cloud" },
713        });
714
715        // When / Then
716        let identity = verifier.verify(&mint(&claims)).await.expect("verifies");
717        assert_eq!(
718            identity.groups,
719            ["admin", "editor"]
720                .map(str::to_string)
721                .into_iter()
722                .collect()
723        );
724    }
725
726    #[test]
727    fn every_documented_groups_shape_is_read() {
728        use serde_json::json;
729        let expect = |value: serde_json::Value, groups: &[&str]| {
730            assert_eq!(
731                groups_from_claim(&value),
732                groups.iter().map(ToString::to_string).collect(),
733                "{value}"
734            );
735        };
736        expect(json!(["a", "b"]), &["a", "b"]);
737        expect(json!("solo"), &["solo"]);
738        expect(json!({"role-a": {}, "role-b": {}}), &["role-a", "role-b"]);
739        expect(
740            json!([{"role-a": {}}, {"role-b": {}}]),
741            &["role-a", "role-b"],
742        );
743        expect(json!(["a", 7, null, {"b": {}}]), &["a", "b"]);
744        expect(json!(42), &[]);
745        expect(json!(null), &[]);
746    }
747
748    #[tokio::test]
749    async fn discovery_fetches_the_key_set_and_verifies() {
750        // Given
751        let server = issuer_serving(JWKS_JSON).await;
752
753        // When
754        let verifier = OidcVerifier::discover(settings(&server.uri()))
755            .await
756            .expect("discovers");
757
758        // Then
759        let token = mint(&claims(&server.uri(), "alice", &["editors"]));
760        assert_eq!(
761            verifier.verify(&token).await.expect("verifies").subject,
762            "alice"
763        );
764    }
765
766    #[tokio::test]
767    async fn discovery_with_a_mismatched_issuer_refuses_startup() {
768        // Given — the provider spells its issuer with a trailing slash.
769        let server = MockServer::start().await;
770        Mock::given(method("GET"))
771            .and(path("/.well-known/openid-configuration"))
772            .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
773                "issuer": format!("{}/", server.uri()),
774                "jwks_uri": format!("{}/jwks", server.uri()),
775            })))
776            .mount(&server)
777            .await;
778
779        // When
780        let error = OidcVerifier::discover(settings(&server.uri()))
781            .await
782            .expect_err("mismatch");
783
784        // Then
785        assert!(error.to_string().contains("trailing slash"), "{error}");
786    }
787
788    #[tokio::test]
789    async fn a_ca_bundle_that_is_not_pem_refuses_startup_before_any_request() {
790        // Given — a file that is not a certificate bundle, and one that is empty.
791        let dir = tempfile::tempdir().expect("tempdir");
792        let garbage = dir.path().join("ca.pem");
793        std::fs::write(&garbage, b"not a certificate").expect("write");
794        let mut settings = settings("https://auth.example.com");
795        settings.ca_cert = Some(garbage.clone());
796
797        // When
798        let error = OidcVerifier::discover(settings).await.expect_err("refused");
799
800        // Then — named, and no discovery request was attempted.
801        let message = error.to_string();
802        assert!(message.contains("NOTEDTHAT_OIDC_CA_CERT"), "{message}");
803        assert!(
804            message.contains(&garbage.display().to_string()),
805            "{message}"
806        );
807    }
808
809    #[tokio::test]
810    async fn discovery_failure_refuses_startup() {
811        let server = MockServer::start().await;
812        let error = OidcVerifier::discover(settings(&server.uri()))
813            .await
814            .expect_err("404");
815        assert!(
816            error.to_string().contains("openid-configuration"),
817            "{error}"
818        );
819    }
820
821    #[tokio::test]
822    async fn an_unknown_kid_triggers_one_refetch_and_then_verifies() {
823        // Given — a verifier holding no keys, whose issuer publishes the fixture.
824        let server = issuer_serving(JWKS_JSON).await;
825        let verifier = OidcVerifier::from_jwks(
826            settings(&server.uri()),
827            &JwkSet { keys: Vec::new() },
828            Some(format!("{}/jwks", server.uri())),
829        );
830        // Age the cache past the refetch interval.
831        verifier.mark_stale();
832
833        // When
834        let token = mint(&claims(&server.uri(), "alice", &[]));
835        let first = verifier.verify(&token).await;
836        let second = verifier.verify(&token).await;
837
838        // Then — one fetch served both.
839        assert!(first.is_ok(), "{first:?}");
840        assert!(second.is_ok());
841        assert_eq!(server.received_requests().await.unwrap().len(), 1);
842    }
843
844    #[tokio::test]
845    async fn refetches_are_rate_limited() {
846        // Given — an empty key set fetched just now.
847        let server = issuer_serving(JWKS_JSON).await;
848        let verifier = OidcVerifier::from_jwks(
849            settings(&server.uri()),
850            &JwkSet { keys: Vec::new() },
851            Some(format!("{}/jwks", server.uri())),
852        );
853
854        // When — tokens with an unknown kid arrive within the interval.
855        let token = mint(&claims(&server.uri(), "alice", &[]));
856        for _ in 0..5 {
857            assert!(verifier.verify(&token).await.is_err());
858        }
859
860        // Then — the issuer was not asked.
861        assert!(server.received_requests().await.unwrap().is_empty());
862    }
863
864    #[tokio::test]
865    async fn a_stale_key_set_is_refreshed_before_use() {
866        // Given — keys older than the maximum age, and a rotated issuer.
867        let server = issuer_serving(JWKS_JSON).await;
868        let verifier = OidcVerifier::from_jwks(
869            settings(&server.uri()),
870            &JwkSet { keys: Vec::new() },
871            Some(format!("{}/jwks", server.uri())),
872        );
873        verifier.mark_stale();
874
875        // When / Then
876        let token = mint(&claims(&server.uri(), "alice", &[]));
877        assert!(verifier.verify(&token).await.is_ok());
878    }
879
880    #[tokio::test]
881    async fn a_failed_refetch_keeps_the_previous_keys() {
882        // Given — a verifier whose issuer has gone away.
883        let server = MockServer::start().await;
884        let verifier = OidcVerifier::from_jwks(
885            settings(ISSUER),
886            &jwks(),
887            Some(format!("{}/jwks", server.uri())),
888        );
889        verifier.mark_stale();
890
891        // When / Then — the stale refresh fails, the old key still verifies.
892        let token = mint(&claims(ISSUER, "alice", &[]));
893        assert!(verifier.verify(&token).await.is_ok());
894    }
895
896    #[test]
897    fn debug_output_names_the_issuer_and_nothing_secret() {
898        let rendered = format!("{:?}", verifier());
899        assert!(rendered.contains(ISSUER));
900        assert!(!rendered.contains("keys:"));
901    }
902}