cheers_server/store.rs
1//! Origin-side persistence contracts — [`UserStore`] and [`RefreshStore`].
2//!
3//! These are the *origin* store traits: identity/provider linkage and
4//! refresh-token rotation state. They moved out of `cheers-core` (R019-F6) so a
5//! verify-only or device-only consumer never names them. The device store,
6//! [`CredentialStore`](cheers_core::CredentialStore), stays in `cheers-core`; the
7//! shared [`StoreError`](cheers_core::StoreError) does too.
8//!
9//! All traits are `async` via [`async_trait`] so they remain dyn-compatible.
10//! Concrete impls (Postgres, Yubaba-backed, in-memory) live in product code.
11//!
12//! @yah:ticket(R020-F4, "Ownership table schema + writers (POST/DELETE /ownership) + cascading revoke")
13//! @yah:assignee(agent:claude)
14//! @yah:at(2026-06-04T01:35:23Z)
15//! @yah:status(review)
16//! @yah:phase(P1)
17//! @yah:parent(R020)
18//! @yah:gotcha("CHECK (granted_by LIKE 'svc:%') and CHECK (on_behalf_of IS NULL OR on_behalf_of LIKE 'user:%') are invariants. A row violating either is a bug — humans never appear in granted_by, services never in on_behalf_of.")
19//! @yah:assumes("ownership_version (yah/W159 Layer 3 freshness backstop) is intentionally deferred in v1 — add the column/endpoint when the staleness budget needs tightening.")
20//! @arch:see(.yah/docs/working/mcp-auth-and-ownership.md)
21//! @yah:depends_on(R020-F2)
22//! @yah:next("Sign off the store layer + cascade primitive (this ticket).")
23//! @yah:next("On sign-off: R020-T1 (McpClaims mint/verify helpers) unblocks → R020-T2 (Bearer middleware) unblocks → R020-T3 (HTTP routes) unblocks. After T3 lands the F4 title's HTTP claim is fully realized in the codebase.")
24//! @yah:next("Separately: wire the cascade revoke caller into SessionAuthority's user-revocation hook — file as a peer task when that surface grows MCP awareness.")
25//! @yah:handoff("STORE LAYER COMPLETE & TESTED. Landed: (a) cheers-server/src/ownership.rs — OwnershipStore trait (insert/get/revoke_by_id/revoke_by_on_behalf_of/list_for_principal) + NewOwnership::new() enforcing the granted_by=Service + on_behalf_of=User|None invariants up front + OwnershipRow + OwnershipValidationError. (b) cheers-sqlx migrations/{pg,sqlite}/0002_ownership.sql with the schema verbatim from §Ownership table — CHECKs + ix_ownership_principal + ix_ownership_on_behalf_of (both partial WHERE revoked_at IS NULL). (c) PgOwnershipStore + SqliteOwnershipStore. (d) common::ownership_store_lifecycle + check_constraints_reject_bad_rows scenarios.")
26//! @yah:handoff("Cascade revoke landed at the STORE level (revoke_by_on_behalf_of — single UPDATE … WHERE on_behalf_of=$1 AND revoked_at IS NULL, returns row count). revoke_by_id is idempotent (re-revoke doesn't overwrite revoked_at; unknown id => NotFound). Row id is UUIDv4 hex (load-bearing property is 'crypto-random 128-bit', not the ULID encoding the doc names).")
27//! @yah:handoff("SCOPE NOTE — HTTP routes (POST/DELETE /ownership) split out. The 'writers' in the F4 title are satisfied at the OwnershipStore trait layer (the layer that actually enforces the row invariants); the HTTP wrappers need cheers-axum infrastructure that didn't exist (Bearer/McpClaims middleware, McpClaims mint/verify helpers — TokenMinter/Verifier are hard-coded to Claims). Split to peer tasks R020-T1 (McpClaims mint/verify helpers in cheers-server) → R020-T2 (Bearer/McpClaims middleware in cheers-axum) → R020-T3 (POST/DELETE /ownership routes). Cleaner than expanding F4 inline; also resolves the F6→F4 circular dep noted in the prior handoff.")
28//! @yah:handoff("DEFERRED: caller of revoke_by_on_behalf_of in the user-revocation hook (SessionAuthority::revoke_device or peer). The cascade primitive itself is tested; composing it into user-revocation is a follow-up under the same store-side scope (peer task — file when SessionAuthority grows MCP awareness).")
29//! @yah:handoff("Verified GREEN: cargo test -p cheers-core (51), -p cheers-server (39 incl. 4 new ownership tests), -p cheers-verify (35+9+2+4), -p cheers-sqlx --features sqlite (7 incl. 2 new ownership integration tests). pg-integration --tests compiles (full pg run needs Docker).")
30//! @yah:verify("cargo test -p cheers-server && cargo test -p cheers-sqlx --features sqlite")
31//! @yah:verify("ownership_store_lifecycle scenario: insert → revoke_by_id → list_for_principal excludes the revoked row; re-revoke is idempotent (revoked_at unchanged).")
32//! @yah:verify("check_constraints_reject_bad_rows: a row with granted_by NOT LIKE 'svc:%' or on_behalf_of NOT LIKE 'user:%' fails the SQL CHECK (both pg + sqlite).")
33//! @yah:verify("Cascade revoke: revoke_by_on_behalf_of(user, now) sweeps every live row with that on_behalf_of in one UPDATE, returns the row count, and a follow-up revoke returns 0.")
34//!
35//! @yah:ticket(R020-F13, "Audit ingest endpoint + centralized audit table (POST /audit/ingest)")
36//! @yah:assignee(agent:claude)
37//! @yah:at(2026-06-04T01:36:39Z)
38//! @yah:status(review)
39//! @yah:phase(P4)
40//! @yah:parent(R020)
41//! @yah:gotcha("audit:write is kind=service only. Same enforcement as ownership:write — reject at grant write time.")
42//! @arch:see(.yah/docs/working/mcp-auth-and-ownership.md)
43//! @yah:depends_on(R020-F3)
44//! @yah:depends_on(R020-F4)
45//! @yah:handoff("LANDED end-to-end (store layer + sqlx persistence + HTTP route). cheers-server/src/audit.rs: AuditRecord { at, sub, act, camp_id, aud, method, scope, result, request_id } + AuditValidationError (NonPositiveAt / EmptyAud / EmptyMethod / EmptyResult / EmptyRequestId) + AuditRecord::new() validates up front + AuditRecord::validate() so deserialized records re-check + AuditRow { id, record, ingested_at } + AuditStore trait (single mutation — insert_batch) + MemoryAuditStore. Re-exported from cheers_server::* (AuditRecord, AuditRow, AuditStore, AuditValidationError, MemoryAuditStore).")
46//! @yah:handoff("PERSISTENCE: cheers-sqlx/migrations/{pg,sqlite}/0004_audit.sql adds an append-only `audit` table with the wire-shape columns + (id, ingested_at) cheers stamps. Two indexes: ix_audit_sub_at on (sub, at DESC) for the F14 'who did what' lookup, and a partial ix_audit_act_sub_at WHERE act_sub IS NOT NULL for agent-attribution queries. cheers-sqlx/src/audit_store.rs: PgAuditStore + SqliteAuditStore — each insert_batch runs in a single sqlx transaction so a mid-batch DB failure leaves the table untouched (matches kamaji's atomic-retry contract). scope column stored as JSON text (serde array of wire scope strings) — keeps it forward-compatible if Scope grows variants between writer and reader.")
47//! @yah:handoff("HTTP ROUTE: cheers-axum/src/audit.rs adds AuditState<A> { mcp, store } + AuditIngestBody (transparent Vec<AuditRecord>) + AuditIngestResponse { rows } + POST /audit/ingest handler. Pipeline: authenticate_mcp → claims.require_scope(Scope::AuditWrite) → validate every record (whole-batch first, atomic) → store.insert_batch(records, now). Returns 201 + the inserted rows. RouteError::AuditInvalid(#[from] AuditValidationError) variant maps to 400 'audit_invalid' (distinct from 401/403 so kamaji can tell auth failure from a forbidden-shape batch and not retry as-is). audit module re-exported from cheers_axum::* (AuditIngestBody, AuditIngestResponse, AuditState).")
48//! @yah:handoff("USER-PRINCIPAL AUDIT:WRITE REJECTION is the canonical grant-time check — validate_grant(PrincipalKind::User, Scope::AuditWrite) is already pinned by cheers-core::mcp::tests::validate_grant_rejects_service_only_for_user (it covers OwnershipWrite + AuditWrite explicitly). Mint paths run validate_grant per expanded scope (R020-F6/F7/F8). This route is defense-in-depth: a token reaching POST /audit/ingest with audit:write must already have cleared grant-time + mint-time checks.")
49//! @yah:handoff("ATOMIC BATCH SEMANTICS: validate-all-then-insert at the handler; sqlx impls run insert_batch in a transaction. A 4xx on a malformed record leaves the audit table untouched, so kamaji's bounded-backoff retry of the corrected batch sees a clean ledger — no partial commits, no dedup logic needed on either side. Empty batch is a no-op (returns 201 + empty rows).")
50//! @yah:handoff("LIBSQL/TURSO MIGRATION COMPAT (R020-T19): the libsql-integration harness applies every migration in migrations/sqlite/ statement-by-statement; 0004_audit.sql is picked up automatically when the harness runs. Smoke verified locally via `cargo test -p cheers-sqlx --features sqlite` (10 → 11 tests, all green); a libsql-integration run is the standing follow-up per T19's @yah:assumes (Docker required).")
51//! @yah:handoff("R020-F14 (read endpoint) consumes this directly — the trait grows a paged read method, the schema is in place with the correct index, and AuditRow carries the columns F14's response needs. depends_on(R020-F13) edge on F14 stands.")
52//! @yah:next("Sign off F13 — tasks-met, awaiting human review.")
53//! @yah:next("Claim R020-F14 to add the AuditStore read surface (list_by_on_behalf_of with cursor pagination) and the GET /audit/by-on-behalf-of/<user> HTTP route.")
54//! @yah:next("When kamaji's wire-side begins forwarding live batches, coordinate yah-side W159 / R428 to point at POST /audit/ingest + the AuditIngestBody shape (bare JSON array of records).")
55//! @yah:verify("cargo test -p cheers-server audit:: — 8/8 trait + memory-impl tests pass.")
56//! @yah:verify("cargo test -p cheers-sqlx --features sqlite audit_store_batch_insert_round_trip — sqlite trait conformance pass (100-record batch + act-bearing record + svc-sub record round-trip via SqliteAuditStore).")
57//! @yah:verify("cargo test -p cheers-core validate_grant_rejects_service_only_for_user — already pins composition rule (4) for AuditWrite at the grant-time edge (verify item 'User-principal token requesting audit:write at grant time is rejected').")
58//! @yah:verify("Parent relay smoke: cargo test -p cheers-core -p cheers-server -p cheers-verify -p cheers-axum + cargo test -p cheers-sqlx --features sqlite — all green; cargo check --workspace --all-features clean.")
59//! @yah:verify("cargo test -p cheers-axum --test main audit_basic:: — 4/4 integration tests pass: batch POST 100 records all landed; forbidden shape returns 400 + corrected retry succeeds; token without audit:write to 403 before any store call; missing bearer to 401. (Retargeted by R514: the nine cheers-axum test binaries were merged into one target named `main`; the old `--test audit_basic` no longer resolves.)")
60
61use async_trait::async_trait;
62use serde::{Deserialize, Serialize};
63
64use cheers_core::{Credential, DeviceId, StoreError, User, UserId};
65
66/// The external identity-provider namespace a `subject` string lives in.
67///
68/// Two users with the same Google `sub` must collide; the same email address
69/// presented to Apple vs. Google must *not* collide. `ProviderKey` is the
70/// namespace tag that disambiguates.
71#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
72#[non_exhaustive]
73#[serde(tag = "provider", rename_all = "snake_case")]
74pub enum ProviderKey {
75 /// Google OIDC — `subject` is the `sub` claim from Google's ID token.
76 OidcGoogle,
77 /// Apple Sign In — `subject` is the `sub` claim from Apple's ID token.
78 OidcApple,
79 /// Any other OIDC issuer; `subject` is that issuer's `sub` claim.
80 OidcGeneric { issuer: String },
81 /// Email-based identity (magic-link or password). `subject` is the email.
82 Email,
83 /// LAN-pair identity. `subject` is the device's mshr node-id.
84 LanPair,
85}
86
87/// Fields required to mint a fresh `User`.
88///
89/// `UserStore::create` returns the resulting `User` with a freshly minted
90/// `UserId`; the caller has no say in the id shape.
91#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
92#[non_exhaustive]
93pub struct NewUser {
94 pub email: Option<String>,
95 pub name: Option<String>,
96}
97
98impl NewUser {
99 pub fn new() -> Self {
100 Self::default()
101 }
102
103 pub fn with_email(mut self, email: impl Into<String>) -> Self {
104 self.email = Some(email.into());
105 self
106 }
107
108 pub fn with_name(mut self, name: impl Into<String>) -> Self {
109 self.name = Some(name.into());
110 self
111 }
112}
113
114/// User identity + provider-link persistence.
115///
116/// One `User` may be reachable through multiple `(ProviderKey, subject)`
117/// pairs — that's what `link_provider` is for. `list_devices` and
118/// `revoke_device` are the session-management surface: products surface them
119/// to end-users on an /account/sessions page (P12).
120#[async_trait]
121pub trait UserStore: Send + Sync {
122 /// Look up the user reachable via `(provider, subject)`. `None` if no
123 /// such link exists.
124 async fn find_by_provider(
125 &self,
126 provider: &ProviderKey,
127 subject: &str,
128 ) -> Result<Option<User>, StoreError>;
129
130 /// Create a fresh user (no provider linked yet). Returns the user with
131 /// its newly minted `UserId`.
132 async fn create(&self, new_user: NewUser) -> Result<User, StoreError>;
133
134 /// Link `(provider, subject)` to an existing user. Returns
135 /// `StoreError::Conflict` if the link already exists for a *different*
136 /// user; idempotent if it matches.
137 async fn link_provider(
138 &self,
139 user_id: &UserId,
140 provider: &ProviderKey,
141 subject: &str,
142 ) -> Result<(), StoreError>;
143
144 /// Enumerate every device this user has signed in from (and not revoked).
145 async fn list_devices(&self, user_id: &UserId) -> Result<Vec<DeviceId>, StoreError>;
146
147 /// Revoke a device. The two halves of "revoked" are first-class (R019-F4):
148 /// block *new* sessions by revoking the device's refresh chains
149 /// ([`RefreshStore::revoke_chain`]), and kill an *in-flight* access token by
150 /// its `jti` via [`RevocationWriter::revoke`](crate::revocation::RevocationWriter::revoke);
151 /// the edge enforces the latter through
152 /// [`RevocationReader`](cheers_verify::RevocationReader). This method records
153 /// the device-level intent; composing those calls is
154 /// [`SessionAuthority`](crate::session::SessionAuthority)'s job.
155 async fn revoke_device(
156 &self,
157 user_id: &UserId,
158 device_id: &DeviceId,
159 ) -> Result<(), StoreError>;
160}
161
162/// One row in a refresh-token rotation chain.
163///
164/// This struct is the persistence contract the rotation impls
165/// ([`RefreshRotator`](crate::refresh::RefreshRotator)) write against. The
166/// struct is `#[non_exhaustive]` (so adding fields stays SemVer-clean for
167/// downstream consumers), which blocks external struct-literal construction —
168/// use [`RefreshTokenRecord::new`] from a [`RefreshStore`] impl that needs to
169/// build one (e.g. in a `get` query handler).
170#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
171#[non_exhaustive]
172pub struct RefreshTokenRecord {
173 /// Storage key for this token: the SHA-256 hash (hex) of the opaque secret
174 /// the client holds, computed by the rotator. The raw secret is never
175 /// persisted, so a read-only store leak yields hashes, not usable tokens.
176 /// `RefreshStore` impls treat this as an opaque primary key.
177 pub token: String,
178 /// Stable identifier shared across every token in this rotation chain.
179 pub chain_id: String,
180 /// The `token` (hash) this one rotated from (`None` for the chain root).
181 pub parent: Option<String>,
182 pub user_id: UserId,
183 pub device_id: DeviceId,
184 pub issued_at: i64,
185 pub expires_at: i64,
186 /// `true` once this token has minted a successor. Re-presenting a
187 /// consumed token MUST revoke the whole chain (replay).
188 pub consumed: bool,
189 /// `true` once the chain has been revoked — by replay detection,
190 /// explicit logout, or device revocation.
191 pub revoked: bool,
192}
193
194impl RefreshTokenRecord {
195 /// Construct a record from its constituent fields. Use this from external
196 /// [`RefreshStore`] impls (the struct is `#[non_exhaustive]`, so direct
197 /// struct-literal construction is crate-local only). New fields land here
198 /// behind the `#[non_exhaustive]` shield so existing callers stay green.
199 #[allow(clippy::too_many_arguments)]
200 pub fn new(
201 token: String,
202 chain_id: String,
203 parent: Option<String>,
204 user_id: UserId,
205 device_id: DeviceId,
206 issued_at: i64,
207 expires_at: i64,
208 consumed: bool,
209 revoked: bool,
210 ) -> Self {
211 Self {
212 token,
213 chain_id,
214 parent,
215 user_id,
216 device_id,
217 issued_at,
218 expires_at,
219 consumed,
220 revoked,
221 }
222 }
223}
224
225/// Persistence for refresh-token rotation chains.
226#[async_trait]
227pub trait RefreshStore: Send + Sync {
228 async fn put(&self, record: &RefreshTokenRecord) -> Result<(), StoreError>;
229 async fn get(&self, token: &str) -> Result<Option<RefreshTokenRecord>, StoreError>;
230 /// Atomically transition `token` from unconsumed to consumed.
231 ///
232 /// Returns `Ok(true)` iff *this* call flipped `consumed` false→true, and
233 /// `Ok(false)` if no unconsumed row matched — the token was already
234 /// consumed (the losing side of a concurrent rotation) or is absent. The
235 /// check-and-set MUST be a single atomic operation (a conditional
236 /// `UPDATE … WHERE consumed = FALSE`, a Lua CAS, a guarded map mutation);
237 /// a read-then-write lets two concurrent rotations of the same token both
238 /// observe `consumed = false` and each mint a live successor. The rotator
239 /// relies on the returned bool to detect that double-spend and revoke the
240 /// chain, so an impl that always returns `true` reopens the replay hole.
241 async fn mark_consumed(&self, token: &str) -> Result<bool, StoreError>;
242 /// Revoke every record in `chain_id`. Idempotent.
243 async fn revoke_chain(&self, chain_id: &str) -> Result<(), StoreError>;
244}
245
246/// Origin-side multi-passkey-per-user persistence for the WebAuthn relying-party
247/// flow (P7 / R014).
248///
249/// Each row stores one passkey [`Credential`] (binding
250/// [`DeviceBinding::Passkey`](cheers_core::DeviceBinding::Passkey), `material`
251/// the `serde_json`-encoded `webauthn-rs` `Passkey`). The trait sits on
252/// `cheers-core`'s [`Credential`] rather than `webauthn-rs::Passkey` so this
253/// crate stays free of webauthn-rs in its public API — products bridge with
254/// `cheers::passkey::passkey_to_credential` /
255/// `cheers::passkey::passkey_from_credential`.
256///
257/// A user holds zero-or-more passkey credentials (phone, laptop, security key);
258/// `(user_id, device_id)` is the unique key. Non-discoverable WebAuthn flow:
259/// products call [`list_for_user`](Self::list_for_user) to assemble the
260/// `allow_credentials` set before `start_authentication`, and
261/// [`update`](Self::update) after a successful ceremony if the signature
262/// counter advanced (see `cheers::passkey::apply_authentication_result`).
263///
264/// Long-lived credential material, indexed by user — belongs in the relational
265/// SQL store (see `cheers-sqlx`), never in redis.
266#[async_trait]
267pub trait PasskeyCredentialStore: Send + Sync {
268 /// Insert a fresh passkey credential. The credential's
269 /// [`user_id`](Credential::user_id) and [`device_id`](Credential::device_id)
270 /// fields are the unique key; [`StoreError::Conflict`] if the pair is
271 /// already taken.
272 async fn put(&self, cred: &Credential) -> Result<(), StoreError>;
273
274 /// Every passkey credential owned by `user_id`. Order is unspecified.
275 async fn list_for_user(&self, user_id: &UserId) -> Result<Vec<Credential>, StoreError>;
276
277 /// Remove the passkey credential at `(user_id, device_id)`.
278 /// [`StoreError::NotFound`] if no such row exists.
279 async fn delete(&self, user_id: &UserId, device_id: &DeviceId) -> Result<(), StoreError>;
280
281 /// Rewrite the stored material for the credential's
282 /// `(user_id, device_id)` pair. Use after
283 /// `apply_authentication_result` to persist an advanced counter / updated
284 /// backup flags. [`StoreError::NotFound`] if the pair was never `put`.
285 async fn update(&self, cred: &Credential) -> Result<(), StoreError>;
286}
287
288#[cfg(test)]
289mod tests {
290 //! Trait-shape smoke tests via tiny in-memory impls. The "real" memory
291 //! impls live in the `cheers` crate (R015-T3).
292
293 use super::*;
294 use std::collections::HashMap;
295 use std::sync::Mutex;
296
297 #[derive(Default)]
298 struct MemUserStore {
299 inner: Mutex<MemUserInner>,
300 }
301
302 #[derive(Default)]
303 struct MemUserInner {
304 next_id: u64,
305 users: HashMap<UserId, User>,
306 links: HashMap<(ProviderKey, String), UserId>,
307 devices: HashMap<UserId, Vec<DeviceId>>,
308 }
309
310 #[async_trait]
311 impl UserStore for MemUserStore {
312 async fn find_by_provider(
313 &self,
314 provider: &ProviderKey,
315 subject: &str,
316 ) -> Result<Option<User>, StoreError> {
317 let g = self.inner.lock().unwrap();
318 Ok(g.links
319 .get(&(provider.clone(), subject.to_owned()))
320 .and_then(|id| g.users.get(id).cloned()))
321 }
322
323 async fn create(&self, new_user: NewUser) -> Result<User, StoreError> {
324 let mut g = self.inner.lock().unwrap();
325 g.next_id += 1;
326 let id = UserId::new(format!("u-{}", g.next_id));
327 let mut u = User::new(id.clone());
328 u.email = new_user.email;
329 u.name = new_user.name;
330 g.users.insert(id, u.clone());
331 Ok(u)
332 }
333
334 async fn link_provider(
335 &self,
336 user_id: &UserId,
337 provider: &ProviderKey,
338 subject: &str,
339 ) -> Result<(), StoreError> {
340 let mut g = self.inner.lock().unwrap();
341 let key = (provider.clone(), subject.to_owned());
342 match g.links.get(&key) {
343 Some(existing) if existing == user_id => Ok(()),
344 Some(_) => Err(StoreError::Conflict),
345 None => {
346 g.links.insert(key, user_id.clone());
347 Ok(())
348 }
349 }
350 }
351
352 async fn list_devices(&self, user_id: &UserId) -> Result<Vec<DeviceId>, StoreError> {
353 let g = self.inner.lock().unwrap();
354 Ok(g.devices.get(user_id).cloned().unwrap_or_default())
355 }
356
357 async fn revoke_device(
358 &self,
359 user_id: &UserId,
360 device_id: &DeviceId,
361 ) -> Result<(), StoreError> {
362 let mut g = self.inner.lock().unwrap();
363 let v = g.devices.entry(user_id.clone()).or_default();
364 let before = v.len();
365 v.retain(|d| d != device_id);
366 if v.len() == before {
367 return Err(StoreError::NotFound);
368 }
369 Ok(())
370 }
371 }
372
373 #[derive(Default)]
374 struct MemRefreshStore(Mutex<HashMap<String, RefreshTokenRecord>>);
375
376 #[async_trait]
377 impl RefreshStore for MemRefreshStore {
378 async fn put(&self, record: &RefreshTokenRecord) -> Result<(), StoreError> {
379 self.0
380 .lock()
381 .unwrap()
382 .insert(record.token.clone(), record.clone());
383 Ok(())
384 }
385 async fn get(&self, token: &str) -> Result<Option<RefreshTokenRecord>, StoreError> {
386 Ok(self.0.lock().unwrap().get(token).cloned())
387 }
388 async fn mark_consumed(&self, token: &str) -> Result<bool, StoreError> {
389 // Atomic under the map lock: only the caller that observes
390 // `consumed == false` flips it and reports `true`.
391 let mut g = self.0.lock().unwrap();
392 match g.get_mut(token) {
393 Some(r) if !r.consumed => {
394 r.consumed = true;
395 Ok(true)
396 }
397 _ => Ok(false),
398 }
399 }
400 async fn revoke_chain(&self, chain_id: &str) -> Result<(), StoreError> {
401 let mut g = self.0.lock().unwrap();
402 for r in g.values_mut() {
403 if r.chain_id == chain_id {
404 r.revoked = true;
405 }
406 }
407 Ok(())
408 }
409 }
410
411 #[test]
412 fn user_store_create_link_find() {
413 let s = MemUserStore::default();
414 pollster::block_on(async {
415 let u = s.create(NewUser::new().with_email("a@b")).await.unwrap();
416 assert_eq!(u.email.as_deref(), Some("a@b"));
417 s.link_provider(&u.id, &ProviderKey::OidcGoogle, "google-sub-1")
418 .await
419 .unwrap();
420 // Idempotent re-link on same user.
421 s.link_provider(&u.id, &ProviderKey::OidcGoogle, "google-sub-1")
422 .await
423 .unwrap();
424 // Conflict with a different user.
425 let u2 = s.create(NewUser::new()).await.unwrap();
426 assert!(matches!(
427 s.link_provider(&u2.id, &ProviderKey::OidcGoogle, "google-sub-1")
428 .await,
429 Err(StoreError::Conflict)
430 ));
431 // Lookup succeeds.
432 let found = s
433 .find_by_provider(&ProviderKey::OidcGoogle, "google-sub-1")
434 .await
435 .unwrap()
436 .unwrap();
437 assert_eq!(found.id, u.id);
438 });
439 }
440
441 #[test]
442 fn refresh_store_consume_and_revoke_chain() {
443 let s = MemRefreshStore::default();
444 pollster::block_on(async {
445 let r = RefreshTokenRecord {
446 token: "tok-1".into(),
447 chain_id: "chain-A".into(),
448 parent: None,
449 user_id: UserId::new("u1"),
450 device_id: DeviceId::new("d1"),
451 issued_at: 100,
452 expires_at: 1_000,
453 consumed: false,
454 revoked: false,
455 };
456 s.put(&r).await.unwrap();
457 // First consume transitions the row and reports true; a second
458 // consume finds nothing unconsumed and reports false (this is the
459 // signal the rotator turns into replay detection).
460 assert!(s.mark_consumed("tok-1").await.unwrap());
461 assert!(!s.mark_consumed("tok-1").await.unwrap());
462 assert!(!s.mark_consumed("no-such-token").await.unwrap());
463 assert!(s.get("tok-1").await.unwrap().unwrap().consumed);
464 s.revoke_chain("chain-A").await.unwrap();
465 assert!(s.get("tok-1").await.unwrap().unwrap().revoked);
466 });
467 }
468
469 #[derive(Default)]
470 struct MemPasskeyCredentialStore(Mutex<HashMap<(UserId, DeviceId), Credential>>);
471
472 #[async_trait]
473 impl PasskeyCredentialStore for MemPasskeyCredentialStore {
474 async fn put(&self, cred: &Credential) -> Result<(), StoreError> {
475 let mut g = self.0.lock().unwrap();
476 let key = (cred.user_id.clone(), cred.device_id.clone());
477 if g.contains_key(&key) {
478 return Err(StoreError::Conflict);
479 }
480 g.insert(key, cred.clone());
481 Ok(())
482 }
483
484 async fn list_for_user(&self, user_id: &UserId) -> Result<Vec<Credential>, StoreError> {
485 let g = self.0.lock().unwrap();
486 Ok(g.iter()
487 .filter(|((u, _), _)| u == user_id)
488 .map(|(_, c)| c.clone())
489 .collect())
490 }
491
492 async fn delete(
493 &self,
494 user_id: &UserId,
495 device_id: &DeviceId,
496 ) -> Result<(), StoreError> {
497 let mut g = self.0.lock().unwrap();
498 g.remove(&(user_id.clone(), device_id.clone()))
499 .ok_or(StoreError::NotFound)
500 .map(|_| ())
501 }
502
503 async fn update(&self, cred: &Credential) -> Result<(), StoreError> {
504 let mut g = self.0.lock().unwrap();
505 let key = (cred.user_id.clone(), cred.device_id.clone());
506 if !g.contains_key(&key) {
507 return Err(StoreError::NotFound);
508 }
509 g.insert(key, cred.clone());
510 Ok(())
511 }
512 }
513
514 fn passkey_cred(user: &str, device: &str, material: &[u8]) -> Credential {
515 Credential::new(
516 UserId::new(user),
517 DeviceId::new(device),
518 cheers_core::DeviceBinding::Passkey,
519 material.to_vec(),
520 )
521 }
522
523 #[test]
524 fn passkey_store_put_list_update_delete() {
525 let s = MemPasskeyCredentialStore::default();
526 pollster::block_on(async {
527 // Empty list for a fresh user.
528 assert!(s
529 .list_for_user(&UserId::new("u1"))
530 .await
531 .unwrap()
532 .is_empty());
533
534 // Put a credential, list it.
535 let phone = passkey_cred("u1", "phone", b"v1");
536 s.put(&phone).await.unwrap();
537 let list = s.list_for_user(&UserId::new("u1")).await.unwrap();
538 assert_eq!(list, vec![phone.clone()]);
539
540 // Put a second credential for the same user.
541 let laptop = passkey_cred("u1", "laptop", b"v1-laptop");
542 s.put(&laptop).await.unwrap();
543 let mut list = s.list_for_user(&UserId::new("u1")).await.unwrap();
544 list.sort_by(|a, b| a.device_id.as_str().cmp(b.device_id.as_str()));
545 assert_eq!(list, vec![laptop.clone(), phone.clone()]);
546
547 // Re-putting the same (user, device) conflicts.
548 assert!(matches!(
549 s.put(&phone).await,
550 Err(StoreError::Conflict)
551 ));
552
553 // Update rewrites the material (counter advance).
554 let phone_v2 = passkey_cred("u1", "phone", b"v2");
555 s.update(&phone_v2).await.unwrap();
556 let mut list = s.list_for_user(&UserId::new("u1")).await.unwrap();
557 list.sort_by(|a, b| a.device_id.as_str().cmp(b.device_id.as_str()));
558 assert_eq!(list[1].material, b"v2");
559
560 // Update on a missing (user, device) is NotFound.
561 let ghost = passkey_cred("u1", "ghost", b"x");
562 assert!(matches!(
563 s.update(&ghost).await,
564 Err(StoreError::NotFound)
565 ));
566
567 // Delete works once.
568 s.delete(&UserId::new("u1"), &DeviceId::new("phone"))
569 .await
570 .unwrap();
571 assert!(matches!(
572 s.delete(&UserId::new("u1"), &DeviceId::new("phone"))
573 .await,
574 Err(StoreError::NotFound)
575 ));
576
577 // Second user's credentials are unaffected.
578 let other = passkey_cred("u2", "phone", b"u2-v1");
579 s.put(&other).await.unwrap();
580 assert_eq!(
581 s.list_for_user(&UserId::new("u1")).await.unwrap(),
582 vec![laptop]
583 );
584 assert_eq!(
585 s.list_for_user(&UserId::new("u2")).await.unwrap(),
586 vec![other]
587 );
588 });
589 }
590
591 #[test]
592 fn traits_are_dyn_compatible() {
593 fn _u(_: &dyn UserStore) {}
594 fn _r(_: &dyn RefreshStore) {}
595 fn _p(_: &dyn PasskeyCredentialStore) {}
596 }
597}