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    let [dpop_key_jwk, access_token, refresh_token] = encrypt_secrets(codec, session);
426    sqlx::query(
427        r#"
428        INSERT INTO oauth_session (
429            sub, issuer, aud, dpop_key_jwk, access_token, refresh_token,
430            token_type, granted_scope, expires_at
431        ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)
432        ON CONFLICT(sub) DO UPDATE SET
433            issuer        = excluded.issuer,
434            aud           = excluded.aud,
435            dpop_key_jwk  = excluded.dpop_key_jwk,
436            access_token  = excluded.access_token,
437            refresh_token = excluded.refresh_token,
438            token_type    = excluded.token_type,
439            granted_scope = excluded.granted_scope,
440            expires_at    = excluded.expires_at
441        "#,
442    )
443    .bind(&session.sub)
444    .bind(&session.issuer)
445    .bind(&session.aud)
446    .bind(dpop_key_jwk)
447    .bind(access_token)
448    .bind(refresh_token)
449    .bind(&session.token_type)
450    .bind(&session.granted_scope)
451    .bind(session.expires_at)
452    .execute(pool)
453    .await
454    .context("storing the OAuth session")?;
455    Ok(())
456}
457
458/// Read a session by subject DID.
459pub async fn get_session(
460    pool: &SqlitePool,
461    codec: &Codec,
462    sub: &str,
463) -> Result<Option<OAuthSession>> {
464    Ok(get_session_versioned(pool, codec, sub)
465        .await?
466        .map(|(session, _)| session))
467}
468
469/// The stored form of a session's secrets, exactly as read — what
470/// [`delete_session_if_unchanged`] compares against.
471///
472/// Every write re-encrypts under a fresh nonce, so ANY `put_session` since the
473/// read (a refresh rotating the tokens, a re-login) changes these, even one that
474/// happened to store the same plaintext.
475#[derive(Debug, Clone, PartialEq, Eq)]
476pub struct SessionVersion {
477    dpop_key_jwk: String,
478    access_token: String,
479    refresh_token: String,
480}
481
482/// [`get_session`], also returning the [`SessionVersion`] of the row it read —
483/// both from ONE row read, so the version is guaranteed to be the one the
484/// returned tokens came from.
485pub async fn get_session_versioned(
486    pool: &SqlitePool,
487    codec: &Codec,
488    sub: &str,
489) -> Result<Option<(OAuthSession, SessionVersion)>> {
490    let row: Option<SessionRow> = sqlx::query_as(
491        r#"
492        SELECT sub, issuer, aud, dpop_key_jwk, access_token, refresh_token,
493               token_type, granted_scope, expires_at
494        FROM oauth_session WHERE sub = ?1
495        "#,
496    )
497    .bind(sub)
498    .fetch_optional(pool)
499    .await
500    .context("reading the OAuth session")?;
501
502    let Some(row) = row else { return Ok(None) };
503    let aad = |column: &str| {
504        session_aad(
505            &row.sub,
506            column,
507            &row.issuer,
508            &row.aud,
509            &row.token_type,
510            &row.granted_scope,
511            row.expires_at,
512        )
513    };
514    let session = OAuthSession {
515        dpop_key_jwk: codec
516            .decrypt_bound(&row.dpop_key_jwk, &aad("dpop_key_jwk"))
517            .context("decrypting the session DPoP key (or its bound context was altered)")?,
518        access_token: codec
519            .decrypt_bound(&row.access_token, &aad("access_token"))
520            .context("decrypting the stored access token (or its bound context was altered)")?,
521        refresh_token: codec
522            .decrypt_bound(&row.refresh_token, &aad("refresh_token"))
523            .context("decrypting the stored refresh token (or its bound context was altered)")?,
524        sub: row.sub,
525        issuer: row.issuer,
526        aud: row.aud,
527        token_type: row.token_type,
528        granted_scope: row.granted_scope,
529        expires_at: row.expires_at,
530    };
531    let version = SessionVersion {
532        dpop_key_jwk: row.dpop_key_jwk,
533        access_token: row.access_token,
534        refresh_token: row.refresh_token,
535    };
536    Ok(Some((session, version)))
537}
538
539/// Replace a session's tokens ONLY if the row still holds the secrets of
540/// `version` — the one the refresh started from. `true` if it was written;
541/// `false` if the row is gone or was rewritten since (nothing is written).
542///
543/// The refresh's write. [`put_session`] is an upsert, which is right for a
544/// login and wrong here: a sign-out (a `/logout`, the operator's revoke-all)
545/// that deleted the row while the refresh was in flight would have it
546/// RESURRECTED with the brand-new tokens, after reporting it signed out. And a
547/// row another writer rotated meanwhile would be silently overwritten.
548pub async fn update_session_if_unchanged(
549    pool: &SqlitePool,
550    codec: &Codec,
551    session: &OAuthSession,
552    version: &SessionVersion,
553) -> Result<bool> {
554    let [dpop_key_jwk, access_token, refresh_token] = encrypt_secrets(codec, session);
555    // One statement, so the comparison and the write are atomic.
556    let result = sqlx::query(
557        r#"
558        UPDATE oauth_session SET
559            issuer        = ?2,
560            aud           = ?3,
561            dpop_key_jwk  = ?4,
562            access_token  = ?5,
563            refresh_token = ?6,
564            token_type    = ?7,
565            granted_scope = ?8,
566            expires_at    = ?9
567        WHERE sub = ?1
568          AND dpop_key_jwk = ?10 AND access_token = ?11 AND refresh_token = ?12
569        "#,
570    )
571    .bind(&session.sub)
572    .bind(&session.issuer)
573    .bind(&session.aud)
574    .bind(dpop_key_jwk)
575    .bind(access_token)
576    .bind(refresh_token)
577    .bind(&session.token_type)
578    .bind(&session.granted_scope)
579    .bind(session.expires_at)
580    .bind(&version.dpop_key_jwk)
581    .bind(&version.access_token)
582    .bind(&version.refresh_token)
583    .execute(pool)
584    .await
585    .context("updating the OAuth session (if unchanged)")?;
586    Ok(result.rows_affected() > 0)
587}
588
589/// A session's three secret columns, encrypted and bound to the row's context
590/// exactly as [`put_session`] stores them.
591fn encrypt_secrets(codec: &Codec, session: &OAuthSession) -> [String; 3] {
592    let bound = |column: &str, plaintext: &str| {
593        codec.encrypt_bound(
594            plaintext,
595            &session_aad(
596                &session.sub,
597                column,
598                &session.issuer,
599                &session.aud,
600                &session.token_type,
601                &session.granted_scope,
602                session.expires_at,
603            ),
604        )
605    };
606    [
607        bound("dpop_key_jwk", &session.dpop_key_jwk),
608        bound("access_token", &session.access_token),
609        bound("refresh_token", &session.refresh_token),
610    ]
611}
612
613/// Delete a session ONLY if it still holds the secrets of `version`. `true` if
614/// it was deleted; `false` if the row is gone or has been rewritten since.
615///
616/// The sign-out's delete. A sign-out revokes the token it READ and then
617/// deletes; if a refresh rotated the row in between (the live app refreshes
618/// under an in-process lock a separate process cannot share), an
619/// unconditional delete removes the NEW token — never revoked, and no longer
620/// on record for anyone to revoke.
621pub async fn delete_session_if_unchanged(
622    pool: &SqlitePool,
623    sub: &str,
624    version: &SessionVersion,
625) -> Result<bool> {
626    // One statement: the comparison and the delete are atomic, so a write
627    // landing between "check" and "delete" is impossible.
628    let result = sqlx::query(
629        "DELETE FROM oauth_session WHERE sub = ?1 AND dpop_key_jwk = ?2 \
630         AND access_token = ?3 AND refresh_token = ?4",
631    )
632    .bind(sub)
633    .bind(&version.dpop_key_jwk)
634    .bind(&version.access_token)
635    .bind(&version.refresh_token)
636    .execute(pool)
637    .await
638    .context("deleting the OAuth session (if unchanged)")?;
639    Ok(result.rows_affected() > 0)
640}
641
642/// Every stored session's subject DID, in a stable order.
643///
644/// Reads the `sub` column only, so a row whose secrets no longer decrypt (a
645/// rotated encryption key, a pre-AAD row) is listed like any other. That is the
646/// point: the operator revoke-all walks this list, and an unreadable row still
647/// needs its sign-out (which deletes it) before the database is wiped.
648pub async fn list_session_subs(pool: &SqlitePool) -> Result<Vec<String>> {
649    sqlx::query_scalar("SELECT sub FROM oauth_session ORDER BY sub")
650        .fetch_all(pool)
651        .await
652        .context("listing the OAuth sessions")
653}
654
655/// Delete a session. `true` if one existed.
656pub async fn delete_session(pool: &SqlitePool, sub: &str) -> Result<bool> {
657    let result = sqlx::query("DELETE FROM oauth_session WHERE sub = ?1")
658        .bind(sub)
659        .execute(pool)
660        .await
661        .context("deleting the OAuth session")?;
662    Ok(result.rows_affected() > 0)
663}
664
665/// Delete every pending login that has expired. Returns how many went.
666///
667/// An abandoned login -- the user is redirected and closes the tab -- leaves a
668/// row holding a sealed DPoP key and is never consumed by `take_pending`, which
669/// only runs when a callback arrives. Without this they accumulate forever, and
670/// on a publicly reachable login form that is an unbounded write primitive
671/// against the volume.
672pub async fn sweep_expired_pending(pool: &SqlitePool, now: i64) -> Result<u64> {
673    let result = sqlx::query("DELETE FROM oauth_state WHERE expires_at <= ?1")
674        .bind(now)
675        .execute(pool)
676        .await
677        .context("sweeping expired pending logins")?;
678    Ok(result.rows_affected())
679}
680
681/// Delete DPoP nonces untouched since `cutoff`. Returns how many went.
682///
683/// The origins come from whatever handle a visitor typed into the login form,
684/// and `put_nonce` runs during PAR — before any authentication. So this is a
685/// pre-auth write primitive against the volume, the same argument that
686/// justifies sweeping abandoned logins, applied to the one table that had no
687/// sweeper. A nonce is also worthless once stale: the server issues a new one
688/// with the next challenge.
689pub async fn sweep_stale_nonces(pool: &SqlitePool, cutoff: i64) -> Result<u64> {
690    let result = sqlx::query("DELETE FROM oauth_nonce WHERE updated_at <= ?1")
691        .bind(cutoff)
692        .execute(pool)
693        .await
694        .context("sweeping stale DPoP nonces")?;
695    Ok(result.rows_affected())
696}
697
698/// The stored DPoP nonce for an origin, if any.
699pub async fn get_nonce(pool: &SqlitePool, origin: &str) -> Result<Option<String>> {
700    sqlx::query_scalar("SELECT nonce FROM oauth_nonce WHERE origin = ?1")
701        .bind(origin)
702        .fetch_optional(pool)
703        .await
704        .context("reading the stored DPoP nonce")
705}
706
707/// Record the latest DPoP nonce for an origin. Servers rotate nonces, so a
708/// later value replaces the earlier one.
709/// `now` is passed in rather than read here, matching the rest of this module —
710/// and so the sweeper's age rule can be tested without waiting for a clock.
711pub async fn put_nonce(pool: &SqlitePool, origin: &str, nonce: &str, now: i64) -> Result<()> {
712    sqlx::query(
713        r#"
714        INSERT INTO oauth_nonce (origin, nonce, updated_at) VALUES (?1, ?2, ?3)
715        ON CONFLICT(origin) DO UPDATE SET
716            nonce = excluded.nonce, updated_at = excluded.updated_at
717        "#,
718    )
719    .bind(origin)
720    .bind(nonce)
721    .bind(now)
722    .execute(pool)
723    .await
724    .context("storing the DPoP nonce")?;
725    Ok(())
726}
727
728#[cfg(test)]
729mod tests {
730    use super::*;
731    use crate::store::init_url;
732
733    const KEY: &str = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa";
734    const DID: &str = "did:plc:ewvi7nxzyoun6zhxrhs64oiz";
735    const NOW: i64 = 1_700_000_000;
736
737    async fn db() -> (sqlx::SqlitePool, Codec) {
738        let pool = init_url("sqlite::memory:").await.unwrap();
739        init_schema(&pool).await.unwrap();
740        (pool, Codec::new(Some(KEY)).unwrap())
741    }
742
743    /// `sqlx::query` demands a `&'static str`, so a test that varies a COLUMN
744    /// name has to leak. Test-only, bounded by the fixed column lists below.
745    fn leak(sql: String) -> &'static str {
746        Box::leak(sql.into_boxed_str())
747    }
748
749    fn pending(state: &str) -> PendingAuth {
750        PendingAuth {
751            state: state.to_string(),
752            browser_binding_hash: "hash-of-cookie".into(),
753            pkce_verifier: "verifier-secret".into(),
754            dpop_key_jwk: r#"{"kty":"EC","d":"secret"}"#.into(),
755            issuer: "https://auth.example.com".into(),
756            pds_url: "https://pds.example.com".into(),
757            did: DID.into(),
758            auth_method: "private_key_jwt".into(),
759            auth_kid: Some("featherreader-oauth-1".into()),
760            redirect_uri: "https://feather-reader.com/oauth/callback".into(),
761            requested_scope: "atproto transition:generic".into(),
762            request_uri: "urn:ietf:params:oauth:request_uri:abc".into(),
763            app_return_to: Some("/reader".into()),
764            expires_at: NOW + 600,
765        }
766    }
767
768    // ── the atomic consume ───────────────────────────────────────────────────
769
770    #[tokio::test]
771    async fn a_pending_login_round_trips() -> anyhow::Result<()> {
772        let (pool, codec) = db().await;
773        let want = pending("state-1");
774        put_pending(&pool, &codec, &want).await?;
775
776        let got = take_pending(&pool, &codec, "state-1", NOW).await?.unwrap();
777        assert_eq!(got, want);
778        Ok(())
779    }
780
781    /// **Single use.** The second arrival must find nothing, so it is rejected
782    /// before any token call — a replayed `code` exchange can make the
783    /// authorization server revoke the session the first one just created.
784    #[tokio::test]
785    async fn a_pending_login_can_only_be_taken_once() -> anyhow::Result<()> {
786        let (pool, codec) = db().await;
787        put_pending(&pool, &codec, &pending("state-1")).await?;
788
789        assert!(take_pending(&pool, &codec, "state-1", NOW).await?.is_some());
790        assert!(take_pending(&pool, &codec, "state-1", NOW).await?.is_none());
791        Ok(())
792    }
793
794    /// The consume is one statement, so concurrent callbacks cannot both win.
795    #[tokio::test]
796    async fn concurrent_takes_yield_exactly_one_winner() -> anyhow::Result<()> {
797        let (pool, codec) = db().await;
798        put_pending(&pool, &codec, &pending("race")).await?;
799
800        // Concurrent futures interleave at every `.await`, which is exactly
801        // where a SELECT-then-DELETE would let two callers both see the row.
802        let (a, b, c, d) = tokio::join!(
803            take_pending(&pool, &codec, "race", NOW),
804            take_pending(&pool, &codec, "race", NOW),
805            take_pending(&pool, &codec, "race", NOW),
806            take_pending(&pool, &codec, "race", NOW),
807        );
808        let winners = [a?, b?, c?, d?].iter().filter(|r| r.is_some()).count();
809        assert_eq!(winners, 1, "more than one caller consumed the same state");
810        Ok(())
811    }
812
813    /// An expired row is not returned — and is consumed anyway, so it cannot be
814    /// probed for existence afterwards.
815    #[tokio::test]
816    async fn an_expired_pending_login_is_rejected_and_removed() -> anyhow::Result<()> {
817        let (pool, codec) = db().await;
818        put_pending(&pool, &codec, &pending("stale")).await?;
819
820        let after_expiry = NOW + 601;
821        assert!(take_pending(&pool, &codec, "stale", after_expiry)
822            .await?
823            .is_none());
824        // Gone even at a time when it would have been valid.
825        assert!(take_pending(&pool, &codec, "stale", NOW).await?.is_none());
826        Ok(())
827    }
828
829    #[tokio::test]
830    async fn an_unknown_state_is_simply_absent() -> anyhow::Result<()> {
831        let (pool, codec) = db().await;
832        assert!(take_pending(&pool, &codec, "never-existed", NOW)
833            .await?
834            .is_none());
835        Ok(())
836    }
837
838    // ── AAD binding, end to end ──────────────────────────────────────────────
839
840    /// The secrets must not be readable from the database itself.
841    #[tokio::test]
842    async fn secret_columns_are_stored_encrypted() -> anyhow::Result<()> {
843        let (pool, codec) = db().await;
844        put_pending(&pool, &codec, &pending("state-1")).await?;
845
846        let (verifier, jwk): (String, String) =
847            sqlx::query_as("SELECT pkce_verifier, dpop_key_jwk FROM oauth_state WHERE state = ?")
848                .bind("state-1")
849                .fetch_one(&pool)
850                .await?;
851        for stored in [&verifier, &jwk] {
852            assert!(stored.starts_with("enc.v2.gcm."), "not bound: {stored}");
853        }
854        assert!(!verifier.contains("verifier-secret"));
855        assert!(!jwk.contains("secret"));
856        Ok(())
857    }
858
859    /// **The reason AAD was pulled forward.** Anything able to write the
860    /// database must not be able to graft one login flow's DPoP key onto
861    /// another flow's state row.
862    #[tokio::test]
863    async fn a_secret_moved_between_rows_does_not_decrypt() -> anyhow::Result<()> {
864        let (pool, codec) = db().await;
865        put_pending(&pool, &codec, &pending("victim")).await?;
866        let mut attacker = pending("attacker");
867        attacker.dpop_key_jwk = r#"{"kty":"EC","d":"attacker-key"}"#.into();
868        put_pending(&pool, &codec, &attacker).await?;
869
870        // Lift the attacker's sealed DPoP key into the victim's row.
871        let stolen: String =
872            sqlx::query_scalar("SELECT dpop_key_jwk FROM oauth_state WHERE state = ?")
873                .bind("attacker")
874                .fetch_one(&pool)
875                .await?;
876        sqlx::query("UPDATE oauth_state SET dpop_key_jwk = ? WHERE state = ?")
877            .bind(&stolen)
878            .bind("victim")
879            .execute(&pool)
880            .await?;
881
882        assert!(
883            take_pending(&pool, &codec, "victim", NOW).await.is_err(),
884            "a grafted ciphertext decrypted in the wrong row"
885        );
886        Ok(())
887    }
888
889    /// And between COLUMNS of the same row — the binding names the column too.
890    #[tokio::test]
891    async fn a_secret_moved_between_columns_does_not_decrypt() -> anyhow::Result<()> {
892        let (pool, codec) = db().await;
893        put_pending(&pool, &codec, &pending("state-1")).await?;
894
895        let verifier: String =
896            sqlx::query_scalar("SELECT pkce_verifier FROM oauth_state WHERE state = ?")
897                .bind("state-1")
898                .fetch_one(&pool)
899                .await?;
900        sqlx::query("UPDATE oauth_state SET dpop_key_jwk = ? WHERE state = ?")
901            .bind(&verifier)
902            .bind("state-1")
903            .execute(&pool)
904            .await?;
905
906        assert!(take_pending(&pool, &codec, "state-1", NOW).await.is_err());
907        Ok(())
908    }
909
910    /// **A session secret moved between columns of the same row does not
911    /// decrypt** — the `oauth_state` twin above, which never got written for
912    /// `oauth_session`. `an_unbound_session_ciphertext_is_refused` says in its
913    /// own doc that the column is part of the binding; nothing checked it, so
914    /// dropping `column` from `session_aad` left the suite green. Against the
915    /// declared adversary — anything that can write the database — that is
916    /// `access_token` ↔ `refresh_token` swapped inside one row with both
917    /// still authenticating, and `get_session` handing the refresh token to
918    /// the PDS as an access token.
919    #[tokio::test]
920    async fn a_session_secret_moved_between_columns_does_not_decrypt() -> anyhow::Result<()> {
921        let (pool, codec) = db().await;
922        put_session(&pool, &codec, &session()).await?;
923        let access: String =
924            sqlx::query_scalar("SELECT access_token FROM oauth_session WHERE sub = ?")
925                .bind(DID)
926                .fetch_one(&pool)
927                .await?;
928        sqlx::query("UPDATE oauth_session SET refresh_token = ? WHERE sub = ?")
929            .bind(&access)
930            .bind(DID)
931            .execute(&pool)
932            .await?;
933        assert!(
934            get_session(&pool, &codec, DID).await.is_err(),
935            "the access token's ciphertext was accepted in the refresh_token column"
936        );
937        Ok(())
938    }
939
940    /// **An absent expiry and a zero expiry are different sessions.** The AAD
941    /// comment says `None` and `0` must not collide; the only tamper test
942    /// stored `Some(NOW + 3600)` and flipped it to NULL, which differs under
943    /// either encoding — so `unwrap_or(0)` in place of the `"none"` marker
944    /// left the suite green. Flipping between NULL and 0 with every token
945    /// still decrypting pins `is_stale` permanently one way or the other.
946    #[tokio::test]
947    async fn an_absent_expiry_and_a_zero_expiry_are_different_sessions() -> anyhow::Result<()> {
948        for (stored, flipped_to) in [(None, "0"), (Some(0), "NULL")] {
949            let (pool, codec) = db().await;
950            put_session(
951                &pool,
952                &codec,
953                &OAuthSession {
954                    expires_at: stored,
955                    ..session()
956                },
957            )
958            .await?;
959            // Two literals, chosen by the loop — not a bound parameter, because
960            // binding `None` would write NULL through the same path the code
961            // under test uses, and the point is a raw flip.
962            sqlx::query(sqlx::AssertSqlSafe(format!(
963                "UPDATE oauth_session SET expires_at = {flipped_to} WHERE sub = ?"
964            )))
965            .bind(DID)
966            .execute(&pool)
967            .await?;
968            assert!(
969                get_session(&pool, &codec, DID).await.is_err(),
970                "expires_at {stored:?} → {flipped_to} still decrypted"
971            );
972        }
973        Ok(())
974    }
975
976    /// **Binding the secrets is not enough: the DESTINATIONS must be bound too.**
977    ///
978    /// The declared adversary is anything able to write the database. Against
979    /// that adversary, leaving `issuer`/`pds_url`/`did`/`redirect_uri` as plain
980    /// unauthenticated columns defeats the whole scheme without touching a
981    /// ciphertext — repoint the issuer and we mint a client assertion for the
982    /// attacker's server and neutralise the RFC 9207 `iss` check, while every
983    /// secret still decrypts perfectly.
984    #[tokio::test]
985    async fn tampering_with_a_pending_logins_destinations_breaks_it() -> anyhow::Result<()> {
986        for column in [
987            "issuer",
988            "pds_url",
989            "did",
990            "redirect_uri",
991            "browser_binding_hash",
992            "auth_method",
993            // Added after a cold review found each of these tamperable while
994            // every ciphertext still verified:
995            //
996            // `auth_kid` selects the signing key and is never re-verified;
997            // `requested_scope` and `request_uri` describe the grant being
998            // completed; `app_return_to` is a declared post-login redirect
999            // target, so unbound it becomes an open redirect the day it is
1000            // wired up — binding it now costs nothing.
1001            "auth_kid",
1002            "requested_scope",
1003            "request_uri",
1004            "app_return_to",
1005        ] {
1006            let (pool, codec) = db().await;
1007            put_pending(&pool, &codec, &pending("state-1")).await?;
1008            sqlx::query(leak(format!(
1009                "UPDATE oauth_state SET {column} = ? WHERE state = ?"
1010            )))
1011            .bind("https://evil.example")
1012            .bind("state-1")
1013            .execute(&pool)
1014            .await?;
1015            assert!(
1016                take_pending(&pool, &codec, "state-1", NOW).await.is_err(),
1017                "tampering with `{column}` went undetected"
1018            );
1019        }
1020        Ok(())
1021    }
1022
1023    /// The session's `aud` IS the PDS every later request is sent to, so
1024    /// repointing it would ship a live DPoP-bound access token to the attacker's
1025    /// host. It must break the tokens, not travel alongside them.
1026    #[tokio::test]
1027    async fn tampering_with_a_sessions_destinations_breaks_it() -> anyhow::Result<()> {
1028        for column in [
1029            "aud",
1030            "issuer",
1031            // `token_type` is refused outright on the wire if it is not `DPoP`,
1032            // but the stored copy was neither authenticated nor re-checked.
1033            "token_type",
1034            "granted_scope",
1035        ] {
1036            let (pool, codec) = db().await;
1037            put_session(&pool, &codec, &session()).await?;
1038            sqlx::query(leak(format!(
1039                "UPDATE oauth_session SET {column} = ? WHERE sub = ?"
1040            )))
1041            .bind("https://evil.example")
1042            .bind(DID)
1043            .execute(&pool)
1044            .await?;
1045            assert!(
1046                get_session(&pool, &codec, DID).await.is_err(),
1047                "tampering with `{column}` went undetected"
1048            );
1049        }
1050        Ok(())
1051    }
1052
1053    /// Stale nonces are swept; fresh ones are not.
1054    ///
1055    /// Its sibling `sweep_expired_pending` has a test and this had none —
1056    /// replacing the whole body with `Ok(0)` passed. The origins come from
1057    /// whatever handle a visitor types into the login form and are written
1058    /// during PAR, before any authentication, so the table is a pre-auth write
1059    /// primitive against the volume.
1060    #[tokio::test]
1061    async fn stale_nonces_are_swept_and_fresh_ones_kept() -> anyhow::Result<()> {
1062        let (pool, _codec) = db().await;
1063        put_nonce(&pool, "https://old.example", "n1", NOW - 10_000).await?;
1064        put_nonce(&pool, "https://new.example", "n2", NOW).await?;
1065
1066        assert_eq!(sweep_stale_nonces(&pool, NOW - 5_000).await?, 1);
1067        assert_eq!(get_nonce(&pool, "https://old.example").await?, None);
1068        assert_eq!(
1069            get_nonce(&pool, "https://new.example").await?.as_deref(),
1070            Some("n2"),
1071            "a nonce still in use was swept"
1072        );
1073        Ok(())
1074    }
1075
1076    /// **An absent optional column and an empty one must not encode alike.**
1077    ///
1078    /// The tamper test above only ever writes a NON-EMPTY value, so it passed
1079    /// while `NULL` and `''` produced byte-identical AAD and either could be
1080    /// flipped to the other undetected. This covers both directions for both
1081    /// optional columns, which is the case that test could not see.
1082    #[tokio::test]
1083    async fn swapping_an_absent_optional_column_for_an_empty_one_breaks_it() -> anyhow::Result<()> {
1084        for (column, set_to_empty) in [
1085            ("auth_kid", true),
1086            ("auth_kid", false),
1087            ("app_return_to", true),
1088            ("app_return_to", false),
1089        ] {
1090            let (pool, codec) = db().await;
1091            let mut auth = pending("state-1");
1092            // Start from whichever state we are NOT flipping to.
1093            if set_to_empty {
1094                // Stored absent; an adversary makes it empty.
1095                if column == "auth_kid" {
1096                    auth.auth_kid = None;
1097                } else {
1098                    auth.app_return_to = None;
1099                }
1100            } else {
1101                // Stored empty; an adversary makes it absent.
1102                if column == "auth_kid" {
1103                    auth.auth_kid = Some(String::new());
1104                } else {
1105                    auth.app_return_to = Some(String::new());
1106                }
1107            }
1108            put_pending(&pool, &codec, &auth).await?;
1109
1110            let sql = leak(format!(
1111                "UPDATE oauth_state SET {column} = ? WHERE state = ?"
1112            ));
1113            let query = if set_to_empty {
1114                sqlx::query(sql).bind(Some(String::new()))
1115            } else {
1116                sqlx::query(sql).bind(Option::<String>::None)
1117            };
1118            query.bind("state-1").execute(&pool).await?;
1119
1120            assert!(
1121                take_pending(&pool, &codec, "state-1", NOW).await.is_err(),
1122                "`{column}`: {} went undetected",
1123                if set_to_empty {
1124                    "NULL -> ''"
1125                } else {
1126                    "'' -> NULL"
1127                }
1128            );
1129        }
1130        Ok(())
1131    }
1132
1133    /// **A row's own lifetime is a destination.**
1134    ///
1135    /// Both the expiry check in `take_pending` and `sweep_expired_pending`
1136    /// filter on `expires_at`. While it was outside the AAD, anyone who could
1137    /// write the database could push it a year out and keep a pending login —
1138    /// with its sealed DPoP key and PKCE verifier — alive indefinitely, which is
1139    /// exactly what the ten-minute cap exists to prevent. Every ciphertext still
1140    /// verified.
1141    #[tokio::test]
1142    async fn extending_a_pending_logins_expiry_breaks_it() -> anyhow::Result<()> {
1143        let (pool, codec) = db().await;
1144        put_pending(&pool, &codec, &pending("state-1")).await?;
1145        sqlx::query("UPDATE oauth_state SET expires_at = ? WHERE state = ?")
1146            .bind(NOW + 31_536_000)
1147            .bind("state-1")
1148            .execute(&pool)
1149            .await?;
1150        assert!(
1151            take_pending(&pool, &codec, "state-1", NOW).await.is_err(),
1152            "the expiry was extended without breaking the row"
1153        );
1154        Ok(())
1155    }
1156
1157    /// Clearing a session's expiry made `is_stale` permanently false, so the
1158    /// session was never proactively refreshed — behaviour steered by an
1159    /// unauthenticated column while every token decrypted cleanly.
1160    #[tokio::test]
1161    async fn clearing_a_sessions_expiry_breaks_it() -> anyhow::Result<()> {
1162        let (pool, codec) = db().await;
1163        put_session(&pool, &codec, &session()).await?;
1164        sqlx::query("UPDATE oauth_session SET expires_at = NULL WHERE sub = ?")
1165            .bind(DID)
1166            .execute(&pool)
1167            .await?;
1168        assert!(
1169            get_session(&pool, &codec, DID).await.is_err(),
1170            "the expiry was cleared without breaking the row"
1171        );
1172        Ok(())
1173    }
1174
1175    /// The AAD is length-prefixed, so no rearrangement of field boundaries can
1176    /// produce the same bytes. A delimiter-joined encoding is only safe while no
1177    /// field can contain the delimiter — and `did:web:…` subjects and URL
1178    /// issuers are exactly the inputs that erode that assumption.
1179    #[test]
1180    fn the_aad_encoding_is_unambiguous_across_field_boundaries() {
1181        assert_ne!(
1182            structured_aad("t", &["ab", "c"]),
1183            structured_aad("t", &["a", "bc"])
1184        );
1185        assert_ne!(
1186            structured_aad("t", &["a:b"]),
1187            structured_aad("t", &["a", "b"])
1188        );
1189        assert_ne!(
1190            structured_aad("t", &["a", ""]),
1191            structured_aad("t", &["", "a"])
1192        );
1193        assert_ne!(structured_aad("t1", &["a"]), structured_aad("t2", &["a"]));
1194    }
1195
1196    /// Both nullable columns must survive the round trip as `None`.
1197    #[tokio::test]
1198    async fn a_pending_login_round_trips_with_its_optional_fields_absent() -> anyhow::Result<()> {
1199        let (pool, codec) = db().await;
1200        let mut want = pending("state-1");
1201        want.auth_kid = None;
1202        want.app_return_to = None;
1203        put_pending(&pool, &codec, &want).await?;
1204        assert_eq!(
1205            take_pending(&pool, &codec, "state-1", NOW).await?.unwrap(),
1206            want
1207        );
1208        Ok(())
1209    }
1210
1211    /// A row is expired AT `expires_at`, not one second later. The earlier test
1212    /// probed `expires_at + 1`, which cannot tell `<` from `<=`.
1213    #[tokio::test]
1214    async fn a_pending_login_is_expired_at_exactly_its_expiry() -> anyhow::Result<()> {
1215        let (pool, codec) = db().await;
1216        put_pending(&pool, &codec, &pending("edge")).await?;
1217        assert!(take_pending(&pool, &codec, "edge", NOW + 600)
1218            .await?
1219            .is_none());
1220
1221        let (pool, codec) = db().await;
1222        put_pending(&pool, &codec, &pending("edge")).await?;
1223        assert!(take_pending(&pool, &codec, "edge", NOW + 599)
1224            .await?
1225            .is_some());
1226        Ok(())
1227    }
1228
1229    /// An abandoned login — the user closes the tab after being redirected —
1230    /// leaves a row holding a sealed DPoP key. Without a sweep those accumulate
1231    /// forever, and on a publicly reachable login form that is an unbounded
1232    /// write primitive against the volume.
1233    #[tokio::test]
1234    async fn expired_pending_logins_are_swept() -> anyhow::Result<()> {
1235        let (pool, codec) = db().await;
1236        put_pending(&pool, &codec, &pending("old")).await?;
1237        let mut fresh = pending("fresh");
1238        fresh.expires_at = NOW + 3600;
1239        put_pending(&pool, &codec, &fresh).await?;
1240
1241        assert_eq!(sweep_expired_pending(&pool, NOW + 700).await?, 1);
1242        assert!(take_pending(&pool, &codec, "old", NOW).await?.is_none());
1243        assert!(take_pending(&pool, &codec, "fresh", NOW).await?.is_some());
1244        Ok(())
1245    }
1246
1247    /// An UNBOUND `enc.v1` value must be refused where a bound one is expected,
1248    /// or the binding is opt-out for anyone who can write the row.
1249    #[tokio::test]
1250    async fn an_unbound_ciphertext_is_refused() -> anyhow::Result<()> {
1251        let (pool, codec) = db().await;
1252        put_pending(&pool, &codec, &pending("state-1")).await?;
1253
1254        sqlx::query("UPDATE oauth_state SET pkce_verifier = ? WHERE state = ?")
1255            .bind(codec.encrypt("verifier-secret"))
1256            .bind("state-1")
1257            .execute(&pool)
1258            .await?;
1259
1260        assert!(take_pending(&pool, &codec, "state-1", NOW).await.is_err());
1261        Ok(())
1262    }
1263
1264    /// The same downgrade check for sessions, which hold the LONG-LIVED tokens.
1265    /// Covering only `oauth_state` would leave an implementation that used
1266    /// `decrypt` instead of `decrypt_bound` here passing the whole suite.
1267    #[tokio::test]
1268    async fn an_unbound_session_ciphertext_is_refused() -> anyhow::Result<()> {
1269        for column in ["access_token", "refresh_token", "dpop_key_jwk"] {
1270            let (pool, codec) = db().await;
1271            put_session(&pool, &codec, &session()).await?;
1272            sqlx::query(leak(format!(
1273                "UPDATE oauth_session SET {column} = ? WHERE sub = ?"
1274            )))
1275            .bind(codec.encrypt("some-value"))
1276            .bind(DID)
1277            .execute(&pool)
1278            .await?;
1279            assert!(
1280                get_session(&pool, &codec, DID).await.is_err(),
1281                "an unbound value was accepted in `{column}`"
1282            );
1283        }
1284        Ok(())
1285    }
1286
1287    // ── sessions ─────────────────────────────────────────────────────────────
1288
1289    fn session() -> OAuthSession {
1290        OAuthSession {
1291            sub: DID.into(),
1292            issuer: "https://auth.example.com".into(),
1293            aud: "https://pds.example.com".into(),
1294            dpop_key_jwk: r#"{"kty":"EC","d":"session-key"}"#.into(),
1295            access_token: "access-abc".into(),
1296            refresh_token: "refresh-xyz".into(),
1297            token_type: "DPoP".into(),
1298            granted_scope: "atproto transition:generic".into(),
1299            expires_at: Some(NOW + 3600),
1300        }
1301    }
1302
1303    #[tokio::test]
1304    async fn a_session_round_trips() -> anyhow::Result<()> {
1305        let (pool, codec) = db().await;
1306        put_session(&pool, &codec, &session()).await?;
1307        assert_eq!(get_session(&pool, &codec, DID).await?.unwrap(), session());
1308        Ok(())
1309    }
1310
1311    /// Logging in again must REPLACE the session, not fail on the primary key.
1312    /// A plain INSERT here is the bug that 500s every second login.
1313    #[tokio::test]
1314    async fn re_login_replaces_the_existing_session() -> anyhow::Result<()> {
1315        let (pool, codec) = db().await;
1316        put_session(&pool, &codec, &session()).await?;
1317
1318        let mut second = session();
1319        second.access_token = "access-second".into();
1320        second.refresh_token = "refresh-second".into();
1321        put_session(&pool, &codec, &second).await?;
1322
1323        let got = get_session(&pool, &codec, DID).await?.unwrap();
1324        assert_eq!(got.access_token, "access-second");
1325        assert_eq!(got.refresh_token, "refresh-second");
1326        Ok(())
1327    }
1328
1329    /// `expires_in` is optional in a token response, so the column is nullable
1330    /// and a session without one must survive the round trip.
1331    #[tokio::test]
1332    async fn a_session_without_an_expiry_round_trips() -> anyhow::Result<()> {
1333        let (pool, codec) = db().await;
1334        let mut s = session();
1335        s.expires_at = None;
1336        put_session(&pool, &codec, &s).await?;
1337        assert_eq!(
1338            get_session(&pool, &codec, DID).await?.unwrap().expires_at,
1339            None
1340        );
1341        Ok(())
1342    }
1343
1344    #[tokio::test]
1345    async fn session_tokens_are_bound_to_their_subject() -> anyhow::Result<()> {
1346        let (pool, codec) = db().await;
1347        put_session(&pool, &codec, &session()).await?;
1348
1349        let other = OAuthSession {
1350            sub: "did:plc:aaaaaaaaaaaaaaaaaaaaaaaa".into(),
1351            access_token: "access-other".into(),
1352            ..session()
1353        };
1354        put_session(&pool, &codec, &other).await?;
1355
1356        let stolen: String =
1357            sqlx::query_scalar("SELECT access_token FROM oauth_session WHERE sub = ?")
1358                .bind(&other.sub)
1359                .fetch_one(&pool)
1360                .await?;
1361        sqlx::query("UPDATE oauth_session SET access_token = ? WHERE sub = ?")
1362            .bind(&stolen)
1363            .bind(DID)
1364            .execute(&pool)
1365            .await?;
1366
1367        assert!(get_session(&pool, &codec, DID).await.is_err());
1368        Ok(())
1369    }
1370
1371    /// **The conditional delete removes only the version that was read.** A
1372    /// refresh that rotated the row since — even one storing identical
1373    /// plaintext, since every write re-encrypts — leaves it in place.
1374    #[tokio::test]
1375    async fn a_rewritten_session_is_not_deleted_by_a_stale_version() -> anyhow::Result<()> {
1376        let (pool, codec) = db().await;
1377        put_session(&pool, &codec, &session()).await?;
1378        let (_, stale) = get_session_versioned(&pool, &codec, DID).await?.unwrap();
1379
1380        let rotated = OAuthSession {
1381            refresh_token: "refresh-rotated".into(),
1382            ..session()
1383        };
1384        put_session(&pool, &codec, &rotated).await?;
1385        assert!(
1386            !delete_session_if_unchanged(&pool, DID, &stale).await?,
1387            "reported deleting a row it should have left"
1388        );
1389        assert_eq!(
1390            get_session(&pool, &codec, DID)
1391                .await?
1392                .unwrap()
1393                .refresh_token,
1394            "refresh-rotated",
1395            "the ROTATED token was deleted on the strength of a stale read"
1396        );
1397
1398        // Identical plaintext, rewritten: still a different version.
1399        let (_, before) = get_session_versioned(&pool, &codec, DID).await?.unwrap();
1400        put_session(&pool, &codec, &rotated).await?;
1401        assert!(!delete_session_if_unchanged(&pool, DID, &before).await?);
1402
1403        // The current version deletes.
1404        let (_, current) = get_session_versioned(&pool, &codec, DID).await?.unwrap();
1405        assert!(delete_session_if_unchanged(&pool, DID, &current).await?);
1406        assert!(get_session(&pool, &codec, DID).await?.is_none());
1407        assert!(!delete_session_if_unchanged(&pool, DID, &current).await?);
1408        Ok(())
1409    }
1410
1411    #[tokio::test]
1412    async fn a_deleted_session_is_gone() -> anyhow::Result<()> {
1413        let (pool, codec) = db().await;
1414        put_session(&pool, &codec, &session()).await?;
1415        assert!(delete_session(&pool, DID).await?);
1416        assert!(get_session(&pool, &codec, DID).await?.is_none());
1417        assert!(!delete_session(&pool, DID).await?);
1418        Ok(())
1419    }
1420
1421    /// **Every stored session is listed — including one that no longer
1422    /// decrypts.** The operator revoke-all walks this list; an unreadable row
1423    /// left off it would survive the revoke pass and be dropped by the wipe
1424    /// without the sign-out it is owed.
1425    #[tokio::test]
1426    async fn every_session_subject_is_listed_including_an_unreadable_one() -> anyhow::Result<()> {
1427        let (pool, codec) = db().await;
1428        assert!(list_session_subs(&pool).await?.is_empty());
1429
1430        put_session(&pool, &codec, &session()).await?;
1431        let other = OAuthSession {
1432            sub: "did:plc:bbbbbbbbbbbbbbbbbbbbbbbb".into(),
1433            ..session()
1434        };
1435        put_session(&pool, &codec, &other).await?;
1436        // A row whose ciphertext cannot be decrypted at all.
1437        sqlx::query(
1438            "INSERT INTO oauth_session (sub, issuer, aud, dpop_key_jwk, access_token, \
1439             refresh_token, token_type, granted_scope, expires_at) \
1440             VALUES (?, 'https://auth.example.com', 'https://pds.example.com', \
1441             'garbage', 'garbage', 'garbage', 'DPoP', 'atproto', NULL)",
1442        )
1443        .bind("did:plc:cccccccccccccccccccccccc")
1444        .execute(&pool)
1445        .await?;
1446        assert!(
1447            get_session(&pool, &codec, "did:plc:cccccccccccccccccccccccc")
1448                .await
1449                .is_err(),
1450            "precondition: the raw row must be unreadable"
1451        );
1452
1453        assert_eq!(
1454            list_session_subs(&pool).await?,
1455            vec![
1456                "did:plc:bbbbbbbbbbbbbbbbbbbbbbbb".to_string(),
1457                "did:plc:cccccccccccccccccccccccc".to_string(),
1458                DID.to_string(),
1459            ]
1460        );
1461        Ok(())
1462    }
1463
1464    // ── DPoP nonces ──────────────────────────────────────────────────────────
1465
1466    /// Nonces are per-ORIGIN and persist between requests: using one only for an
1467    /// immediate retry means every request pays a wasted round trip.
1468    #[tokio::test]
1469    async fn nonces_are_stored_and_replaced_per_origin() -> anyhow::Result<()> {
1470        let (pool, _) = db().await;
1471        assert_eq!(get_nonce(&pool, "https://a.example").await?, None);
1472
1473        put_nonce(&pool, "https://a.example", "n1", NOW).await?;
1474        put_nonce(&pool, "https://b.example", "n2", NOW).await?;
1475        assert_eq!(
1476            get_nonce(&pool, "https://a.example").await?.as_deref(),
1477            Some("n1")
1478        );
1479        assert_eq!(
1480            get_nonce(&pool, "https://b.example").await?.as_deref(),
1481            Some("n2")
1482        );
1483
1484        // Rotation: servers rotate nonces, so a later value replaces the earlier.
1485        put_nonce(&pool, "https://a.example", "n3", NOW).await?;
1486        assert_eq!(
1487            get_nonce(&pool, "https://a.example").await?.as_deref(),
1488            Some("n3")
1489        );
1490        Ok(())
1491    }
1492}