Skip to main content

feather_reader/oauth/
store.rs

1//! Persistence for in-flight logins, authenticated sessions, and DPoP nonces.
2//!
3//! Two properties here are security-relevant and neither is obvious from the
4//! SQL:
5//!
6//! * **`state` is consumed atomically.** A `SELECT` followed by a `DELETE` lets
7//!   two concurrent callbacks both pass and both exchange the same `code` —
8//!   and the authorization server is entitled to revoke *"any outstanding
9//!   sessions and tokens associated with the earlier use of the `code`"*, so the
10//!   loser destroys the winner's session. One statement, `RETURNING`, zero rows
11//!   means rejected.
12//!
13//! * **Secrets are AAD-bound to their row, column, AND destinations.** Binding
14//!   only the secrets is not enough: the declared adversary is anything able to
15//!   write the database, and against that adversary a plain unauthenticated
16//!   `aud` or `issuer` column defeats the scheme without touching a ciphertext
17//!   at all — repoint the PDS and a live DPoP-bound token is sent to the
18//!   attacker's host, with everything still decrypting perfectly. So the AAD
19//!   covers the destinations too, and is length-prefixed rather than
20//!   delimiter-joined so no rearrangement of fields can collide. See
21//!   [`super::crypto`]; the unbound `enc.v1` form is rejected outright here.
22
23use anyhow::{Context as _, Result};
24use sqlx::SqlitePool;
25
26use super::crypto::Codec;
27
28/// Tables for the OAuth flow. `CREATE TABLE IF NOT EXISTS`, matching the
29/// convention in [`crate::store`].
30const SCHEMA: &str = r#"
31-- One row per in-flight login. Short-lived and single-use; see `take_pending`.
32CREATE TABLE IF NOT EXISTS oauth_state (
33    state                TEXT PRIMARY KEY NOT NULL,
34    -- SHA-256 of the cookie value set before the redirect. The callback must
35    -- present the cookie; without it a callback URL fired by any other browser
36    -- would complete the login and hand out the session.
37    browser_binding_hash TEXT NOT NULL,
38    pkce_verifier        TEXT NOT NULL,   -- AAD-bound
39    dpop_key_jwk         TEXT NOT NULL,   -- AAD-bound
40    issuer               TEXT NOT NULL,
41    pds_url              TEXT NOT NULL,
42    did                  TEXT NOT NULL,
43    -- The negotiated client-auth method is stored so the callback re-creates the
44    -- same client rather than re-negotiating against possibly-changed metadata.
45    auth_method          TEXT NOT NULL,
46    auth_kid             TEXT,
47    -- The EXACT redirect_uri sent in PAR; it must match byte-for-byte at the
48    -- token endpoint.
49    redirect_uri         TEXT NOT NULL,
50    requested_scope      TEXT NOT NULL,
51    request_uri          TEXT NOT NULL,
52    app_return_to        TEXT,
53    expires_at           INTEGER NOT NULL
54);
55CREATE INDEX IF NOT EXISTS oauth_state_expires_at ON oauth_state(expires_at);
56
57-- One row per authenticated account.
58CREATE TABLE IF NOT EXISTS oauth_session (
59    sub            TEXT PRIMARY KEY NOT NULL,
60    issuer         TEXT NOT NULL,
61    -- The PDS. Every XRPC request is built against this rather than re-derived,
62    -- so it belongs to the token set.
63    aud            TEXT NOT NULL,
64    dpop_key_jwk   TEXT NOT NULL,   -- AAD-bound
65    access_token   TEXT NOT NULL,   -- AAD-bound
66    refresh_token  TEXT NOT NULL,   -- AAD-bound
67    token_type     TEXT NOT NULL,
68    granted_scope  TEXT NOT NULL,
69    -- NULL is legitimate: `expires_in` is optional in a token response.
70    expires_at     INTEGER
71);
72
73-- Server-issued DPoP nonces, per origin. Persisted rather than used once,
74-- because a nonce is expected on every subsequent request to that origin.
75CREATE TABLE IF NOT EXISTS oauth_nonce (
76    origin     TEXT PRIMARY KEY NOT NULL,
77    nonce      TEXT NOT NULL,
78    updated_at INTEGER NOT NULL
79);
80"#;
81
82/// Create the OAuth tables.
83pub async fn init_schema(pool: &SqlitePool) -> Result<()> {
84    sqlx::query(SCHEMA)
85        .execute(pool)
86        .await
87        .context("creating the OAuth tables")?;
88    Ok(())
89}
90
91/// Build an AAD from a table name and a list of fields, **length-prefixed**.
92///
93/// Not delimiter-joined. A `table:field:field` encoding is only unambiguous
94/// while no field can contain the delimiter, and the fields here include
95/// `did:web:…` subjects and URL issuers — precisely the inputs that erode that
96/// assumption. Length prefixes make the encoding injective unconditionally,
97/// rather than by an invariant nobody is enforcing.
98fn structured_aad(table: &str, fields: &[&str]) -> Vec<u8> {
99    let mut out = Vec::new();
100    for field in std::iter::once(&table).chain(fields.iter()) {
101        out.extend_from_slice(&(field.len() as u64).to_be_bytes());
102        out.extend_from_slice(field.as_bytes());
103    }
104    out
105}
106
107/// The AAD a state-row secret is sealed against.
108///
109/// It covers the **destinations**, not just the row and column. Binding only the
110/// secrets leaves `issuer`/`pds_url`/`did`/`redirect_uri` as plain
111/// unauthenticated columns — and against the declared adversary (anything able
112/// to write the database) the scheme is then defeated without touching a
113/// ciphertext at all: repoint the issuer and we mint a client assertion for the
114/// attacker's server and neutralise the `iss` check, while every secret still
115/// decrypts perfectly.
116///
117/// `browser_binding_hash` is in here for the same reason — it is the only thing
118/// standing between a server-global state table and a login-CSRF, so it must not
119/// be swappable either.
120/// Every non-secret column of a state row, so the AAD covers the whole row.
121///
122/// A struct rather than a long argument list: the failure mode here is a field
123/// that nobody remembered to bind, and a struct makes adding a column without
124/// binding it a visible omission rather than an invisible one.
125struct StateBinding<'a> {
126    state: &'a str,
127    issuer: &'a str,
128    pds_url: &'a str,
129    did: &'a str,
130    redirect_uri: &'a str,
131    browser_binding_hash: &'a str,
132    auth_method: &'a str,
133    auth_kid: Option<&'a str>,
134    requested_scope: &'a str,
135    request_uri: &'a str,
136    app_return_to: Option<&'a str>,
137    expires_at: i64,
138}
139
140/// A fixed marker distinguishing an absent optional field from an empty one.
141///
142/// Emitted as its OWN element beside the value. Encoding presence into the value
143/// (a prefix, a sentinel string) would only move the collision: a real value can
144/// always be chosen to look like the sentinel.
145fn present_or_absent(value: Option<&str>) -> &'static str {
146    match value {
147        Some(_) => "present",
148        None => "absent",
149    }
150}
151
152/// The AAD a state-row secret is sealed against: every non-secret column.
153///
154/// `column` is included so a ciphertext cannot be moved between columns of the
155/// same row, and `browser_binding_hash` because it is the only thing standing
156/// between a server-global state table and a login CSRF.
157fn state_aad(binding: &StateBinding<'_>, column: &str) -> Vec<u8> {
158    let expires_at = binding.expires_at.to_string();
159    structured_aad(
160        "oauth_state",
161        &[
162            binding.state,
163            column,
164            binding.issuer,
165            binding.pds_url,
166            binding.did,
167            binding.redirect_uri,
168            binding.browser_binding_hash,
169            binding.auth_method,
170            // **An absent field and an empty one must not encode alike.**
171            // `unwrap_or("")` made `NULL` and `''` byte-identical, so either
172            // could be flipped to the other with every ciphertext still
173            // verifying — the very collision the session AAD below avoids with
174            // its `"none"` marker. A separate presence element keeps them
175            // distinct without depending on the value's own bytes.
176            present_or_absent(binding.auth_kid),
177            binding.auth_kid.unwrap_or(""),
178            binding.requested_scope,
179            binding.request_uri,
180            // Declared as a post-login redirect target. Nothing writes it yet,
181            // which is exactly why binding it now costs nothing — unbound, it
182            // becomes an open redirect the day it is wired up. `None` versus
183            // `Some("")` is precisely the distinction a redirect helper would
184            // branch on, so the presence element matters here most.
185            present_or_absent(binding.app_return_to),
186            binding.app_return_to.unwrap_or(""),
187            // **The row's own lifetime is a destination too.** Both the expiry
188            // check and the sweeper read this column RAW, so an adversary with
189            // database write can still keep the row itself around by pushing it
190            // out — binding it does not stop that. What it does stop is the row
191            // remaining USABLE: the sealed DPoP key and PKCE verifier no longer
192            // decrypt, so an extended `state` cannot be replayed into a
193            // completed login. The residual is row growth, not a live credential.
194            &expires_at,
195        ],
196    )
197}
198
199/// The AAD a session-row secret is sealed against.
200///
201/// `aud` is the PDS every subsequent request is built against, so it is bound:
202/// repointing it would otherwise ship a live DPoP-bound access token to a host
203/// of the attacker's choosing, with the tokens decrypting perfectly.
204fn session_aad(
205    sub: &str,
206    column: &str,
207    issuer: &str,
208    aud: &str,
209    token_type: &str,
210    granted_scope: &str,
211    expires_at: Option<i64>,
212) -> Vec<u8> {
213    // `None` and `0` must not collide, so an absent expiry gets its own marker
214    // rather than a numeric stand-in.
215    let expires_at = expires_at.map_or_else(|| "none".to_string(), |secs| secs.to_string());
216    structured_aad(
217        "oauth_session",
218        &[
219            sub,
220            column,
221            issuer,
222            aud,
223            // Enforced strictly on the wire (`Bearer` is refused outright) and
224            // previously neither authenticated nor re-checked on read.
225            token_type,
226            granted_scope,
227            // Clearing this to NULL made `is_stale` permanently false, so the
228            // session was never proactively refreshed.
229            &expires_at,
230        ],
231    )
232}
233
234/// An in-flight login.
235#[derive(Debug, Clone, PartialEq, Eq)]
236pub struct PendingAuth {
237    pub state: String,
238    pub browser_binding_hash: String,
239    pub pkce_verifier: String,
240    pub dpop_key_jwk: String,
241    pub issuer: String,
242    pub pds_url: String,
243    pub did: String,
244    pub auth_method: String,
245    pub auth_kid: Option<String>,
246    pub redirect_uri: String,
247    pub requested_scope: String,
248    pub request_uri: String,
249    pub app_return_to: Option<String>,
250    pub expires_at: i64,
251}
252
253/// Row shape for `oauth_state`, secrets still sealed.
254#[derive(sqlx::FromRow)]
255struct PendingRow {
256    state: String,
257    browser_binding_hash: String,
258    pkce_verifier: String,
259    dpop_key_jwk: String,
260    issuer: String,
261    pds_url: String,
262    did: String,
263    auth_method: String,
264    auth_kid: Option<String>,
265    redirect_uri: String,
266    requested_scope: String,
267    request_uri: String,
268    app_return_to: Option<String>,
269    expires_at: i64,
270}
271
272/// Record an in-flight login.
273pub async fn put_pending(pool: &SqlitePool, codec: &Codec, auth: &PendingAuth) -> Result<()> {
274    let binding = StateBinding {
275        state: &auth.state,
276        issuer: &auth.issuer,
277        pds_url: &auth.pds_url,
278        did: &auth.did,
279        redirect_uri: &auth.redirect_uri,
280        browser_binding_hash: &auth.browser_binding_hash,
281        auth_method: &auth.auth_method,
282        auth_kid: auth.auth_kid.as_deref(),
283        requested_scope: &auth.requested_scope,
284        request_uri: &auth.request_uri,
285        app_return_to: auth.app_return_to.as_deref(),
286        expires_at: auth.expires_at,
287    };
288
289    sqlx::query(
290        r#"
291        INSERT INTO oauth_state (
292            state, browser_binding_hash, pkce_verifier, dpop_key_jwk, issuer,
293            pds_url, did, auth_method, auth_kid, redirect_uri, requested_scope,
294            request_uri, app_return_to, expires_at
295        ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14)
296        "#,
297    )
298    .bind(&auth.state)
299    .bind(&auth.browser_binding_hash)
300    .bind(codec.encrypt_bound(&auth.pkce_verifier, &state_aad(&binding, "pkce_verifier")))
301    .bind(codec.encrypt_bound(&auth.dpop_key_jwk, &state_aad(&binding, "dpop_key_jwk")))
302    .bind(&auth.issuer)
303    .bind(&auth.pds_url)
304    .bind(&auth.did)
305    .bind(&auth.auth_method)
306    .bind(&auth.auth_kid)
307    .bind(&auth.redirect_uri)
308    .bind(&auth.requested_scope)
309    .bind(&auth.request_uri)
310    .bind(&auth.app_return_to)
311    .bind(auth.expires_at)
312    .execute(pool)
313    .await
314    .context("recording the pending login")?;
315    Ok(())
316}
317
318/// **Consume** an in-flight login: return it and delete it, atomically.
319///
320/// One statement, so two concurrent callbacks cannot both succeed. A
321/// `SELECT` then `DELETE` would let both pass and both exchange the same
322/// `code` — and the authorization server is entitled to revoke every session
323/// associated with the earlier use, so the loser destroys the winner's session.
324///
325/// An EXPIRED row is deleted as well and reported absent, so it cannot be
326/// probed for existence after the fact.
327pub async fn take_pending(
328    pool: &SqlitePool,
329    codec: &Codec,
330    state: &str,
331    now: i64,
332) -> Result<Option<PendingAuth>> {
333    let row: Option<PendingRow> = sqlx::query_as(
334        r#"
335        DELETE FROM oauth_state WHERE state = ?1
336        RETURNING state, browser_binding_hash, pkce_verifier, dpop_key_jwk,
337                  issuer, pds_url, did, auth_method, auth_kid, redirect_uri,
338                  requested_scope, request_uri, app_return_to, expires_at
339        "#,
340    )
341    .bind(state)
342    .fetch_optional(pool)
343    .await
344    .context("consuming the pending login")?;
345
346    let Some(row) = row else { return Ok(None) };
347    // Expired AT `expires_at`, not one second later.
348    if row.expires_at <= now {
349        // Deleted above regardless; an expired flow is simply gone.
350        return Ok(None);
351    }
352
353    // The AAD is rebuilt from the STORED destinations, so any edit to them
354    // makes the secrets undecryptable rather than merely unnoticed.
355    let binding = StateBinding {
356        state: &row.state,
357        issuer: &row.issuer,
358        pds_url: &row.pds_url,
359        did: &row.did,
360        redirect_uri: &row.redirect_uri,
361        browser_binding_hash: &row.browser_binding_hash,
362        auth_method: &row.auth_method,
363        auth_kid: row.auth_kid.as_deref(),
364        requested_scope: &row.requested_scope,
365        request_uri: &row.request_uri,
366        app_return_to: row.app_return_to.as_deref(),
367        expires_at: row.expires_at,
368    };
369    let aad = |column: &str| state_aad(&binding, column);
370    Ok(Some(PendingAuth {
371        pkce_verifier: codec
372            .decrypt_bound(&row.pkce_verifier, &aad("pkce_verifier"))
373            .context("decrypting the stored PKCE verifier (or its bound context was altered)")?,
374        dpop_key_jwk: codec
375            .decrypt_bound(&row.dpop_key_jwk, &aad("dpop_key_jwk"))
376            .context("decrypting the stored DPoP key (or its bound context was altered)")?,
377        state: row.state,
378        browser_binding_hash: row.browser_binding_hash,
379        issuer: row.issuer,
380        pds_url: row.pds_url,
381        did: row.did,
382        auth_method: row.auth_method,
383        auth_kid: row.auth_kid,
384        redirect_uri: row.redirect_uri,
385        requested_scope: row.requested_scope,
386        request_uri: row.request_uri,
387        app_return_to: row.app_return_to,
388        expires_at: row.expires_at,
389    }))
390}
391
392/// An authenticated account's tokens.
393#[derive(Debug, Clone, PartialEq, Eq)]
394pub struct OAuthSession {
395    pub sub: String,
396    pub issuer: String,
397    pub aud: String,
398    pub dpop_key_jwk: String,
399    pub access_token: String,
400    pub refresh_token: String,
401    pub token_type: String,
402    pub granted_scope: String,
403    pub expires_at: Option<i64>,
404}
405
406#[derive(sqlx::FromRow)]
407struct SessionRow {
408    sub: String,
409    issuer: String,
410    aud: String,
411    dpop_key_jwk: String,
412    access_token: String,
413    refresh_token: String,
414    token_type: String,
415    granted_scope: String,
416    expires_at: Option<i64>,
417}
418
419/// Store or REPLACE a session.
420///
421/// An upsert, not an insert: logging in again is normal (a second browser, or
422/// re-auth after expiry), and a plain insert would fail on the primary key and
423/// 500 every subsequent login.
424pub async fn put_session(pool: &SqlitePool, codec: &Codec, session: &OAuthSession) -> Result<()> {
425    sqlx::query(
426        r#"
427        INSERT INTO oauth_session (
428            sub, issuer, aud, dpop_key_jwk, access_token, refresh_token,
429            token_type, granted_scope, expires_at
430        ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)
431        ON CONFLICT(sub) DO UPDATE SET
432            issuer        = excluded.issuer,
433            aud           = excluded.aud,
434            dpop_key_jwk  = excluded.dpop_key_jwk,
435            access_token  = excluded.access_token,
436            refresh_token = excluded.refresh_token,
437            token_type    = excluded.token_type,
438            granted_scope = excluded.granted_scope,
439            expires_at    = excluded.expires_at
440        "#,
441    )
442    .bind(&session.sub)
443    .bind(&session.issuer)
444    .bind(&session.aud)
445    .bind(codec.encrypt_bound(
446        &session.dpop_key_jwk,
447        &session_aad(
448            &session.sub,
449            "dpop_key_jwk",
450            &session.issuer,
451            &session.aud,
452            &session.token_type,
453            &session.granted_scope,
454            session.expires_at,
455        ),
456    ))
457    .bind(codec.encrypt_bound(
458        &session.access_token,
459        &session_aad(
460            &session.sub,
461            "access_token",
462            &session.issuer,
463            &session.aud,
464            &session.token_type,
465            &session.granted_scope,
466            session.expires_at,
467        ),
468    ))
469    .bind(codec.encrypt_bound(
470        &session.refresh_token,
471        &session_aad(
472            &session.sub,
473            "refresh_token",
474            &session.issuer,
475            &session.aud,
476            &session.token_type,
477            &session.granted_scope,
478            session.expires_at,
479        ),
480    ))
481    .bind(&session.token_type)
482    .bind(&session.granted_scope)
483    .bind(session.expires_at)
484    .execute(pool)
485    .await
486    .context("storing the OAuth session")?;
487    Ok(())
488}
489
490/// Read a session by subject DID.
491pub async fn get_session(
492    pool: &SqlitePool,
493    codec: &Codec,
494    sub: &str,
495) -> Result<Option<OAuthSession>> {
496    let row: Option<SessionRow> = sqlx::query_as(
497        r#"
498        SELECT sub, issuer, aud, dpop_key_jwk, access_token, refresh_token,
499               token_type, granted_scope, expires_at
500        FROM oauth_session WHERE sub = ?1
501        "#,
502    )
503    .bind(sub)
504    .fetch_optional(pool)
505    .await
506    .context("reading the OAuth session")?;
507
508    let Some(row) = row else { return Ok(None) };
509    let aad = |column: &str| {
510        session_aad(
511            &row.sub,
512            column,
513            &row.issuer,
514            &row.aud,
515            &row.token_type,
516            &row.granted_scope,
517            row.expires_at,
518        )
519    };
520    Ok(Some(OAuthSession {
521        dpop_key_jwk: codec
522            .decrypt_bound(&row.dpop_key_jwk, &aad("dpop_key_jwk"))
523            .context("decrypting the session DPoP key (or its bound context was altered)")?,
524        access_token: codec
525            .decrypt_bound(&row.access_token, &aad("access_token"))
526            .context("decrypting the stored access token (or its bound context was altered)")?,
527        refresh_token: codec
528            .decrypt_bound(&row.refresh_token, &aad("refresh_token"))
529            .context("decrypting the stored refresh token (or its bound context was altered)")?,
530        sub: row.sub,
531        issuer: row.issuer,
532        aud: row.aud,
533        token_type: row.token_type,
534        granted_scope: row.granted_scope,
535        expires_at: row.expires_at,
536    }))
537}
538
539/// Delete a session. `true` if one existed.
540pub async fn delete_session(pool: &SqlitePool, sub: &str) -> Result<bool> {
541    let result = sqlx::query("DELETE FROM oauth_session WHERE sub = ?1")
542        .bind(sub)
543        .execute(pool)
544        .await
545        .context("deleting the OAuth session")?;
546    Ok(result.rows_affected() > 0)
547}
548
549/// Delete every pending login that has expired. Returns how many went.
550///
551/// An abandoned login -- the user is redirected and closes the tab -- leaves a
552/// row holding a sealed DPoP key and is never consumed by `take_pending`, which
553/// only runs when a callback arrives. Without this they accumulate forever, and
554/// on a publicly reachable login form that is an unbounded write primitive
555/// against the volume.
556pub async fn sweep_expired_pending(pool: &SqlitePool, now: i64) -> Result<u64> {
557    let result = sqlx::query("DELETE FROM oauth_state WHERE expires_at <= ?1")
558        .bind(now)
559        .execute(pool)
560        .await
561        .context("sweeping expired pending logins")?;
562    Ok(result.rows_affected())
563}
564
565/// Delete DPoP nonces untouched since `cutoff`. Returns how many went.
566///
567/// The origins come from whatever handle a visitor typed into the login form,
568/// and `put_nonce` runs during PAR — before any authentication. So this is a
569/// pre-auth write primitive against the volume, the same argument that
570/// justifies sweeping abandoned logins, applied to the one table that had no
571/// sweeper. A nonce is also worthless once stale: the server issues a new one
572/// with the next challenge.
573pub async fn sweep_stale_nonces(pool: &SqlitePool, cutoff: i64) -> Result<u64> {
574    let result = sqlx::query("DELETE FROM oauth_nonce WHERE updated_at <= ?1")
575        .bind(cutoff)
576        .execute(pool)
577        .await
578        .context("sweeping stale DPoP nonces")?;
579    Ok(result.rows_affected())
580}
581
582/// The stored DPoP nonce for an origin, if any.
583pub async fn get_nonce(pool: &SqlitePool, origin: &str) -> Result<Option<String>> {
584    sqlx::query_scalar("SELECT nonce FROM oauth_nonce WHERE origin = ?1")
585        .bind(origin)
586        .fetch_optional(pool)
587        .await
588        .context("reading the stored DPoP nonce")
589}
590
591/// Record the latest DPoP nonce for an origin. Servers rotate nonces, so a
592/// later value replaces the earlier one.
593/// `now` is passed in rather than read here, matching the rest of this module —
594/// and so the sweeper's age rule can be tested without waiting for a clock.
595pub async fn put_nonce(pool: &SqlitePool, origin: &str, nonce: &str, now: i64) -> Result<()> {
596    sqlx::query(
597        r#"
598        INSERT INTO oauth_nonce (origin, nonce, updated_at) VALUES (?1, ?2, ?3)
599        ON CONFLICT(origin) DO UPDATE SET
600            nonce = excluded.nonce, updated_at = excluded.updated_at
601        "#,
602    )
603    .bind(origin)
604    .bind(nonce)
605    .bind(now)
606    .execute(pool)
607    .await
608    .context("storing the DPoP nonce")?;
609    Ok(())
610}
611
612#[cfg(test)]
613mod tests {
614    use super::*;
615    use crate::store::init_url;
616
617    const KEY: &str = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa";
618    const DID: &str = "did:plc:ewvi7nxzyoun6zhxrhs64oiz";
619    const NOW: i64 = 1_700_000_000;
620
621    async fn db() -> (sqlx::SqlitePool, Codec) {
622        let pool = init_url("sqlite::memory:").await.unwrap();
623        init_schema(&pool).await.unwrap();
624        (pool, Codec::new(Some(KEY)).unwrap())
625    }
626
627    /// `sqlx::query` demands a `&'static str`, so a test that varies a COLUMN
628    /// name has to leak. Test-only, bounded by the fixed column lists below.
629    fn leak(sql: String) -> &'static str {
630        Box::leak(sql.into_boxed_str())
631    }
632
633    fn pending(state: &str) -> PendingAuth {
634        PendingAuth {
635            state: state.to_string(),
636            browser_binding_hash: "hash-of-cookie".into(),
637            pkce_verifier: "verifier-secret".into(),
638            dpop_key_jwk: r#"{"kty":"EC","d":"secret"}"#.into(),
639            issuer: "https://auth.example.com".into(),
640            pds_url: "https://pds.example.com".into(),
641            did: DID.into(),
642            auth_method: "private_key_jwt".into(),
643            auth_kid: Some("featherreader-oauth-1".into()),
644            redirect_uri: "https://feather-reader.com/oauth/callback".into(),
645            requested_scope: "atproto transition:generic".into(),
646            request_uri: "urn:ietf:params:oauth:request_uri:abc".into(),
647            app_return_to: Some("/reader".into()),
648            expires_at: NOW + 600,
649        }
650    }
651
652    // ── the atomic consume ───────────────────────────────────────────────────
653
654    #[tokio::test]
655    async fn a_pending_login_round_trips() -> anyhow::Result<()> {
656        let (pool, codec) = db().await;
657        let want = pending("state-1");
658        put_pending(&pool, &codec, &want).await?;
659
660        let got = take_pending(&pool, &codec, "state-1", NOW).await?.unwrap();
661        assert_eq!(got, want);
662        Ok(())
663    }
664
665    /// **Single use.** The second arrival must find nothing, so it is rejected
666    /// before any token call — a replayed `code` exchange can make the
667    /// authorization server revoke the session the first one just created.
668    #[tokio::test]
669    async fn a_pending_login_can_only_be_taken_once() -> anyhow::Result<()> {
670        let (pool, codec) = db().await;
671        put_pending(&pool, &codec, &pending("state-1")).await?;
672
673        assert!(take_pending(&pool, &codec, "state-1", NOW).await?.is_some());
674        assert!(take_pending(&pool, &codec, "state-1", NOW).await?.is_none());
675        Ok(())
676    }
677
678    /// The consume is one statement, so concurrent callbacks cannot both win.
679    #[tokio::test]
680    async fn concurrent_takes_yield_exactly_one_winner() -> anyhow::Result<()> {
681        let (pool, codec) = db().await;
682        put_pending(&pool, &codec, &pending("race")).await?;
683
684        // Concurrent futures interleave at every `.await`, which is exactly
685        // where a SELECT-then-DELETE would let two callers both see the row.
686        let (a, b, c, d) = tokio::join!(
687            take_pending(&pool, &codec, "race", NOW),
688            take_pending(&pool, &codec, "race", NOW),
689            take_pending(&pool, &codec, "race", NOW),
690            take_pending(&pool, &codec, "race", NOW),
691        );
692        let winners = [a?, b?, c?, d?].iter().filter(|r| r.is_some()).count();
693        assert_eq!(winners, 1, "more than one caller consumed the same state");
694        Ok(())
695    }
696
697    /// An expired row is not returned — and is consumed anyway, so it cannot be
698    /// probed for existence afterwards.
699    #[tokio::test]
700    async fn an_expired_pending_login_is_rejected_and_removed() -> anyhow::Result<()> {
701        let (pool, codec) = db().await;
702        put_pending(&pool, &codec, &pending("stale")).await?;
703
704        let after_expiry = NOW + 601;
705        assert!(take_pending(&pool, &codec, "stale", after_expiry)
706            .await?
707            .is_none());
708        // Gone even at a time when it would have been valid.
709        assert!(take_pending(&pool, &codec, "stale", NOW).await?.is_none());
710        Ok(())
711    }
712
713    #[tokio::test]
714    async fn an_unknown_state_is_simply_absent() -> anyhow::Result<()> {
715        let (pool, codec) = db().await;
716        assert!(take_pending(&pool, &codec, "never-existed", NOW)
717            .await?
718            .is_none());
719        Ok(())
720    }
721
722    // ── AAD binding, end to end ──────────────────────────────────────────────
723
724    /// The secrets must not be readable from the database itself.
725    #[tokio::test]
726    async fn secret_columns_are_stored_encrypted() -> anyhow::Result<()> {
727        let (pool, codec) = db().await;
728        put_pending(&pool, &codec, &pending("state-1")).await?;
729
730        let (verifier, jwk): (String, String) =
731            sqlx::query_as("SELECT pkce_verifier, dpop_key_jwk FROM oauth_state WHERE state = ?")
732                .bind("state-1")
733                .fetch_one(&pool)
734                .await?;
735        for stored in [&verifier, &jwk] {
736            assert!(stored.starts_with("enc.v2.gcm."), "not bound: {stored}");
737        }
738        assert!(!verifier.contains("verifier-secret"));
739        assert!(!jwk.contains("secret"));
740        Ok(())
741    }
742
743    /// **The reason AAD was pulled forward.** Anything able to write the
744    /// database must not be able to graft one login flow's DPoP key onto
745    /// another flow's state row.
746    #[tokio::test]
747    async fn a_secret_moved_between_rows_does_not_decrypt() -> anyhow::Result<()> {
748        let (pool, codec) = db().await;
749        put_pending(&pool, &codec, &pending("victim")).await?;
750        let mut attacker = pending("attacker");
751        attacker.dpop_key_jwk = r#"{"kty":"EC","d":"attacker-key"}"#.into();
752        put_pending(&pool, &codec, &attacker).await?;
753
754        // Lift the attacker's sealed DPoP key into the victim's row.
755        let stolen: String =
756            sqlx::query_scalar("SELECT dpop_key_jwk FROM oauth_state WHERE state = ?")
757                .bind("attacker")
758                .fetch_one(&pool)
759                .await?;
760        sqlx::query("UPDATE oauth_state SET dpop_key_jwk = ? WHERE state = ?")
761            .bind(&stolen)
762            .bind("victim")
763            .execute(&pool)
764            .await?;
765
766        assert!(
767            take_pending(&pool, &codec, "victim", NOW).await.is_err(),
768            "a grafted ciphertext decrypted in the wrong row"
769        );
770        Ok(())
771    }
772
773    /// And between COLUMNS of the same row — the binding names the column too.
774    #[tokio::test]
775    async fn a_secret_moved_between_columns_does_not_decrypt() -> anyhow::Result<()> {
776        let (pool, codec) = db().await;
777        put_pending(&pool, &codec, &pending("state-1")).await?;
778
779        let verifier: String =
780            sqlx::query_scalar("SELECT pkce_verifier FROM oauth_state WHERE state = ?")
781                .bind("state-1")
782                .fetch_one(&pool)
783                .await?;
784        sqlx::query("UPDATE oauth_state SET dpop_key_jwk = ? WHERE state = ?")
785            .bind(&verifier)
786            .bind("state-1")
787            .execute(&pool)
788            .await?;
789
790        assert!(take_pending(&pool, &codec, "state-1", NOW).await.is_err());
791        Ok(())
792    }
793
794    /// **A session secret moved between columns of the same row does not
795    /// decrypt** — the `oauth_state` twin above, which never got written for
796    /// `oauth_session`. `an_unbound_session_ciphertext_is_refused` says in its
797    /// own doc that the column is part of the binding; nothing checked it, so
798    /// dropping `column` from `session_aad` left the suite green. Against the
799    /// declared adversary — anything that can write the database — that is
800    /// `access_token` ↔ `refresh_token` swapped inside one row with both
801    /// still authenticating, and `get_session` handing the refresh token to
802    /// the PDS as an access token.
803    #[tokio::test]
804    async fn a_session_secret_moved_between_columns_does_not_decrypt() -> anyhow::Result<()> {
805        let (pool, codec) = db().await;
806        put_session(&pool, &codec, &session()).await?;
807        let access: String =
808            sqlx::query_scalar("SELECT access_token FROM oauth_session WHERE sub = ?")
809                .bind(DID)
810                .fetch_one(&pool)
811                .await?;
812        sqlx::query("UPDATE oauth_session SET refresh_token = ? WHERE sub = ?")
813            .bind(&access)
814            .bind(DID)
815            .execute(&pool)
816            .await?;
817        assert!(
818            get_session(&pool, &codec, DID).await.is_err(),
819            "the access token's ciphertext was accepted in the refresh_token column"
820        );
821        Ok(())
822    }
823
824    /// **An absent expiry and a zero expiry are different sessions.** The AAD
825    /// comment says `None` and `0` must not collide; the only tamper test
826    /// stored `Some(NOW + 3600)` and flipped it to NULL, which differs under
827    /// either encoding — so `unwrap_or(0)` in place of the `"none"` marker
828    /// left the suite green. Flipping between NULL and 0 with every token
829    /// still decrypting pins `is_stale` permanently one way or the other.
830    #[tokio::test]
831    async fn an_absent_expiry_and_a_zero_expiry_are_different_sessions() -> anyhow::Result<()> {
832        for (stored, flipped_to) in [(None, "0"), (Some(0), "NULL")] {
833            let (pool, codec) = db().await;
834            put_session(
835                &pool,
836                &codec,
837                &OAuthSession {
838                    expires_at: stored,
839                    ..session()
840                },
841            )
842            .await?;
843            // Two literals, chosen by the loop — not a bound parameter, because
844            // binding `None` would write NULL through the same path the code
845            // under test uses, and the point is a raw flip.
846            sqlx::query(sqlx::AssertSqlSafe(format!(
847                "UPDATE oauth_session SET expires_at = {flipped_to} WHERE sub = ?"
848            )))
849            .bind(DID)
850            .execute(&pool)
851            .await?;
852            assert!(
853                get_session(&pool, &codec, DID).await.is_err(),
854                "expires_at {stored:?} → {flipped_to} still decrypted"
855            );
856        }
857        Ok(())
858    }
859
860    /// **Binding the secrets is not enough: the DESTINATIONS must be bound too.**
861    ///
862    /// The declared adversary is anything able to write the database. Against
863    /// that adversary, leaving `issuer`/`pds_url`/`did`/`redirect_uri` as plain
864    /// unauthenticated columns defeats the whole scheme without touching a
865    /// ciphertext — repoint the issuer and we mint a client assertion for the
866    /// attacker's server and neutralise the RFC 9207 `iss` check, while every
867    /// secret still decrypts perfectly.
868    #[tokio::test]
869    async fn tampering_with_a_pending_logins_destinations_breaks_it() -> anyhow::Result<()> {
870        for column in [
871            "issuer",
872            "pds_url",
873            "did",
874            "redirect_uri",
875            "browser_binding_hash",
876            "auth_method",
877            // Added after a cold review found each of these tamperable while
878            // every ciphertext still verified:
879            //
880            // `auth_kid` selects the signing key and is never re-verified;
881            // `requested_scope` and `request_uri` describe the grant being
882            // completed; `app_return_to` is a declared post-login redirect
883            // target, so unbound it becomes an open redirect the day it is
884            // wired up — binding it now costs nothing.
885            "auth_kid",
886            "requested_scope",
887            "request_uri",
888            "app_return_to",
889        ] {
890            let (pool, codec) = db().await;
891            put_pending(&pool, &codec, &pending("state-1")).await?;
892            sqlx::query(leak(format!(
893                "UPDATE oauth_state SET {column} = ? WHERE state = ?"
894            )))
895            .bind("https://evil.example")
896            .bind("state-1")
897            .execute(&pool)
898            .await?;
899            assert!(
900                take_pending(&pool, &codec, "state-1", NOW).await.is_err(),
901                "tampering with `{column}` went undetected"
902            );
903        }
904        Ok(())
905    }
906
907    /// The session's `aud` IS the PDS every later request is sent to, so
908    /// repointing it would ship a live DPoP-bound access token to the attacker's
909    /// host. It must break the tokens, not travel alongside them.
910    #[tokio::test]
911    async fn tampering_with_a_sessions_destinations_breaks_it() -> anyhow::Result<()> {
912        for column in [
913            "aud",
914            "issuer",
915            // `token_type` is refused outright on the wire if it is not `DPoP`,
916            // but the stored copy was neither authenticated nor re-checked.
917            "token_type",
918            "granted_scope",
919        ] {
920            let (pool, codec) = db().await;
921            put_session(&pool, &codec, &session()).await?;
922            sqlx::query(leak(format!(
923                "UPDATE oauth_session SET {column} = ? WHERE sub = ?"
924            )))
925            .bind("https://evil.example")
926            .bind(DID)
927            .execute(&pool)
928            .await?;
929            assert!(
930                get_session(&pool, &codec, DID).await.is_err(),
931                "tampering with `{column}` went undetected"
932            );
933        }
934        Ok(())
935    }
936
937    /// Stale nonces are swept; fresh ones are not.
938    ///
939    /// Its sibling `sweep_expired_pending` has a test and this had none —
940    /// replacing the whole body with `Ok(0)` passed. The origins come from
941    /// whatever handle a visitor types into the login form and are written
942    /// during PAR, before any authentication, so the table is a pre-auth write
943    /// primitive against the volume.
944    #[tokio::test]
945    async fn stale_nonces_are_swept_and_fresh_ones_kept() -> anyhow::Result<()> {
946        let (pool, _codec) = db().await;
947        put_nonce(&pool, "https://old.example", "n1", NOW - 10_000).await?;
948        put_nonce(&pool, "https://new.example", "n2", NOW).await?;
949
950        assert_eq!(sweep_stale_nonces(&pool, NOW - 5_000).await?, 1);
951        assert_eq!(get_nonce(&pool, "https://old.example").await?, None);
952        assert_eq!(
953            get_nonce(&pool, "https://new.example").await?.as_deref(),
954            Some("n2"),
955            "a nonce still in use was swept"
956        );
957        Ok(())
958    }
959
960    /// **An absent optional column and an empty one must not encode alike.**
961    ///
962    /// The tamper test above only ever writes a NON-EMPTY value, so it passed
963    /// while `NULL` and `''` produced byte-identical AAD and either could be
964    /// flipped to the other undetected. This covers both directions for both
965    /// optional columns, which is the case that test could not see.
966    #[tokio::test]
967    async fn swapping_an_absent_optional_column_for_an_empty_one_breaks_it() -> anyhow::Result<()> {
968        for (column, set_to_empty) in [
969            ("auth_kid", true),
970            ("auth_kid", false),
971            ("app_return_to", true),
972            ("app_return_to", false),
973        ] {
974            let (pool, codec) = db().await;
975            let mut auth = pending("state-1");
976            // Start from whichever state we are NOT flipping to.
977            if set_to_empty {
978                // Stored absent; an adversary makes it empty.
979                if column == "auth_kid" {
980                    auth.auth_kid = None;
981                } else {
982                    auth.app_return_to = None;
983                }
984            } else {
985                // Stored empty; an adversary makes it absent.
986                if column == "auth_kid" {
987                    auth.auth_kid = Some(String::new());
988                } else {
989                    auth.app_return_to = Some(String::new());
990                }
991            }
992            put_pending(&pool, &codec, &auth).await?;
993
994            let sql = leak(format!(
995                "UPDATE oauth_state SET {column} = ? WHERE state = ?"
996            ));
997            let query = if set_to_empty {
998                sqlx::query(sql).bind(Some(String::new()))
999            } else {
1000                sqlx::query(sql).bind(Option::<String>::None)
1001            };
1002            query.bind("state-1").execute(&pool).await?;
1003
1004            assert!(
1005                take_pending(&pool, &codec, "state-1", NOW).await.is_err(),
1006                "`{column}`: {} went undetected",
1007                if set_to_empty {
1008                    "NULL -> ''"
1009                } else {
1010                    "'' -> NULL"
1011                }
1012            );
1013        }
1014        Ok(())
1015    }
1016
1017    /// **A row's own lifetime is a destination.**
1018    ///
1019    /// Both the expiry check in `take_pending` and `sweep_expired_pending`
1020    /// filter on `expires_at`. While it was outside the AAD, anyone who could
1021    /// write the database could push it a year out and keep a pending login —
1022    /// with its sealed DPoP key and PKCE verifier — alive indefinitely, which is
1023    /// exactly what the ten-minute cap exists to prevent. Every ciphertext still
1024    /// verified.
1025    #[tokio::test]
1026    async fn extending_a_pending_logins_expiry_breaks_it() -> anyhow::Result<()> {
1027        let (pool, codec) = db().await;
1028        put_pending(&pool, &codec, &pending("state-1")).await?;
1029        sqlx::query("UPDATE oauth_state SET expires_at = ? WHERE state = ?")
1030            .bind(NOW + 31_536_000)
1031            .bind("state-1")
1032            .execute(&pool)
1033            .await?;
1034        assert!(
1035            take_pending(&pool, &codec, "state-1", NOW).await.is_err(),
1036            "the expiry was extended without breaking the row"
1037        );
1038        Ok(())
1039    }
1040
1041    /// Clearing a session's expiry made `is_stale` permanently false, so the
1042    /// session was never proactively refreshed — behaviour steered by an
1043    /// unauthenticated column while every token decrypted cleanly.
1044    #[tokio::test]
1045    async fn clearing_a_sessions_expiry_breaks_it() -> anyhow::Result<()> {
1046        let (pool, codec) = db().await;
1047        put_session(&pool, &codec, &session()).await?;
1048        sqlx::query("UPDATE oauth_session SET expires_at = NULL WHERE sub = ?")
1049            .bind(DID)
1050            .execute(&pool)
1051            .await?;
1052        assert!(
1053            get_session(&pool, &codec, DID).await.is_err(),
1054            "the expiry was cleared without breaking the row"
1055        );
1056        Ok(())
1057    }
1058
1059    /// The AAD is length-prefixed, so no rearrangement of field boundaries can
1060    /// produce the same bytes. A delimiter-joined encoding is only safe while no
1061    /// field can contain the delimiter — and `did:web:…` subjects and URL
1062    /// issuers are exactly the inputs that erode that assumption.
1063    #[test]
1064    fn the_aad_encoding_is_unambiguous_across_field_boundaries() {
1065        assert_ne!(
1066            structured_aad("t", &["ab", "c"]),
1067            structured_aad("t", &["a", "bc"])
1068        );
1069        assert_ne!(
1070            structured_aad("t", &["a:b"]),
1071            structured_aad("t", &["a", "b"])
1072        );
1073        assert_ne!(
1074            structured_aad("t", &["a", ""]),
1075            structured_aad("t", &["", "a"])
1076        );
1077        assert_ne!(structured_aad("t1", &["a"]), structured_aad("t2", &["a"]));
1078    }
1079
1080    /// Both nullable columns must survive the round trip as `None`.
1081    #[tokio::test]
1082    async fn a_pending_login_round_trips_with_its_optional_fields_absent() -> anyhow::Result<()> {
1083        let (pool, codec) = db().await;
1084        let mut want = pending("state-1");
1085        want.auth_kid = None;
1086        want.app_return_to = None;
1087        put_pending(&pool, &codec, &want).await?;
1088        assert_eq!(
1089            take_pending(&pool, &codec, "state-1", NOW).await?.unwrap(),
1090            want
1091        );
1092        Ok(())
1093    }
1094
1095    /// A row is expired AT `expires_at`, not one second later. The earlier test
1096    /// probed `expires_at + 1`, which cannot tell `<` from `<=`.
1097    #[tokio::test]
1098    async fn a_pending_login_is_expired_at_exactly_its_expiry() -> anyhow::Result<()> {
1099        let (pool, codec) = db().await;
1100        put_pending(&pool, &codec, &pending("edge")).await?;
1101        assert!(take_pending(&pool, &codec, "edge", NOW + 600)
1102            .await?
1103            .is_none());
1104
1105        let (pool, codec) = db().await;
1106        put_pending(&pool, &codec, &pending("edge")).await?;
1107        assert!(take_pending(&pool, &codec, "edge", NOW + 599)
1108            .await?
1109            .is_some());
1110        Ok(())
1111    }
1112
1113    /// An abandoned login — the user closes the tab after being redirected —
1114    /// leaves a row holding a sealed DPoP key. Without a sweep those accumulate
1115    /// forever, and on a publicly reachable login form that is an unbounded
1116    /// write primitive against the volume.
1117    #[tokio::test]
1118    async fn expired_pending_logins_are_swept() -> anyhow::Result<()> {
1119        let (pool, codec) = db().await;
1120        put_pending(&pool, &codec, &pending("old")).await?;
1121        let mut fresh = pending("fresh");
1122        fresh.expires_at = NOW + 3600;
1123        put_pending(&pool, &codec, &fresh).await?;
1124
1125        assert_eq!(sweep_expired_pending(&pool, NOW + 700).await?, 1);
1126        assert!(take_pending(&pool, &codec, "old", NOW).await?.is_none());
1127        assert!(take_pending(&pool, &codec, "fresh", NOW).await?.is_some());
1128        Ok(())
1129    }
1130
1131    /// An UNBOUND `enc.v1` value must be refused where a bound one is expected,
1132    /// or the binding is opt-out for anyone who can write the row.
1133    #[tokio::test]
1134    async fn an_unbound_ciphertext_is_refused() -> anyhow::Result<()> {
1135        let (pool, codec) = db().await;
1136        put_pending(&pool, &codec, &pending("state-1")).await?;
1137
1138        sqlx::query("UPDATE oauth_state SET pkce_verifier = ? WHERE state = ?")
1139            .bind(codec.encrypt("verifier-secret"))
1140            .bind("state-1")
1141            .execute(&pool)
1142            .await?;
1143
1144        assert!(take_pending(&pool, &codec, "state-1", NOW).await.is_err());
1145        Ok(())
1146    }
1147
1148    /// The same downgrade check for sessions, which hold the LONG-LIVED tokens.
1149    /// Covering only `oauth_state` would leave an implementation that used
1150    /// `decrypt` instead of `decrypt_bound` here passing the whole suite.
1151    #[tokio::test]
1152    async fn an_unbound_session_ciphertext_is_refused() -> anyhow::Result<()> {
1153        for column in ["access_token", "refresh_token", "dpop_key_jwk"] {
1154            let (pool, codec) = db().await;
1155            put_session(&pool, &codec, &session()).await?;
1156            sqlx::query(leak(format!(
1157                "UPDATE oauth_session SET {column} = ? WHERE sub = ?"
1158            )))
1159            .bind(codec.encrypt("some-value"))
1160            .bind(DID)
1161            .execute(&pool)
1162            .await?;
1163            assert!(
1164                get_session(&pool, &codec, DID).await.is_err(),
1165                "an unbound value was accepted in `{column}`"
1166            );
1167        }
1168        Ok(())
1169    }
1170
1171    // ── sessions ─────────────────────────────────────────────────────────────
1172
1173    fn session() -> OAuthSession {
1174        OAuthSession {
1175            sub: DID.into(),
1176            issuer: "https://auth.example.com".into(),
1177            aud: "https://pds.example.com".into(),
1178            dpop_key_jwk: r#"{"kty":"EC","d":"session-key"}"#.into(),
1179            access_token: "access-abc".into(),
1180            refresh_token: "refresh-xyz".into(),
1181            token_type: "DPoP".into(),
1182            granted_scope: "atproto transition:generic".into(),
1183            expires_at: Some(NOW + 3600),
1184        }
1185    }
1186
1187    #[tokio::test]
1188    async fn a_session_round_trips() -> anyhow::Result<()> {
1189        let (pool, codec) = db().await;
1190        put_session(&pool, &codec, &session()).await?;
1191        assert_eq!(get_session(&pool, &codec, DID).await?.unwrap(), session());
1192        Ok(())
1193    }
1194
1195    /// Logging in again must REPLACE the session, not fail on the primary key.
1196    /// A plain INSERT here is the bug that 500s every second login.
1197    #[tokio::test]
1198    async fn re_login_replaces_the_existing_session() -> anyhow::Result<()> {
1199        let (pool, codec) = db().await;
1200        put_session(&pool, &codec, &session()).await?;
1201
1202        let mut second = session();
1203        second.access_token = "access-second".into();
1204        second.refresh_token = "refresh-second".into();
1205        put_session(&pool, &codec, &second).await?;
1206
1207        let got = get_session(&pool, &codec, DID).await?.unwrap();
1208        assert_eq!(got.access_token, "access-second");
1209        assert_eq!(got.refresh_token, "refresh-second");
1210        Ok(())
1211    }
1212
1213    /// `expires_in` is optional in a token response, so the column is nullable
1214    /// and a session without one must survive the round trip.
1215    #[tokio::test]
1216    async fn a_session_without_an_expiry_round_trips() -> anyhow::Result<()> {
1217        let (pool, codec) = db().await;
1218        let mut s = session();
1219        s.expires_at = None;
1220        put_session(&pool, &codec, &s).await?;
1221        assert_eq!(
1222            get_session(&pool, &codec, DID).await?.unwrap().expires_at,
1223            None
1224        );
1225        Ok(())
1226    }
1227
1228    #[tokio::test]
1229    async fn session_tokens_are_bound_to_their_subject() -> anyhow::Result<()> {
1230        let (pool, codec) = db().await;
1231        put_session(&pool, &codec, &session()).await?;
1232
1233        let other = OAuthSession {
1234            sub: "did:plc:aaaaaaaaaaaaaaaaaaaaaaaa".into(),
1235            access_token: "access-other".into(),
1236            ..session()
1237        };
1238        put_session(&pool, &codec, &other).await?;
1239
1240        let stolen: String =
1241            sqlx::query_scalar("SELECT access_token FROM oauth_session WHERE sub = ?")
1242                .bind(&other.sub)
1243                .fetch_one(&pool)
1244                .await?;
1245        sqlx::query("UPDATE oauth_session SET access_token = ? WHERE sub = ?")
1246            .bind(&stolen)
1247            .bind(DID)
1248            .execute(&pool)
1249            .await?;
1250
1251        assert!(get_session(&pool, &codec, DID).await.is_err());
1252        Ok(())
1253    }
1254
1255    #[tokio::test]
1256    async fn a_deleted_session_is_gone() -> anyhow::Result<()> {
1257        let (pool, codec) = db().await;
1258        put_session(&pool, &codec, &session()).await?;
1259        assert!(delete_session(&pool, DID).await?);
1260        assert!(get_session(&pool, &codec, DID).await?.is_none());
1261        assert!(!delete_session(&pool, DID).await?);
1262        Ok(())
1263    }
1264
1265    // ── DPoP nonces ──────────────────────────────────────────────────────────
1266
1267    /// Nonces are per-ORIGIN and persist between requests: using one only for an
1268    /// immediate retry means every request pays a wasted round trip.
1269    #[tokio::test]
1270    async fn nonces_are_stored_and_replaced_per_origin() -> anyhow::Result<()> {
1271        let (pool, _) = db().await;
1272        assert_eq!(get_nonce(&pool, "https://a.example").await?, None);
1273
1274        put_nonce(&pool, "https://a.example", "n1", NOW).await?;
1275        put_nonce(&pool, "https://b.example", "n2", NOW).await?;
1276        assert_eq!(
1277            get_nonce(&pool, "https://a.example").await?.as_deref(),
1278            Some("n1")
1279        );
1280        assert_eq!(
1281            get_nonce(&pool, "https://b.example").await?.as_deref(),
1282            Some("n2")
1283        );
1284
1285        // Rotation: servers rotate nonces, so a later value replaces the earlier.
1286        put_nonce(&pool, "https://a.example", "n3", NOW).await?;
1287        assert_eq!(
1288            get_nonce(&pool, "https://a.example").await?.as_deref(),
1289            Some("n3")
1290        );
1291        Ok(())
1292    }
1293}