cheers_server/session.rs
1//! The [`SessionAuthority`] origin facade + [`SessionPolicy`] TTL defaults.
2//!
3//! `SessionAuthority` assembles the origin-tier capabilities into the role that
4//! can *create or destroy* sessions. Its edge counterpart,
5//! [`EdgeVerifier`](cheers_verify::EdgeVerifier), lives in `cheers-verify` and
6//! holds only a [`TokenVerifier`] + a `RevocationReader` — no minter, so it is
7//! physically unable to forge sessions. That asymmetry is the whole point of the
8//! split: minting power is structurally confined to this crate.
9//!
10//! A [`SessionPolicy`] carries the TTL defaults (access ≈ minutes, refresh ≈
11//! days) so a consumer doesn't have to invent durations — and the short access
12//! TTL is the bound on revocation-propagation lag (see
13//! `cheers_verify::RevocationReader`).
14//!
15//! @yah:ticket(R020-F5, "Bundle expansion at mint time (named bundles → explicit scope list)")
16//! @yah:assignee(agent:claude)
17//! @yah:at(2026-06-04T01:35:29Z)
18//! @yah:status(review)
19//! @yah:phase(P1)
20//! @yah:parent(R020)
21//! @yah:verify("Integration: grant 'camp-operator' to a user, mint a token, observe the expanded literal scope list on the wire; edit the bundle to remove a scope, re-mint, observe the removal propagates without rewriting the grant.")
22//! @yah:assumes("Bundle expansion at mint time (vs at grant time) so bundle edits propagate on next mint. Costs one join per mint — acceptable at SMB scale per doc §Open questions.")
23//! @arch:see(.yah/docs/working/mcp-auth-and-ownership.md)
24//! @yah:depends_on(R020-F3)
25//! @yah:next("R020-F6/F7 mint paths: after expand_scopes, run validate_grant(kind, scope) per expanded Scope before signing — the defense-in-depth for composition rule 4 (a bundle granted to a User principal must not carry ownership:write through to the wire).")
26//! @yah:next("When the persistent ownership/grant store lands (peer of R020-F4), add a PgBundleStore + SqliteBundleStore impl mirroring the MemoryBundleStore shape — same trait, swap the backing map for a table.")
27//! @yah:handoff("Landed crates/cheers-server/src/bundles.rs (exported from cheers-server::*): BundleName newtype, ScopeOrBundle enum (the grant-time entry), BundleStore trait, MemoryBundleStore::with_defaults() seeding 'camp-operator' and 'deploy-admin', expand_scopes(store, &[ScopeOrBundle]) -> Result<Vec<Scope>, BundleExpansionError>.")
28//! @yah:handoff("Wire-shape invariant is structural, not runtime: McpClaims.scope is Vec<Scope>, Scope::from_str only accepts the closed-vocabulary wire strings, so a BundleName fed in by mistake fails parse — pinned with the expanded_scopes_serialize_as_wire_strings_no_bundle_name test (asserts 'camp-operator' string is absent from the McpClaims JSON and the literal scope list roundtrips).")
29//! @yah:handoff("Mint-time propagation pinned by bundle_edit_propagates_on_next_expand_without_rewriting_grant: expand once, store.put() mutates the bundle to drop CloudDestroy, expand AGAIN against the SAME grants vector, observe removal — no grant rewrite needed.")
30//! @yah:handoff("Bundles hold literal Scopes only — no nested bundles. Rules out cycles by construction; documented at module top.")
31//! @yah:handoff("ScopeOrBundle serializes as externally-tagged JSON ({\"scope\":\"cloud:deploy\"} or {\"bundle\":\"camp-operator\"}) so a grant row can persist + audit + reload without losing the distinction.")
32//! @yah:handoff("Default 'camp-operator' / 'deploy-admin' scope contents are illustrative — operator-side choice in production, not a wire contract; called out in MemoryBundleStore::with_defaults docs.")
33//! @yah:handoff("Verified GREEN: cargo test -p cheers-core -p cheers-server -p cheers-verify — 10 new bundle tests pass; nothing else regressed.")
34//! @yah:handoff("FOLLOW-UP for R020-F6/F7: at mint, after expand_scopes, the caller MUST still run validate_grant(kind, scope) per expanded scope (composition rule 4 — bundle expansion could otherwise let a user-kind grant of a bundle reach ownership:write). This is a mint-path concern, intentionally not bolted into expand_scopes — keeps the helper focused.")
35//!
36//! @yah:ticket(R020-F6, "Mint path 1: user-initiated MCP token (passkey-fresh, auth_strength=user-fresh)")
37//! @yah:assignee(agent:claude)
38//! @yah:at(2026-06-04T01:35:38Z)
39//! @yah:status(review)
40//! @yah:phase(P2)
41//! @yah:parent(R020)
42//! @arch:see(.yah/docs/working/mcp-auth-and-ownership.md)
43//! @yah:depends_on(R020-S1)
44//! @yah:depends_on(R020-F2)
45//! @yah:depends_on(R020-F3)
46//! @yah:depends_on(R020-F4)
47//! @yah:depends_on(R020-F5)
48//! @yah:depends_on(R020-T15)
49//! @yah:depends_on(R019-F6)
50//! @yah:next("JWKS publication (R020-F11) so origin can publish the verify pubkey + edge kamaji can fetch it. Until then, tests use the in-process PasetoV4PublicVerifier from the same keypair — equivalent property, narrower wiring.")
51//! @yah:next("Promote the inline test-only MemOwnershipStore to a pub MemoryOwnershipStore in ownership.rs when R020-F7 (camp bootstrap mint) lands — it'll want the same shape.")
52//! @yah:next("GrantStore writes (HTTP grant API): the grant-time validate_grant call belongs there (composition rule (4) is enforced at the grant API per cheers_core::mcp docs; the mint path is defence in depth).")
53//! @yah:next("R020-T16 (Bearer/McpClaims middleware in cheers-axum) consumes MintedMcpToken + verify_mcp_at — wire McpMintError::AudNotEntitled → 401/403, WrongPrincipalKind/GrantMisconfigured → 500, Store → 503.")
54//! @yah:handoff("Landed crates/cheers-server/src/mcp_authority.rs (exported from cheers-server::*): McpAuthority<B,G,O>{minter:PasetoV4SecretMinter, bundles:B, grants:G, ownership:O, iss, policy} + McpPolicy (default 10 min access TTL, middle of doc's 5–15 min) + MintedMcpToken{token, claims} + McpMintError (AudNotEntitled, WrongPrincipalKind, GrantMisconfigured(GrantError), BundleExpansion, Store, Codec). Generic over B/G/O so the assembled capability set is visible in the type — same shape as SessionAuthority.")
55//! @yah:handoff("Landed crates/cheers-server/src/grants.rs: GrantStore trait (list_for(principal, aud) -> Vec<ScopeOrBundle>) + MemoryGrantStore for tests / single-node bootstrap. Empty result = no entitlement; mint MUST reject (composition rule (5), aud-scoping mandatory). Grants store ScopeOrBundle entries verbatim — bundles deferred to mint time per F5 (rule (2)).")
56//! @yah:handoff("mint_user_fresh pipeline matches the doc §Mint flows step-for-step: (1) check user.kind == User (WrongPrincipalKind otherwise); (2) grants.list_for(user, aud), empty → AudNotEntitled; (3) expand_scopes(bundles, entries) per F5; (4) validate_grant(user.kind, scope) per expanded scope — defence in depth for rule (4), catches ownership:write smuggled through a bundle BEFORE signing (test: mint_user_fresh_rejects_service_only_scope_smuggled_via_bundle); (5) ownership.list_for_principal(camp:<camp_id>) → rows_to_owns (service/arch_doc named fields + extra spill via F3's forward-compat slot); (6) McpClaims with auth_strength=user-fresh + jti from session::generate_jti (made pub(crate) to share with this module) + iat=now / exp=now+policy.access_ttl; (7) mint_mcp via T15.")
57//! @yah:handoff("McpMintError → cheers_core::Error mapping is intentional: Store/Codec stay typed; AudNotEntitled / WrongPrincipalKind / GrantMisconfigured / BundleExpansion::Unknown route through Error::InvalidInput so non-MCP callers still get a sensible umbrella error. HTTP layer (T16/T17) should pattern-match on McpMintError directly for typed status codes (403 / 500 / 503).")
58//! @yah:handoff("owns claim is omitted on the wire when empty (covered by McpClaims's skip_serializing_if = Owns::is_empty) — pinned by mint_user_fresh_omits_owns_when_camp_owns_nothing. Kamaji's local membership check therefore won't see a phantom empty {} that could be mistaken for 'no resources, fail open'.")
59//! @yah:handoff("Tests landed: 8 in mcp_authority::tests (edge-roundtrip via verify_mcp_at, bundle-expansion-at-mint propagation, act+camp_id+owns population, owns-omitted-when-empty, AudNotEntitled rejection, WrongPrincipalKind rejection, service-only-scope-via-bundle rejection, error-conversion) + 1 policy default. 3 in grants::tests (missing-key empty, put-then-list, per-aud isolation). Inline MemOwnershipStore in mcp_authority::tests covers what cheers-sqlx persists in pg/sqlite — left inline since promoting to a public memory store is F7-scoped follow-up.")
60//! @yah:handoff("Verified GREEN: cargo test -p cheers-server (67 unit + 9 proptest + 2 doctest, up from 49+9+2 — 8 new mcp_authority tests + 3 new grants tests + Mc Policy = 12 new), cargo test -p cheers-core (unchanged), cargo test -p cheers-verify (unchanged), cargo check --workspace --all-features clean.")
61//! @yah:handoff("FOLLOW-UP wire to W159: this lands the cheers-side producer of mint path 1; kamaji's verify-only consumer on yah/R426 already has verify_mcp_at via cheers-verify. No new wire-shape — McpClaims fields unchanged from T15/F3, so no coordinated change required.")
62//! @yah:verify("cargo test -p cheers-server (mcp_authority::tests::mint_user_fresh_signs_token_verifiable_at_edge: round-trips through PasetoV4PublicVerifier::verify_mcp_at, asserts auth_strength=user-fresh, jti non-empty, exp=iat+policy.access_ttl).")
63//! @yah:verify("cargo test -p cheers-server (mcp_authority::tests::mint_user_fresh_rejects_unentitled_aud: empty grants for (user, aud) → McpMintError::AudNotEntitled BEFORE signing).")
64//! @yah:verify("cargo test -p cheers-core && cargo test -p cheers-server && cargo test -p cheers-verify (parent relay smoke — catches regressions in adjacent sub-tickets).")
65//!
66//! @yah:ticket(R020-F7, "Mint path 2: bootstrapped camp MCP token (auth_strength=bootstrap)")
67//! @yah:assignee(agent:claude)
68//! @yah:at(2026-06-04T01:35:45Z)
69//! @yah:status(review)
70//! @yah:phase(P2)
71//! @yah:parent(R020)
72//! @yah:next("Camp presents its bootstrap credential to cheers; verify the credential, fetch the camp's grants + ownership rows, expand bundles, sign with auth_strength=bootstrap.")
73//! @yah:next("Sign: { sub: camp:<C>, camp_id: <C>, scope: [...], owns: {...}, auth_strength: 'bootstrap' }. No act claim on this path.")
74//! @yah:verify("Mint a token from a camp's bootstrap credential; auth_strength=bootstrap; owns claim reflects ownership table state at mint time.")
75//! @yah:verify("Downstream kamaji accepts the token via cheers-verify with no per-call cheers round trip.")
76//! @yah:assumes("Bootstrap-bound model (yah/W159 Local desktop vs remote camp option 1) is the v1 shape — a single bootstrap credential per camp, refresh-rotated.")
77//! @arch:see(.yah/docs/working/mcp-auth-and-ownership.md)
78//! @yah:depends_on(R020-S1)
79//! @yah:depends_on(R020-F2)
80//! @yah:depends_on(R020-F3)
81//! @yah:depends_on(R020-F4)
82//! @yah:depends_on(R020-F5)
83//! @yah:depends_on(R019-F6)
84//! @yah:handoff("Landed McpAuthority::mint_bootstrap in crates/cheers-server/src/mcp_authority.rs: same B/G/O capability surface as mint_user_fresh, pipeline matches doc §Mint flows step 2 — (1) reject non-Camp principal (WrongPrincipalKind); (2) grants.list_for(&camp, &aud), empty → AudNotEntitled (composition rule (5)); (3) expand_scopes (R020-F5, rule (2) propagation); (4) validate_grant(Camp, scope) per expanded scope — defence in depth for rule (4), catches ownership:write smuggled through a camp bundle BEFORE signing; (5) ownership.list_for_principal(&camp) → rows_to_owns; (6) McpClaims { sub: camp, camp_id: camp.id (bare, no prefix), owns, auth_strength: Bootstrap, jti, iat, exp } — NO act claim on this path; (7) mint_mcp via T15.")
85//! @yah:handoff("Credential verification deliberately UPSTREAM of mint_bootstrap — the function takes an already-verified Camp principal, mirroring mint_user_fresh which takes the user principal after passkey assertion. The HTTP token endpoint that handles credential→mint dispatch is the right home for verifying the bootstrap credential against R008's refresh chain; that's the separate concern T16/F10 or the token-endpoint ticket will pick up.")
86//! @yah:handoff("camp_id claim is set to the bare id (no `camp:` prefix) so a consumer doesn't have to re-parse sub. Pinned by mint_bootstrap_signs_token_verifiable_at_edge (asserts claims.camp_id.as_deref() == Some('c-xyz') when sub is camp:c-xyz). When sub is a camp principal the camp_id is always present — bootstrap tokens are always camp-scoped by construction.")
87//! @yah:handoff("owns claim is omitted on the wire when empty (skip_serializing_if = Owns::is_empty in McpClaims) — re-pinned independently of F6 by mint_bootstrap_omits_owns_when_camp_owns_nothing (asserts no 'owns' key in JSON, but camp_id is still present). Kamaji's local membership check won't see a phantom empty {} that could be mistaken for 'no resources, fail open'.")
88//! @yah:handoff("7 new tests in mcp_authority::tests (74 total in -p cheers-server up from 67): mint_bootstrap_signs_token_verifiable_at_edge (round-trip via PasetoV4PublicVerifier::verify_mcp_at, auth_strength=Bootstrap, jti non-empty, exp=iat+policy.access_ttl, act is None); mint_bootstrap_owns_reflects_ownership_table_state (seeds service+arch_doc ownership rows on the camp principal, asserts owns claim populated); mint_bootstrap_omits_owns_when_camp_owns_nothing (wire-shape pin); mint_bootstrap_expands_bundles_at_mint_time (rule (2) re-pin on bootstrap path); mint_bootstrap_rejects_non_camp_principal (User and Service both rejected with WrongPrincipalKind); mint_bootstrap_rejects_unentitled_aud (composition rule (5)); mint_bootstrap_rejects_service_only_scope_smuggled_via_bundle (composition rule (4) defence-in-depth on Camp path — bundle containing OwnershipWrite is caught BEFORE signing).")
89//! @yah:handoff("No wire-shape change — McpClaims fields unchanged from T15/F3 (sub, camp_id, owns, auth_strength all pre-existed). Yah-side W159/R426 already consumes the same envelope via cheers-verify::verify_mcp_at. No coordinated cross-workspace change required.")
90//! @yah:handoff("Verified GREEN: cargo test -p cheers-core (51 + 1 doctest), cargo test -p cheers-server (74 unit + 9 proptest + 2 doctest, up from 67+9+2 = 7 new bootstrap tests), cargo test -p cheers-verify (4).")
91//! @yah:next("R020-F8 (mint path 3 — RFC 8693 token-exchange) now has both mint primitives it needs: mint_user_fresh (F6) + mint_bootstrap (F7). The exchange endpoint verifies subject_token (camp bootstrap) AND actor_token (user session), intersects grants from both, and signs a user-fresh claim with the camp as context. Reuse rows_to_owns and the validate_grant defence-in-depth pattern.")
92//! @yah:next("Token endpoint / HTTP dispatch (probably under R020-T16 Bearer/McpClaims middleware): the camp's bootstrap credential is presented at the token endpoint, verified against R008's RefreshStore (the refresh chain piggybacks per doc §TTLs), THEN mint_bootstrap is called with the now-verified Camp principal. Credential verification is the endpoint's job, not the mint function's.")
93//! @yah:next("When persistent OwnershipStore lands (peer of R020-F4), promote the inline MemOwnershipStore in mcp_authority::tests to a pub MemoryOwnershipStore in ownership.rs — both mint_user_fresh and mint_bootstrap tests want the same shape now.")
94//! @yah:verify("cargo test -p cheers-server mcp_authority::tests::mint_bootstrap_signs_token_verifiable_at_edge — round-trips through PasetoV4PublicVerifier::verify_mcp_at, asserts auth_strength=Bootstrap, act=None, camp_id=Some(bare-id), jti non-empty, exp=iat+policy.access_ttl.")
95//! @yah:verify("cargo test -p cheers-server mcp_authority::tests::mint_bootstrap_owns_reflects_ownership_table_state — seeds 2 ownership rows under camp principal, mints, asserts owns claim service/arch_doc fields populated from the table.")
96//! @yah:verify("cargo test -p cheers-server mcp_authority::tests::mint_bootstrap_rejects_service_only_scope_smuggled_via_bundle — Camp principal granted a bundle that expands to OwnershipWrite → McpMintError::GrantMisconfigured(GrantError::ServiceOnlyScope{scope: OwnershipWrite, kind: Camp}) BEFORE signing.")
97//! @yah:verify("cargo test -p cheers-core && cargo test -p cheers-server && cargo test -p cheers-verify (parent relay smoke).")
98//!
99//! @yah:ticket(R020-F8, "Mint path 3: RFC 8693 token-exchange endpoint (multi-player camp daemons)")
100//! @yah:assignee(agent:claude)
101//! @yah:at(2026-06-04T01:35:52Z)
102//! @yah:status(review)
103//! @yah:phase(P2)
104//! @yah:parent(R020)
105//! @yah:next("POST /token with grant_type=urn:ietf:params:oauth:grant-type:token-exchange.")
106//! @yah:next("Verify subject_token (camp bootstrap credential) AND actor_token (human session token); intersect both principals' grants against requested scope; reject if intersection is empty for any requested scope.")
107//! @yah:next("Sign: { sub: user:<U>, act: { sub: agent:<V> }, camp_id: <C>, scope: […intersection…], owns: {…}, auth_strength: 'user-fresh' }. TTL ≤ access-token TTL.")
108//! @yah:verify("Exchange a (camp, user) pair for a token; scope is the intersection (verify by removing a scope from one side and seeing it drop from the result).")
109//! @yah:verify("Empty intersection on a requested scope yields invalid_scope per RFC 8693, not a partial token.")
110//! @yah:gotcha("This is the ONLY path that crosses principals — user authenticates, camp is the bearer, resulting token attributes to the user with camp as context. Audit must capture both legs.")
111//! @arch:see(.yah/docs/working/mcp-auth-and-ownership.md)
112//! @yah:depends_on(R020-F6)
113//! @yah:depends_on(R020-F7)
114//! @yah:handoff("Landed McpAuthority::mint_token_exchange in crates/cheers-server/src/mcp_authority.rs: same B/G/O capability surface as the other mint paths. Pipeline matches doc §Mint flows step 3 — (1) reject non-User user-slot and non-Camp camp-slot (WrongPrincipalKind); (2) user's grants.list_for(&user, &aud), empty → AudNotEntitled (composition rule (5) on user side); (3) expand + validate_grant(User, _) per scope (defence in depth); (4) camp's grants.list_for(&camp, &aud), empty → AudNotEntitled (composition rule (5) on camp side too — even though sub is user, the camp must also be entitled); (5) expand + validate_grant(Camp, _) per scope; (6) RFC 8693 intersection — for each requested scope: if !user_scopes.contains(s) || !camp_scopes.contains(s) → InvalidScope; (7) ownership.list_for_principal(&camp) → rows_to_owns (camp is the resource context, NOT the user); (8) McpClaims { sub: user, act, camp_id: camp.id (bare), owns from camp, auth_strength: UserFresh, jti, iat, exp }; (9) mint_mcp via T15.")
115//! @yah:handoff("Added McpMintError::InvalidScope { scope: Scope, aud: String } variant for the RFC 8693 reject-all-or-nothing case — routes through Error::InvalidInput in the umbrella conversion (existing 'other' arm catches it). HTTP layer should map this to 400 invalid_scope per RFC 8693. To use Scope in the variant, added `Scope` to the cheers_core imports.")
116//! @yah:handoff("auth_strength is UserFresh on this path (NOT Bootstrap) — the doc §Mint flows is explicit: 'the user authenticated locally, the camp is the bearer'. The user's session token is the freshness evidence; the camp bootstrap credential is just the HTTP-layer bearer. Pinned by mint_token_exchange_attributes_to_user_with_camp_as_context asserting AuthStrength::UserFresh and sub == user (not camp).")
117//! @yah:handoff("owns comes from the CAMP, not the user — the call is scoped to the camp's resources. This is intentionally asymmetric with mint_user_fresh where camp_id is optional; the exchange path always has a camp. Pinned in the success test (asserts owns.service contains the camp's seeded svc-prod, not anything seeded under the user).")
118//! @yah:handoff("Credential verification deliberately UPSTREAM, mirroring F6/F7. The function takes already-verified User + Camp principals. The HTTP /token endpoint (likely under T16 or its peer) is the place that decodes subject_token (camp bootstrap) and actor_token (user session) into PrincipalIds before calling mint_token_exchange.")
119//! @yah:handoff("All-or-nothing intersection per the doc §Mint flows + the F8 verify: 'Empty intersection on a requested scope yields invalid_scope per RFC 8693, not a partial token.' Tests pin both directions: mint_token_exchange_intersection_drops_scope_one_side_lacks (camp lacks CloudDeploy → InvalidScope) and mint_token_exchange_rejects_scope_user_lacks (user lacks CloudDeploy → InvalidScope).")
120//! @yah:handoff("8 new tests in mcp_authority::tests (82 unit total, up from 74): mint_token_exchange_attributes_to_user_with_camp_as_context (edge-roundtrip; sub=user, camp_id=camp, act=agent, owns from camp, auth_strength=UserFresh); mint_token_exchange_intersection_drops_scope_one_side_lacks (camp-narrows-user); mint_token_exchange_rejects_scope_user_lacks (mirror); mint_token_exchange_rejects_wrong_user_kind; mint_token_exchange_rejects_wrong_camp_kind; mint_token_exchange_rejects_unentitled_user; mint_token_exchange_rejects_unentitled_camp; mint_token_exchange_catches_service_only_scope_smuggled_via_camp_bundle (rule (4) defence on camp side).")
121//! @yah:handoff("Audit (R020-F13) capture-both-legs concern is satisfied by the result claim shape: sub records the user, camp_id records the camp, act records the agent variant — all three principal identities are in the signed token, so the audit layer can ingest them straight from the verified claim without a side-channel from the /token endpoint.")
122//! @yah:handoff("Verified GREEN: cargo test -p cheers-core (51 + 1 doctest), cargo test -p cheers-server (82 unit + 9 proptest + 2 doctest, up from 74+9+2 — 8 new exchange tests), cargo test -p cheers-verify (4), cargo check --workspace --all-features clean.")
123//! @yah:next("Mint primitives trilogy (F6 + F7 + F8) is now complete. T16 (Bearer/McpClaims auth middleware in cheers-axum) is the next piece that wires these to HTTP — the /token endpoint dispatches grant_type to mint_user_fresh / mint_bootstrap / mint_token_exchange, the Bearer middleware verifies inbound McpClaims via cheers-verify::verify_mcp_at.")
124//! @yah:next("The /token endpoint's RFC 8693 dispatch needs to decode subject_token (camp PASETO bootstrap credential) AND actor_token (user session token). The session-side verification path already exists in cheers-verify for the actor_token; the camp bootstrap credential's refresh chain piggybacks on R008's RefreshStore per doc §TTLs, so reuse RefreshRotator there.")
125//! @yah:next("McpMintError::InvalidScope → HTTP 400 invalid_scope in T16/T17's mapping (per RFC 8693 §6.2). The existing mapping table in mcp_authority.rs doc comment (AudNotEntitled → 403, GrantMisconfigured → 500, Store → 503, Codec → 500) should grow this row.")
126//! @yah:verify("cargo test -p cheers-server mcp_authority::tests::mint_token_exchange_attributes_to_user_with_camp_as_context — round-trips through PasetoV4PublicVerifier::verify_mcp_at, asserts sub=user, camp_id=camp, act=agent, owns sourced from camp, auth_strength=UserFresh, jti non-empty.")
127//! @yah:verify("cargo test -p cheers-server mcp_authority::tests::mint_token_exchange_intersection_drops_scope_one_side_lacks — user has [CloudDeploy, CloudRead], camp has [CloudRead]; requesting CloudRead succeeds, requesting [CloudRead, CloudDeploy] rejects with InvalidScope{scope: CloudDeploy}. Mirror test mint_token_exchange_rejects_scope_user_lacks pins the other direction.")
128//! @yah:verify("cargo test -p cheers-server mcp_authority::tests::mint_token_exchange_catches_service_only_scope_smuggled_via_camp_bundle — camp granted a bundle expanding to OwnershipWrite → GrantMisconfigured(ServiceOnlyScope{scope: OwnershipWrite, kind: Camp}) BEFORE intersection.")
129//! @yah:verify("cargo test -p cheers-core && cargo test -p cheers-server && cargo test -p cheers-verify (parent relay smoke).")
130
131use base64::Engine as _;
132use base64::engine::general_purpose::URL_SAFE_NO_PAD;
133
134use cheers_core::{Claims, DeviceBinding, DeviceId, Error, PeerKey, TokenMinter, UserId};
135
136use crate::refresh::{RefreshRotator, Rotated};
137use crate::revocation::RevocationWriter;
138use crate::store::{RefreshStore, UserStore};
139
140/// Length of a generated `jti` in bytes (128 bits — uniqueness only, not a
141/// secret). base64url-no-pad encodes to 22 chars.
142const JTI_BYTES: usize = 16;
143
144/// Generate a fresh `jti` from the OS CSPRNG. Origin-only — the device and edge
145/// tiers never mint, so this never compiles into them.
146pub(crate) fn generate_jti() -> String {
147 let mut bytes = [0u8; JTI_BYTES];
148 getrandom::fill(&mut bytes).expect("OS CSPRNG must be available");
149 URL_SAFE_NO_PAD.encode(bytes)
150}
151
152/// TTL defaults for a session, so consumers don't pick the wrong durations.
153///
154/// Access tokens are minutes-scale (a stateless token can't be un-minted, so
155/// it's kept short — that short window is also the revocation-propagation
156/// bound); refresh tokens are days-scale (the stateful, rotatable half).
157#[derive(Debug, Clone, Copy, PartialEq, Eq)]
158#[non_exhaustive]
159pub struct SessionPolicy {
160 /// Access-token lifetime in seconds. Also the upper bound on how long a
161 /// revoked-but-not-yet-propagated access token stays accepted at the edge.
162 pub access_ttl_seconds: i64,
163 /// Refresh-token lifetime in seconds (applied to root and every successor).
164 pub refresh_ttl_seconds: i64,
165}
166
167impl SessionPolicy {
168 /// 15 minutes — short enough to bound revocation lag, long enough to avoid
169 /// rotating on every request.
170 pub const DEFAULT_ACCESS_TTL_SECONDS: i64 = 15 * 60;
171 /// 30 days.
172 pub const DEFAULT_REFRESH_TTL_SECONDS: i64 = 30 * 24 * 60 * 60;
173
174 pub fn new(access_ttl_seconds: i64, refresh_ttl_seconds: i64) -> Self {
175 Self {
176 access_ttl_seconds,
177 refresh_ttl_seconds,
178 }
179 }
180
181 pub fn with_access_ttl(mut self, seconds: i64) -> Self {
182 self.access_ttl_seconds = seconds;
183 self
184 }
185
186 pub fn with_refresh_ttl(mut self, seconds: i64) -> Self {
187 self.refresh_ttl_seconds = seconds;
188 self
189 }
190}
191
192impl Default for SessionPolicy {
193 fn default() -> Self {
194 Self {
195 access_ttl_seconds: Self::DEFAULT_ACCESS_TTL_SECONDS,
196 refresh_ttl_seconds: Self::DEFAULT_REFRESH_TTL_SECONDS,
197 }
198 }
199}
200
201/// A freshly established or rotated session: the minted access token (hand to
202/// the client as a bearer/cookie), the [`Claims`] it carries (so the caller can
203/// read `jti` / expiry without re-verifying), and the refresh token + record.
204#[derive(Debug, Clone)]
205#[non_exhaustive]
206pub struct NewSession {
207 /// The signed/encrypted access token string.
208 pub access_token: String,
209 /// The claims embedded in `access_token`, including the freshly minted `jti`.
210 pub claims: Claims,
211 /// The refresh token + persisted record (the stateful, rotatable half).
212 pub refresh: Rotated,
213}
214
215/// Origin facade: everything that can *create or destroy* sessions.
216///
217/// Holds the four origin-tier capabilities — a [`TokenMinter`], a
218/// [`RefreshStore`], a [`UserStore`], and a [`RevocationWriter`] — plus a
219/// [`SessionPolicy`]. Generic (not `dyn`) so the assembled capability set is
220/// visible in the type; a consumer wires concrete impls (e.g.
221/// [`PasetoV4SecretMinter`](crate::codec::PasetoV4SecretMinter) + Yubaba-backed
222/// stores).
223pub struct SessionAuthority<M, R, U, W> {
224 minter: M,
225 refresh: R,
226 users: U,
227 revoke: W,
228 policy: SessionPolicy,
229}
230
231impl<M, R, U, W> SessionAuthority<M, R, U, W>
232where
233 M: TokenMinter + Send + Sync,
234 R: RefreshStore,
235 U: UserStore,
236 W: RevocationWriter,
237{
238 /// Assemble an authority with the [default policy](SessionPolicy::default).
239 pub fn new(minter: M, refresh: R, users: U, revoke: W) -> Self {
240 Self {
241 minter,
242 refresh,
243 users,
244 revoke,
245 policy: SessionPolicy::default(),
246 }
247 }
248
249 /// Override the TTL policy.
250 pub fn with_policy(mut self, policy: SessionPolicy) -> Self {
251 self.policy = policy;
252 self
253 }
254
255 pub fn policy(&self) -> &SessionPolicy {
256 &self.policy
257 }
258
259 /// The held [`UserStore`], for device-management reads
260 /// ([`list_devices`](UserStore::list_devices)).
261 pub fn users(&self) -> &U {
262 &self.users
263 }
264
265 /// Establish a fresh session: mint a short-TTL access token (with a fresh
266 /// `jti`) and start a homed refresh chain for `(sub, device)`.
267 pub async fn establish(
268 &self,
269 sub: UserId,
270 device: DeviceId,
271 binding: DeviceBinding,
272 now: i64,
273 ) -> Result<NewSession, Error> {
274 self.establish_inner(sub, device, binding, None, now).await
275 }
276
277 /// Establish a session whose access token is **bound to `peer_key`** — the
278 /// mint half of R515.
279 ///
280 /// Identical to [`establish`](Self::establish) except the minted claims
281 /// carry [`Claims::peer_key`], so a verifier that has already authenticated
282 /// the connecting peer under that key can prove *token holder == connecting
283 /// peer* (see `EdgeVerifier::verify_bound_at`). A token stolen from this
284 /// client is then useless to a thief connecting under their own key.
285 ///
286 /// cheers does **not** establish that the caller possesses `peer_key` —
287 /// that proof belongs to the enrollment ceremony in the consuming service
288 /// (the app proves possession of the key, presents its existing session,
289 /// and *then* calls this). cheers's own HTTP ceremonies can't do it: a
290 /// browser passkey or magic-link sign-in has no node key at all.
291 ///
292 /// The refresh chain is unchanged, and deliberately does not record the
293 /// key: like `binding`, the peer key is a fact about *this* access token,
294 /// not about the chain — so [`rotate_bound`](Self::rotate_bound) takes it
295 /// from the caller again.
296 pub async fn establish_bound(
297 &self,
298 sub: UserId,
299 device: DeviceId,
300 binding: DeviceBinding,
301 peer_key: PeerKey,
302 now: i64,
303 ) -> Result<NewSession, Error> {
304 self.establish_inner(sub, device, binding, Some(peer_key), now)
305 .await
306 }
307
308 async fn establish_inner(
309 &self,
310 sub: UserId,
311 device: DeviceId,
312 binding: DeviceBinding,
313 peer_key: Option<PeerKey>,
314 now: i64,
315 ) -> Result<NewSession, Error> {
316 let claims = self.mint_access(sub.clone(), device.clone(), binding, peer_key, now)?;
317 let refresh = RefreshRotator::new(&self.refresh, self.policy.refresh_ttl_seconds)
318 .mint_root(sub, device, now)
319 .await?;
320 Ok(NewSession {
321 access_token: self.minter.mint(&claims)?,
322 claims,
323 refresh,
324 })
325 }
326
327 /// Rotate `presented_refresh` → fresh access token + successor refresh
328 /// token. Replay detection / chain revocation are the rotator's
329 /// (see [`RefreshRotator::rotate`]). The `binding` is supplied by the caller
330 /// because the refresh record doesn't carry it — guide by omission, the
331 /// refresh chain is about *which session*, not *how it authenticated*.
332 pub async fn rotate(
333 &self,
334 presented_refresh: &str,
335 binding: DeviceBinding,
336 now: i64,
337 ) -> Result<NewSession, Error> {
338 self.rotate_inner(presented_refresh, binding, None, now)
339 .await
340 }
341
342 /// Rotate into a fresh access token that is **bound to `peer_key`** (R515).
343 ///
344 /// The peer key is supplied by the caller for the same reason `binding` is:
345 /// the refresh record is about *which session*, not about how this
346 /// particular access token is being presented. A node re-binds on every
347 /// rotation, which is what keeps the binding honest if the node key
348 /// changes.
349 pub async fn rotate_bound(
350 &self,
351 presented_refresh: &str,
352 binding: DeviceBinding,
353 peer_key: PeerKey,
354 now: i64,
355 ) -> Result<NewSession, Error> {
356 self.rotate_inner(presented_refresh, binding, Some(peer_key), now)
357 .await
358 }
359
360 async fn rotate_inner(
361 &self,
362 presented_refresh: &str,
363 binding: DeviceBinding,
364 peer_key: Option<PeerKey>,
365 now: i64,
366 ) -> Result<NewSession, Error> {
367 let refresh = RefreshRotator::new(&self.refresh, self.policy.refresh_ttl_seconds)
368 .rotate(presented_refresh, now)
369 .await?;
370 let claims = self.mint_access(
371 refresh.record.user_id.clone(),
372 refresh.record.device_id.clone(),
373 binding,
374 peer_key,
375 now,
376 )?;
377 Ok(NewSession {
378 access_token: self.minter.mint(&claims)?,
379 claims,
380 refresh,
381 })
382 }
383
384 /// Revoke a single access token by its `jti` — the immediate, edge-visible
385 /// kill (within the propagation window bounded by the access TTL).
386 pub async fn revoke_session(&self, jti: &str) -> Result<(), Error> {
387 self.revoke.revoke(jti).await?;
388 Ok(())
389 }
390
391 /// Records device-level revocation intent via the [`UserStore`]. Blocking
392 /// *new* sessions also means revoking that device's refresh chains via the
393 /// [`RefreshStore`]; killing an *in-flight* access token is
394 /// [`revoke_session`](Self::revoke_session). The product owns the
395 /// device→chain index that drives the first, so this facade exposes the
396 /// store call and the per-jti kill, not a magic "log out everywhere".
397 pub async fn revoke_device(
398 &self,
399 user_id: &UserId,
400 device_id: &DeviceId,
401 ) -> Result<(), Error> {
402 self.users.revoke_device(user_id, device_id).await?;
403 Ok(())
404 }
405
406 /// Build access-token claims with a fresh `jti`, the policy's access TTL,
407 /// and — when the caller asked for one — a peer-key binding (R515).
408 fn mint_access(
409 &self,
410 sub: UserId,
411 device: DeviceId,
412 binding: DeviceBinding,
413 peer_key: Option<PeerKey>,
414 now: i64,
415 ) -> Result<Claims, Error> {
416 let claims = Claims::new(sub, device, binding, now, now + self.policy.access_ttl_seconds)
417 .with_jti(generate_jti());
418 Ok(match peer_key {
419 Some(key) => claims.with_peer_key(key),
420 None => claims,
421 })
422 }
423}
424
425#[cfg(test)]
426mod tests {
427 use super::*;
428 use crate::codec::PasetoV4SecretMinter;
429 use crate::store::{NewUser, ProviderKey, RefreshTokenRecord};
430 use cheers_core::{DeviceBinding, StoreError, User};
431 use cheers_verify::{EdgeVerifier, PasetoV4PublicVerifier, RevocationReader};
432 use async_trait::async_trait;
433 use std::collections::{HashMap, HashSet};
434 use std::sync::{Arc, Mutex};
435
436 // ---- minimal in-memory capability impls --------------------------------
437
438 #[derive(Default)]
439 struct MemRefreshStore(Mutex<HashMap<String, RefreshTokenRecord>>);
440
441 #[async_trait]
442 impl RefreshStore for MemRefreshStore {
443 async fn put(&self, record: &RefreshTokenRecord) -> Result<(), StoreError> {
444 self.0
445 .lock()
446 .unwrap()
447 .insert(record.token.clone(), record.clone());
448 Ok(())
449 }
450 async fn get(&self, token: &str) -> Result<Option<RefreshTokenRecord>, StoreError> {
451 Ok(self.0.lock().unwrap().get(token).cloned())
452 }
453 async fn mark_consumed(&self, token: &str) -> Result<bool, StoreError> {
454 let mut g = self.0.lock().unwrap();
455 match g.get_mut(token) {
456 Some(r) if !r.consumed => {
457 r.consumed = true;
458 Ok(true)
459 }
460 _ => Ok(false),
461 }
462 }
463 async fn revoke_chain(&self, chain_id: &str) -> Result<(), StoreError> {
464 let mut g = self.0.lock().unwrap();
465 for r in g.values_mut() {
466 if r.chain_id == chain_id {
467 r.revoked = true;
468 }
469 }
470 Ok(())
471 }
472 }
473
474 /// A `UserStore` stub — `SessionAuthority` holds one for the management
475 /// surface, but `establish`/`rotate` don't touch it.
476 #[derive(Default)]
477 struct StubUsers {
478 revoked: Mutex<Vec<(String, String)>>,
479 }
480
481 #[async_trait]
482 impl UserStore for StubUsers {
483 async fn find_by_provider(
484 &self,
485 _: &ProviderKey,
486 _: &str,
487 ) -> Result<Option<User>, StoreError> {
488 Ok(None)
489 }
490 async fn create(&self, _: NewUser) -> Result<User, StoreError> {
491 Ok(User::new(UserId::new("u")))
492 }
493 async fn link_provider(
494 &self,
495 _: &UserId,
496 _: &ProviderKey,
497 _: &str,
498 ) -> Result<(), StoreError> {
499 Ok(())
500 }
501 async fn list_devices(&self, _: &UserId) -> Result<Vec<DeviceId>, StoreError> {
502 Ok(vec![])
503 }
504 async fn revoke_device(
505 &self,
506 user_id: &UserId,
507 device_id: &DeviceId,
508 ) -> Result<(), StoreError> {
509 self.revoked
510 .lock()
511 .unwrap()
512 .push((user_id.to_string(), device_id.to_string()));
513 Ok(())
514 }
515 }
516
517 /// Process-local revocation set; clone shares the backing store so an
518 /// authority-side writer and an edge-side reader see the same set.
519 #[derive(Clone, Default)]
520 struct MemRevocations(Arc<Mutex<HashSet<String>>>);
521
522 #[async_trait]
523 impl RevocationReader for MemRevocations {
524 async fn is_revoked(&self, jti: &str) -> Result<bool, StoreError> {
525 Ok(self.0.lock().unwrap().contains(jti))
526 }
527 }
528
529 #[async_trait]
530 impl RevocationWriter for MemRevocations {
531 async fn revoke(&self, jti: &str) -> Result<(), StoreError> {
532 self.0.lock().unwrap().insert(jti.to_owned());
533 Ok(())
534 }
535 }
536
537 /// Assemble an authority + an edge verifier sharing one revocation set and
538 /// one Ed25519 keypair (origin holds the secret minter, edge the public
539 /// verifier — the asymmetric, edge-safe shape).
540 fn rig() -> (
541 SessionAuthority<PasetoV4SecretMinter, MemRefreshStore, StubUsers, MemRevocations>,
542 EdgeVerifier<PasetoV4PublicVerifier, MemRevocations>,
543 MemRevocations,
544 ) {
545 let (minter, verifier) = PasetoV4SecretMinter::generate().unwrap();
546 let revocations = MemRevocations::default();
547 let authority = SessionAuthority::new(
548 minter,
549 MemRefreshStore::default(),
550 StubUsers::default(),
551 revocations.clone(),
552 );
553 let edge = EdgeVerifier::new(verifier, revocations.clone());
554 (authority, edge, revocations)
555 }
556
557 #[test]
558 fn session_policy_defaults_are_minutes_and_days() {
559 let p = SessionPolicy::default();
560 assert_eq!(p.access_ttl_seconds, 15 * 60);
561 assert_eq!(p.refresh_ttl_seconds, 30 * 24 * 60 * 60);
562 // Builder overrides.
563 let p = SessionPolicy::default().with_access_ttl(60);
564 assert_eq!(p.access_ttl_seconds, 60);
565 assert_eq!(p.refresh_ttl_seconds, 30 * 24 * 60 * 60);
566 }
567
568 #[test]
569 fn establish_mints_access_with_jti_and_starts_refresh_chain() {
570 let (authority, edge, _) = rig();
571 pollster::block_on(async {
572 let s = authority
573 .establish(
574 UserId::new("u1"),
575 DeviceId::new("d1"),
576 DeviceBinding::Passkey,
577 1_000,
578 )
579 .await
580 .unwrap();
581
582 // Access token carries a fresh, non-empty jti and the policy TTL.
583 assert!(!s.claims.jti.is_empty());
584 assert_eq!(s.claims.expires_at, 1_000 + SessionPolicy::DEFAULT_ACCESS_TTL_SECONDS);
585 // Refresh root: fresh, unconsumed, days-scale TTL.
586 assert!(s.refresh.record.parent.is_none());
587 assert!(!s.refresh.record.consumed);
588 assert_eq!(
589 s.refresh.record.expires_at,
590 1_000 + SessionPolicy::DEFAULT_REFRESH_TTL_SECONDS
591 );
592
593 // The edge verifies the freshly minted token.
594 let back = edge.verify_at(&s.access_token, 1_100).await.unwrap();
595 assert_eq!(back.jti, s.claims.jti);
596 assert_eq!(back.sub, UserId::new("u1"));
597 });
598 }
599
600 #[test]
601 fn edge_accepts_then_rejects_after_revoke() {
602 let (authority, edge, _) = rig();
603 pollster::block_on(async {
604 let s = authority
605 .establish(
606 UserId::new("u1"),
607 DeviceId::new("d1"),
608 DeviceBinding::OidcGoogle,
609 1_000,
610 )
611 .await
612 .unwrap();
613
614 // Accepted before revocation.
615 assert!(edge.verify_at(&s.access_token, 1_100).await.is_ok());
616
617 // Origin revokes the jti; the edge (sharing the set) now rejects it
618 // even though the signature is still valid and it hasn't expired.
619 authority.revoke_session(&s.claims.jti).await.unwrap();
620 let err = edge.verify_at(&s.access_token, 1_100).await.unwrap_err();
621 assert!(matches!(err, Error::Revoked), "got {err:?}");
622 });
623 }
624
625 #[test]
626 fn edge_rejects_expired_before_consulting_revocation() {
627 let (authority, edge, _) = rig();
628 pollster::block_on(async {
629 let s = authority
630 .establish(
631 UserId::new("u1"),
632 DeviceId::new("d1"),
633 DeviceBinding::Passkey,
634 1_000,
635 )
636 .await
637 .unwrap();
638 // now == expires_at -> expired (Codec layer), not Revoked.
639 let now = 1_000 + SessionPolicy::DEFAULT_ACCESS_TTL_SECONDS;
640 let err = edge.verify_at(&s.access_token, now).await.unwrap_err();
641 assert!(matches!(err, Error::Codec(_)), "got {err:?}");
642 });
643 }
644
645 #[test]
646 fn rotate_issues_fresh_access_and_successor_refresh() {
647 let (authority, edge, _) = rig();
648 pollster::block_on(async {
649 let first = authority
650 .establish(
651 UserId::new("u1"),
652 DeviceId::new("d1"),
653 DeviceBinding::Passkey,
654 1_000,
655 )
656 .await
657 .unwrap();
658
659 let rotated = authority
660 .rotate(first.refresh.token.as_str(), DeviceBinding::Passkey, 1_050)
661 .await
662 .unwrap();
663
664 // Successor refresh links back to the root and shares the chain.
665 // `parent` is the stored key (the hash), not the raw secret.
666 assert_eq!(
667 rotated.refresh.record.parent.as_deref(),
668 Some(first.refresh.record.token.as_str())
669 );
670 assert_eq!(
671 rotated.refresh.record.chain_id,
672 first.refresh.record.chain_id
673 );
674 // Fresh access token with a *different* jti, still edge-verifiable.
675 assert_ne!(rotated.claims.jti, first.claims.jti);
676 assert_eq!(rotated.claims.sub, UserId::new("u1"));
677 assert!(edge.verify_at(&rotated.access_token, 1_100).await.is_ok());
678 });
679 }
680
681 #[test]
682 fn revoke_device_records_intent_through_user_store() {
683 let (minter, _verifier) = PasetoV4SecretMinter::generate().unwrap();
684 let users = StubUsers::default();
685 let authority = SessionAuthority::new(
686 minter,
687 MemRefreshStore::default(),
688 users,
689 MemRevocations::default(),
690 );
691 pollster::block_on(async {
692 authority
693 .revoke_device(&UserId::new("u1"), &DeviceId::new("d1"))
694 .await
695 .unwrap();
696 let recorded = authority.users().revoked.lock().unwrap().clone();
697 assert_eq!(recorded, vec![("u1".to_string(), "d1".to_string())]);
698 });
699 }
700
701 // ---- R515: peer-key binding, mint → wire → door ------------------------
702
703 fn node_key(byte: u8) -> PeerKey {
704 PeerKey::ed25519([byte; 32])
705 }
706
707 #[test]
708 fn establish_bound_survives_the_wire_and_proves_holder_is_the_peer() {
709 let (authority, edge, _) = rig();
710 pollster::block_on(async {
711 let s = authority
712 .establish_bound(
713 UserId::new("u1"),
714 DeviceId::new("d1"),
715 DeviceBinding::LanPair,
716 node_key(0x11),
717 1_000,
718 )
719 .await
720 .unwrap();
721
722 // The binding is in the *signed* token, not just the local claims —
723 // it survives serialization through the minter and back out of the
724 // public verifier at the edge.
725 assert_eq!(s.claims.peer_key.as_ref(), Some(&node_key(0x11)));
726 let back = edge
727 .verify_bound_at(&s.access_token, &node_key(0x11), 1_100)
728 .await
729 .unwrap();
730 assert_eq!(back.peer_key.as_ref(), Some(&node_key(0x11)));
731 assert_eq!(back.sub, UserId::new("u1"));
732
733 // The whole point: the same token presented by a peer that
734 // handshook under a *different* node key is refused. This is the
735 // stolen-token replay.
736 let err = edge
737 .verify_bound_at(&s.access_token, &node_key(0x22), 1_100)
738 .await
739 .unwrap_err();
740 assert!(matches!(err, Error::PeerKeyMismatch), "got {err:?}");
741
742 // Binding is layer 2, not a second admission door: revocation still
743 // kills a correctly-bound token.
744 authority.revoke_session(&s.claims.jti).await.unwrap();
745 let err = edge
746 .verify_bound_at(&s.access_token, &node_key(0x11), 1_100)
747 .await
748 .unwrap_err();
749 assert!(matches!(err, Error::Revoked), "got {err:?}");
750 });
751 }
752
753 #[test]
754 fn unbound_token_is_wire_compatible_but_fails_a_binding_check() {
755 let (authority, edge, _) = rig();
756 pollster::block_on(async {
757 let s = authority
758 .establish(
759 UserId::new("u1"),
760 DeviceId::new("d1"),
761 DeviceBinding::Passkey,
762 1_000,
763 )
764 .await
765 .unwrap();
766
767 // Unchanged for every existing consumer…
768 assert_eq!(s.claims.peer_key, None);
769 assert!(edge.verify_at(&s.access_token, 1_100).await.is_ok());
770 // …and fails closed at a door that demands a binding.
771 let err = edge
772 .verify_bound_at(&s.access_token, &node_key(0x11), 1_100)
773 .await
774 .unwrap_err();
775 assert!(matches!(err, Error::PeerKeyMismatch), "got {err:?}");
776 });
777 }
778
779 #[test]
780 fn rotate_bound_rebinds_from_the_caller_not_the_refresh_chain() {
781 let (authority, edge, _) = rig();
782 pollster::block_on(async {
783 let first = authority
784 .establish_bound(
785 UserId::new("u1"),
786 DeviceId::new("d1"),
787 DeviceBinding::LanPair,
788 node_key(0x11),
789 1_000,
790 )
791 .await
792 .unwrap();
793
794 // A plain rotate drops the binding — the refresh chain never
795 // carried it, so an unaware caller gets an unbound token rather
796 // than a silently stale binding.
797 let plain = authority
798 .rotate(first.refresh.token.as_str(), DeviceBinding::LanPair, 1_050)
799 .await
800 .unwrap();
801 assert_eq!(plain.claims.peer_key, None);
802
803 // rotate_bound re-binds — and to whatever key the node presents
804 // *now*, so a rekeyed node rebinds instead of inheriting.
805 let rebound = authority
806 .rotate_bound(
807 plain.refresh.token.as_str(),
808 DeviceBinding::LanPair,
809 node_key(0x22),
810 1_100,
811 )
812 .await
813 .unwrap();
814 assert_eq!(rebound.claims.peer_key.as_ref(), Some(&node_key(0x22)));
815 assert_ne!(rebound.claims.jti, first.claims.jti);
816 assert!(
817 edge.verify_bound_at(&rebound.access_token, &node_key(0x22), 1_150)
818 .await
819 .is_ok()
820 );
821 let err = edge
822 .verify_bound_at(&rebound.access_token, &node_key(0x11), 1_150)
823 .await
824 .unwrap_err();
825 assert!(matches!(err, Error::PeerKeyMismatch), "got {err:?}");
826 });
827 }
828}