beck_rt/
oidc.rs

1//! An OpenID Connect **relying party** — the half of [`docs/10`](../../../../../docs/10-decisions.md)
2//! D6 that [`48`](../../../../../docs/48-identity-report.md) §48.13 named as unbuilt.
3//!
4//! [`crate::identity`] made an actor a decision of the runtime and offered two providers, both of
5//! which the process can also *mint* for: `DevIdentity` believes a claim and `SignedIdentity`
6//! verifies a shared secret. Neither suits a public identity provider, because a process that can
7//! verify a credential it could also have issued cannot tell "this user authenticated" from "this
8//! process said so". This module is the asymmetric answer: the signature is checked against a
9//! **public** key fetched from the issuer, so the only thing that can produce a credential is the
10//! issuer.
11//!
12//! # What is here
13//!
14//! * **Discovery** — `{issuer}/.well-known/openid-configuration`, which supplies the authorization,
15//!   token and JWKS endpoints so an operator configures one URL rather than four.
16//! * **A key set**, fetched over TLS, cached, and refetched when a `kid` misses.
17//! * **ID-token verification** — RS256/384/512, PS256/384/512, ES256 and ES384, and *nothing else*:
18//!   `none` and the HMAC family are refused by name, because "verify with whatever the token says"
19//!   is how a relying party is talked into treating a public key as a shared secret.
20//! * **The claim checks**, all of them, in one place: issuer, audience, authorized party, expiry,
21//!   not-before, and the nonce when there is a nonce to check.
22//! * **The authorization-code flow with PKCE**, so a browser can obtain a token in the first place.
23//!
24//! # Where the trust actually comes from
25//!
26//! Two links, and both are load-bearing. The signature says the *issuer* produced the token. TLS
27//! says the key set came from the issuer — there is nothing else protecting it, which is why the
28//! issuer must be an `https` URL and why there is no flag to relax that. [`crate::outbound`] is
29//! what makes the second link real, and its own tests are where a handshake is actually performed:
30//! this module is tested against a scripted [`beck_core::net::Outbound`], because a relying party
31//! tested against a server written beside it tests agreement with itself
32//! ([`docs/82`](../../../../../docs/82-the-edge-report.md) §82.10).
33//!
34//! # What is deliberately not here
35//!
36//! No **session of Beck's own**: the cookie the flow sets *is* the ID token, so a session lasts as
37//! long as the issuer said it should and no longer. That makes token refresh unnecessary rather
38//! than missing — there is no local session to keep alive — and it makes logout the deletion of one
39//! cookie. §48.13 of the report says what it costs: a user is sent back to the issuer when the token
40//! expires, and an issuer that mints five-minute tokens will do that every five minutes.
41//!
42//! No **UserInfo request**: the claims are the ID token's. `identity = managed()` is built and is
43//! `beck-infra`'s (§48.8) — what reaches here from it is one thing, [`Config::in_cluster`], and its
44//! field says what it costs.
45
46use std::collections::BTreeMap;
47use std::sync::atomic::{AtomicBool, AtomicI64, Ordering};
48use std::sync::{Arc, RwLock};
49
50use aws_lc_rs::signature::{
51    self, EcdsaVerificationAlgorithm, RsaParameters, RsaPublicKeyComponents, UnparsedPublicKey,
52};
53use beck_core::clock::Clock;
54use beck_core::net::{Outbound, Request};
55
56use crate::identity::{Actor, Identity, Rejected};
57
58/// How long a fetched key set is used before it is fetched again.
59///
60/// Five minutes. Short enough that a rotated key is picked up without anybody doing anything, long
61/// enough that a Beck process is not a load generator against its own identity provider — and the
62/// *interesting* case is not this one anyway: a token signed by a key the set does not carry
63/// triggers a refetch by itself, so this interval only decides how quickly a **retired** key stops
64/// being accepted.
65pub const REFRESH_EVERY_MS: i64 = 5 * 60 * 1_000;
66
67/// The floor between two key-set fetches, whatever asks for one.
68///
69/// A token naming an unknown `kid` schedules a refetch, and a `kid` is a string an anonymous client
70/// chooses — so without a floor, "verify this token" would be a request that makes a Beck process
71/// call its identity provider (§43.1's A2, one hop further out). Ten seconds.
72pub const REFETCH_FLOOR_MS: i64 = 10_000;
73
74/// How far the clock may be wrong before a token is refused for it.
75///
76/// Sixty seconds, in **both** directions: a token from an issuer whose clock is a minute ahead is
77/// not yet valid, and one whose clock is a minute behind has already expired. This is the number
78/// every OIDC library has and none of them explain; it is here because a distributed system without
79/// one refuses valid tokens on a machine whose NTP has drifted, and because the alternative to
80/// choosing it is choosing it accidentally.
81pub const CLOCK_SKEW_MS: i64 = 60_000;
82
83/// How long a login may take between `/auth/login` and `/auth/callback`.
84pub const LOGIN_WINDOW_MS: i64 = 10 * 60 * 1_000;
85
86/// Claims that describe the **token** rather than the person, and never reach the program.
87///
88/// A program reads `session.claims` to decide what somebody may do. `exp` is not something anybody
89/// may do; neither is `at_hash`. Excluding them is not a security control — the token is verified
90/// either way — it is the difference between a map of an identity and a dump of a wire format.
91const PROTOCOL_CLAIMS: &[&str] = &[
92    "aud", "azp", "at_hash", "c_hash", "exp", "iat", "iss", "jti", "nbf", "nonce", "typ",
93];
94
95// ---------------------------------------------------------------------------------------------
96// Configuration
97// ---------------------------------------------------------------------------------------------
98
99/// What an operator states. Everything else is discovered.
100#[derive(Clone, Debug)]
101pub struct Config {
102    /// The issuer, as an `https` URL. It is both what is fetched and what every token's `iss` is
103    /// compared against, which is why there is one field rather than two.
104    pub issuer: String,
105    pub client_id: String,
106    /// `None` is a public client, which is what a browser-facing app with PKCE is. A confidential
107    /// client authenticates to the token endpoint with this.
108    pub client_secret: Option<String>,
109    /// Where the issuer sends the browser back. Registered with the issuer, so it is stated rather
110    /// than derived from whatever `Host` a request happened to carry.
111    pub redirect_uri: String,
112    /// The scopes asked for. `openid` is mandatory and is added if it is missing.
113    pub scopes: String,
114    /// Which claim names the actor. `sub` is the only one an issuer guarantees is stable and
115    /// unique, which is why it is the default and why choosing another is a decision.
116    pub actor_claim: String,
117    /// Whether the issuer may be reached over a plaintext hop.
118    ///
119    /// **False except for a provider this deployment provisioned.** An external issuer must be
120    /// `https`, because the key set has no integrity protection but the transport. A *managed* one
121    /// is a `Service` §6.5 emitted, in the application's own namespace, reachable only through a
122    /// NetworkPolicy §6.5 wrote — so what protects the key set there is the policy, and §6.5's
123    /// gateway is where TLS is terminated for everything that crosses a network anybody else is on
124    /// ([`docs/48`](../../../../../docs/48-identity-report.md) §48.8).
125    ///
126    /// Private, and set by [`Config::in_cluster`] rather than assignable: a `pub` bool here would
127    /// be the flag §48.4 says does not exist.
128    in_cluster: bool,
129}
130
131impl Config {
132    /// A relying party to somebody else's identity provider. The issuer must be `https`.
133    pub fn new(issuer: &str, client_id: &str, redirect_uri: &str) -> Config {
134        Config {
135            issuer: issuer.trim_end_matches('/').to_string(),
136            client_id: client_id.to_string(),
137            client_secret: None,
138            redirect_uri: redirect_uri.to_string(),
139            scopes: "openid profile email".to_string(),
140            actor_claim: "sub".to_string(),
141            in_cluster: false,
142        }
143    }
144
145    /// A relying party to a provider **this deployment provisioned**, reached inside one namespace.
146    ///
147    /// A second constructor rather than a field, so that the trust story is chosen by name at the
148    /// one place that knows which of the two this is — `identity = managed()` in the program, read
149    /// by `beck run`. See [`Config::in_cluster`]'s field for what it costs.
150    pub fn in_cluster(issuer: &str, client_id: &str, redirect_uri: &str) -> Config {
151        Config {
152            in_cluster: true,
153            ..Config::new(issuer, client_id, redirect_uri)
154        }
155    }
156}
157
158/// The endpoints, as the issuer published them.
159#[derive(Clone, Debug, PartialEq, Eq)]
160pub struct Provider {
161    pub issuer: String,
162    pub authorization_endpoint: String,
163    pub token_endpoint: String,
164    pub jwks_uri: String,
165}
166
167// ---------------------------------------------------------------------------------------------
168// The relying party
169// ---------------------------------------------------------------------------------------------
170
171/// Why a token was refused, in the operator's words.
172///
173/// [`Rejected`] is what the *client* gets and has three values on purpose ([`docs/48`](../../../../../docs/48-identity-report.md)
174/// §48.2). This is the other half of that decision: an operator debugging a login needs to know
175/// that the audience was wrong, and telling the client would be telling an attacker which of a
176/// dozen checks to work on next.
177#[derive(Clone, Debug, PartialEq, Eq)]
178pub struct Refusal {
179    pub client: Rejected,
180    pub why: String,
181}
182
183impl Refusal {
184    fn invalid(why: impl Into<String>) -> Refusal {
185        Refusal {
186            client: Rejected::Invalid,
187            why: why.into(),
188        }
189    }
190
191    fn expired(why: impl Into<String>) -> Refusal {
192        Refusal {
193            client: Rejected::Expired,
194            why: why.into(),
195        }
196    }
197}
198
199#[derive(Debug, Default)]
200struct KeySet {
201    keys: Vec<Key>,
202    fetched_at_millis: i64,
203}
204
205/// A relying party: one issuer, one client, one key set.
206#[derive(Debug)]
207pub struct RelyingParty {
208    config: Config,
209    clock: Arc<dyn Clock>,
210    http: Arc<dyn Outbound>,
211    provider: RwLock<Option<Provider>>,
212    keys: RwLock<KeySet>,
213    /// Set when a token named a `kid` the set does not carry. Read by the refresher rather than
214    /// acted on here: verification is on the connection path and must not make a network call.
215    stale: AtomicBool,
216    /// When the last fetch was attempted, successful or not — [`REFETCH_FLOOR_MS`]'s counter.
217    last_attempt_millis: AtomicI64,
218    /// The key the login transaction cookie is sealed with. Random per process: a restart
219    /// invalidates logins that are in flight, which is ten minutes of nobody.
220    seal: [u8; 32],
221}
222
223impl RelyingParty {
224    pub fn new(config: Config, clock: Arc<dyn Clock>, http: Arc<dyn Outbound>) -> RelyingParty {
225        let mut seal = [0u8; 32];
226        aws_lc_rs::rand::fill(&mut seal).expect("the system random source answers");
227        RelyingParty {
228            config,
229            clock,
230            http,
231            provider: RwLock::new(None),
232            keys: RwLock::new(KeySet::default()),
233            stale: AtomicBool::new(false),
234            last_attempt_millis: AtomicI64::new(i64::MIN),
235            seal,
236        }
237    }
238
239    pub fn config(&self) -> &Config {
240        &self.config
241    }
242
243    /// The endpoints, once discovery has run.
244    pub fn provider(&self) -> Option<Provider> {
245        self.provider.read().expect("not poisoned").clone()
246    }
247
248    pub fn key_count(&self) -> usize {
249        self.keys.read().expect("not poisoned").keys.len()
250    }
251
252    /// Fetch the discovery document and the key set.
253    ///
254    /// Called once at startup — a process that cannot reach its identity provider should say so
255    /// then rather than when the first person tries to log in — and then on
256    /// [`REFRESH_EVERY_MS`], and then whenever a token names an unknown key.
257    pub fn refresh(&self) -> Result<(), String> {
258        let now = self.clock.now_millis();
259        self.last_attempt_millis.store(now, Ordering::Relaxed);
260        let provider = match self.provider() {
261            Some(p) => p,
262            None => {
263                let p = self.discover()?;
264                *self.provider.write().expect("not poisoned") = Some(p.clone());
265                p
266            }
267        };
268        let body = self.get(&provider.jwks_uri)?;
269        let keys = parse_jwks(&body)?;
270        if keys.is_empty() {
271            return Err(format!(
272                "`{}` published a key set with no key this relying party can use",
273                provider.jwks_uri
274            ));
275        }
276        *self.keys.write().expect("not poisoned") = KeySet {
277            keys,
278            fetched_at_millis: now,
279        };
280        self.stale.store(false, Ordering::Relaxed);
281        Ok(())
282    }
283
284    /// Whether [`RelyingParty::refresh`] is due — the interval has passed, or a token named a key
285    /// the set does not carry and the floor between fetches has passed.
286    pub fn refresh_due(&self) -> bool {
287        let now = self.clock.now_millis();
288        let last = self.last_attempt_millis.load(Ordering::Relaxed);
289        if now.saturating_sub(last) < REFETCH_FLOOR_MS {
290            return false;
291        }
292        self.stale.load(Ordering::Relaxed)
293            || now.saturating_sub(self.keys.read().expect("not poisoned").fetched_at_millis)
294                >= REFRESH_EVERY_MS
295    }
296
297    fn discover(&self) -> Result<Provider, String> {
298        let url = format!("{}/.well-known/openid-configuration", self.config.issuer);
299        let body = self.get(&url)?;
300        let doc: serde_json::Value =
301            serde_json::from_str(&body).map_err(|e| format!("`{url}` is not JSON: {e}"))?;
302        let field = |name: &str| -> Result<String, String> {
303            doc.get(name)
304                .and_then(|v| v.as_str())
305                .map(|s| s.to_string())
306                .ok_or_else(|| format!("`{url}` has no `{name}`"))
307        };
308        let provider = Provider {
309            issuer: field("issuer")?,
310            authorization_endpoint: field("authorization_endpoint")?,
311            token_endpoint: field("token_endpoint")?,
312            jwks_uri: field("jwks_uri")?,
313        };
314        // The issuer identifier is the one thing in this document that is *also* in every token, so
315        // a document whose `issuer` is not the one we asked for is a document about somebody else.
316        if provider.issuer.trim_end_matches('/') != self.config.issuer {
317            return Err(format!(
318                "`{url}` says its issuer is `{}`, which is not `{}`",
319                provider.issuer, self.config.issuer
320            ));
321        }
322        // Every endpoint on the issuer's own host, which narrows the egress rule to one name and
323        // stops a discovery document moving the token exchange — and the client secret with it —
324        // somewhere else. An issuer that splits its endpoints across hosts is not usable here, and
325        // that is a limit rather than an oversight (§48.13).
326        let host = url_host(&self.config.issuer, self.config.in_cluster)?;
327        for (name, endpoint) in [
328            ("authorization_endpoint", &provider.authorization_endpoint),
329            ("token_endpoint", &provider.token_endpoint),
330            ("jwks_uri", &provider.jwks_uri),
331        ] {
332            let elsewhere = url_host(endpoint, self.config.in_cluster)?;
333            if elsewhere != host {
334                return Err(format!(
335                    "`{name}` is on `{elsewhere}`, and this relying party only reaches `{host}`"
336                ));
337            }
338        }
339        Ok(provider)
340    }
341
342    fn get(&self, url: &str) -> Result<String, String> {
343        let target = Target::parse(url, self.config.in_cluster)?;
344        let reply = self
345            .http
346            .fetch(
347                &Request {
348                    host: Arc::from(target.host.as_str()),
349                    port: target.port,
350                    tls: target.tls,
351                    method: Arc::from("GET"),
352                    path: Arc::from(target.path.as_str()),
353                    headers: vec![(Arc::from("accept"), Arc::from("application/json"))],
354                    body: Arc::from(""),
355                },
356                &beck_core::net::Stop::never(),
357            )
358            .map_err(|e| format!("`{url}` was not reached: {e:?}"))?;
359        if reply.status != 200 {
360            return Err(format!("`{url}` answered {}", reply.status));
361        }
362        Ok(reply.body.to_string())
363    }
364
365    // ------------------------------------------------------------------------------ verification
366
367    /// Verify an ID token and say who it is about.
368    ///
369    /// `nonce` is `Some` exactly once in a token's life — at the callback, where the relying party
370    /// still remembers what it asked for. On every later connection there is nothing to compare
371    /// against, and pretending otherwise would be a check that always passes.
372    pub fn verify_id_token(&self, token: &str, nonce: Option<&str>) -> Result<Verified, Refusal> {
373        let (signed, signature, header, payload) = split(token)?;
374        let alg = header
375            .get("alg")
376            .and_then(|v| v.as_str())
377            .ok_or_else(|| Refusal::invalid("the token's header names no algorithm"))?;
378        let alg = Algorithm::named(alg).ok_or_else(|| {
379            Refusal::invalid(format!(
380                "`{alg}` is not an algorithm this relying party verifies"
381            ))
382        })?;
383        let kid = header.get("kid").and_then(|v| v.as_str());
384
385        let keys = self.keys.read().expect("not poisoned");
386        let candidates: Vec<&Key> = keys
387            .keys
388            .iter()
389            .filter(|k| k.usable_for(alg, kid))
390            .collect();
391        if candidates.is_empty() {
392            // The key set may have rotated under us. Say so to the refresher rather than fetching
393            // here: this runs on the connection path, and an anonymous client chooses the `kid`.
394            self.stale.store(true, Ordering::Relaxed);
395            return Err(Refusal::invalid(match kid {
396                Some(kid) => format!("no key `{kid}` for {alg:?} in the issuer's key set"),
397                None => format!("no {alg:?} key in the issuer's key set"),
398            }));
399        }
400        if !candidates
401            .iter()
402            .any(|k| k.verifies(alg, signed.as_bytes(), &signature))
403        {
404            return Err(Refusal::invalid("the signature does not verify"));
405        }
406        drop(keys);
407
408        self.check_claims(&payload, nonce)
409    }
410
411    fn check_claims(
412        &self,
413        payload: &serde_json::Value,
414        nonce: Option<&str>,
415    ) -> Result<Verified, Refusal> {
416        let str_claim = |name: &str| payload.get(name).and_then(|v| v.as_str());
417
418        match str_claim("iss") {
419            Some(iss) if iss.trim_end_matches('/') == self.config.issuer => {}
420            Some(iss) => {
421                return Err(Refusal::invalid(format!(
422                    "the token is from `{iss}`, not `{}`",
423                    self.config.issuer
424                )))
425            }
426            None => return Err(Refusal::invalid("the token names no issuer")),
427        }
428
429        // `aud` is a string or an array of them, and a token with several audiences must say which
430        // party it is *for* — otherwise a token minted for another client of the same issuer is a
431        // token for this one.
432        let audiences = audiences(payload);
433        if !audiences.iter().any(|a| a == &self.config.client_id) {
434            return Err(Refusal::invalid(format!(
435                "the token's audience is {audiences:?}, which does not include `{}`",
436                self.config.client_id
437            )));
438        }
439        if audiences.len() > 1 {
440            match str_claim("azp") {
441                Some(azp) if azp == self.config.client_id => {}
442                _ => return Err(Refusal::invalid(
443                    "the token has several audiences and its authorized party is not this client",
444                )),
445            }
446        }
447
448        let now = self.clock.now_millis();
449        let seconds = |name: &str| payload.get(name).and_then(|v| v.as_i64());
450        let exp = seconds("exp").ok_or_else(|| Refusal::invalid("the token has no expiry"))?;
451        let expires_at = exp.saturating_mul(1_000);
452        if now.saturating_sub(CLOCK_SKEW_MS) >= expires_at {
453            return Err(Refusal::expired(format!(
454                "the token expired at {expires_at} and it is {now}"
455            )));
456        }
457        if let Some(nbf) = seconds("nbf") {
458            if now.saturating_add(CLOCK_SKEW_MS) < nbf.saturating_mul(1_000) {
459                return Err(Refusal::invalid("the token is not valid yet"));
460            }
461        }
462
463        if let Some(expected) = nonce {
464            match str_claim("nonce") {
465                Some(got) if got == expected => {}
466                Some(_) => return Err(Refusal::invalid("the token replies to another login")),
467                None => return Err(Refusal::invalid("the token carries no nonce")),
468            }
469        }
470
471        let subject = str_claim(&self.config.actor_claim)
472            .filter(|s| !s.is_empty())
473            .ok_or_else(|| {
474                Refusal::invalid(format!(
475                    "the token has no `{}` to name an actor with",
476                    self.config.actor_claim
477                ))
478            })?
479            .to_string();
480
481        Ok(Verified {
482            subject,
483            claims: person_claims(payload),
484            expires_at_millis: expires_at,
485        })
486    }
487
488    // ------------------------------------------------------------------------------- the flow
489
490    /// Where to send a browser that wants to log in, and the cookie that remembers what we asked.
491    ///
492    /// `return_to` is a **path**, checked to be one: a redirect target a client supplies is an open
493    /// redirect if it can name a host.
494    pub fn begin_login(&self, return_to: &str) -> Result<Login, String> {
495        let provider = self
496            .provider()
497            .ok_or_else(|| "the identity provider has not been discovered yet".to_string())?;
498        let return_to = if return_to.starts_with('/') && !return_to.starts_with("//") {
499            return_to
500        } else {
501            "/"
502        };
503        let state = random_token();
504        let nonce = random_token();
505        let verifier = random_token();
506        let challenge = beck_core::digest::base64_encode_bytes(
507            aws_lc_rs::digest::digest(&aws_lc_rs::digest::SHA256, verifier.as_bytes()).as_ref(),
508        );
509
510        let scopes = if self.config.scopes.split_whitespace().any(|s| s == "openid") {
511            self.config.scopes.clone()
512        } else {
513            format!("openid {}", self.config.scopes)
514        };
515        let query = [
516            ("response_type", "code"),
517            ("client_id", &self.config.client_id),
518            ("redirect_uri", &self.config.redirect_uri),
519            ("scope", &scopes),
520            ("state", &state),
521            ("nonce", &nonce),
522            ("code_challenge", &challenge),
523            ("code_challenge_method", "S256"),
524        ]
525        .iter()
526        .map(|(k, v)| format!("{k}={}", percent_encode(v)))
527        .collect::<Vec<_>>()
528        .join("&");
529        let joiner = if provider.authorization_endpoint.contains('?') {
530            '&'
531        } else {
532            '?'
533        };
534
535        Ok(Login {
536            url: format!("{}{joiner}{query}", provider.authorization_endpoint),
537            transaction: self.seal_transaction(&Transaction {
538                state,
539                nonce,
540                verifier,
541                return_to: return_to.to_string(),
542                expires_at_millis: self.clock.now_millis() + LOGIN_WINDOW_MS,
543            }),
544        })
545    }
546
547    /// The browser is back. Check what it brought, swap the code for a token, and verify it.
548    ///
549    /// The result is the ID token itself: it is what the session cookie carries, so that every
550    /// later connection re-verifies the *issuer's* signature rather than one this process made up.
551    pub fn complete_login(&self, query: &str, transaction: &str) -> Result<Completion, String> {
552        let tx = self.open_transaction(transaction)?;
553        let params = query_params(query);
554        let got = |name: &str| {
555            params
556                .iter()
557                .find(|(k, _)| k == name)
558                .map(|(_, v)| v.clone())
559        };
560
561        if let Some(error) = got("error") {
562            return Err(format!(
563                "the identity provider refused the login: {error}{}",
564                got("error_description")
565                    .map(|d| format!(" ({d})"))
566                    .unwrap_or_default()
567            ));
568        }
569        let state = got("state").ok_or_else(|| "the reply carries no state".to_string())?;
570        // Constant-time, because this is a comparison of a secret against something an attacker
571        // supplies and can vary one byte at a time.
572        if !beck_core::digest::same(&state, &tx.state) {
573            return Err("the reply's state is not the one this login asked for".to_string());
574        }
575        let code = got("code").ok_or_else(|| "the reply carries no code".to_string())?;
576
577        let id_token = self.exchange(&code, &tx.verifier)?;
578        let verified = self
579            .verify_id_token(&id_token, Some(&tx.nonce))
580            .map_err(|r| r.why)?;
581        Ok(Completion {
582            id_token,
583            verified,
584            return_to: tx.return_to,
585        })
586    }
587
588    fn exchange(&self, code: &str, verifier: &str) -> Result<String, String> {
589        let provider = self
590            .provider()
591            .ok_or_else(|| "the identity provider has not been discovered yet".to_string())?;
592        let target = Target::parse(&provider.token_endpoint, self.config.in_cluster)?;
593
594        let mut form = vec![
595            ("grant_type", "authorization_code".to_string()),
596            ("code", code.to_string()),
597            ("redirect_uri", self.config.redirect_uri.clone()),
598            ("client_id", self.config.client_id.clone()),
599            ("code_verifier", verifier.to_string()),
600        ];
601        let mut headers = vec![
602            (
603                Arc::from("content-type"),
604                Arc::from("application/x-www-form-urlencoded"),
605            ),
606            (Arc::from("accept"), Arc::from("application/json")),
607        ];
608        // `client_secret_basic` when there is a secret, PKCE alone when there is not — which is a
609        // public client, and is what a browser-facing app is.
610        if let Some(secret) = &self.config.client_secret {
611            let basic = beck_core::digest::base64_encode(&format!(
612                "{}:{secret}",
613                percent_encode(&self.config.client_id)
614            ));
615            headers.push((
616                Arc::from("authorization"),
617                Arc::from(format!("Basic {basic}")),
618            ));
619        } else {
620            form.retain(|(k, _)| *k != "client_secret");
621        }
622        let body = form
623            .iter()
624            .map(|(k, v)| format!("{k}={}", percent_encode(v)))
625            .collect::<Vec<_>>()
626            .join("&");
627        headers.push((
628            Arc::from("content-length"),
629            Arc::from(body.len().to_string()),
630        ));
631
632        let reply = self
633            .http
634            .fetch(
635                &Request {
636                    host: Arc::from(target.host.as_str()),
637                    port: target.port,
638                    tls: target.tls,
639                    method: Arc::from("POST"),
640                    path: Arc::from(target.path.as_str()),
641                    headers,
642                    body: Arc::from(body.as_str()),
643                },
644                &beck_core::net::Stop::never(),
645            )
646            .map_err(|e| format!("the token endpoint was not reached: {e:?}"))?;
647        if reply.status != 200 {
648            // The body of a failed token exchange names the client and sometimes the code; the
649            // status is what an operator needs and the rest is not ours to log.
650            return Err(format!("the token endpoint answered {}", reply.status));
651        }
652        let doc: serde_json::Value = serde_json::from_str(&reply.body)
653            .map_err(|e| format!("the token endpoint did not answer JSON: {e}"))?;
654        doc.get("id_token")
655            .and_then(|v| v.as_str())
656            .map(|s| s.to_string())
657            .ok_or_else(|| "the token endpoint's answer carries no id_token".to_string())
658    }
659
660    // --------------------------------------------------------------------- the sealed transaction
661
662    fn seal_transaction(&self, tx: &Transaction) -> String {
663        let payload = format!(
664            "{}.{}.{}.{}.{}",
665            tx.state,
666            tx.nonce,
667            tx.verifier,
668            tx.expires_at_millis,
669            beck_core::digest::base64_encode(&tx.return_to)
670        );
671        let mac = blake3::keyed_hash(&self.seal, payload.as_bytes());
672        format!("{payload}.{}", mac.to_hex())
673    }
674
675    fn open_transaction(&self, sealed: &str) -> Result<Transaction, String> {
676        let (payload, mac) = sealed
677            .rsplit_once('.')
678            .ok_or_else(|| "the login has no cookie to check against".to_string())?;
679        let expected = blake3::keyed_hash(&self.seal, payload.as_bytes());
680        let given: blake3::Hash = mac
681            .parse()
682            .map_err(|_| "the login cookie is malformed".to_string())?;
683        if expected != given {
684            return Err("the login cookie was not sealed by this process".to_string());
685        }
686        let parts: Vec<&str> = payload.split('.').collect();
687        let [state, nonce, verifier, expiry, return_to] = parts[..] else {
688            return Err("the login cookie is malformed".to_string());
689        };
690        let expires_at_millis: i64 = expiry
691            .parse()
692            .map_err(|_| "the login cookie is malformed".to_string())?;
693        if self.clock.now_millis() >= expires_at_millis {
694            return Err("the login took longer than the window allows".to_string());
695        }
696        Ok(Transaction {
697            state: state.to_string(),
698            nonce: nonce.to_string(),
699            verifier: verifier.to_string(),
700            return_to: beck_core::digest::base64_decode(return_to)
701                .map_err(|_| "the login cookie is malformed".to_string())?,
702            expires_at_millis,
703        })
704    }
705}
706
707/// What a verified ID token said.
708#[derive(Clone, Debug, PartialEq, Eq)]
709pub struct Verified {
710    pub subject: String,
711    pub claims: BTreeMap<Arc<str>, Arc<str>>,
712    pub expires_at_millis: i64,
713}
714
715/// Where to send the browser, and what to remember while it is gone.
716#[derive(Clone, Debug)]
717pub struct Login {
718    pub url: String,
719    /// The sealed transaction, for the cookie. It carries the state, the nonce and the PKCE
720    /// verifier — so the relying party holds no per-login memory and a login cannot be a way to
721    /// make it allocate.
722    pub transaction: String,
723}
724
725/// The browser came back and the token verified.
726#[derive(Clone, Debug)]
727pub struct Completion {
728    /// The session cookie's value. It is the issuer's token, not one this process made.
729    pub id_token: String,
730    pub verified: Verified,
731    pub return_to: String,
732}
733
734#[derive(Clone, Debug)]
735struct Transaction {
736    state: String,
737    nonce: String,
738    verifier: String,
739    return_to: String,
740    expires_at_millis: i64,
741}
742
743impl Identity for RelyingParty {
744    /// The claim is the ID token, from the session cookie or from the `hello` frame.
745    ///
746    /// There is no nonce here — see [`RelyingParty::verify_id_token`]. Everything else is checked
747    /// on **every** connection rather than once at login, which is what makes the session's
748    /// lifetime the issuer's decision.
749    fn verify(&self, claim: &str) -> Result<Actor, Rejected> {
750        if claim.is_empty() {
751            return Err(Rejected::Missing);
752        }
753        match self.verify_id_token(claim, None) {
754            Ok(v) => Ok(Actor::verified(&v.subject, v.claims)),
755            Err(refusal) => {
756                // Specific to the operator, coarse to the client (`docs/48` §48.2). This is the
757                // only place the distinction between a dozen checks survives.
758                tracing::warn!(why = %refusal.why, "an id token did not verify");
759                Err(refusal.client)
760            }
761        }
762    }
763
764    fn kind(&self) -> &'static str {
765        "oidc"
766    }
767
768    fn login(&self) -> Option<&RelyingParty> {
769        Some(self)
770    }
771}
772
773// ---------------------------------------------------------------------------------------------
774// JOSE
775// ---------------------------------------------------------------------------------------------
776
777/// The algorithms this relying party verifies, and by construction the only ones.
778///
779/// The absences are the point. `none` is an algorithm in the JWS registry and means "there is no
780/// signature"; the HMAC family is symmetric, so a relying party that accepted `HS256` could be
781/// handed a token signed with the issuer's own **public** key as the shared secret. Both are the
782/// canonical way a relying party is broken, and both are refused by not being here rather than by
783/// a check somebody has to remember to write.
784#[derive(Clone, Copy, Debug, PartialEq, Eq)]
785enum Algorithm {
786    Rs256,
787    Rs384,
788    Rs512,
789    Ps256,
790    Ps384,
791    Ps512,
792    Es256,
793    Es384,
794}
795
796impl Algorithm {
797    fn named(alg: &str) -> Option<Algorithm> {
798        Some(match alg {
799            "RS256" => Algorithm::Rs256,
800            "RS384" => Algorithm::Rs384,
801            "RS512" => Algorithm::Rs512,
802            "PS256" => Algorithm::Ps256,
803            "PS384" => Algorithm::Ps384,
804            "PS512" => Algorithm::Ps512,
805            "ES256" => Algorithm::Es256,
806            "ES384" => Algorithm::Es384,
807            _ => return None,
808        })
809    }
810
811    fn family(self) -> Family {
812        match self {
813            Algorithm::Es256 => Family::Ec("P-256"),
814            Algorithm::Es384 => Family::Ec("P-384"),
815            _ => Family::Rsa,
816        }
817    }
818
819    fn rsa(self) -> &'static RsaParameters {
820        match self {
821            Algorithm::Rs256 => &signature::RSA_PKCS1_2048_8192_SHA256,
822            Algorithm::Rs384 => &signature::RSA_PKCS1_2048_8192_SHA384,
823            Algorithm::Rs512 => &signature::RSA_PKCS1_2048_8192_SHA512,
824            Algorithm::Ps256 => &signature::RSA_PSS_2048_8192_SHA256,
825            Algorithm::Ps384 => &signature::RSA_PSS_2048_8192_SHA384,
826            Algorithm::Ps512 => &signature::RSA_PSS_2048_8192_SHA512,
827            // Not reachable: the caller matches on `family` first, and an EC algorithm has no RSA
828            // parameters to give. Returning the narrowest thing rather than panicking, so a future
829            // caller that gets this wrong fails a signature instead of the process.
830            Algorithm::Es256 | Algorithm::Es384 => &signature::RSA_PKCS1_2048_8192_SHA256,
831        }
832    }
833
834    /// The **fixed** ECDSA encoding, which is what JWS uses: `r` and `s` concatenated at the
835    /// curve's width. The ASN.1 encoding is a different byte string for the same signature, and a
836    /// verifier that accepted both would accept two spellings of one token.
837    fn ecdsa(self) -> &'static EcdsaVerificationAlgorithm {
838        match self {
839            Algorithm::Es384 => &signature::ECDSA_P384_SHA384_FIXED,
840            _ => &signature::ECDSA_P256_SHA256_FIXED,
841        }
842    }
843}
844
845#[derive(Clone, Copy, Debug, PartialEq, Eq)]
846enum Family {
847    Rsa,
848    Ec(&'static str),
849}
850
851/// One key from the issuer's set.
852#[derive(Clone, Debug, PartialEq, Eq)]
853struct Key {
854    kid: Option<String>,
855    /// The `alg` the key declares, if it declares one. A key that names an algorithm may only be
856    /// used for that one — otherwise an issuer publishing one key for signing and one for
857    /// encryption is an issuer whose encryption key verifies signatures.
858    alg: Option<String>,
859    material: Material,
860}
861
862#[derive(Clone, Debug, PartialEq, Eq)]
863enum Material {
864    Rsa {
865        n: Vec<u8>,
866        e: Vec<u8>,
867    },
868    /// `0x04 || x || y`, the uncompressed point encoding both aws-lc-rs and the JWK spec agree on.
869    Ec {
870        curve: &'static str,
871        point: Vec<u8>,
872    },
873}
874
875impl Key {
876    fn usable_for(&self, alg: Algorithm, kid: Option<&str>) -> bool {
877        // A token that names a `kid` may only be verified by that key. A token that names none is
878        // offered every key of the right shape, which is what a set with one key needs.
879        if let Some(kid) = kid {
880            if self.kid.as_deref() != Some(kid) {
881                return false;
882            }
883        }
884        if let Some(declared) = &self.alg {
885            if Algorithm::named(declared) != Some(alg) {
886                return false;
887            }
888        }
889        match (&self.material, alg.family()) {
890            (Material::Rsa { .. }, Family::Rsa) => true,
891            (Material::Ec { curve, .. }, Family::Ec(wanted)) => *curve == wanted,
892            _ => false,
893        }
894    }
895
896    fn verifies(&self, alg: Algorithm, message: &[u8], signature: &[u8]) -> bool {
897        match &self.material {
898            Material::Rsa { n, e } => RsaPublicKeyComponents { n, e }
899                .verify(alg.rsa(), message, signature)
900                .is_ok(),
901            Material::Ec { point, .. } => UnparsedPublicKey::new(alg.ecdsa(), point)
902                .verify(message, signature)
903                .is_ok(),
904        }
905    }
906}
907
908/// The signing input, the signature, and the two decoded segments.
909///
910/// The signing input is the token's own bytes — `header.payload` exactly as received — rather than
911/// a re-encoding of what was parsed out of them. Re-encoding is how a verifier ends up checking a
912/// signature over something the issuer did not sign.
913fn split(token: &str) -> Result<(&str, Vec<u8>, serde_json::Value, serde_json::Value), Refusal> {
914    let mut parts = token.split('.');
915    let (Some(header), Some(payload), Some(signature), None) =
916        (parts.next(), parts.next(), parts.next(), parts.next())
917    else {
918        return Err(Refusal::invalid(
919            "a JWS compact serialization has exactly three segments",
920        ));
921    };
922    let signed = &token[..header.len() + 1 + payload.len()];
923    let signature = beck_core::digest::base64_decode_bytes(signature)
924        .map_err(|e| Refusal::invalid(format!("the signature segment is not base64url: {e}")))?;
925    let decode = |segment: &str, what: &str| -> Result<serde_json::Value, Refusal> {
926        let bytes = beck_core::digest::base64_decode_bytes(segment)
927            .map_err(|e| Refusal::invalid(format!("the {what} is not base64url: {e}")))?;
928        serde_json::from_slice(&bytes)
929            .map_err(|e| Refusal::invalid(format!("the {what} is not JSON: {e}")))
930    };
931    Ok((
932        signed,
933        signature,
934        decode(header, "header")?,
935        decode(payload, "payload")?,
936    ))
937}
938
939fn parse_jwks(body: &str) -> Result<Vec<Key>, String> {
940    let doc: serde_json::Value =
941        serde_json::from_str(body).map_err(|e| format!("the key set is not JSON: {e}"))?;
942    let keys = doc
943        .get("keys")
944        .and_then(|k| k.as_array())
945        .ok_or_else(|| "the key set has no `keys` array".to_string())?;
946    // A key this build cannot use is skipped rather than fatal: an issuer publishing an Ed25519
947    // key beside its RSA one is an issuer doing nothing wrong, and refusing the whole set would
948    // make somebody else's roadmap our outage.
949    Ok(keys.iter().filter_map(parse_jwk).collect())
950}
951
952fn parse_jwk(jwk: &serde_json::Value) -> Option<Key> {
953    let field = |name: &str| jwk.get(name).and_then(|v| v.as_str());
954    // `use` is optional; when it is present and says `enc`, this is an encryption key.
955    if matches!(field("use"), Some(u) if u != "sig") {
956        return None;
957    }
958    let bytes = |name: &str| beck_core::digest::base64_decode_bytes(field(name)?).ok();
959    let material = match field("kty")? {
960        "RSA" => Material::Rsa {
961            n: bytes("n")?,
962            e: bytes("e")?,
963        },
964        "EC" => {
965            let curve = match field("crv")? {
966                "P-256" => "P-256",
967                "P-384" => "P-384",
968                _ => return None,
969            };
970            let (x, y) = (bytes("x")?, bytes("y")?);
971            // Each coordinate is left-padded to the curve's width: a JWK writes them fixed-width,
972            // and a shorter one is a key we cannot assemble rather than one to guess at.
973            let width = if curve == "P-256" { 32 } else { 48 };
974            if x.len() != width || y.len() != width {
975                return None;
976            }
977            let mut point = Vec::with_capacity(1 + 2 * width);
978            point.push(0x04);
979            point.extend_from_slice(&x);
980            point.extend_from_slice(&y);
981            Material::Ec { curve, point }
982        }
983        _ => return None,
984    };
985    Some(Key {
986        kid: field("kid").map(|s| s.to_string()),
987        alg: field("alg").map(|s| s.to_string()),
988        material,
989    })
990}
991
992fn audiences(payload: &serde_json::Value) -> Vec<String> {
993    match payload.get("aud") {
994        Some(serde_json::Value::String(s)) => vec![s.clone()],
995        Some(serde_json::Value::Array(xs)) => xs
996            .iter()
997            .filter_map(|x| x.as_str().map(|s| s.to_string()))
998            .collect(),
999        _ => Vec::new(),
1000    }
1001}
1002
1003/// The claims that describe a person, as strings.
1004///
1005/// Scalars only: a claim whose value is an object is a shape a `map[Str, Str]` cannot hold, and
1006/// flattening it would invent names the issuer never used. An array is joined with a space, which
1007/// is what `scope` and `groups` already are in every issuer that emits them.
1008fn person_claims(payload: &serde_json::Value) -> BTreeMap<Arc<str>, Arc<str>> {
1009    let mut out = BTreeMap::new();
1010    let Some(fields) = payload.as_object() else {
1011        return out;
1012    };
1013    for (name, value) in fields {
1014        if PROTOCOL_CLAIMS.contains(&name.as_str()) {
1015            continue;
1016        }
1017        let rendered = match value {
1018            serde_json::Value::String(s) => s.clone(),
1019            serde_json::Value::Bool(b) => b.to_string(),
1020            serde_json::Value::Number(n) => n.to_string(),
1021            serde_json::Value::Array(xs) => {
1022                let parts: Vec<&str> = xs.iter().filter_map(|x| x.as_str()).collect();
1023                if parts.len() != xs.len() {
1024                    continue;
1025                }
1026                parts.join(" ")
1027            }
1028            _ => continue,
1029        };
1030        out.insert(Arc::from(name.as_str()), Arc::from(rendered.as_str()));
1031    }
1032    out
1033}
1034
1035// ---------------------------------------------------------------------------------------------
1036// URLs, in the small amount this needs
1037// ---------------------------------------------------------------------------------------------
1038
1039/// Where an `https` URL points, in the three things [`beck_core::net::Request`] asks for.
1040#[derive(Clone, Debug, PartialEq, Eq)]
1041struct Target {
1042    host: String,
1043    port: u16,
1044    path: String,
1045    /// Whether the exchange goes inside a TLS session. Always true for an external issuer.
1046    tls: bool,
1047}
1048
1049impl Target {
1050    /// `https` only, and a host a `net.out` atom could have named.
1051    ///
1052    /// Both refusals are the same one from two sides. The key set is only trustworthy because TLS
1053    /// says who sent it, so an `http` issuer is not a weaker configuration but a different feature.
1054    /// And a host with a userinfo section or a port in the name is a host §6.5's egress rule cannot
1055    /// be written from.
1056    fn parse(url: &str, in_cluster: bool) -> Result<Target, String> {
1057        let (rest, tls) = match url.strip_prefix("https://") {
1058            Some(rest) => (rest, true),
1059            None => match url.strip_prefix("http://").filter(|_| in_cluster) {
1060                Some(rest) => (rest, false),
1061                None => {
1062                    return Err(format!(
1063                        "`{url}` is not an https URL, and this one has to be"
1064                    ))
1065                }
1066            },
1067        };
1068        let (authority, path) = match rest.find('/') {
1069            Some(at) => (&rest[..at], &rest[at..]),
1070            None => (rest, "/"),
1071        };
1072        if authority.contains('@') {
1073            return Err(format!("`{url}` carries credentials in its authority"));
1074        }
1075        let (host, port) = match authority.rsplit_once(':') {
1076            Some((h, p)) => (
1077                h,
1078                p.parse::<u16>()
1079                    .map_err(|_| format!("`{url}` has no readable port"))?,
1080            ),
1081            None => (authority, if tls { 443 } else { 80 }),
1082        };
1083        if !beck_core::net::is_nameable_host(host) {
1084            return Err(format!(
1085                "`{host}` is not a host an egress rule could be written for"
1086            ));
1087        }
1088        Ok(Target {
1089            host: host.to_string(),
1090            port,
1091            path: path.to_string(),
1092            tls,
1093        })
1094    }
1095}
1096
1097fn url_host(url: &str, in_cluster: bool) -> Result<String, String> {
1098    Ok(Target::parse(url, in_cluster)?.host)
1099}
1100
1101/// RFC 3986's unreserved set, and everything else escaped.
1102fn percent_encode(text: &str) -> String {
1103    let mut out = String::with_capacity(text.len());
1104    for byte in text.as_bytes() {
1105        match byte {
1106            b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'.' | b'_' | b'~' => {
1107                out.push(char::from(*byte))
1108            }
1109            _ => out.push_str(&format!("%{byte:02X}")),
1110        }
1111    }
1112    out
1113}
1114
1115fn percent_decode(text: &str) -> String {
1116    let bytes = text.as_bytes();
1117    let mut out = Vec::with_capacity(bytes.len());
1118    let mut i = 0;
1119    while i < bytes.len() {
1120        match bytes[i] {
1121            b'%' if i + 2 < bytes.len() => {
1122                match u8::from_str_radix(&text[i + 1..i + 3], 16) {
1123                    Ok(b) => {
1124                        out.push(b);
1125                        i += 3;
1126                    }
1127                    Err(_) => {
1128                        out.push(bytes[i]);
1129                        i += 1;
1130                    }
1131                }
1132                continue;
1133            }
1134            b'+' => out.push(b' '),
1135            b => out.push(b),
1136        }
1137        i += 1;
1138    }
1139    String::from_utf8_lossy(&out).into_owned()
1140}
1141
1142/// `a=1&b=2`, decoded. A repeated name keeps every value, and the reader takes the first — which
1143/// is the behaviour that makes `?code=good&code=evil` unambiguous.
1144///
1145/// Public because a `application/x-www-form-urlencoded` body has the same shape, and because the
1146/// harness reads a query the same way the edge does: two readers would be two sets of rules about
1147/// what `+` means.
1148pub fn query_params(query: &str) -> Vec<(String, String)> {
1149    query
1150        .split('&')
1151        .filter(|pair| !pair.is_empty())
1152        .map(|pair| match pair.split_once('=') {
1153            Some((k, v)) => (percent_decode(k), percent_decode(v)),
1154            None => (percent_decode(pair), String::new()),
1155        })
1156        .collect()
1157}
1158
1159/// 256 bits, base64url. Used for the state, the nonce and the PKCE verifier — all three are values
1160/// an attacker must not be able to guess, and one function is one place to be right about it.
1161fn random_token() -> String {
1162    let mut bytes = [0u8; 32];
1163    aws_lc_rs::rand::fill(&mut bytes).expect("the system random source answers");
1164    beck_core::digest::base64_encode_bytes(&bytes)
1165}
1166
1167#[cfg(test)]
1168mod tests {
1169    use super::*;
1170
1171    #[test]
1172    fn only_an_https_url_with_a_nameable_host_is_a_target() {
1173        assert_eq!(
1174            Target::parse("https://login.acme.com/authorize", false),
1175            Ok(Target {
1176                host: "login.acme.com".into(),
1177                port: 443,
1178                path: "/authorize".into(),
1179                tls: true,
1180            })
1181        );
1182        assert_eq!(
1183            Target::parse("https://login.acme.com:8443", false),
1184            Ok(Target {
1185                host: "login.acme.com".into(),
1186                port: 8443,
1187                path: "/".into(),
1188                tls: true,
1189            })
1190        );
1191        // The three refusals, each for its own reason.
1192        assert!(Target::parse("http://login.acme.com/", false).is_err());
1193        assert!(Target::parse("https://user:[email protected]/", false).is_err());
1194        assert!(Target::parse("https://not a host/", false).is_err());
1195    }
1196
1197    /// A provider this deployment provisioned is reached inside one namespace, so `http` is
1198    /// admissible there and **only** there — and it is the declaration that decides, not the URL.
1199    #[test]
1200    fn a_plaintext_issuer_is_a_target_only_for_a_provisioned_provider() {
1201        assert_eq!(
1202            Target::parse("http://todo-identity:8080/realms/todo", true),
1203            Ok(Target {
1204                host: "todo-identity".into(),
1205                port: 8080,
1206                path: "/realms/todo".into(),
1207                tls: false,
1208            })
1209        );
1210        // The same URL, for a relying party that did not provision its provider.
1211        assert!(Target::parse("http://todo-identity:8080/realms/todo", false).is_err());
1212        // And the relaxation is about the transport only: a host an egress rule could not name is
1213        // still refused, in cluster or out.
1214        assert!(Target::parse("http://user:pw@todo-identity/", true).is_err());
1215        assert!(Target::parse("http://not a host/", true).is_err());
1216    }
1217
1218    #[test]
1219    fn a_query_is_read_the_way_a_browser_wrote_it() {
1220        assert_eq!(
1221            query_params("code=a%2Fb&state=x+y"),
1222            vec![
1223                ("code".to_string(), "a/b".to_string()),
1224                ("state".to_string(), "x y".to_string())
1225            ]
1226        );
1227        assert_eq!(query_params(""), Vec::new());
1228    }
1229
1230    #[test]
1231    fn the_algorithms_that_are_not_here_are_the_point() {
1232        assert_eq!(Algorithm::named("RS256"), Some(Algorithm::Rs256));
1233        assert_eq!(Algorithm::named("ES256"), Some(Algorithm::Es256));
1234        // The two that break a relying party, refused by not existing.
1235        assert_eq!(Algorithm::named("none"), None);
1236        assert_eq!(Algorithm::named("HS256"), None);
1237        assert_eq!(Algorithm::named("EdDSA"), None);
1238    }
1239
1240    #[test]
1241    fn a_key_set_keeps_what_it_understands_and_drops_the_rest() {
1242        let set = parse_jwks(
1243            r#"{"keys":[
1244                {"kty":"RSA","kid":"a","n":"AQAB","e":"AQAB"},
1245                {"kty":"RSA","kid":"enc","use":"enc","n":"AQAB","e":"AQAB"},
1246                {"kty":"OKP","kid":"ed","crv":"Ed25519","x":"AQAB"}
1247            ]}"#,
1248        )
1249        .expect("a key set");
1250        assert_eq!(set.len(), 1, "{set:?}");
1251        assert_eq!(set[0].kid.as_deref(), Some("a"));
1252    }
1253
1254    #[test]
1255    fn a_claim_map_carries_the_person_and_not_the_envelope() {
1256        let payload = serde_json::json!({
1257            "sub": "ana", "email": "[email protected]", "email_verified": true,
1258            "groups": ["admin", "billing"], "address": {"country": "GB"},
1259            "iss": "https://login.acme.com", "exp": 1, "nonce": "n"
1260        });
1261        let claims = person_claims(&payload);
1262        assert_eq!(claims.get("sub").map(|s| s.as_ref()), Some("ana"));
1263        assert_eq!(
1264            claims.get("email_verified").map(|s| s.as_ref()),
1265            Some("true")
1266        );
1267        assert_eq!(
1268            claims.get("groups").map(|s| s.as_ref()),
1269            Some("admin billing")
1270        );
1271        assert!(!claims.contains_key("iss"), "{claims:?}");
1272        assert!(!claims.contains_key("exp"), "{claims:?}");
1273        assert!(!claims.contains_key("nonce"), "{claims:?}");
1274        // An object has no rendering a `map[Str, Str]` can hold, so it is absent rather than
1275        // flattened into names the issuer never used.
1276        assert!(!claims.contains_key("address"), "{claims:?}");
1277    }
1278}