Skip to main content

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 async_trait::async_trait;
135use cheers_core::{
136    Claims, DeviceBinding, DeviceId, Error, PeerKey, RefreshError, Revoked, StoreError, TokenMinter,
137    UserId,
138};
139
140use crate::refresh::{RefreshRotator, Rotated};
141use crate::revocation::RevocationWriter;
142use crate::standing::{SignedStandingBinding, StandingBinder};
143use crate::store::{RefreshStore, UserStore};
144
145/// Length of a generated `jti` in bytes (128 bits — uniqueness only, not a
146/// secret). base64url-no-pad encodes to 22 chars.
147const JTI_BYTES: usize = 16;
148
149/// Generate a fresh `jti` from the OS CSPRNG. Origin-only — the device and edge
150/// tiers never mint, so this never compiles into them.
151pub(crate) fn generate_jti() -> String {
152    let mut bytes = [0u8; JTI_BYTES];
153    getrandom::fill(&mut bytes).expect("OS CSPRNG must be available");
154    URL_SAFE_NO_PAD.encode(bytes)
155}
156
157/// TTL defaults for a session, so consumers don't pick the wrong durations.
158///
159/// Access tokens are minutes-scale (a stateless token can't be un-minted, so
160/// it's kept short — that short window is also the revocation-propagation
161/// bound); refresh tokens are days-scale (the stateful, rotatable half).
162#[derive(Debug, Clone, Copy, PartialEq, Eq)]
163#[non_exhaustive]
164pub struct SessionPolicy {
165    /// Access-token lifetime in seconds. Also the upper bound on how long a
166    /// revoked-but-not-yet-propagated access token stays accepted at the edge.
167    pub access_ttl_seconds: i64,
168    /// Refresh-token lifetime in seconds (applied to root and every successor).
169    pub refresh_ttl_seconds: i64,
170}
171
172impl SessionPolicy {
173    /// 15 minutes — short enough to bound revocation lag, long enough to avoid
174    /// rotating on every request.
175    pub const DEFAULT_ACCESS_TTL_SECONDS: i64 = 15 * 60;
176    /// 30 days.
177    pub const DEFAULT_REFRESH_TTL_SECONDS: i64 = 30 * 24 * 60 * 60;
178
179    pub fn new(access_ttl_seconds: i64, refresh_ttl_seconds: i64) -> Self {
180        Self {
181            access_ttl_seconds,
182            refresh_ttl_seconds,
183        }
184    }
185
186    pub fn with_access_ttl(mut self, seconds: i64) -> Self {
187        self.access_ttl_seconds = seconds;
188        self
189    }
190
191    pub fn with_refresh_ttl(mut self, seconds: i64) -> Self {
192        self.refresh_ttl_seconds = seconds;
193        self
194    }
195}
196
197impl Default for SessionPolicy {
198    fn default() -> Self {
199        Self {
200            access_ttl_seconds: Self::DEFAULT_ACCESS_TTL_SECONDS,
201            refresh_ttl_seconds: Self::DEFAULT_REFRESH_TTL_SECONDS,
202        }
203    }
204}
205
206/// Where a rotation gets the [`DeviceBinding`] to mint its access token with.
207///
208/// The refresh record deliberately carries no binding (R018, guide by
209/// omission), so whoever rotates must supply one. A fixed [`DeviceBinding`] is
210/// itself a resolver — the caller that ran the ceremony already knows it. A
211/// generic refresh door does not, and resolves it from the product's own
212/// per-device session rows (R730; cheers-axum's `refresh` module adapts a
213/// `SessionDirectory`).
214///
215/// `Ok(None)` means "no binding recorded for this device" and makes the
216/// rotation refuse with [`RefreshError::Unbound`] before the token is spent.
217/// `Err` is a store failure and surfaces as [`Error::Store`].
218#[async_trait]
219pub trait BindingResolver: Send + Sync {
220    async fn resolve_binding(
221        &self,
222        user_id: &UserId,
223        device_id: &DeviceId,
224        now: i64,
225    ) -> Result<Option<DeviceBinding>, StoreError>;
226}
227
228#[async_trait]
229impl BindingResolver for DeviceBinding {
230    async fn resolve_binding(
231        &self,
232        _user_id: &UserId,
233        _device_id: &DeviceId,
234        _now: i64,
235    ) -> Result<Option<DeviceBinding>, StoreError> {
236        Ok(Some(self.clone()))
237    }
238}
239
240/// A freshly established or rotated session: the minted access token (hand to
241/// the client as a bearer/cookie), the [`Claims`] it carries (so the caller can
242/// read `jti` / expiry without re-verifying), and the refresh token + record.
243#[derive(Debug, Clone)]
244#[non_exhaustive]
245pub struct NewSession {
246    /// The signed/encrypted access token string.
247    pub access_token: String,
248    /// The claims embedded in `access_token`, including the freshly minted `jti`.
249    pub claims: Claims,
250    /// The refresh token + persisted record (the stateful, rotatable half).
251    pub refresh: Rotated,
252    /// The standing node binding (R732-F5) — present exactly when the access
253    /// token is peer-key-bound and the binding is
254    /// [`DeviceBinding::LanPair`]. It is the credential an offline edge
255    /// admits the node on; the access token keeps its short TTL.
256    pub standing: Option<SignedStandingBinding>,
257}
258
259/// Origin facade: everything that can *create or destroy* sessions.
260///
261/// Holds the four origin-tier capabilities — a [`TokenMinter`], a
262/// [`RefreshStore`], a [`UserStore`], and a [`RevocationWriter`] — plus a
263/// [`SessionPolicy`]. Generic (not `dyn`) so the assembled capability set is
264/// visible in the type; a consumer wires concrete impls (e.g.
265/// [`PasetoV4SecretMinter`](crate::codec::PasetoV4SecretMinter) + Yubaba-backed
266/// stores).
267pub struct SessionAuthority<M, R, U, W> {
268    minter: M,
269    refresh: R,
270    users: U,
271    revoke: W,
272    policy: SessionPolicy,
273    /// Mints standing node bindings for LanPair sessions (R732-F5). `None` on
274    /// an authority that serves no LAN nodes; such an authority refuses a
275    /// bound LanPair session ([`Error::NoStandingBinder`]).
276    standing: Option<StandingBinder>,
277}
278
279impl<M, R, U, W> SessionAuthority<M, R, U, W>
280where
281    M: TokenMinter + Send + Sync,
282    R: RefreshStore,
283    U: UserStore,
284    W: RevocationWriter,
285{
286    /// Assemble an authority with the [default policy](SessionPolicy::default).
287    pub fn new(minter: M, refresh: R, users: U, revoke: W) -> Self {
288        Self {
289            minter,
290            refresh,
291            users,
292            revoke,
293            policy: SessionPolicy::default(),
294            standing: None,
295        }
296    }
297
298    /// Override the TTL policy.
299    pub fn with_policy(mut self, policy: SessionPolicy) -> Self {
300        self.policy = policy;
301        self
302    }
303
304    /// Mint standing node bindings with `binder` (R732-F5). Required before
305    /// [`establish_bound`](Self::establish_bound) /
306    /// [`rotate_bound`](Self::rotate_bound) will serve a
307    /// [`DeviceBinding::LanPair`] session.
308    pub fn with_standing_binder(mut self, binder: StandingBinder) -> Self {
309        self.standing = Some(binder);
310        self
311    }
312
313    pub fn policy(&self) -> &SessionPolicy {
314        &self.policy
315    }
316
317    /// The held [`UserStore`], for device-management reads
318    /// ([`list_devices`](UserStore::list_devices)).
319    pub fn users(&self) -> &U {
320        &self.users
321    }
322
323    /// Establish a fresh session: mint a short-TTL access token (with a fresh
324    /// `jti`) and start a homed refresh chain for `(sub, device)`.
325    pub async fn establish(
326        &self,
327        sub: UserId,
328        device: DeviceId,
329        binding: DeviceBinding,
330        now: i64,
331    ) -> Result<NewSession, Error> {
332        self.establish_inner(sub, device, binding, None, now).await
333    }
334
335    /// Establish a session whose access token is **bound to `peer_key`** — the
336    /// mint half of R515.
337    ///
338    /// Identical to [`establish`](Self::establish) except the minted claims
339    /// carry [`Claims::peer_key`], so a verifier that has already authenticated
340    /// the connecting peer under that key can prove *token holder == connecting
341    /// peer* (see `EdgeVerifier::verify_bound_at`). A token stolen from this
342    /// client is then useless to a thief connecting under their own key.
343    ///
344    /// cheers does **not** establish that the caller possesses `peer_key` —
345    /// that proof belongs to the enrollment ceremony in the consuming service
346    /// (the app proves possession of the key, presents its existing session,
347    /// and *then* calls this). cheers's own HTTP ceremonies can't do it: a
348    /// browser passkey or magic-link sign-in has no node key at all.
349    ///
350    /// The refresh chain is unchanged, and deliberately does not record the
351    /// key: like `binding`, the peer key is a fact about *this* access token,
352    /// not about the chain — so [`rotate_bound`](Self::rotate_bound) takes it
353    /// from the caller again.
354    ///
355    /// **LanPair (R732-F5).** For [`DeviceBinding::LanPair`] the session also
356    /// carries a [standing binding](NewSession::standing) — user to node key,
357    /// no expiry, ended only by revocation or a newer binding for `device` —
358    /// minted by the [standing binder](Self::with_standing_binder). Without
359    /// one this refuses with [`Error::NoStandingBinder`] before anything is
360    /// written.
361    pub async fn establish_bound(
362        &self,
363        sub: UserId,
364        device: DeviceId,
365        binding: DeviceBinding,
366        peer_key: PeerKey,
367        now: i64,
368    ) -> Result<NewSession, Error> {
369        self.establish_inner(sub, device, binding, Some(peer_key), now)
370            .await
371    }
372
373    async fn establish_inner(
374        &self,
375        sub: UserId,
376        device: DeviceId,
377        binding: DeviceBinding,
378        peer_key: Option<PeerKey>,
379        now: i64,
380    ) -> Result<NewSession, Error> {
381        let standing = self
382            .mint_standing(&sub, &device, &binding, peer_key.as_ref(), now)
383            .await?;
384        let claims = self.mint_access(sub.clone(), device.clone(), binding, peer_key, now)?;
385        let refresh = RefreshRotator::new(&self.refresh, self.policy.refresh_ttl_seconds)
386            .mint_root(sub, device, now)
387            .await?;
388        Ok(NewSession {
389            access_token: self.minter.mint(&claims)?,
390            claims,
391            refresh,
392            standing,
393        })
394    }
395
396    /// Rotate `presented_refresh` → fresh access token + successor refresh
397    /// token. Replay detection / chain revocation are the rotator's
398    /// (see [`RefreshRotator::rotate`]).
399    ///
400    /// The binding comes from the caller's [`BindingResolver`] because the
401    /// refresh record doesn't carry it — guide by omission, the refresh chain
402    /// is about *which session*, not *how it authenticated*. A caller that
403    /// already knows the binding passes `&DeviceBinding::…` (a fixed binding is
404    /// a resolver); a generic door that only holds the refresh token passes
405    /// the product's session rows, keyed by the chain's `(user, device)`.
406    ///
407    /// **Ordering (R730).** The token is checked, *then* the binding resolved,
408    /// *then* the token spent and the access token minted. A resolver that
409    /// knows no binding yields [`RefreshError::Unbound`] with the token still
410    /// unspent and the chain untouched — never a guessed or unbound mint, and
411    /// never a half-rotated chain whose successor the client was not handed.
412    /// Replay detection still runs first, so a replayed token revokes its
413    /// chain whether or not a binding exists.
414    pub async fn rotate<B: BindingResolver + ?Sized>(
415        &self,
416        presented_refresh: &str,
417        binding: &B,
418        now: i64,
419    ) -> Result<NewSession, Error> {
420        self.rotate_inner(presented_refresh, binding, None, now)
421            .await
422    }
423
424    /// Rotate into a fresh access token that is **bound to `peer_key`** (R515).
425    ///
426    /// The peer key is supplied by the caller for the same reason `binding` is:
427    /// the refresh record is about *which session*, not about how this
428    /// particular access token is being presented. A node re-binds on every
429    /// rotation, which is what keeps the binding honest if the node key
430    /// changes — for a [`DeviceBinding::LanPair`] session that includes a fresh
431    /// [standing binding](NewSession::standing) whose higher sequence
432    /// supersedes the previous one (see [`establish_bound`](Self::establish_bound)).
433    pub async fn rotate_bound<B: BindingResolver + ?Sized>(
434        &self,
435        presented_refresh: &str,
436        binding: &B,
437        peer_key: PeerKey,
438        now: i64,
439    ) -> Result<NewSession, Error> {
440        self.rotate_inner(presented_refresh, binding, Some(peer_key), now)
441            .await
442    }
443
444    async fn rotate_inner<B: BindingResolver + ?Sized>(
445        &self,
446        presented_refresh: &str,
447        bindings: &B,
448        peer_key: Option<PeerKey>,
449        now: i64,
450    ) -> Result<NewSession, Error> {
451        let rotator = RefreshRotator::new(&self.refresh, self.policy.refresh_ttl_seconds);
452        // check → resolve → commit: see `rotate`'s ordering note. Resolving
453        // between the check and the consume is what lets a missing binding
454        // refuse without spending the token.
455        let live = rotator.check(presented_refresh, now).await?;
456        let binding = bindings
457            .resolve_binding(&live.record().user_id, &live.record().device_id, now)
458            .await?
459            .ok_or(RefreshError::Unbound)?;
460        // Before the commit, like the binding resolve: a missing binder
461        // refuses with the token unspent, and a failed mint costs only a
462        // skipped sequence number.
463        let standing = self
464            .mint_standing(
465                &live.record().user_id,
466                &live.record().device_id,
467                &binding,
468                peer_key.as_ref(),
469                now,
470            )
471            .await?;
472        let refresh = rotator.commit(live, now).await?;
473        let claims = self.mint_access(
474            refresh.record.user_id.clone(),
475            refresh.record.device_id.clone(),
476            binding,
477            peer_key,
478            now,
479        )?;
480        Ok(NewSession {
481            access_token: self.minter.mint(&claims)?,
482            claims,
483            refresh,
484            standing,
485        })
486    }
487
488    /// The standing binding a session carries: one for every peer-key-bound
489    /// [`DeviceBinding::LanPair`] access token, none otherwise (R732-F5).
490    async fn mint_standing(
491        &self,
492        sub: &UserId,
493        device: &DeviceId,
494        binding: &DeviceBinding,
495        peer_key: Option<&PeerKey>,
496        now: i64,
497    ) -> Result<Option<SignedStandingBinding>, Error> {
498        let (DeviceBinding::LanPair, Some(key)) = (binding, peer_key) else {
499            return Ok(None);
500        };
501        let binder = self.standing.as_ref().ok_or(Error::NoStandingBinder)?;
502        Ok(Some(binder.mint(sub.clone(), device.clone(), key.clone(), now).await?))
503    }
504
505    /// Revoke a single access token by its `jti` — the immediate, edge-visible
506    /// kill (within the propagation window bounded by the access TTL). `exp`
507    /// is the token's own expiry: past it the entry lapses and gc drops it.
508    pub async fn revoke_session(&self, jti: &str, exp: i64) -> Result<(), Error> {
509        self.revoke.revoke(&Revoked::jti(jti, Some(exp))).await?;
510        Ok(())
511    }
512
513    /// Revoke one standing node binding by its `jti`. A standing binding has
514    /// no `exp`, so the entry never lapses.
515    pub async fn revoke_standing_binding(&self, jti: &str) -> Result<(), Error> {
516        self.revoke.revoke(&Revoked::jti(jti, None)).await?;
517        Ok(())
518    }
519
520    /// Records device-level revocation intent via the [`UserStore`], and
521    /// [`Revoked::Device`] in the revocation set — the entry that ends a
522    /// standing node binding for this device at offline replicas (R732-F6).
523    ///
524    /// The entry's `at_seq` is the device's next binding sequence, consumed
525    /// from the [standing binder](Self::with_standing_binder) at `now`: every
526    /// binding minted before it is masked, and re-enrolling the device later
527    /// mints above it and is admitted (R732-T7). With no binder configured
528    /// this authority has minted no standing bindings, so there is nothing
529    /// for a device entry to end and none is recorded.
530    /// Blocking *new* sessions also means revoking that device's refresh
531    /// chains via the [`RefreshStore`]; killing an *in-flight* access token is
532    /// [`revoke_session`](Self::revoke_session). The product owns the
533    /// device→chain index that drives the first, so this facade exposes the
534    /// store call and the per-jti kill, not a magic "log out everywhere".
535    pub async fn revoke_device(
536        &self,
537        user_id: &UserId,
538        device_id: &DeviceId,
539        now: i64,
540    ) -> Result<(), Error> {
541        self.users.revoke_device(user_id, device_id).await?;
542        if let Some(binder) = &self.standing {
543            let at_seq = binder.revocation_seq(device_id, now).await?;
544            self.revoke.revoke(&Revoked::device(device_id.clone(), at_seq)).await?;
545        }
546        Ok(())
547    }
548
549    /// Build access-token claims with a fresh `jti`, the policy's access TTL,
550    /// and — when the caller asked for one — a peer-key binding (R515).
551    fn mint_access(
552        &self,
553        sub: UserId,
554        device: DeviceId,
555        binding: DeviceBinding,
556        peer_key: Option<PeerKey>,
557        now: i64,
558    ) -> Result<Claims, Error> {
559        let claims = Claims::new(sub, device, binding, now, now + self.policy.access_ttl_seconds)
560            .with_jti(generate_jti());
561        Ok(match peer_key {
562            Some(key) => claims.with_peer_key(key),
563            None => claims,
564        })
565    }
566}
567
568#[cfg(test)]
569mod tests {
570    use super::*;
571    use cheers_core::LeaseState;
572    use crate::codec::PasetoV4SecretMinter;
573    use crate::revocation::{MemoryRevocationStore, RevocationPublisher};
574    use crate::standing::MemoryBindingSequenceStore;
575    use crate::store::{NewUser, ProviderKey, RefreshTokenRecord};
576    use cheers_core::{DeviceBinding, StoreError, User};
577    use cheers_verify::{EdgeVerifier, PasetoV4PublicVerifier, RevocationReader};
578    use async_trait::async_trait;
579    use std::collections::HashMap;
580    use std::sync::Mutex;
581
582    // ---- minimal in-memory capability impls --------------------------------
583
584    #[derive(Default)]
585    struct MemRefreshStore(Mutex<HashMap<String, RefreshTokenRecord>>);
586
587    #[async_trait]
588    impl RefreshStore for MemRefreshStore {
589        async fn put(&self, record: &RefreshTokenRecord) -> Result<(), StoreError> {
590            self.0
591                .lock()
592                .unwrap()
593                .insert(record.token.clone(), record.clone());
594            Ok(())
595        }
596        async fn get(&self, token: &str) -> Result<Option<RefreshTokenRecord>, StoreError> {
597            Ok(self.0.lock().unwrap().get(token).cloned())
598        }
599        async fn mark_consumed(&self, token: &str) -> Result<bool, StoreError> {
600            let mut g = self.0.lock().unwrap();
601            match g.get_mut(token) {
602                Some(r) if !r.consumed => {
603                    r.consumed = true;
604                    Ok(true)
605                }
606                _ => Ok(false),
607            }
608        }
609        async fn revoke_chain(&self, chain_id: &str) -> Result<(), StoreError> {
610            let mut g = self.0.lock().unwrap();
611            for r in g.values_mut() {
612                if r.chain_id == chain_id {
613                    r.revoked = true;
614                }
615            }
616            Ok(())
617        }
618    }
619
620    /// A `UserStore` stub — `SessionAuthority` holds one for the management
621    /// surface, but `establish`/`rotate` don't touch it.
622    #[derive(Default)]
623    struct StubUsers {
624        revoked: Mutex<Vec<(String, String)>>,
625    }
626
627    #[async_trait]
628    impl UserStore for StubUsers {
629        async fn get(&self, _: &UserId) -> Result<Option<User>, StoreError> {
630            Ok(None)
631        }
632        async fn find_by_provider(
633            &self,
634            _: &ProviderKey,
635            _: &str,
636        ) -> Result<Option<User>, StoreError> {
637            Ok(None)
638        }
639        async fn create(&self, _: NewUser) -> Result<User, StoreError> {
640            Ok(User::new(UserId::new("u")))
641        }
642        async fn link_provider(
643            &self,
644            _: &UserId,
645            _: &ProviderKey,
646            _: &str,
647        ) -> Result<(), StoreError> {
648            Ok(())
649        }
650        async fn list_devices(&self, _: &UserId) -> Result<Vec<DeviceId>, StoreError> {
651            Ok(vec![])
652        }
653        async fn revoke_device(
654            &self,
655            user_id: &UserId,
656            device_id: &DeviceId,
657        ) -> Result<(), StoreError> {
658            self.revoked
659                .lock()
660                .unwrap()
661                .push((user_id.to_string(), device_id.to_string()));
662            Ok(())
663        }
664    }
665
666    /// Assemble an authority + an edge verifier sharing one revocation set and
667    /// one Ed25519 keypair (origin holds the secret minter, edge the public
668    /// verifier — the asymmetric, edge-safe shape).
669    fn rig() -> (
670        SessionAuthority<PasetoV4SecretMinter, MemRefreshStore, StubUsers, MemoryRevocationStore>,
671        EdgeVerifier<PasetoV4PublicVerifier, MemoryRevocationStore>,
672        MemoryRevocationStore,
673    ) {
674        let (minter, verifier) = PasetoV4SecretMinter::generate().unwrap();
675        let revocations = MemoryRevocationStore::default();
676        let binder = StandingBinder::new(MemoryBindingSequenceStore::new(), same_key(&minter), ISS, KID);
677        let authority = SessionAuthority::new(
678            minter,
679            MemRefreshStore::default(),
680            StubUsers::default(),
681            revocations.clone(),
682        )
683        .with_standing_binder(binder);
684        let edge = EdgeVerifier::new(verifier, revocations.clone());
685        (authority, edge, revocations)
686    }
687
688    const ISS: &str = "https://cheers.test";
689    const KID: &str = "iss-1";
690
691    /// A second handle on the issuer key: the binder and the revocation
692    /// publisher sign with the same key as the access-token minter.
693    fn same_key(minter: &PasetoV4SecretMinter) -> PasetoV4SecretMinter {
694        PasetoV4SecretMinter::from_secret_key(minter.secret_key_bytes().try_into().unwrap()).unwrap()
695    }
696
697    #[test]
698    fn session_policy_defaults_are_minutes_and_days() {
699        let p = SessionPolicy::default();
700        assert_eq!(p.access_ttl_seconds, 15 * 60);
701        assert_eq!(p.refresh_ttl_seconds, 30 * 24 * 60 * 60);
702        // Builder overrides.
703        let p = SessionPolicy::default().with_access_ttl(60);
704        assert_eq!(p.access_ttl_seconds, 60);
705        assert_eq!(p.refresh_ttl_seconds, 30 * 24 * 60 * 60);
706    }
707
708    #[test]
709    fn establish_mints_access_with_jti_and_starts_refresh_chain() {
710        let (authority, edge, _) = rig();
711        pollster::block_on(async {
712            let s = authority
713                .establish(
714                    UserId::new("u1"),
715                    DeviceId::new("d1"),
716                    DeviceBinding::Passkey,
717                    1_000,
718                )
719                .await
720                .unwrap();
721
722            // Access token carries a fresh, non-empty jti and the policy TTL.
723            assert!(!s.claims.jti.is_empty());
724            assert_eq!(s.claims.expires_at, 1_000 + SessionPolicy::DEFAULT_ACCESS_TTL_SECONDS);
725            // Refresh root: fresh, unconsumed, days-scale TTL.
726            assert!(s.refresh.record.parent.is_none());
727            assert!(!s.refresh.record.consumed);
728            assert_eq!(
729                s.refresh.record.expires_at,
730                1_000 + SessionPolicy::DEFAULT_REFRESH_TTL_SECONDS
731            );
732
733            // The edge verifies the freshly minted token.
734            let back = edge.verify_at(&s.access_token, 1_100).await.unwrap();
735            assert_eq!(back.jti, s.claims.jti);
736            assert_eq!(back.sub, UserId::new("u1"));
737        });
738    }
739
740    #[test]
741    fn edge_accepts_then_rejects_after_revoke() {
742        let (authority, edge, _) = rig();
743        pollster::block_on(async {
744            let s = authority
745                .establish(
746                    UserId::new("u1"),
747                    DeviceId::new("d1"),
748                    DeviceBinding::OidcGoogle,
749                    1_000,
750                )
751                .await
752                .unwrap();
753
754            // Accepted before revocation.
755            assert!(edge.verify_at(&s.access_token, 1_100).await.is_ok());
756
757            // Origin revokes the jti; the edge (sharing the set) now rejects it
758            // even though the signature is still valid and it hasn't expired.
759            authority.revoke_session(&s.claims.jti, s.claims.expires_at).await.unwrap();
760            let err = edge.verify_at(&s.access_token, 1_100).await.unwrap_err();
761            assert!(matches!(err, Error::Revoked), "got {err:?}");
762        });
763    }
764
765    #[test]
766    fn edge_rejects_expired_before_consulting_revocation() {
767        let (authority, edge, _) = rig();
768        pollster::block_on(async {
769            let s = authority
770                .establish(
771                    UserId::new("u1"),
772                    DeviceId::new("d1"),
773                    DeviceBinding::Passkey,
774                    1_000,
775                )
776                .await
777                .unwrap();
778            // now == expires_at -> expired (Codec layer), not Revoked.
779            let now = 1_000 + SessionPolicy::DEFAULT_ACCESS_TTL_SECONDS;
780            let err = edge.verify_at(&s.access_token, now).await.unwrap_err();
781            assert!(matches!(err, Error::Codec(_)), "got {err:?}");
782        });
783    }
784
785    #[test]
786    fn rotate_issues_fresh_access_and_successor_refresh() {
787        let (authority, edge, _) = rig();
788        pollster::block_on(async {
789            let first = authority
790                .establish(
791                    UserId::new("u1"),
792                    DeviceId::new("d1"),
793                    DeviceBinding::Passkey,
794                    1_000,
795                )
796                .await
797                .unwrap();
798
799            let rotated = authority
800                .rotate(first.refresh.token.as_str(), &DeviceBinding::Passkey, 1_050)
801                .await
802                .unwrap();
803
804            // Successor refresh links back to the root and shares the chain.
805            // `parent` is the stored key (the hash), not the raw secret.
806            assert_eq!(
807                rotated.refresh.record.parent.as_deref(),
808                Some(first.refresh.record.token.as_str())
809            );
810            assert_eq!(
811                rotated.refresh.record.chain_id,
812                first.refresh.record.chain_id
813            );
814            // Fresh access token with a *different* jti, still edge-verifiable.
815            assert_ne!(rotated.claims.jti, first.claims.jti);
816            assert_eq!(rotated.claims.sub, UserId::new("u1"));
817            assert!(edge.verify_at(&rotated.access_token, 1_100).await.is_ok());
818        });
819    }
820
821    #[test]
822    fn revoke_device_without_a_binder_records_intent_but_no_set_entry() {
823        let (minter, _verifier) = PasetoV4SecretMinter::generate().unwrap();
824        let users = StubUsers::default();
825        let revocations = MemoryRevocationStore::default();
826        let authority = SessionAuthority::new(
827            minter,
828            MemRefreshStore::default(),
829            users,
830            revocations.clone(),
831        );
832        pollster::block_on(async {
833            authority
834                .revoke_device(&UserId::new("u1"), &DeviceId::new("d1"), 1_000)
835                .await
836                .unwrap();
837            let recorded = authority.users().revoked.lock().unwrap().clone();
838            assert_eq!(recorded, vec![("u1".to_string(), "d1".to_string())]);
839            // R732-T7: no binder, so no standing binding was ever minted and
840            // there is nothing for a device entry to end.
841            assert!(!revocations.is_device_revoked(&DeviceId::new("d1"), 0).await.unwrap());
842            assert_eq!(revocations.snapshot().await.unwrap().epoch, 0);
843        });
844    }
845
846    // ---- R515: peer-key binding, mint → wire → door ------------------------
847
848    fn node_key(byte: u8) -> PeerKey {
849        PeerKey::ed25519([byte; 32])
850    }
851
852    #[test]
853    fn establish_bound_survives_the_wire_and_proves_holder_is_the_peer() {
854        let (authority, edge, _) = rig();
855        pollster::block_on(async {
856            let s = authority
857                .establish_bound(
858                    UserId::new("u1"),
859                    DeviceId::new("d1"),
860                    DeviceBinding::LanPair,
861                    node_key(0x11),
862                    1_000,
863                )
864                .await
865                .unwrap();
866
867            // The binding is in the *signed* token, not just the local claims —
868            // it survives serialization through the minter and back out of the
869            // public verifier at the edge.
870            assert_eq!(s.claims.peer_key.as_ref(), Some(&node_key(0x11)));
871            let back = edge
872                .verify_bound_at(&s.access_token, &node_key(0x11), 1_100)
873                .await
874                .unwrap();
875            assert_eq!(back.peer_key.as_ref(), Some(&node_key(0x11)));
876            assert_eq!(back.sub, UserId::new("u1"));
877
878            // The whole point: the same token presented by a peer that
879            // handshook under a *different* node key is refused. This is the
880            // stolen-token replay.
881            let err = edge
882                .verify_bound_at(&s.access_token, &node_key(0x22), 1_100)
883                .await
884                .unwrap_err();
885            assert!(matches!(err, Error::PeerKeyMismatch), "got {err:?}");
886
887            // Binding is layer 2, not a second admission door: revocation still
888            // kills a correctly-bound token.
889            authority.revoke_session(&s.claims.jti, s.claims.expires_at).await.unwrap();
890            let err = edge
891                .verify_bound_at(&s.access_token, &node_key(0x11), 1_100)
892                .await
893                .unwrap_err();
894            assert!(matches!(err, Error::Revoked), "got {err:?}");
895        });
896    }
897
898    #[test]
899    fn unbound_token_is_wire_compatible_but_fails_a_binding_check() {
900        let (authority, edge, _) = rig();
901        pollster::block_on(async {
902            let s = authority
903                .establish(
904                    UserId::new("u1"),
905                    DeviceId::new("d1"),
906                    DeviceBinding::Passkey,
907                    1_000,
908                )
909                .await
910                .unwrap();
911
912            // Unchanged for every existing consumer…
913            assert_eq!(s.claims.peer_key, None);
914            assert!(edge.verify_at(&s.access_token, 1_100).await.is_ok());
915            // …and fails closed at a door that demands a binding.
916            let err = edge
917                .verify_bound_at(&s.access_token, &node_key(0x11), 1_100)
918                .await
919                .unwrap_err();
920            assert!(matches!(err, Error::PeerKeyMismatch), "got {err:?}");
921        });
922    }
923
924    #[test]
925    fn rotate_bound_rebinds_from_the_caller_not_the_refresh_chain() {
926        let (authority, edge, _) = rig();
927        pollster::block_on(async {
928            let first = authority
929                .establish_bound(
930                    UserId::new("u1"),
931                    DeviceId::new("d1"),
932                    DeviceBinding::LanPair,
933                    node_key(0x11),
934                    1_000,
935                )
936                .await
937                .unwrap();
938
939            // A plain rotate drops the binding — the refresh chain never
940            // carried it, so an unaware caller gets an unbound token rather
941            // than a silently stale binding.
942            let plain = authority
943                .rotate(first.refresh.token.as_str(), &DeviceBinding::LanPair, 1_050)
944                .await
945                .unwrap();
946            assert_eq!(plain.claims.peer_key, None);
947
948            // rotate_bound re-binds — and to whatever key the node presents
949            // *now*, so a rekeyed node rebinds instead of inheriting.
950            let rebound = authority
951                .rotate_bound(
952                    plain.refresh.token.as_str(),
953                    &DeviceBinding::LanPair,
954                    node_key(0x22),
955                    1_100,
956                )
957                .await
958                .unwrap();
959            assert_eq!(rebound.claims.peer_key.as_ref(), Some(&node_key(0x22)));
960            assert_ne!(rebound.claims.jti, first.claims.jti);
961            assert!(
962                edge.verify_bound_at(&rebound.access_token, &node_key(0x22), 1_150)
963                    .await
964                    .is_ok()
965            );
966            let err = edge
967                .verify_bound_at(&rebound.access_token, &node_key(0x11), 1_150)
968                .await
969                .unwrap_err();
970            assert!(matches!(err, Error::PeerKeyMismatch), "got {err:?}");
971        });
972    }
973
974    // ---- R730: binding resolved from the chain's (user, device) ------------
975
976    /// A product-shaped resolver: per-device rows, plus a log of what it was
977    /// asked so tests can prove it was keyed off the refresh chain.
978    #[derive(Default)]
979    struct RowResolver {
980        rows: Mutex<HashMap<(String, String), DeviceBinding>>,
981        asked: Mutex<Vec<(String, String)>>,
982        fail: bool,
983    }
984
985    impl RowResolver {
986        fn with(user: &str, device: &str, binding: DeviceBinding) -> Self {
987            let r = Self::default();
988            r.rows
989                .lock()
990                .unwrap()
991                .insert((user.into(), device.into()), binding);
992            r
993        }
994    }
995
996    #[async_trait]
997    impl BindingResolver for RowResolver {
998        async fn resolve_binding(
999            &self,
1000            user_id: &UserId,
1001            device_id: &DeviceId,
1002            _now: i64,
1003        ) -> Result<Option<DeviceBinding>, StoreError> {
1004            let key = (user_id.as_str().to_owned(), device_id.as_str().to_owned());
1005            self.asked.lock().unwrap().push(key.clone());
1006            if self.fail {
1007                return Err(StoreError::Backend("directory down".into()));
1008            }
1009            Ok(self.rows.lock().unwrap().get(&key).cloned())
1010        }
1011    }
1012
1013    #[test]
1014    fn rotate_mints_with_the_binding_resolved_for_the_chains_device() {
1015        let (authority, edge, _) = rig();
1016        pollster::block_on(async {
1017            let first = authority
1018                .establish(
1019                    UserId::new("u1"),
1020                    DeviceId::new("d1"),
1021                    DeviceBinding::EmailMagicLink,
1022                    1_000,
1023                )
1024                .await
1025                .unwrap();
1026            let resolver = RowResolver::with("u1", "d1", DeviceBinding::EmailMagicLink);
1027
1028            let rotated = authority
1029                .rotate(first.refresh.token.as_str(), &resolver, 1_050)
1030                .await
1031                .unwrap();
1032
1033            assert_eq!(rotated.claims.binding, DeviceBinding::EmailMagicLink);
1034            assert_eq!(rotated.claims.device, DeviceId::new("d1"));
1035            assert_eq!(
1036                *resolver.asked.lock().unwrap(),
1037                vec![("u1".to_string(), "d1".to_string())]
1038            );
1039            let back = edge.verify_at(&rotated.access_token, 1_100).await.unwrap();
1040            assert_eq!(back.binding, DeviceBinding::EmailMagicLink);
1041        });
1042    }
1043
1044    #[test]
1045    fn rotate_refuses_an_unbound_device_without_spending_the_token() {
1046        let (authority, _edge, _) = rig();
1047        pollster::block_on(async {
1048            let first = authority
1049                .establish(
1050                    UserId::new("u1"),
1051                    DeviceId::new("d1"),
1052                    DeviceBinding::Passkey,
1053                    1_000,
1054                )
1055                .await
1056                .unwrap();
1057            // Rows exist, but not for this device.
1058            let resolver = RowResolver::with("u1", "other", DeviceBinding::Passkey);
1059
1060            let err = authority
1061                .rotate(first.refresh.token.as_str(), &resolver, 1_050)
1062                .await
1063                .unwrap_err();
1064            assert!(
1065                matches!(err, Error::Refresh(RefreshError::Unbound)),
1066                "got {err:?}"
1067            );
1068
1069            // Not spent, chain untouched: once a binding is known, the very
1070            // same token still rotates (no Replay, no ChainRevoked).
1071            let ok = authority
1072                .rotate(first.refresh.token.as_str(), &DeviceBinding::Passkey, 1_060)
1073                .await
1074                .unwrap();
1075            assert_eq!(ok.refresh.record.chain_id, first.refresh.record.chain_id);
1076        });
1077    }
1078
1079    #[test]
1080    fn rotate_resolver_failure_is_a_store_error_and_spends_nothing() {
1081        let (authority, _edge, _) = rig();
1082        pollster::block_on(async {
1083            let first = authority
1084                .establish(
1085                    UserId::new("u1"),
1086                    DeviceId::new("d1"),
1087                    DeviceBinding::Passkey,
1088                    1_000,
1089                )
1090                .await
1091                .unwrap();
1092            let resolver = RowResolver {
1093                fail: true,
1094                ..RowResolver::default()
1095            };
1096            let err = authority
1097                .rotate(first.refresh.token.as_str(), &resolver, 1_050)
1098                .await
1099                .unwrap_err();
1100            assert!(matches!(err, Error::Store(_)), "got {err:?}");
1101            assert!(
1102                authority
1103                    .rotate(first.refresh.token.as_str(), &DeviceBinding::Passkey, 1_060)
1104                    .await
1105                    .is_ok()
1106            );
1107        });
1108    }
1109
1110    #[test]
1111    fn replay_revokes_the_chain_before_the_resolver_is_consulted() {
1112        let (authority, _edge, _) = rig();
1113        pollster::block_on(async {
1114            let first = authority
1115                .establish(
1116                    UserId::new("u1"),
1117                    DeviceId::new("d1"),
1118                    DeviceBinding::Passkey,
1119                    1_000,
1120                )
1121                .await
1122                .unwrap();
1123            let resolver = RowResolver::with("u1", "d1", DeviceBinding::Passkey);
1124            let second = authority
1125                .rotate(first.refresh.token.as_str(), &resolver, 1_050)
1126                .await
1127                .unwrap();
1128
1129            // Replay with a resolver that knows NO binding: still Replay, not
1130            // Unbound — reuse detection outranks the binding lookup.
1131            let empty = RowResolver::default();
1132            let err = authority
1133                .rotate(first.refresh.token.as_str(), &empty, 1_060)
1134                .await
1135                .unwrap_err();
1136            assert!(
1137                matches!(err, Error::Refresh(RefreshError::Replay)),
1138                "got {err:?}"
1139            );
1140            assert!(empty.asked.lock().unwrap().is_empty());
1141
1142            // The whole chain is dead, including the legitimate successor.
1143            let err = authority
1144                .rotate(second.refresh.token.as_str(), &resolver, 1_070)
1145                .await
1146                .unwrap_err();
1147            assert!(
1148                matches!(err, Error::Refresh(RefreshError::ChainRevoked)),
1149                "got {err:?}"
1150            );
1151        });
1152    }
1153
1154    // ---- R732-F5: standing node binding, mint → offline edge ---------------
1155
1156    use cheers_verify::{IssuerTrust, ReplicatedRevocations, StandingError, StandingVerifier};
1157    use std::sync::Arc;
1158
1159    /// Ten years on: far past any access-token window.
1160    const YEARS_LATER: i64 = 1_000 + 10 * 365 * 24 * 60 * 60;
1161
1162    type Authority = SessionAuthority<PasetoV4SecretMinter, MemRefreshStore, StubUsers, MemoryRevocationStore>;
1163
1164    /// An authority with a standing binder, an online access-token edge, and
1165    /// an offline edge that knows the issuer key and learns revocations only
1166    /// from signed sets it is handed.
1167    struct Standing {
1168        authority: Authority,
1169        access_edge: EdgeVerifier<PasetoV4PublicVerifier, MemoryRevocationStore>,
1170        publisher: RevocationPublisher<MemoryRevocationStore>,
1171        replica: Arc<ReplicatedRevocations>,
1172        edge: StandingVerifier<Arc<ReplicatedRevocations>>,
1173    }
1174
1175    impl Standing {
1176        /// Gossip the issuer's current revocation set to the offline edge.
1177        async fn sync_revocations(&self) {
1178            let set = self.publisher.current(0).await.unwrap();
1179            self.replica.adopt(&set.token).await.unwrap();
1180        }
1181
1182        async fn bind(&self, device: &str, key: PeerKey, now: i64) -> NewSession {
1183            self.authority
1184                .establish_bound(UserId::new("u1"), DeviceId::new(device), DeviceBinding::LanPair, key, now)
1185                .await
1186                .unwrap()
1187        }
1188    }
1189
1190    fn standing_rig() -> Standing {
1191        let (minter, verifier) = PasetoV4SecretMinter::generate().unwrap();
1192        let trust = IssuerTrust::pinned(ISS, minter.verifier().unwrap());
1193        let revocations = MemoryRevocationStore::default();
1194        let ownership: Arc<dyn crate::OwnershipStore> = Arc::new(crate::MemoryOwnershipStore::new());
1195        let publisher = RevocationPublisher::new(revocations.clone(), ownership, same_key(&minter), ISS, KID);
1196        let binder = StandingBinder::new(MemoryBindingSequenceStore::new(), same_key(&minter), ISS, KID);
1197        let authority = SessionAuthority::new(minter, MemRefreshStore::default(), StubUsers::default(), revocations.clone())
1198            .with_standing_binder(binder);
1199        let replica = Arc::new(ReplicatedRevocations::new(trust.clone()));
1200        Standing {
1201            authority,
1202            access_edge: EdgeVerifier::new(verifier, revocations),
1203            publisher,
1204            edge: StandingVerifier::new(trust, replica.clone()),
1205            replica,
1206        }
1207    }
1208
1209    #[test]
1210    fn a_lan_pair_session_carries_a_standing_binding_that_outlives_its_access_token() {
1211        let r = standing_rig();
1212        pollster::block_on(async {
1213            let s = r.bind("node:a", node_key(0x11), 1_000).await;
1214            let standing = s.standing.expect("LanPair + peer key mints a binding");
1215            let b = &standing.binding;
1216            assert_eq!((b.issuer.as_str(), b.sub.as_str(), b.device.as_str()), (ISS, "u1", "node:a"));
1217            assert_eq!(b.peer_key, node_key(0x11));
1218            assert_eq!(b.seq, 1_000, "first binding: the clock floor");
1219            assert_eq!(b.iat, 1_000);
1220            assert_eq!(b.lease.refresh_after(), 1_000 + StandingBinder::DEFAULT_REFRESH_AFTER_SECONDS);
1221            assert_ne!(b.jti, s.claims.jti, "the binding is its own credential");
1222
1223            // Years later the access token is long dead...
1224            let err = r
1225                .access_edge
1226                .verify_bound_at(&s.access_token, &node_key(0x11), YEARS_LATER)
1227                .await
1228                .unwrap_err();
1229            assert!(matches!(err, Error::Codec(_)), "got {err:?}");
1230            // ...and the binding still admits the node, flagged for refresh.
1231            let ok = r
1232                .edge
1233                .verify_standing_at(&standing.token, &node_key(0x11), YEARS_LATER)
1234                .await
1235                .unwrap();
1236            assert_eq!(&ok.binding, b);
1237            assert_eq!(ok.lease, LeaseState::Warning { exp: None });
1238            // Still bound to the node key.
1239            assert!(matches!(
1240                r.edge.verify_standing_at(&standing.token, &node_key(0x22), YEARS_LATER).await,
1241                Err(StandingError::PeerKeyMismatch)
1242            ));
1243        });
1244    }
1245
1246    #[test]
1247    fn only_peer_bound_lan_pair_sessions_carry_a_binding() {
1248        let r = standing_rig();
1249        pollster::block_on(async {
1250            let passkey = r
1251                .authority
1252                .establish_bound(UserId::new("u1"), DeviceId::new("d1"), DeviceBinding::Passkey, node_key(1), 1_000)
1253                .await
1254                .unwrap();
1255            assert!(passkey.standing.is_none());
1256            let unbound = r
1257                .authority
1258                .establish(UserId::new("u1"), DeviceId::new("d2"), DeviceBinding::LanPair, 1_000)
1259                .await
1260                .unwrap();
1261            assert!(unbound.standing.is_none());
1262        });
1263    }
1264
1265    #[test]
1266    fn lan_pair_without_a_binder_refuses_before_anything_is_written() {
1267        let (minter, _) = PasetoV4SecretMinter::generate().unwrap();
1268        let authority = SessionAuthority::new(
1269            minter,
1270            MemRefreshStore::default(),
1271            StubUsers::default(),
1272            MemoryRevocationStore::default(),
1273        );
1274        pollster::block_on(async {
1275            let err = authority
1276                .establish_bound(UserId::new("u1"), DeviceId::new("d1"), DeviceBinding::LanPair, node_key(1), 1_000)
1277                .await
1278                .unwrap_err();
1279            assert!(matches!(err, Error::NoStandingBinder), "got {err:?}");
1280            assert!(authority.refresh.0.lock().unwrap().is_empty(), "no refresh chain started");
1281
1282            // rotate_bound refuses with the presented token unspent.
1283            let first = authority
1284                .establish(UserId::new("u1"), DeviceId::new("d1"), DeviceBinding::LanPair, 1_000)
1285                .await
1286                .unwrap();
1287            let err = authority
1288                .rotate_bound(first.refresh.token.as_str(), &DeviceBinding::LanPair, node_key(1), 1_050)
1289                .await
1290                .unwrap_err();
1291            assert!(matches!(err, Error::NoStandingBinder), "got {err:?}");
1292            let records = authority.refresh.0.lock().unwrap();
1293            assert!(records.values().all(|r| !r.consumed));
1294        });
1295    }
1296
1297    #[test]
1298    fn re_enrolling_a_device_supersedes_its_previous_binding_at_the_edge() {
1299        let r = standing_rig();
1300        pollster::block_on(async {
1301            let first = r.bind("node:a", node_key(0x11), 1_000).await.standing.unwrap();
1302            // Same second, same device, new node key: the sequence still rises.
1303            let second = r.bind("node:a", node_key(0x22), 1_000).await.standing.unwrap();
1304            assert_eq!(second.binding.seq, first.binding.seq + 1);
1305
1306            r.edge.verify_standing_at(&first.token, &node_key(0x11), 1_000).await.unwrap();
1307            r.edge.verify_standing_at(&second.token, &node_key(0x22), 1_000).await.unwrap();
1308            assert!(matches!(
1309                r.edge.verify_standing_at(&first.token, &node_key(0x11), 1_000).await,
1310                Err(StandingError::Superseded { .. })
1311            ));
1312            // Another device's binding is untouched.
1313            let other = r.bind("node:b", node_key(0x33), 1_000).await.standing.unwrap();
1314            r.edge.verify_standing_at(&other.token, &node_key(0x33), 1_000).await.unwrap();
1315        });
1316    }
1317
1318    #[test]
1319    fn rotate_bound_mints_a_successor_binding() {
1320        let r = standing_rig();
1321        pollster::block_on(async {
1322            let first = r.bind("node:a", node_key(0x11), 1_000).await;
1323            let rotated = r
1324                .authority
1325                .rotate_bound(first.refresh.token.as_str(), &DeviceBinding::LanPair, node_key(0x22), 1_050)
1326                .await
1327                .unwrap();
1328            let (old, new) = (first.standing.unwrap(), rotated.standing.unwrap());
1329            assert!(new.binding.seq > old.binding.seq);
1330            assert_eq!(new.binding.peer_key, node_key(0x22));
1331            r.edge.adopt(&new.token).await.unwrap();
1332            assert!(matches!(
1333                r.edge.verify_standing_at(&old.token, &node_key(0x11), 1_050).await,
1334                Err(StandingError::Superseded { .. })
1335            ));
1336            r.edge.verify_standing_at(&new.token, &node_key(0x22), YEARS_LATER).await.unwrap();
1337        });
1338    }
1339
1340    #[test]
1341    fn revocation_is_what_ends_a_standing_binding_at_an_offline_edge() {
1342        let r = standing_rig();
1343        pollster::block_on(async {
1344            let a = r.bind("node:a", node_key(0x11), 1_000).await.standing.unwrap();
1345            let b = r.bind("node:b", node_key(0x22), 1_000).await.standing.unwrap();
1346            r.sync_revocations().await;
1347            r.edge.verify_standing_at(&a.token, &node_key(0x11), YEARS_LATER).await.unwrap();
1348
1349            // The device: SessionAuthority::revoke_device records Revoked::Device.
1350            // Its bound consumes node:a's next seq, above a's.
1351            r.authority
1352                .revoke_device(&UserId::new("u1"), &DeviceId::new("node:a"), 1_000)
1353                .await
1354                .unwrap();
1355            let entry = r.publisher.store().snapshot().await.unwrap().revoked;
1356            assert!(matches!(
1357                entry.as_slice(),
1358                [Revoked::Device { at_seq, .. }] if *at_seq > a.binding.seq
1359            ));
1360            // Not yet heard offline: still admitted (the accepted cost).
1361            r.edge.verify_standing_at(&a.token, &node_key(0x11), YEARS_LATER).await.unwrap();
1362            r.sync_revocations().await;
1363            assert!(matches!(
1364                r.edge.verify_standing_at(&a.token, &node_key(0x11), YEARS_LATER).await,
1365                Err(StandingError::DeviceRevoked { .. })
1366            ));
1367
1368            // Re-enrolling the same machine mints above the revoke: admitted.
1369            let again = r.bind("node:a", node_key(0x11), 1_000).await.standing.unwrap();
1370            r.edge.adopt(&again.token).await.unwrap();
1371            r.edge.verify_standing_at(&again.token, &node_key(0x11), YEARS_LATER).await.unwrap();
1372            // ...and the access-token-only server reader agrees seq-for-seq.
1373            let device = DeviceId::new("node:a");
1374            assert!(r.publisher.store().is_device_revoked(&device, a.binding.seq).await.unwrap());
1375            assert!(!r.publisher.store().is_device_revoked(&device, again.binding.seq).await.unwrap());
1376
1377            // The binding alone, by its jti.
1378            r.authority.revoke_standing_binding(&b.binding.jti).await.unwrap();
1379            r.sync_revocations().await;
1380            assert!(matches!(
1381                r.edge.verify_standing_at(&b.token, &node_key(0x22), YEARS_LATER).await,
1382                Err(StandingError::Revoked { .. })
1383            ));
1384        });
1385    }
1386}