Skip to main content

feather_reader/oauth/
login.rs

1//! The browser-facing halves of the OAuth flow: starting a login and finishing
2//! one.
3//!
4//! This is the part of the cutover that **inverts** rather than swapping. Under
5//! the sidecar the app redirects to *its* `/login`, the sidecar owns the
6//! callback, and the app receives a one-shot `session_id` to exchange for an
7//! identity. Here the app owns both ends: it performs PAR itself, and the PDS
8//! redirects the browser straight back to the app with a `code`.
9//!
10//! The logic lives here rather than in `web.rs` so it can be tested without an
11//! HTTP server, and so the handler stays a thin shell that does cookies and
12//! redirects.
13
14use anyhow::{bail, Context as _, Result};
15
16use super::runtime::OauthRuntime;
17use super::{client_auth, discovery, dpop, flow, keys, request, store, token};
18
19/// Longest a pending login may sit before it is swept.
20///
21/// Ten minutes, or the PAR request_uri's own lifetime if shorter. The row holds
22/// a sealed DPoP key and a PKCE verifier; keeping it past the point the
23/// `request_uri` is usable protects nothing and widens the window in which a
24/// stolen `state` is worth replaying.
25const MAX_PENDING_SECS: i64 = 600;
26
27/// A login that has been pushed to the authorization server and is waiting for
28/// the user.
29pub struct StartedLogin {
30    /// Where to send the browser.
31    pub authorize_url: String,
32    /// The browser-binding token. Set as a cookie; the callback must present it
33    /// or the flow is refused. This is what stops a login CSRF: `state` alone
34    /// lives in a server-global table and says nothing about *which* browser
35    /// started the flow.
36    pub binding_token: String,
37}
38
39/// Begin a login for whatever the user typed.
40///
41/// The identity is resolved **before** anything is pushed to an authorization
42/// server, because the PDS we discover from is the one the resolved DID names.
43/// Starting from a handle and trusting a server to tell us whose it is would be
44/// the whole attack.
45pub async fn start(
46    runtime: &OauthRuntime,
47    http: &reqwest::Client,
48    pool: &sqlx::SqlitePool,
49    subject: &str,
50    now: i64,
51) -> Result<StartedLogin> {
52    start_with(
53        runtime,
54        pool,
55        subject,
56        now,
57        |subject| async move {
58            super::resolve::resolve(&runtime.resolver, http, &subject, &runtime.plc_directory).await
59        },
60        |pds_url, auth_method, expected_issuer| async move {
61            discovery::discover(http, &pds_url, auth_method, expected_issuer.as_deref()).await
62        },
63        |req: ParPost| async move {
64            post_form(http, pool, &req.url, &req.key, &req.params, req.retry).await
65        },
66    )
67    .await
68}
69
70/// What [`start_with`] hands its PAR transport: a fully-formed request.
71///
72/// Mirrors [`TokenPost`] deliberately, and for the same reason it exists — a
73/// transport that receives ingredients instead of a request lets the decision
74/// be wrong where no test can see it.
75struct ParPost {
76    url: String,
77    params: Vec<(&'static str, String)>,
78    /// The ACTUAL key the push is signed under, so a test can compare its
79    /// thumbprint against the one the pending row stores. Passing a JWK string
80    /// and re-parsing it inside the adapter would let a freshly generated key
81    /// be accepted — the authorization server binds `request_uri` to this
82    /// key's thumbprint, so a different one stored means every login fails at
83    /// the token endpoint with a binding error nothing explains.
84    key: std::sync::Arc<keys::SigningKey>,
85    retry: request::Retry,
86}
87
88/// [`start`] with its three network boundaries injected.
89///
90/// **Nothing drove `start` at all.** Every guard in `complete` had a test and
91/// the whole callback got one; this half had neither, because it needs handle
92/// resolution, discovery and a PAR endpoint, all over the network. Five
93/// decisions made here were therefore unfalsifiable, and each is a silent
94/// production failure rather than a loud one:
95///
96/// - `client_id` — PAR pushed under a foreign client identity
97/// - `code_challenge` — derived from a different verifier than the one stored,
98///   so PKCE fails at the exchange
99/// - the assertion's audience — RFC 7523 requires the issuer, not the PDS
100/// - `browser_binding_hash` — hashing a token other than the one returned
101///   defeats the login-CSRF binding
102/// - `dpop_key_jwk` — storing a key other than the one PAR was signed under
103///
104/// **Every decision lives here, not in the caller's closures**, which is the
105/// lesson a review of `complete_with`'s first cut paid for: arguments assembled
106/// inside an adapter are invisible to a test, so they can be wrong with the
107/// suite green. The transports receive fully-formed values.
108#[allow(clippy::too_many_arguments)]
109async fn start_with<R, RFut, D, DFut, P, PFut>(
110    runtime: &OauthRuntime,
111    pool: &sqlx::SqlitePool,
112    subject: &str,
113    now: i64,
114    resolve: R,
115    discover: D,
116    push: P,
117) -> Result<StartedLogin>
118where
119    R: FnOnce(String) -> RFut,
120    RFut: std::future::Future<Output = Result<super::resolve::ResolvedAccount>>,
121    D: FnOnce(String, &'static str, Option<String>) -> DFut,
122    DFut: std::future::Future<Output = Result<discovery::AuthorizationServer>>,
123    P: FnOnce(ParPost) -> PFut,
124    PFut: std::future::Future<Output = Result<request::PostOutcome>>,
125{
126    let account = resolve(subject.to_string())
127        .await
128        .with_context(|| format!("resolving {subject:?}"))?;
129
130    // No prior issuer: this IS the login that establishes one. Passed
131    // explicitly so the `None` is visible to a test rather than buried in an
132    // adapter — the mix-up re-check on the callback side is armed by the
133    // mirror-image argument, and that one was wrong in review.
134    let server = discover(account.pds_url.clone(), runtime.auth_method.as_str(), None)
135        .await
136        .with_context(|| {
137            format!(
138                "discovering the authorization server for {}",
139                account.pds_url
140            )
141        })?;
142
143    // Generated BEFORE the push: the authorization server binds the request_uri
144    // to this key's thumbprint, and every later call on this grant must use the
145    // same key.
146    let session_key = keys::SigningKey::generate("session");
147    let verifier = flow::new_pkce_verifier();
148    let state = flow::new_state();
149    let binding_token = flow::new_binding_token();
150
151    let mut params = flow::par_params(&flow::ParRequest {
152        redirect_uri: &super::metadata::redirect_uri(&runtime.client),
153        scope: runtime.client.scope_str(),
154        state: &state,
155        code_challenge: &flow::pkce_challenge(&verifier),
156        login_hint: Some(subject),
157    });
158    let assertion = client_assertion(runtime, runtime.auth_method, &server.issuer, now)?;
159    params.extend(client_auth::credential_params(
160        runtime.auth_method,
161        &runtime.client_id,
162        assertion.as_deref(),
163    )?);
164
165    // `Arc` so the transport can be handed the real key rather than a recipe
166    // for one, while `put_pending` below still stores from the same value.
167    let session_key = std::sync::Arc::new(session_key);
168    let outcome = push(ParPost {
169        url: server.par_endpoint.clone(),
170        params,
171        key: std::sync::Arc::clone(&session_key),
172        // PAR is safe to repeat: a rejected attempt consumes nothing.
173        retry: request::Retry::Allowed,
174    })
175    .await?;
176    let par = accept_par_response(&outcome)?;
177
178    store::put_pending(
179        pool,
180        &runtime.codec,
181        &store::PendingAuth {
182            state: state.clone(),
183            browser_binding_hash: flow::binding_hash(&binding_token),
184            pkce_verifier: verifier,
185            dpop_key_jwk: session_key.to_jwk_json()?,
186            issuer: server.issuer.clone(),
187            pds_url: account.pds_url.clone(),
188            did: account.did.clone(),
189            auth_method: runtime.auth_method.as_str().to_string(),
190            auth_kid: runtime.client_key.as_ref().map(|k| k.kid().to_string()),
191            redirect_uri: super::metadata::redirect_uri(&runtime.client),
192            requested_scope: runtime.client.scope_str().to_string(),
193            request_uri: par.request_uri.clone(),
194            app_return_to: None,
195            expires_at: pending_expiry(now, par.expires_in),
196        },
197    )
198    .await?;
199
200    Ok(StartedLogin {
201        authorize_url: flow::authorize_url(
202            &server.authorization_endpoint,
203            &runtime.client_id,
204            &par.request_uri,
205        )?,
206        binding_token,
207    })
208}
209
210/// Who logged in.
211///
212/// `Debug` carries a DID and an optional handle — both already public identity,
213/// and neither a credential. No token, key or code is reachable from here.
214#[derive(Debug)]
215pub struct CompletedLogin {
216    pub did: String,
217    /// Only present when it round-tripped: a handle that cannot be verified
218    /// back to this DID is not displayed beside the account.
219    pub handle: Option<String>,
220}
221
222/// Finish a login from the callback's query parameters.
223///
224/// The session is stored before this returns, so by the time the caller mints a
225/// cookie the tokens are already durable. The reverse would leave a browser
226/// holding a session the server cannot serve.
227pub async fn complete(
228    runtime: &OauthRuntime,
229    http: &reqwest::Client,
230    pool: &sqlx::SqlitePool,
231    params: &flow::CallbackParams,
232    presented_cookie: Option<&str>,
233    now: i64,
234) -> Result<CompletedLogin> {
235    complete_with(
236        runtime,
237        pool,
238        params,
239        presented_cookie,
240        now,
241        // **These three closures must contain NO decisions.** A review found the
242        // first cut had put `Some(&expected_issuer)` and the DPoP key
243        // reconstruction in here — outside the seam the tests drive — so both
244        // could be broken with the whole suite green. Changing the issuer
245        // argument to `None` disabled the authorization-server mix-up defence
246        // for every real login and 702 tests still passed.
247        //
248        // Everything decided is now decided in `complete_with` and arrives
249        // fully formed; these are transports.
250        |req: Discovery| async move {
251            discovery::discover(
252                http,
253                &req.pds_url,
254                &req.auth_method,
255                req.expected_issuer.as_deref(),
256            )
257            .await
258        },
259        |req: TokenPost| async move {
260            post_form(http, pool, &req.url, &req.key, &req.params, req.retry).await
261        },
262        |did: String| async move {
263            super::resolve::resolve(&runtime.resolver, http, &did, &runtime.plc_directory).await
264        },
265    )
266    .await
267}
268
269/// What `complete_with` hands its discovery transport. Every field is already
270/// decided; the transport only performs the call.
271struct Discovery {
272    pds_url: String,
273    auth_method: String,
274    /// `Some(issuer)` arms the authorization-server mix-up re-check inside
275    /// `discovery::discover`. **The `Option` is constructed here, not in the
276    /// adapter**, so a test can see whether the defence is armed at all.
277    expected_issuer: Option<String>,
278}
279
280/// What `complete_with` hands its token transport.
281struct TokenPost {
282    url: String,
283    params: Vec<(&'static str, String)>,
284    /// The ACTUAL key, not a recipe for one. The first cut passed the JWK string
285    /// and let the adapter re-parse it, which meant a freshly generated key would
286    /// have been accepted — signing the grant under a thumbprint it was never
287    /// bound to — with a green suite. `Arc` rather than a borrow only because the
288    /// key is local to `complete_with`; the guarantee is the same.
289    key: std::sync::Arc<keys::SigningKey>,
290    retry: request::Retry,
291}
292
293/// [`complete`] with the three network boundaries injected.
294///
295/// **The SEQUENCING is the thing worth testing, and it was unreachable.** Each
296/// guard in this function has its own unit test, but nothing drove the whole
297/// callback: it needs discovery, a token endpoint and handle resolution, all
298/// over the network — and an issuer is required to be `https`, so even a local
299/// plain-HTTP server cannot stand in for an authorization server. Injecting the
300/// three calls is what makes the ORDER observable, which is where the mix-up
301/// defence actually lives: the value of the issuer re-check is entirely in it
302/// happening BEFORE the code, the PKCE verifier and a `private_key_jwt`
303/// assertion are posted anywhere.
304///
305/// **Every decision lives here, not in the caller's closures.** A review of the
306/// first cut found the opposite: the `Some(issuer)` that arms the mix-up
307/// re-check and the DPoP key the grant is bound to were both assembled inside
308/// the adapters, where no test could see them — so either could be broken with
309/// the entire suite green. The transports now receive fully-formed arguments.
310///
311/// Same shape as [`discovery::discover_with`], and for the same reason.
312#[allow(clippy::too_many_arguments)]
313async fn complete_with<D, DFut, P, PFut, R, RFut>(
314    runtime: &OauthRuntime,
315    pool: &sqlx::SqlitePool,
316    params: &flow::CallbackParams,
317    presented_cookie: Option<&str>,
318    now: i64,
319    discover: D,
320    post: P,
321    resolve_handle: R,
322) -> Result<CompletedLogin>
323where
324    D: Fn(Discovery) -> DFut,
325    DFut: std::future::Future<Output = Result<discovery::AuthorizationServer>>,
326    P: Fn(TokenPost) -> PFut,
327    PFut: std::future::Future<Output = Result<request::PostOutcome>>,
328    R: Fn(String) -> RFut,
329    RFut: std::future::Future<Output = Result<super::resolve::ResolvedAccount>>,
330{
331    // Consumes the pending row, checks the browser binding, and validates `iss`
332    // — all three, or no code comes back.
333    let (pending, code) =
334        flow::complete_callback(pool, &runtime.codec, params, presented_cookie, now).await?;
335
336    // **Unsealed HERE, before discovery and the exchange.** A DPoP key that will
337    // not parse must stop the login before the authorization code is sent
338    // anywhere, not after — the final outcome is a failed login either way, so
339    // only the absence of a POST distinguishes them.
340    //
341    // The key is then handed to the token transport by reference, so nothing
342    // downstream can substitute a different one: the grant is bound to this
343    // thumbprint and no other.
344    let key = std::sync::Arc::new(
345        keys::SigningKey::from_jwk_json(&pending.dpop_key_jwk, "session")
346            .context("unsealing the login's DPoP key")?,
347    );
348
349    // The method the login was STARTED under, not whatever is configured now.
350    // A deploy that flipped dev/production between the push and the callback
351    // would otherwise present credentials that do not match the ones PAR was
352    // authenticated with, and the exchange would fail for a reason nothing in
353    // the logs would explain.
354    let auth_method: client_auth::AuthMethod = pending
355        .auth_method
356        .parse()
357        .context("the pending login stored an unknown auth method")?;
358
359    // The client's IDENTITY must also be the one PAR was pushed under. Both
360    // `client_id` and `redirect_uri` derive from `public_url`, so the stored
361    // redirect is enough to detect a configuration change between the push and
362    // the callback — no second column needed, and no migration for a row that
363    // lives ten minutes.
364    //
365    // Without this the exchange fails at the authorization server with a
366    // mismatched client, and nothing on our side says why. Checked here so the
367    // reason is in the log rather than inferred from a 400.
368    let current_redirect = super::metadata::redirect_uri(&runtime.client);
369    if current_redirect != pending.redirect_uri {
370        bail!(
371            "this login was started under a different public URL (redirect {:?}, now {:?}); \
372             the client identity changed mid-flight and the exchange would be rejected",
373            pending.redirect_uri,
374            current_redirect
375        );
376    }
377
378    let server = discover(Discovery {
379        pds_url: pending.pds_url.clone(),
380        auth_method: auth_method.as_str().to_string(),
381        // `Some`, decided here: this is what arms the mix-up re-check.
382        expected_issuer: Some(pending.issuer.clone()),
383    })
384    .await?;
385
386    // **The re-discovered issuer must be the one PAR was pushed under.**
387    //
388    // Discovery runs again here, from the network, and the token endpoint comes
389    // out of THAT document. Every discovery check is internally consistent — a
390    // hostile pair of documents satisfies all of them — so nothing else notices
391    // if the PDS's protected-resource document was repointed at a different
392    // authorization server between the push and this callback.
393    //
394    // `verify_callback` does not catch it either: the callback legitimately
395    // carries the ORIGINAL issuer, because the real server is what the user
396    // approved at. Without this check the authorization code, the PKCE verifier
397    // and a `private_key_jwt` assertion all go to the new endpoint.
398    //
399    // `store.rs` names this exact threat as the reason `issuer` is AAD-bound.
400    // The AAD protects the column from local tampering; only this protects it
401    // from a network re-read.
402    let token_params = token_exchange_params(runtime, &pending, &code, auth_method, now)?;
403
404    let outcome = post(TokenPost {
405        url: server.token_endpoint.clone(),
406        params: token_params,
407        key: std::sync::Arc::clone(&key),
408        // A nonce challenge is rejected BEFORE the grant is processed, so the
409        // code is not consumed and the request is safe to resend. The nonce
410        // harvested at PAR is routinely stale by now — approval can take minutes
411        // and a server nonce lasts at most five.
412        retry: request::Retry::Allowed,
413    })
414    .await?;
415
416    let did = accept_token_response(pool, &runtime.codec, &pending, &outcome, now).await?;
417
418    // The handle is resolved from the DID rather than remembered from the login
419    // form: what the user typed is not evidence, and `resolve` returns `None`
420    // unless it round-trips. A failure here must not fail the login — the
421    // account is already authenticated, and the handle is a display detail.
422    let handle = match resolve_handle(did.clone()).await {
423        Ok(account) => account.handle,
424        Err(err) => {
425            tracing::warn!(%err, did = %did, "could not resolve a handle for the new session");
426            None
427        }
428    };
429
430    Ok(CompletedLogin { did, handle })
431}
432
433/// When a pending login must be swept, capped at [`MAX_PENDING_SECS`].
434///
435/// **The cap is ours, not the server's.** Without `.min(...)` the authorization
436/// server chooses how long a row holding a sealed DPoP key and a PKCE verifier
437/// survives, and a server answering with a large `expires_in` widens the window
438/// in which a stolen `state` is worth replaying. Dropping the `.min` passed the
439/// entire suite.
440fn pending_expiry(now: i64, par_expires_in: i64) -> i64 {
441    now + par_expires_in.min(MAX_PENDING_SECS)
442}
443
444/// Read a PAR response, refusing a non-2xx before parsing it.
445///
446/// **Split out so the status check is reachable.** Deleting it let a FAILED push
447/// fall through to `parse_par_response`, and a failure body that happened to
448/// carry a `request_uri` would then be used to build an authorize URL — sending
449/// the user to a grant the server never issued. Nothing tested it, because a
450/// test cannot reach the live endpoint that produces the response.
451fn accept_par_response(outcome: &request::PostOutcome) -> Result<flow::ParResponse> {
452    if !outcome.is_success() {
453        bail!(
454            "the pushed authorization request failed with status {}",
455            outcome.status
456        );
457    }
458    flow::parse_par_response(&outcome.json()?)
459}
460
461/// Assemble the token-endpoint form for this pending login.
462///
463/// **Split out of [`complete`] so it can be tested.** Every value here has to
464/// come from the PENDING ROW rather than from current configuration or a fresh
465/// computation: the code, the redirect and the PKCE verifier are all bound by
466/// the authorization server to the request that PAR pushed. Substituting any of
467/// them — which a refactor can do silently, since all three are plain strings —
468/// either breaks every login or, in the PKCE case, removes the proof that the
469/// party redeeming the code is the one that requested it.
470///
471/// A mutation replacing `pending.pkce_verifier` with a literal passed the entire
472/// suite, because nothing ever inspected the form this builds.
473fn token_exchange_params(
474    runtime: &OauthRuntime,
475    pending: &store::PendingAuth,
476    code: &str,
477    auth_method: client_auth::AuthMethod,
478    now: i64,
479) -> Result<Vec<(&'static str, String)>> {
480    let mut params =
481        token::token_request_params(code, &pending.redirect_uri, &pending.pkce_verifier);
482    let assertion = client_assertion(runtime, auth_method, &pending.issuer, now)?;
483    params.extend(client_auth::credential_params(
484        auth_method,
485        &runtime.client_id,
486        assertion.as_deref(),
487    )?);
488    Ok(params)
489}
490
491/// Validate a token-endpoint response and persist the session it grants.
492///
493/// **Split out of [`complete`] so these checks are reachable without a live
494/// authorization server**, which is why they had no tests: `complete` cannot
495/// reach a stub, because discovery requires `https` and the SSRF guard forbids
496/// loopback, so there is nowhere for a test to point it. Every guard below could
497/// be deleted with the whole suite green.
498///
499/// Returns the authenticated DID. Behaviour is identical to the inline version
500/// it replaces, including that the body is parsed only after the status check —
501/// a failed exchange must not have its body examined or echoed.
502async fn accept_token_response(
503    pool: &sqlx::SqlitePool,
504    codec: &super::crypto::Codec,
505    pending: &store::PendingAuth,
506    outcome: &request::PostOutcome,
507    now: i64,
508) -> Result<String> {
509    if !outcome.is_success() {
510        bail!("the token exchange failed with status {}", outcome.status);
511    }
512    let tokens = token::parse_token_response(&outcome.json()?)?;
513
514    // The token's subject must be the DID we resolved and pushed. A server that
515    // returns a different one has authorized a different account than the user
516    // asked for.
517    if tokens.sub != pending.did {
518        bail!(
519            "the authorization server returned tokens for a different subject than the \
520             login was started for"
521        );
522    }
523
524    store::put_session(
525        pool,
526        codec,
527        &store::OAuthSession {
528            sub: tokens.sub.clone(),
529            issuer: pending.issuer.clone(),
530            aud: pending.pds_url.clone(),
531            dpop_key_jwk: pending.dpop_key_jwk.clone(),
532            access_token: tokens.access_token,
533            refresh_token: tokens.refresh_token.unwrap_or_default(),
534            token_type: tokens.token_type,
535            granted_scope: tokens.granted_scope,
536            expires_at: tokens.expires_in.map(|secs| now + secs),
537        },
538    )
539    .await?;
540
541    Ok(tokens.sub)
542}
543
544/// Mint a client assertion when the negotiated method needs one.
545fn client_assertion(
546    runtime: &OauthRuntime,
547    method: client_auth::AuthMethod,
548    issuer: &str,
549    now: i64,
550) -> Result<Option<String>> {
551    match method {
552        client_auth::AuthMethod::None => Ok(None),
553        client_auth::AuthMethod::PrivateKeyJwt => {
554            let key = runtime
555                .client_key
556                .as_ref()
557                .context("private_key_jwt is negotiated but no client signing key is loaded")?;
558            Ok(Some(client_auth::client_assertion(
559                key,
560                &runtime.client_id,
561                issuer,
562                now,
563            )?))
564        }
565    }
566}
567
568/// POST a form to an authorization-server endpoint with a DPoP proof.
569async fn post_form(
570    http: &reqwest::Client,
571    pool: &sqlx::SqlitePool,
572    url: &str,
573    key: &keys::SigningKey,
574    params: &[(&'static str, String)],
575    retry: request::Retry,
576) -> Result<request::PostOutcome> {
577    let borrowed: Vec<(&str, &str)> = params.iter().map(|(k, v)| (*k, v.as_str())).collect();
578    request::send_with_dpop(
579        http,
580        pool,
581        &request::DpopRequest {
582            endpoint: dpop::Endpoint::AuthorizationServer,
583            url,
584            key,
585            access_token: None,
586            body: request::DpopBody::Form(&borrowed),
587            retry,
588        },
589    )
590    .await
591}
592
593#[cfg(test)]
594mod tests {
595    use super::*;
596
597    const TEST_KEY: &str = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa";
598    const PUSHED_REDIRECT: &str = "https://feather-reader.com/oauth/callback";
599    const PENDING_ISSUER: &str = "https://auth.example.com";
600
601    const PENDING_DID: &str = "did:plc:ewvi7nxzyoun6zhxrhs64oiz";
602
603    /// A well-formed token-endpoint response granting `sub`.
604    fn token_body(sub: &str) -> serde_json::Value {
605        serde_json::json!({
606            "access_token": "at-abc",
607            "token_type": "DPoP",
608            // NARROWED on purpose: the pending row REQUESTS
609            // "atproto transition:generic"; the server grants less. Identical
610            // values made an assertion here pass whichever field was stored —
611            // the same trap as deriving the token endpoint from the issuer.
612            "scope": "atproto",
613            "sub": sub,
614            "expires_in": 3600,
615            "refresh_token": "rt-abc",
616        })
617    }
618
619    fn outcome(status: u16, body: &serde_json::Value) -> request::PostOutcome {
620        request::PostOutcome {
621            status,
622            body: serde_json::to_vec(body).unwrap(),
623        }
624    }
625
626    async fn empty_pool() -> sqlx::SqlitePool {
627        let pool = crate::store::init_url("sqlite::memory:").await.unwrap();
628        crate::oauth::store::init_schema(&pool).await.unwrap();
629        pool
630    }
631
632    /// **The authorization server must not be able to log the user in as
633    /// somebody else.**
634    ///
635    /// `complete` pushes PAR for a DID it resolved itself, and the token response
636    /// carries a `sub`. If the two are allowed to differ, a hostile or buggy
637    /// server hands back a working session for an account the user never asked
638    /// for — and everything downstream (`put_session`, the cookie, every later
639    /// repo call) is keyed on that `sub`, so the whole app then acts as the wrong
640    /// identity.
641    ///
642    /// Deleting this check passed all 664 tests. It had no test because it sits
643    /// after a network round trip that a test cannot make: discovery requires
644    /// `https` and the SSRF guard forbids loopback, so there is nowhere to point
645    /// a stub. Splitting the response handling out of `complete` is what makes it
646    /// reachable — the guard is unchanged, it just no longer requires a live
647    /// authorization server to observe.
648    #[tokio::test]
649    async fn tokens_for_a_different_subject_are_refused() {
650        let pool = empty_pool().await;
651        let codec = crate::oauth::crypto::Codec::new(Some(TEST_KEY)).unwrap();
652        let pending = pending_auth("unused-hash");
653
654        let hostile = token_body("did:plc:zzzzzzzzzzzzzzzzzzzzzzzz");
655        let err = accept_token_response(
656            &pool,
657            &codec,
658            &pending,
659            &outcome(200, &hostile),
660            1_700_000_000,
661        )
662        .await
663        .expect_err("a token response for another DID must be refused");
664
665        assert!(
666            format!("{err:#}").contains("different subject"),
667            "refused, but not by the subject check: {err:#}",
668        );
669        assert!(
670            crate::oauth::store::get_session(&pool, &codec, "did:plc:zzzzzzzzzzzzzzzzzzzzzzzz")
671                .await
672                .unwrap()
673                .is_none(),
674            "no session may be stored for a subject the login did not start for",
675        );
676    }
677
678    /// The matching-subject case must still succeed and persist the session —
679    /// otherwise a check that refused every login would satisfy the test above.
680    #[tokio::test]
681    async fn tokens_for_the_pending_subject_are_accepted_and_stored() {
682        let pool = empty_pool().await;
683        let codec = crate::oauth::crypto::Codec::new(Some(TEST_KEY)).unwrap();
684        let pending = pending_auth("unused-hash");
685
686        let did = accept_token_response(
687            &pool,
688            &codec,
689            &pending,
690            &outcome(200, &token_body(PENDING_DID)),
691            1_700_000_000,
692        )
693        .await
694        .expect("a token response for the pending DID must be accepted");
695
696        assert_eq!(did, PENDING_DID);
697        let stored = crate::oauth::store::get_session(&pool, &codec, PENDING_DID)
698            .await
699            .unwrap()
700            .expect("the session must be durable before complete() returns");
701        assert_eq!(stored.access_token, "at-abc");
702        assert_eq!(stored.issuer, PENDING_ISSUER);
703    }
704
705    /// **A non-2xx token response must not be parsed as a session.**
706    ///
707    /// The status check is what stops an error body being read as a grant, and
708    /// it also keeps the failure body from being examined at all — it may be an
709    /// arbitrary proxy or WAF page.
710    #[tokio::test]
711    async fn a_failed_token_exchange_stores_nothing() {
712        let pool = empty_pool().await;
713        let codec = crate::oauth::crypto::Codec::new(Some(TEST_KEY)).unwrap();
714        let pending = pending_auth("unused-hash");
715
716        // A body that WOULD parse as a valid grant for the right DID, behind a
717        // failure status: only the status check stands between it and a session.
718        let err = accept_token_response(
719            &pool,
720            &codec,
721            &pending,
722            &outcome(400, &token_body(PENDING_DID)),
723            1_700_000_000,
724        )
725        .await
726        .expect_err("a 400 must not yield a session");
727
728        assert!(
729            format!("{err:#}").contains("failed with status 400"),
730            "refused, but not by the status check: {err:#}",
731        );
732        assert!(
733            crate::oauth::store::get_session(&pool, &codec, PENDING_DID)
734                .await
735                .unwrap()
736                .is_none(),
737            "a failed exchange must leave no session behind",
738        );
739    }
740
741    /// **The PKCE verifier sent must be the pending row's.**
742    ///
743    /// PKCE is what proves the party redeeming the code is the party that
744    /// requested it. The verifier is a plain `String` three fields away from two
745    /// other plain `String`s, so substituting it is a one-token edit — and a
746    /// mutation replacing it with a literal passed the whole suite, because
747    /// nothing ever looked at the form `complete` builds.
748    #[test]
749    fn the_token_request_carries_the_pending_rows_pkce_verifier_and_redirect() {
750        let runtime = runtime_at("https://feather-reader.com");
751        let pending = pending_auth("unused-hash");
752
753        let params = token_exchange_params(
754            &runtime,
755            &pending,
756            "the-code",
757            runtime.auth_method,
758            1_700_000_000,
759        )
760        .expect("building the token request");
761        let get = |k: &str| {
762            params
763                .iter()
764                .find(|(name, _)| *name == k)
765                .map(|(_, v)| v.as_str())
766        };
767
768        assert_eq!(
769            get("code_verifier"),
770            Some(pending.pkce_verifier.as_str()),
771            "the verifier must come from the pending row; anything else forfeits PKCE",
772        );
773        assert_eq!(get("code"), Some("the-code"));
774        assert_eq!(
775            get("redirect_uri"),
776            Some(pending.redirect_uri.as_str()),
777            "the redirect must be the one PAR was pushed under",
778        );
779        assert_eq!(get("grant_type"), Some("authorization_code"));
780    }
781
782    /// **A failed PAR push must not be parsed as a successful one.**
783    ///
784    /// Without the status check a failure body falls through to
785    /// `parse_par_response`, so an error response that happens to carry a
786    /// `request_uri` would be used to build an authorize URL and the user would
787    /// be sent to a grant the server never issued. Deleting it passed everything.
788    #[test]
789    fn a_failed_par_push_is_not_parsed_as_a_grant() {
790        // A body that WOULD parse as a valid PAR response, behind a failure
791        // status — so only the status check stands between it and an authorize
792        // URL.
793        let body = serde_json::json!({ "request_uri": "urn:ietf:params:oauth:request_uri:x", "expires_in": 60 });
794
795        assert!(
796            accept_par_response(&outcome(200, &body)).is_ok(),
797            "the same body at 200 must parse — otherwise this test proves nothing",
798        );
799
800        // `match` rather than `expect_err`: that would require `Debug` on
801        // `ParResponse`, and a `request_uri` is a one-time grant reference bound
802        // to our DPoP key — not something to make printable for a test's sake.
803        let err = match accept_par_response(&outcome(400, &body)) {
804            Ok(_) => panic!("a 400 must not yield a request_uri"),
805            Err(err) => err,
806        };
807        assert!(
808            format!("{err:#}").contains("failed with status 400"),
809            "refused, but not by the status check: {err:#}",
810        );
811    }
812
813    /// **The pending row's lifetime is capped by us, not chosen by the server.**
814    ///
815    /// The row holds a sealed DPoP key and a PKCE verifier. Dropping the
816    /// `.min(MAX_PENDING_SECS)` lets an authorization server answering with a
817    /// large `expires_in` decide how long that sits in our database, widening the
818    /// window in which a stolen `state` is worth replaying. The mutation passed
819    /// the whole suite.
820    #[test]
821    fn the_pending_row_lifetime_is_capped_regardless_of_the_server() {
822        let now = 1_700_000_000;
823
824        // A server asking for a day gets ten minutes.
825        assert_eq!(
826            pending_expiry(now, 86_400),
827            now + MAX_PENDING_SECS,
828            "a server must not be able to extend the pending row past our cap",
829        );
830        // A shorter server lifetime still wins — the cap is a ceiling, not a
831        // floor, and pinning the row open past the request_uri's own life would
832        // protect nothing.
833        assert_eq!(pending_expiry(now, 60), now + 60);
834        assert_eq!(
835            pending_expiry(now, MAX_PENDING_SECS),
836            now + MAX_PENDING_SECS
837        );
838    }
839
840    /// One DPoP key for the whole test run.
841    ///
842    /// Deterministic on purpose: a freshly generated key per fixture made it
843    /// impossible to assert WHICH key the token request was signed under, and a
844    /// review found that gap was live — substituting a generated key in the
845    /// transport passed the entire suite while binding the grant to a thumbprint
846    /// the `request_uri` was never issued against.
847    fn fixture_dpop_jwk() -> &'static str {
848        static JWK: std::sync::OnceLock<String> = std::sync::OnceLock::new();
849        JWK.get_or_init(|| {
850            crate::oauth::keys::SigningKey::generate("session")
851                .to_jwk_json()
852                .unwrap()
853        })
854    }
855
856    /// The thumbprint every token request in these tests must carry.
857    fn fixture_thumbprint() -> String {
858        crate::oauth::keys::SigningKey::from_jwk_json(fixture_dpop_jwk(), "session")
859            .unwrap()
860            .thumbprint()
861            .unwrap()
862    }
863
864    /// A pending login pushed under [`PUSHED_REDIRECT`], as a value.
865    fn pending_auth(cookie_hash: &str) -> crate::oauth::store::PendingAuth {
866        crate::oauth::store::PendingAuth {
867            state: "state-value".into(),
868            browser_binding_hash: cookie_hash.into(),
869            pkce_verifier: "verifier".into(),
870            // A REAL private JWK. `complete` unseals the DPoP key before it
871            // compares redirects, so a placeholder here refuses the login one
872            // step too early and the identity check never runs — which the
873            // assertion below caught rather than tolerated.
874            dpop_key_jwk: fixture_dpop_jwk().to_string(),
875            issuer: PENDING_ISSUER.into(),
876            pds_url: "https://pds.example.com".into(),
877            did: PENDING_DID.into(),
878            auth_method: "private_key_jwt".into(),
879            auth_kid: None,
880            redirect_uri: PUSHED_REDIRECT.into(),
881            requested_scope: "atproto transition:generic".into(),
882            request_uri: "urn:x".into(),
883            app_return_to: None,
884            expires_at: 2_000_000_000,
885        }
886    }
887
888    /// A pool holding that pending login, for the tests that drive `complete`
889    /// end to end.
890    async fn pending_login(cookie_hash: &str) -> sqlx::SqlitePool {
891        let pool = empty_pool().await;
892        let codec = crate::oauth::crypto::Codec::new(Some(TEST_KEY)).unwrap();
893        crate::oauth::store::put_pending(&pool, &codec, &pending_auth(cookie_hash))
894            .await
895            .unwrap();
896        pool
897    }
898
899    /// A runtime serving `public_url`, with the same codec key as the pool.
900    fn runtime_at(public_url: &str) -> crate::oauth::runtime::OauthRuntime {
901        crate::oauth::runtime::OauthRuntime::new(&crate::config::Config {
902            repo_backend: crate::metrics::Backend::Rust,
903            public_url: public_url.into(),
904            oauth: crate::config::OauthConfig {
905                encryption_key: Some(TEST_KEY.to_string()),
906                // **Off the repo root.** `key_path` defaults to a RELATIVE
907                // `oauth-signing-key.json`, so a rust-backend runtime here
908                // creates real ES256 key material in whatever the working
909                // directory is — the repo root during `cargo test`. Confirmed by
910                // a review; `runtime.rs` documents this side effect as the very
911                // thing that motivated gating key creation.
912                key_path: std::env::temp_dir().join(format!(
913                    "fr-login-test-key-{}-{:p}.json",
914                    std::process::id(),
915                    &TEST_KEY as *const _
916                )),
917                // **Offline.** The default is the real PLC directory, and
918                // PENDING_DID is a real Bluesky DID — so handle resolution in
919                // these tests was making a live internet call, which is slow,
920                // flaky in CI, and quietly makes the assertion below depend on
921                // someone else's uptime. `.invalid` never resolves (RFC 2606),
922                // so it fails immediately and offline.
923                plc_directory: "https://plc.invalid".to_string(),
924                ..crate::config::OauthConfig::default()
925            },
926            ..crate::config::Config::default()
927        })
928        .expect("the test runtime must build")
929    }
930
931    // ── `start`: the half that had no test at all ───────────────────────────
932
933    const START_PDS: &str = "https://pds.example.com";
934    const START_ISSUER: &str = "https://auth.example.com";
935
936    fn started_account() -> crate::oauth::resolve::ResolvedAccount {
937        crate::oauth::resolve::ResolvedAccount {
938            did: PENDING_DID.into(),
939            pds_url: START_PDS.into(),
940            handle: Some("alice.example.com".into()),
941        }
942    }
943
944    fn started_server() -> discovery::AuthorizationServer {
945        discovery::AuthorizationServer {
946            issuer: START_ISSUER.into(),
947            par_endpoint: format!("{START_ISSUER}/par"),
948            authorization_endpoint: format!("{START_ISSUER}/authorize"),
949            token_endpoint: format!("{START_ISSUER}/token"),
950            revocation_endpoint: None,
951        }
952    }
953
954    fn par_ok() -> request::PostOutcome {
955        request::PostOutcome {
956            status: 201,
957            body: br#"{"request_uri":"urn:ietf:params:oauth:request_uri:abc","expires_in":60}"#
958                .to_vec(),
959        }
960    }
961
962    /// Drive `start_with` against a CHOSEN authorization server and PAR
963    /// response, capturing the request it pushed.
964    ///
965    /// [`run_start`] is this with a well-behaved counterparty. The adversarial
966    /// tests vary the two, because everything `start` consumes after the push
967    /// is bytes from a remote party: the tests above prove `start` says the
968    /// right things, and these prove it survives being lied to.
969    async fn run_start_against(
970        runtime: &crate::oauth::runtime::OauthRuntime,
971        pool: &sqlx::SqlitePool,
972        now: i64,
973        server: discovery::AuthorizationServer,
974        par: request::PostOutcome,
975    ) -> (
976        Result<StartedLogin>,
977        std::sync::Arc<std::sync::Mutex<Vec<ParPost>>>,
978    ) {
979        let pushed = std::sync::Arc::new(std::sync::Mutex::new(Vec::new()));
980        let sink = std::sync::Arc::clone(&pushed);
981        let r = start_with(
982            runtime,
983            pool,
984            "alice.example.com",
985            now,
986            |_subject| async move { Ok(started_account()) },
987            move |_pds, _method, expected| async move {
988                // The mirror image of the callback's mix-up re-check: this side
989                // must pass `None`, because this IS the login that establishes
990                // the issuer. Asserted here so a `Some(..)` cannot creep in.
991                assert!(
992                    expected.is_none(),
993                    "the initial push must not claim a prior issuer, got {expected:?}"
994                );
995                Ok(server)
996            },
997            move |req: ParPost| {
998                let sink = std::sync::Arc::clone(&sink);
999                async move {
1000                    sink.lock().unwrap().push(req);
1001                    Ok(par)
1002                }
1003            },
1004        )
1005        .await;
1006        (r, pushed)
1007    }
1008
1009    /// Drive `start_with` against a well-behaved counterparty.
1010    async fn run_start(
1011        runtime: &crate::oauth::runtime::OauthRuntime,
1012        pool: &sqlx::SqlitePool,
1013        now: i64,
1014    ) -> (
1015        Result<StartedLogin>,
1016        std::sync::Arc<std::sync::Mutex<Vec<ParPost>>>,
1017    ) {
1018        run_start_against(runtime, pool, now, started_server(), par_ok()).await
1019    }
1020
1021    /// A PAR response with a chosen status and raw body.
1022    fn par_body(status: u16, body: &str) -> request::PostOutcome {
1023        request::PostOutcome {
1024            status,
1025            body: body.as_bytes().to_vec(),
1026        }
1027    }
1028
1029    /// The `state` the push carried — the key the pending row is stored under,
1030    /// so a test can ask whether one exists without knowing it in advance.
1031    fn pushed_state(captured: &std::sync::Mutex<Vec<ParPost>>) -> String {
1032        let c = captured.lock().unwrap();
1033        assert_eq!(c.len(), 1, "PAR must be pushed exactly once");
1034        param(&c[0].params, "state")
1035            .expect("state in the push")
1036            .to_string()
1037    }
1038
1039    /// Assert the login failed AND left no pending row behind.
1040    ///
1041    /// Both halves matter. A row written for a grant the server never issued
1042    /// strands a sealed DPoP key and PKCE verifier in the database, and the
1043    /// error alone does not prove one was not written.
1044    async fn assert_refused_without_storing(
1045        pool: &sqlx::SqlitePool,
1046        runtime: &crate::oauth::runtime::OauthRuntime,
1047        now: i64,
1048        started: Result<StartedLogin>,
1049        captured: &std::sync::Mutex<Vec<ParPost>>,
1050        what: &str,
1051    ) -> Result<()> {
1052        assert!(started.is_err(), "{what} must fail the login");
1053        let state = pushed_state(captured);
1054        assert!(
1055            crate::oauth::store::take_pending(pool, &runtime.codec, &state, now)
1056                .await?
1057                .is_none(),
1058            "{what} left a pending row behind",
1059        );
1060        Ok(())
1061    }
1062
1063    fn param<'a>(params: &'a [(&'static str, String)], k: &str) -> Option<&'a str> {
1064        params
1065            .iter()
1066            .find(|(n, _)| *n == k)
1067            .map(|(_, v)| v.as_str())
1068    }
1069
1070    /// **The PAR push carries this client's identity, and the pending row is
1071    /// written from the same values.**
1072    ///
1073    /// Five decisions in `start` were unfalsifiable before this test existed —
1074    /// see `start_with`. Each is silent in production: the login simply fails
1075    /// later, at the authorization server, for a reason nothing local explains.
1076    #[tokio::test]
1077    async fn a_start_pushes_this_clients_identity_and_stores_what_it_pushed() -> Result<()> {
1078        let pool = empty_pool().await;
1079        let runtime = runtime_at("https://app.example.com");
1080        let now = 1_700_000_000;
1081
1082        let (started, pushed) = run_start(&runtime, &pool, now).await;
1083        let started = started?;
1084        // Copy what the assertions need and DROP the guard: everything below
1085        // awaits, and holding a `std::sync::MutexGuard` across an await point
1086        // is a deadlock waiting for a multi-threaded runtime (clippy's
1087        // `await_holding_lock`, which CI treats as an error).
1088        let (url, params, pushed_key_jwk, retry_allowed) = {
1089            let pushed = pushed.lock().unwrap();
1090            assert_eq!(pushed.len(), 1, "PAR must be pushed exactly once");
1091            let r = &pushed[0];
1092            (
1093                r.url.clone(),
1094                r.params.clone(),
1095                r.key.to_jwk_json()?,
1096                matches!(r.retry, request::Retry::Allowed),
1097            )
1098        };
1099        let req = &params;
1100
1101        assert_eq!(url, format!("{START_ISSUER}/par"), "wrong PAR endpoint");
1102        assert_eq!(
1103            param(req, "client_id"),
1104            Some(runtime.client_id.as_str()),
1105            "PAR was pushed under a client_id that is not ours",
1106        );
1107        assert_eq!(
1108            param(req, "redirect_uri"),
1109            Some(crate::oauth::metadata::redirect_uri(&runtime.client).as_str()),
1110        );
1111        assert!(
1112            retry_allowed,
1113            "PAR consumes nothing on rejection and must stay retryable",
1114        );
1115
1116        // **The client assertion is addressed to the AUTHORIZATION SERVER.**
1117        //
1118        // RFC 7523 §3: `aud` is the server the assertion is presented to, and
1119        // `iss` = `sub` = the client. Pointing `aud` at the PDS instead — the
1120        // other URL in scope here, one line away in this function — survived
1121        // every other assertion in this test, because nothing looked inside the
1122        // JWT. A wrong audience is rejected by the server as an invalid client
1123        // assertion, so the login fails with nothing local explaining why.
1124        let assertion = param(req, "client_assertion").expect("a client assertion");
1125        let claims: serde_json::Value = {
1126            use base64::Engine as _;
1127            let payload = assertion.split('.').nth(1).expect("a JWT payload segment");
1128            serde_json::from_slice(
1129                &base64::engine::general_purpose::URL_SAFE_NO_PAD
1130                    .decode(payload)
1131                    .expect("the payload must be base64url"),
1132            )
1133            .expect("the payload must be JSON")
1134        };
1135        assert_eq!(
1136            claims["aud"].as_str(),
1137            Some(START_ISSUER),
1138            "the client assertion is addressed to the wrong audience: {claims}",
1139        );
1140        assert_eq!(
1141            claims["iss"].as_str(),
1142            Some(runtime.client_id.as_str()),
1143            "the client assertion's issuer is not this client",
1144        );
1145        assert_eq!(
1146            claims["sub"].as_str(),
1147            Some(runtime.client_id.as_str()),
1148            "the client assertion's subject is not this client",
1149        );
1150        assert_eq!(
1151            claims["iat"].as_i64(),
1152            Some(now),
1153            "iat is not the passed now"
1154        );
1155        assert!(
1156            claims["exp"]
1157                .as_i64()
1158                .is_some_and(|e| e > now && e <= now + 300),
1159            "exp must be ahead of iat and short-lived: {claims}",
1160        );
1161
1162        // The row `complete` will read back.
1163        let pending = crate::oauth::store::take_pending(
1164            &pool,
1165            &runtime.codec,
1166            param(req, "state").expect("state in the push"),
1167            now,
1168        )
1169        .await?
1170        .expect("the pending row must exist");
1171
1172        // **PKCE: the challenge pushed must derive from the verifier stored.**
1173        // A fresh verifier on either side compiles and fails only at the
1174        // exchange, with `invalid_grant` and nothing pointing here.
1175        assert_eq!(
1176            param(req, "code_challenge"),
1177            Some(flow::pkce_challenge(&pending.pkce_verifier).as_str()),
1178            "the pushed PKCE challenge does not match the stored verifier",
1179        );
1180
1181        // **DPoP: the key stored must be the key the push was signed under.**
1182        // The authorization server binds `request_uri` to this thumbprint.
1183        assert_eq!(
1184            pending.dpop_key_jwk, pushed_key_jwk,
1185            "the pending row stores a different DPoP key than PAR was signed under",
1186        );
1187
1188        // **Browser binding: the hash stored must be of the token returned.**
1189        // Hashing anything else leaves the cookie check unable to match, which
1190        // is a login-CSRF defence that silently never fires.
1191        assert_eq!(
1192            pending.browser_binding_hash,
1193            flow::binding_hash(&started.binding_token),
1194            "the stored binding hash is not of the token handed to the browser",
1195        );
1196
1197        assert_eq!(pending.issuer, START_ISSUER);
1198        assert_eq!(pending.pds_url, START_PDS);
1199        assert_eq!(pending.did, PENDING_DID);
1200        assert_eq!(pending.request_uri, "urn:ietf:params:oauth:request_uri:abc");
1201        assert_eq!(
1202            pending.redirect_uri,
1203            crate::oauth::metadata::redirect_uri(&runtime.client)
1204        );
1205        assert_eq!(pending.requested_scope, runtime.client.scope_str());
1206
1207        // The browser is sent to the discovered endpoint, carrying the
1208        // request_uri the server just issued.
1209        assert!(
1210            started
1211                .authorize_url
1212                .starts_with(&format!("{START_ISSUER}/authorize")),
1213            "authorize_url does not point at the discovered endpoint: {}",
1214            started.authorize_url,
1215        );
1216        assert!(
1217            started
1218                .authorize_url
1219                .contains("urn%3Aietf%3Aparams%3Aoauth%3Arequest_uri%3Aabc")
1220                || started
1221                    .authorize_url
1222                    .contains("request_uri=urn:ietf:params:oauth:request_uri:abc"),
1223            "authorize_url does not carry the issued request_uri: {}",
1224            started.authorize_url,
1225        );
1226        Ok(())
1227    }
1228
1229    /// **A failed PAR push stores nothing.**
1230    ///
1231    /// A pending row written before the server accepted would leave a sealed
1232    /// DPoP key and PKCE verifier for a grant that does not exist, and the
1233    /// row's expiry is derived from the PAR response.
1234    #[tokio::test]
1235    async fn a_rejected_par_push_stores_no_pending_row() -> Result<()> {
1236        let pool = empty_pool().await;
1237        let runtime = runtime_at("https://app.example.com");
1238
1239        let captured = std::sync::Arc::new(std::sync::Mutex::new(Vec::new()));
1240        let sink = std::sync::Arc::clone(&captured);
1241        let r = start_with(
1242            &runtime,
1243            &pool,
1244            "alice.example.com",
1245            1_700_000_000,
1246            |_s| async move { Ok(started_account()) },
1247            |_p, _m, _e| async move { Ok(started_server()) },
1248            move |req: ParPost| {
1249                let sink = std::sync::Arc::clone(&sink);
1250                async move {
1251                    sink.lock().unwrap().push(req);
1252                    Ok(request::PostOutcome {
1253                        status: 400,
1254                        body: br#"{"error":"invalid_request"}"#.to_vec(),
1255                    })
1256                }
1257            },
1258        )
1259        .await;
1260
1261        assert!(r.is_err(), "a 400 from PAR must fail the login");
1262        let state = {
1263            let c = captured.lock().unwrap();
1264            param(&c[0].params, "state").expect("state").to_string()
1265        };
1266        assert!(
1267            crate::oauth::store::take_pending(&pool, &runtime.codec, &state, 1_700_000_000)
1268                .await?
1269                .is_none(),
1270            "a rejected push left a pending row behind",
1271        );
1272        Ok(())
1273    }
1274
1275    // ── `start` against a hostile counterparty ──────────────────────────────
1276    //
1277    // Everything `start` consumes after the push is bytes chosen by a remote
1278    // party. The tests above drive a cooperative server and prove `start` says
1279    // the right things; these drive a lying one and prove what it refuses.
1280
1281    /// **A `request_uri` cannot smuggle extra parameters into the authorize
1282    /// URL.**
1283    ///
1284    /// This value is the one place in `start` where a remote party's bytes are
1285    /// echoed into a URL we hand the user's browser. Built by string formatting
1286    /// rather than a query serializer, a `request_uri` carrying
1287    /// `&redirect_uri=…` appends a SECOND `redirect_uri` to the authorization
1288    /// request — the classic parameter-injection shape, and on this endpoint it
1289    /// is an attempt to redirect the grant somewhere the client never
1290    /// registered.
1291    #[tokio::test]
1292    async fn a_hostile_request_uri_cannot_smuggle_parameters_into_the_authorize_url() -> Result<()>
1293    {
1294        const SMUGGLED: &str =
1295            "urn:ietf:params:oauth:request_uri:abc&redirect_uri=https://evil.example.com";
1296        let pool = empty_pool().await;
1297        let runtime = runtime_at("https://app.example.com");
1298        let body = serde_json::json!({ "request_uri": SMUGGLED, "expires_in": 60 });
1299        let (started, _) = run_start_against(
1300            &runtime,
1301            &pool,
1302            1_700_000_000,
1303            started_server(),
1304            par_body(201, &body.to_string()),
1305        )
1306        .await;
1307
1308        let url = url::Url::parse(&started?.authorize_url)?;
1309        let pairs: Vec<(String, String)> = url
1310            .query_pairs()
1311            .map(|(k, v)| (k.into_owned(), v.into_owned()))
1312            .collect();
1313        assert_eq!(
1314            pairs.len(),
1315            2,
1316            "the authorize URL gained a parameter from the server's bytes: {pairs:?}",
1317        );
1318        assert_eq!(
1319            pairs
1320                .iter()
1321                .find(|(k, _)| k == "request_uri")
1322                .map(|(_, v)| v.as_str()),
1323            Some(SMUGGLED),
1324            "the request_uri must survive as ONE opaque value, not be split",
1325        );
1326        assert!(
1327            !pairs.iter().any(|(k, _)| k == "redirect_uri"),
1328            "a redirect_uri was smuggled into the authorize URL: {pairs:?}",
1329        );
1330        Ok(())
1331    }
1332
1333    /// **A 2xx that is not a grant stores nothing.**
1334    ///
1335    /// `a_rejected_par_push_stores_no_pending_row` covers a 400. A server that
1336    /// answers 200 with an error body gets PAST the status check and reaches
1337    /// the parser — a different path, and the one a misbehaving (rather than
1338    /// refusing) server takes.
1339    #[tokio::test]
1340    async fn a_par_success_carrying_no_grant_stores_nothing() -> Result<()> {
1341        let pool = empty_pool().await;
1342        let runtime = runtime_at("https://app.example.com");
1343        let now = 1_700_000_000;
1344        let (started, captured) = run_start_against(
1345            &runtime,
1346            &pool,
1347            now,
1348            started_server(),
1349            par_body(200, r#"{"error":"invalid_request"}"#),
1350        )
1351        .await;
1352        assert_refused_without_storing(
1353            &pool,
1354            &runtime,
1355            now,
1356            started,
1357            &captured,
1358            "a 200 with no request_uri",
1359        )
1360        .await
1361    }
1362
1363    /// **A body that is not JSON at all stores nothing.**
1364    ///
1365    /// An HTML error page from a proxy in front of the authorization server is
1366    /// the realistic shape here, and it arrives with a 200.
1367    #[tokio::test]
1368    async fn a_par_response_that_is_not_json_stores_nothing() -> Result<()> {
1369        let pool = empty_pool().await;
1370        let runtime = runtime_at("https://app.example.com");
1371        let now = 1_700_000_000;
1372        let (started, captured) = run_start_against(
1373            &runtime,
1374            &pool,
1375            now,
1376            started_server(),
1377            par_body(200, "<html><body>502 Bad Gateway</body></html>"),
1378        )
1379        .await;
1380        assert_refused_without_storing(
1381            &pool,
1382            &runtime,
1383            now,
1384            started,
1385            &captured,
1386            "a non-JSON PAR body",
1387        )
1388        .await
1389    }
1390
1391    /// **A non-positive `expires_in` is refused rather than stored.**
1392    ///
1393    /// Stored as given, a zero or negative lifetime writes a row that is
1394    /// already expired — the login cannot be completed and the failure surfaces
1395    /// at the callback as a missing state, which reads like a browser problem
1396    /// rather than a server one.
1397    #[tokio::test]
1398    async fn a_non_positive_par_lifetime_is_refused_rather_than_stored() -> Result<()> {
1399        let runtime = runtime_at("https://app.example.com");
1400        let now = 1_700_000_000;
1401        for expires_in in ["0", "-1"] {
1402            let pool = empty_pool().await;
1403            let body = format!(
1404                r#"{{"request_uri":"urn:ietf:params:oauth:request_uri:abc","expires_in":{expires_in}}}"#
1405            );
1406            let (started, captured) =
1407                run_start_against(&runtime, &pool, now, started_server(), par_body(201, &body))
1408                    .await;
1409            assert_refused_without_storing(
1410                &pool,
1411                &runtime,
1412                now,
1413                started,
1414                &captured,
1415                &format!("expires_in={expires_in}"),
1416            )
1417            .await?;
1418        }
1419        Ok(())
1420    }
1421
1422    /// **A server cannot pin a pending login open beyond our own cap.**
1423    ///
1424    /// The row holds a sealed DPoP key and PKCE verifier. An authorization
1425    /// server answering `expires_in: 10^9` would otherwise keep that material
1426    /// alive for thirty years, and the lifetime of our secrets is not the
1427    /// counterparty's to choose. `pending_expiry` has a unit test; this is the
1428    /// one that proves `start` actually routes the server's number through it.
1429    #[tokio::test]
1430    async fn a_server_cannot_pin_a_pending_login_beyond_the_cap() -> Result<()> {
1431        let pool = empty_pool().await;
1432        let runtime = runtime_at("https://app.example.com");
1433        let now = 1_700_000_000;
1434        let (started, captured) = run_start_against(
1435            &runtime,
1436            &pool,
1437            now,
1438            started_server(),
1439            par_body(
1440                201,
1441                r#"{"request_uri":"urn:ietf:params:oauth:request_uri:abc","expires_in":1000000000}"#,
1442            ),
1443        )
1444        .await;
1445        started?;
1446
1447        let state = pushed_state(&captured);
1448        let pending = crate::oauth::store::take_pending(&pool, &runtime.codec, &state, now)
1449            .await?
1450            .expect("the pending row must exist");
1451        assert_eq!(
1452            pending.expires_at,
1453            now + MAX_PENDING_SECS,
1454            "the server's lifetime was accepted instead of our cap",
1455        );
1456        Ok(())
1457    }
1458
1459    /// **A non-https authorization endpoint fails the login.**
1460    ///
1461    /// `discovery` already requires https on every endpoint it returns, so this
1462    /// is the second layer rather than the first — it holds even if a future
1463    /// change to discovery, or a different path into `start_with`, hands one
1464    /// through. Downgrading this URL puts the authorization request, and the
1465    /// user's credentials at the other end of it, on the wire in clear.
1466    #[tokio::test]
1467    async fn a_non_https_authorization_endpoint_fails_the_login() -> Result<()> {
1468        let pool = empty_pool().await;
1469        let runtime = runtime_at("https://app.example.com");
1470        let downgraded = discovery::AuthorizationServer {
1471            authorization_endpoint: "http://auth.example.com/authorize".into(),
1472            ..started_server()
1473        };
1474        let (started, _) = run_start_against(
1475            &runtime,
1476            &pool,
1477            1_700_000_000,
1478            downgraded,
1479            par_body(
1480                201,
1481                r#"{"request_uri":"urn:ietf:params:oauth:request_uri:abc","expires_in":60}"#,
1482            ),
1483        )
1484        .await;
1485        // Destructured rather than `expect_err`, which would need
1486        // `StartedLogin: Debug` — and that type holds `binding_token`. It is
1487        // deliberately not `Debug`, so a panic message can never print it.
1488        let Err(err) = started else {
1489            panic!("an http authorization endpoint must fail the login")
1490        };
1491        assert!(
1492            format!("{err:#}").contains("https"),
1493            "the failure should name the scheme, got: {err:#}",
1494        );
1495        Ok(())
1496    }
1497
1498    fn callback_params() -> flow::CallbackParams {
1499        flow::CallbackParams {
1500            code: Some("the-code".into()),
1501            state: Some("state-value".into()),
1502            iss: Some(PENDING_ISSUER.into()),
1503            error: None,
1504            error_description: None,
1505            response: None,
1506        }
1507    }
1508
1509    /// **`complete` — the code exchange — had no test at all, and this is the
1510    /// first one to actually call it.**
1511    ///
1512    /// `login.rs` contained exactly two tests and neither invoked `complete`;
1513    /// both asserted that `metadata::redirect_uri` is a function of its input,
1514    /// one of them as the literal tautology `f(x) == f(x)`. Five separate guards
1515    /// inside `complete` could each be deleted with the whole suite still green,
1516    /// including the check that the tokens belong to the DID the login started
1517    /// for. The function is reachable only on the rust backend, so it is dormant
1518    /// today — the cutover makes it every user's login path.
1519    ///
1520    /// This pins the identity check: the pending row was pushed under
1521    /// `feather-reader.com`, the runtime now serves loopback, and the exchange
1522    /// must be refused BY THAT CHECK.
1523    ///
1524    /// Asserting the specific message matters more than usual here. Discovery
1525    /// against `pds.example.com` would fail anyway — so `is_err()` alone would
1526    /// pass with the check deleted, which is exactly how the rest of this module
1527    /// came to be untested. Matching the message is what makes the mutation
1528    /// visible.
1529    #[tokio::test]
1530    async fn a_login_started_under_a_different_public_url_is_refused() {
1531        let cookie = flow::new_binding_token();
1532        let pool = pending_login(&flow::binding_hash(&cookie)).await;
1533        let runtime = runtime_at("http://127.0.0.1:8080");
1534
1535        let err = complete(
1536            &runtime,
1537            &reqwest::Client::new(),
1538            &pool,
1539            &callback_params(),
1540            Some(&cookie),
1541            1_700_000_000,
1542        )
1543        .await
1544        .expect_err("a client-identity change mid-flight must refuse the exchange");
1545
1546        let rendered = format!("{err:#}");
1547        assert!(
1548            rendered.contains("started under a different public URL")
1549                && rendered.contains(PUSHED_REDIRECT),
1550            "refused, but not BY the client-identity check — this is the failure mode \
1551             where discovery merely errored instead: {rendered}",
1552        );
1553    }
1554
1555    /// **The identity check must also let a MATCHING login through.**
1556    ///
1557    /// A check that refuses everything satisfies the test above, so this pins the
1558    /// other direction: with the runtime serving the same public URL the row was
1559    /// pushed under, `complete` must get PAST the redirect comparison and fail
1560    /// later — at discovery, which cannot reach `pds.example.com` from a test.
1561    ///
1562    /// This is deliberately an assertion about WHICH error comes back, not about
1563    /// success: a full exchange needs a stub authorization server, and the SSRF
1564    /// guard forbids loopback, so one cannot be reached from here. That is the
1565    /// same wall that left this function untested, and closing it properly needs
1566    /// an injectable resolver rather than another test.
1567    #[tokio::test]
1568    async fn a_matching_public_url_passes_the_identity_check() {
1569        let cookie = flow::new_binding_token();
1570        let pool = pending_login(&flow::binding_hash(&cookie)).await;
1571        let runtime = runtime_at("https://feather-reader.com");
1572
1573        let err = complete(
1574            &runtime,
1575            &reqwest::Client::new(),
1576            &pool,
1577            &callback_params(),
1578            Some(&cookie),
1579            1_700_000_000,
1580        )
1581        .await
1582        .expect_err("discovery cannot reach pds.example.com from a test");
1583
1584        let rendered = format!("{err:#}");
1585        assert!(
1586            rendered.contains("oauth-protected-resource") && rendered.contains("resolving host"),
1587            "the exchange should have got as far as discovery: {rendered}"
1588        );
1589        assert!(
1590            !rendered.contains("started under a different public URL"),
1591            "a login whose public URL never changed must not be refused as though it \
1592             had; the identity check is rejecting valid logins: {rendered}",
1593        );
1594    }
1595
1596    // ── END-TO-END: the SEQUENCING, not the individual guards ────────────────
1597    //
1598    // Every guard in `complete` has its own unit test. Nothing drove the whole
1599    // callback, because it needs discovery and a token endpoint over the
1600    // network — and an issuer must be `https`, so a local plain-HTTP server
1601    // cannot stand in for an authorization server either. `complete_with`
1602    // injects those two calls so the ORDER becomes observable.
1603    //
1604    // What these assert is mostly what did NOT happen: a guard that fires only
1605    // after the authorization code has been posted somewhere is not a guard.
1606
1607    /// Records what the injected boundaries were asked to do — including the
1608    /// fields a review found were being decided in untested adapter code.
1609    #[derive(Default)]
1610    struct Calls {
1611        /// `(pds_url, expected_issuer, auth_method)`. The `Option` is the point:
1612        /// `None` means the mix-up re-check was never armed.
1613        discovered: Vec<(String, Option<String>, String)>,
1614        posted: Vec<PostedCall>,
1615        resolved: Vec<String>,
1616    }
1617    type Log = std::sync::Arc<std::sync::Mutex<Calls>>;
1618
1619    /// One recorded token POST: where, with what, under which key, retryable?
1620    struct PostedCall {
1621        url: String,
1622        params: Vec<(&'static str, String)>,
1623        dpop_thumbprint: String,
1624        retry: request::Retry,
1625    }
1626
1627    /// The token endpoint a real authorization server would publish: on a
1628    /// DIFFERENT host and path from the issuer.
1629    ///
1630    /// **Deliberately not `{issuer}/token`.** With the endpoints derived from the
1631    /// issuer string, ignoring the discovery result entirely — posting to
1632    /// `format!("{}/token", pending.issuer)` — passed all 16 tests. Real servers
1633    /// do not follow that pattern; this repo's own discovery fixture publishes
1634    /// `https://pds.justin-stanley.com/oauth/token` against a different issuer.
1635    const DISCOVERED_TOKEN_ENDPOINT: &str = "https://token.example.net/oauth/v2/token";
1636
1637    fn server_at(issuer: &str) -> crate::oauth::discovery::AuthorizationServer {
1638        crate::oauth::discovery::AuthorizationServer {
1639            issuer: issuer.into(),
1640            par_endpoint: format!("{issuer}/par"),
1641            authorization_endpoint: format!("{issuer}/authorize"),
1642            token_endpoint: DISCOVERED_TOKEN_ENDPOINT.to_string(),
1643            revocation_endpoint: None,
1644        }
1645    }
1646
1647    /// Drive `complete_with`, recording all three boundaries. Fully OFFLINE:
1648    /// handle resolution is injected too, so no test here touches the network.
1649    async fn drive(
1650        pool: &sqlx::SqlitePool,
1651        runtime: &crate::oauth::runtime::OauthRuntime,
1652        params: &flow::CallbackParams,
1653        cookie: Option<&str>,
1654        discovered_issuer: &str,
1655        token_status: u16,
1656        token_body: serde_json::Value,
1657    ) -> (Result<CompletedLogin>, Log) {
1658        let log: Log = Default::default();
1659        let (s1, s2, s3) = (
1660            std::sync::Arc::clone(&log),
1661            std::sync::Arc::clone(&log),
1662            std::sync::Arc::clone(&log),
1663        );
1664        let issuer = discovered_issuer.to_string();
1665        let body = serde_json::to_vec(&token_body).unwrap();
1666        let out = complete_with(
1667            runtime,
1668            pool,
1669            params,
1670            cookie,
1671            1_700_000_000,
1672            move |req| {
1673                let sink = std::sync::Arc::clone(&s1);
1674                let issuer = issuer.clone();
1675                async move {
1676                    sink.lock().unwrap().discovered.push((
1677                        req.pds_url,
1678                        req.expected_issuer,
1679                        req.auth_method,
1680                    ));
1681                    Ok(server_at(&issuer))
1682                }
1683            },
1684            move |req: TokenPost| {
1685                let sink = std::sync::Arc::clone(&s2);
1686                let body = body.clone();
1687                async move {
1688                    // The thumbprint is what the grant is bound to; recording it
1689                    // is how a substituted key becomes visible.
1690                    let tp = req.key.thumbprint().unwrap_or_default();
1691                    sink.lock().unwrap().posted.push(PostedCall {
1692                        url: req.url,
1693                        params: req.params,
1694                        dpop_thumbprint: tp,
1695                        retry: req.retry,
1696                    });
1697                    Ok(request::PostOutcome {
1698                        status: token_status,
1699                        body,
1700                    })
1701                }
1702            },
1703            move |did| {
1704                let sink = std::sync::Arc::clone(&s3);
1705                async move {
1706                    sink.lock().unwrap().resolved.push(did);
1707                    // Offline: the handle lookup is allowed to fail, and the
1708                    // login must survive it.
1709                    Err(anyhow::anyhow!("handle resolution unavailable in tests"))
1710                }
1711            },
1712        )
1713        .await;
1714        (out, log)
1715    }
1716
1717    /// **The happy path, start to finish — the first test that drives one.**
1718    ///
1719    /// Consumes the pending row, checks the browser binding and `iss`, unseals
1720    /// the DPoP key, re-discovers, exchanges, and stores a session. The handle
1721    /// lookup is left to fail (the runtime points at a `.invalid` PLC host, so
1722    /// it fails offline and fast) which also pins that a handle failure does NOT
1723    /// fail the login.
1724    #[tokio::test]
1725    async fn a_full_callback_stores_a_session() {
1726        let cookie = flow::new_binding_token();
1727        let pool = pending_login(&flow::binding_hash(&cookie)).await;
1728        let runtime = runtime_at("https://feather-reader.com");
1729        let (out, log) = drive(
1730            &pool,
1731            &runtime,
1732            &callback_params(),
1733            Some(&cookie),
1734            PENDING_ISSUER,
1735            200,
1736            token_body(PENDING_DID),
1737        )
1738        .await;
1739
1740        let done = out.expect("the full callback should complete");
1741        assert_eq!(done.did, PENDING_DID);
1742        assert!(
1743            done.handle.is_none(),
1744            "the handle lookup was expected to fail offline"
1745        );
1746
1747        // Copied out in a block so the guard is gone before the `await` below;
1748        // a MutexGuard held across an await is a clippy deny in CI.
1749        let (
1750            discoveries,
1751            expected_issuer,
1752            auth_method,
1753            posts,
1754            token_url,
1755            thumbprint,
1756            retry,
1757            token_params,
1758        ) = {
1759            let calls = log.lock().unwrap();
1760            (
1761                calls.discovered.len(),
1762                calls.discovered[0].1.clone(),
1763                calls.discovered[0].2.clone(),
1764                calls.posted.len(),
1765                calls.posted[0].url.clone(),
1766                calls.posted[0].dpop_thumbprint.clone(),
1767                calls.posted[0].retry,
1768                calls.posted[0].params.clone(),
1769            )
1770        };
1771        assert_eq!(discoveries, 1, "discovery ran once");
1772        assert_eq!(
1773            auth_method, "private_key_jwt",
1774            "discovery was told the wrong auth method; `none` would disable the \
1775             token_endpoint_auth_methods_supported check and the server would \
1776             then receive a private_key_jwt assertion it never advertised",
1777        );
1778        assert_eq!(
1779            expected_issuer.as_deref(),
1780            Some(PENDING_ISSUER),
1781            "the mix-up re-check was not armed: discovery got {expected_issuer:?}",
1782        );
1783        assert_eq!(posts, 1, "the token exchange ran once");
1784        assert_eq!(
1785            token_url, DISCOVERED_TOKEN_ENDPOINT,
1786            "the grant went to an endpoint guessed from the issuer rather than \
1787             the one discovery returned",
1788        );
1789        assert_eq!(
1790            retry,
1791            request::Retry::Allowed,
1792            "the token POST must be retryable: a nonce challenge is rejected \
1793             before the grant is processed, so the code is not consumed",
1794        );
1795        // The grant is bound to the PENDING ROW's DPoP key and no other. A
1796        // substituted key also has a non-empty thumbprint, so only an equality
1797        // check catches it.
1798        // The code from THIS callback is what gets exchanged.
1799        assert!(
1800            token_params
1801                .iter()
1802                .any(|(k, v)| *k == "code" && v == "the-code"),
1803            "the token request did not carry the callback's authorization code: {token_params:?}",
1804        );
1805        assert_eq!(
1806            thumbprint,
1807            fixture_thumbprint(),
1808            "the token request was signed under a different key than the one the \
1809             authorization request was bound to",
1810        );
1811
1812        // Resolution happens exactly once, and after the exchange.
1813        //
1814        // NOT "for the DID the grant returned rather than the pending row" — a
1815        // review pointed out that claim is unfalsifiable, because
1816        // `accept_token_response` bails unless `tokens.sub == pending.did`, so
1817        // the two are provably equal on every path that reaches here. What is
1818        // left worth asserting is the count and the subject.
1819        assert_eq!(
1820            log.lock().unwrap().resolved,
1821            vec![PENDING_DID.to_string()],
1822            "the handle lookup did not run exactly once for this subject",
1823        );
1824
1825        let codec = crate::oauth::crypto::Codec::new(Some(TEST_KEY)).unwrap();
1826        let stored = crate::oauth::store::get_session(&pool, &codec, PENDING_DID)
1827            .await
1828            .unwrap()
1829            .expect("no session was stored for a successful login");
1830
1831        // **The session must PERSIST the DPoP key the grant was bound to.**
1832        //
1833        // A review found this unpinned even after the EXCHANGE's key was fixed:
1834        // storing a freshly generated key left 708 tests green. The access token
1835        // is DPoP-bound to the pending key's thumbprint, so a session holding any
1836        // other key fails proof validation on every later PDS call — a login that
1837        // succeeds and then does nothing. That is the worst shape this bug could
1838        // take, because the failure surfaces far from its cause.
1839        let stored_thumbprint = keys::SigningKey::from_jwk_json(&stored.dpop_key_jwk, "session")
1840            .expect("the stored session's DPoP key does not parse")
1841            .thumbprint()
1842            .unwrap();
1843        assert_eq!(
1844            stored_thumbprint,
1845            fixture_thumbprint(),
1846            "the session persisted a different DPoP key than the grant is bound to",
1847        );
1848
1849        // **Every remaining field `accept_token_response` writes.**
1850        //
1851        // A review found four of the nine unpinned — and the same mapping on the
1852        // REFRESH path has dedicated tests for each of them. The login half of a
1853        // mapping tested twice over on the refresh half.
1854        assert_eq!(stored.access_token, "at-abc");
1855        assert_eq!(
1856            stored.refresh_token, "rt-abc",
1857            "an empty refresh token stores an un-refreshable session: the first \
1858             refresh presents \"\" and the server's invalid_grant deletes it, \
1859             which is the spurious logout the token module exists to avoid",
1860        );
1861        assert_eq!(stored.token_type, "DPoP");
1862        assert_eq!(
1863            stored.granted_scope, "atproto",
1864            "the session stored the REQUESTED scope, not the granted one — a \
1865             narrowed grant must be visible now rather than as a mystery write \
1866             failure later",
1867        );
1868        assert_eq!(
1869            stored.expires_at,
1870            Some(1_700_000_000 + 3600),
1871            "the session's expiry is not the token's; `None` means `is_stale` is \
1872             never true, so it is never proactively refreshed and simply dies",
1873        );
1874        assert_eq!(stored.issuer, PENDING_ISSUER);
1875    }
1876
1877    /// **A wrong `iss` stops the flow BEFORE anything is posted.**
1878    ///
1879    /// RFC 9207. The check itself is unit-tested; what was not pinned is that it
1880    /// runs early enough to matter. A version that validated `iss` after the
1881    /// exchange would still "reject the login" while having already handed the
1882    /// code and a client assertion to the wrong server.
1883    #[tokio::test]
1884    async fn a_mismatched_iss_posts_nothing_anywhere() {
1885        let cookie = flow::new_binding_token();
1886        let pool = pending_login(&flow::binding_hash(&cookie)).await;
1887        let runtime = runtime_at("https://feather-reader.com");
1888        let mut params = callback_params();
1889        params.iss = Some("https://evil.example.com".into());
1890
1891        let (out, log) = drive(
1892            &pool,
1893            &runtime,
1894            &params,
1895            Some(&cookie),
1896            PENDING_ISSUER,
1897            200,
1898            token_body(PENDING_DID),
1899        )
1900        .await;
1901
1902        assert!(out.is_err(), "a mismatched iss completed the login");
1903        let calls = log.lock().unwrap();
1904        assert!(
1905            calls.posted.is_empty(),
1906            "the authorization code was posted despite a bad iss: {:?}",
1907            calls.posted.iter().map(|p| &p.url).collect::<Vec<_>>(),
1908        );
1909        assert!(
1910            calls.discovered.is_empty(),
1911            "discovery ran before the iss check",
1912        );
1913    }
1914
1915    /// **The browser binding is checked before anything is posted.**
1916    ///
1917    /// A callback replayed from a different browser must not reach the token
1918    /// endpoint with a valid code.
1919    #[tokio::test]
1920    async fn a_foreign_browser_posts_nothing_anywhere() {
1921        let cookie = flow::new_binding_token();
1922        let pool = pending_login(&flow::binding_hash(&cookie)).await;
1923        let runtime = runtime_at("https://feather-reader.com");
1924        let (out, log) = drive(
1925            &pool,
1926            &runtime,
1927            &callback_params(),
1928            Some("a-different-browser"),
1929            PENDING_ISSUER,
1930            200,
1931            token_body(PENDING_DID),
1932        )
1933        .await;
1934
1935        assert!(out.is_err(), "a foreign browser completed the login");
1936        let calls = log.lock().unwrap();
1937        assert!(calls.posted.is_empty());
1938        assert!(
1939            calls.discovered.is_empty(),
1940            "discovery ran before the browser binding was checked",
1941        );
1942    }
1943
1944    /// **Discovery is told which issuer PAR was pushed under.**
1945    ///
1946    /// The mix-up defence itself lives INSIDE `discovery::discover` — which this
1947    /// seam stubs — and is tested there. What belongs at THIS layer is the
1948    /// contract that makes it reachable: `complete` must hand discovery the
1949    /// issuer from the *pending row*, which is AAD-bound in storage. Passing
1950    /// `None`, or the issuer off the callback, would disable the check without
1951    /// changing a line inside `discover`.
1952    #[tokio::test]
1953    async fn discovery_is_given_the_stored_issuer_to_expect() {
1954        let cookie = flow::new_binding_token();
1955        let pool = pending_login(&flow::binding_hash(&cookie)).await;
1956        let runtime = runtime_at("https://feather-reader.com");
1957        let (out, log) = drive(
1958            &pool,
1959            &runtime,
1960            &callback_params(),
1961            Some(&cookie),
1962            PENDING_ISSUER,
1963            200,
1964            token_body(PENDING_DID),
1965        )
1966        .await;
1967        assert!(out.is_ok());
1968
1969        let calls = log.lock().unwrap();
1970        assert_eq!(
1971            calls.discovered[0].1.as_deref(),
1972            Some(PENDING_ISSUER),
1973            "discovery was not told which issuer to expect; the mix-up defence \
1974             is disabled from the caller's side",
1975        );
1976        assert_eq!(
1977            calls.discovered[0].0, "https://pds.example.com",
1978            "discovery was pointed at something other than the stored PDS",
1979        );
1980    }
1981
1982    /// **The pending row is consumed: the same callback cannot be replayed.**
1983    ///
1984    /// Single-use is what makes a leaked `state` worthless. The second attempt
1985    /// must fail, and must not reach the token endpoint.
1986    #[tokio::test]
1987    async fn a_replayed_callback_is_refused_and_posts_nothing() {
1988        let cookie = flow::new_binding_token();
1989        let pool = pending_login(&flow::binding_hash(&cookie)).await;
1990        let runtime = runtime_at("https://feather-reader.com");
1991        let first = drive(
1992            &pool,
1993            &runtime,
1994            &callback_params(),
1995            Some(&cookie),
1996            PENDING_ISSUER,
1997            200,
1998            token_body(PENDING_DID),
1999        )
2000        .await;
2001        assert!(first.0.is_ok(), "the first callback should succeed");
2002
2003        let (out, log) = drive(
2004            &pool,
2005            &runtime,
2006            &callback_params(),
2007            Some(&cookie),
2008            PENDING_ISSUER,
2009            200,
2010            token_body(PENDING_DID),
2011        )
2012        .await;
2013        assert!(out.is_err(), "the callback was replayable");
2014        assert!(
2015            log.lock().unwrap().posted.is_empty(),
2016            "a replayed callback still reached the token endpoint",
2017        );
2018    }
2019
2020    /// **A DPoP key that will not unseal posts nothing anywhere.**
2021    ///
2022    /// Added because a comment claimed this ordering mattered and no test held
2023    /// it down: moving the unseal to AFTER the token exchange left the whole
2024    /// suite green, because every other fixture carries a valid JWK. The final
2025    /// outcome is identical either way — the login fails — so only the absence
2026    /// of a POST distinguishes them, and that is the whole point. A corrupt key
2027    /// must not cost the authorization code a trip to the token endpoint.
2028    #[tokio::test]
2029    async fn a_corrupt_dpop_key_posts_nothing_anywhere() {
2030        let cookie = flow::new_binding_token();
2031        let pool = empty_pool().await;
2032        let codec = crate::oauth::crypto::Codec::new(Some(TEST_KEY)).unwrap();
2033        let mut pending = pending_auth(&flow::binding_hash(&cookie));
2034        pending.dpop_key_jwk = "{\"kty\":\"EC\",\"crv\":\"bogus\"}".into();
2035        crate::oauth::store::put_pending(&pool, &codec, &pending)
2036            .await
2037            .unwrap();
2038
2039        let runtime = runtime_at("https://feather-reader.com");
2040        let (out, log) = drive(
2041            &pool,
2042            &runtime,
2043            &callback_params(),
2044            Some(&cookie),
2045            PENDING_ISSUER,
2046            200,
2047            token_body(PENDING_DID),
2048        )
2049        .await;
2050
2051        assert!(out.is_err(), "a corrupt DPoP key completed the login");
2052        let calls = log.lock().unwrap();
2053        assert!(
2054            calls.posted.is_empty(),
2055            "the authorization code was posted before the DPoP key was checked",
2056        );
2057        assert!(
2058            calls.discovered.is_empty(),
2059            "discovery ran before the DPoP key was checked",
2060        );
2061    }
2062
2063    // ── THE ADAPTER, over real TLS ───────────────────────────────────────────
2064    //
2065    // Everything above drives `complete_with`, whose transports are stubs — so
2066    // the wiring in `complete` that hands those transports their arguments has
2067    // no coverage. A review proved that gap was live: the mix-up defence could
2068    // be disarmed there with the whole suite green.
2069    //
2070    // These drive the REAL `complete`. That needs a server the client will talk
2071    // to, which needs https (an issuer must be https) on loopback (which the
2072    // SSRF guard refuses) — hence the test CA in `net`.
2073
2074    /// Documents for a well-formed PDS + authorization server on one TLS server.
2075    fn discovery_routes(
2076        pds: &str,
2077        issuer: &str,
2078    ) -> std::collections::HashMap<String, Vec<crate::net::TestResponse>> {
2079        let mut r = std::collections::HashMap::new();
2080        r.insert(
2081            "/.well-known/oauth-protected-resource".to_string(),
2082            vec![crate::net::TestResponse::json(
2083                200,
2084                serde_json::json!({
2085                    "resource": pds,
2086                    "authorization_servers": [issuer],
2087                })
2088                .to_string(),
2089            )],
2090        );
2091        r.insert(
2092            "/.well-known/oauth-authorization-server".to_string(),
2093            vec![crate::net::TestResponse::json(
2094                200,
2095                serde_json::json!({
2096                    "issuer": issuer,
2097                    "pushed_authorization_request_endpoint": format!("{issuer}/par"),
2098                    "authorization_endpoint": format!("{issuer}/authorize"),
2099                    "token_endpoint": format!("{issuer}/token"),
2100                    "protected_resources": [pds],
2101                    "client_id_metadata_document_supported": true,
2102                    "require_pushed_authorization_requests": true,
2103                    "authorization_response_iss_parameter_supported": true,
2104                    "token_endpoint_auth_methods_supported": ["private_key_jwt", "none"],
2105                    "token_endpoint_auth_signing_alg_values_supported": ["ES256"],
2106                    "dpop_signing_alg_values_supported": ["ES256"],
2107                    "scopes_supported": ["atproto"],
2108                    "response_types_supported": ["code"],
2109                    "grant_types_supported": ["authorization_code", "refresh_token"],
2110                    "code_challenge_methods_supported": ["S256"],
2111                })
2112                .to_string(),
2113            )],
2114        );
2115        r
2116    }
2117
2118    /// Seed a pending login pointing at the live TLS server.
2119    async fn pending_against(pds: &str, issuer: &str, cookie: &str) -> sqlx::SqlitePool {
2120        let pool = empty_pool().await;
2121        let codec = crate::oauth::crypto::Codec::new(Some(TEST_KEY)).unwrap();
2122        let mut pending = pending_auth(&flow::binding_hash(cookie));
2123        pending.pds_url = pds.to_string();
2124        pending.issuer = issuer.to_string();
2125        crate::oauth::store::put_pending(&pool, &codec, &pending)
2126            .await
2127            .unwrap();
2128        pool
2129    }
2130
2131    /// **The adapter really forwards the expected issuer — over real TLS.**
2132    ///
2133    /// THE test this whole harness exists for. The PDS names an authorization
2134    /// server that is NOT the one PAR was pushed under; `complete` must refuse
2135    /// before the authorization code is posted anywhere.
2136    ///
2137    /// This is the authorization-server mix-up. It is the one defence whose
2138    /// value is entirely in WHERE it happens, and the wiring that arms it —
2139    /// `Some(expected_issuer)` handed to `discover` — sits in `complete`'s
2140    /// adapter, outside every stub-driven test. Changing it to `None` left 702
2141    /// tests green. It does not leave this one green.
2142    #[tokio::test]
2143    async fn a_repointed_authorization_server_is_refused_before_the_code_is_posted() {
2144        let cookie = flow::new_binding_token();
2145        let (addr, log) = crate::net::spawn_tls(|addr| {
2146            let port = addr.port();
2147            // The PDS points at as-EVIL; the pending row was pushed under as-e2e.
2148            discovery_routes(
2149                &format!("https://pds-e2e.test:{port}"),
2150                &format!("https://as-evil.test:{port}"),
2151            )
2152        })
2153        .await;
2154        for h in ["pds-e2e.test", "as-e2e.test", "as-evil.test"] {
2155            crate::net::test_host_override(h, addr);
2156        }
2157
2158        let port = addr.port();
2159        let pds = format!("https://pds-e2e.test:{port}");
2160        let honest = format!("https://as-e2e.test:{port}");
2161
2162        let pool = pending_against(&pds, &honest, &cookie).await;
2163        let runtime = runtime_at("https://feather-reader.com");
2164        let mut params = callback_params();
2165        params.iss = Some(honest.clone());
2166
2167        let err = complete(
2168            &runtime,
2169            &reqwest::Client::builder().build().unwrap(),
2170            &pool,
2171            &params,
2172            Some(&cookie),
2173            1_700_000_000,
2174        )
2175        .await
2176        .expect_err("a repointed authorization server completed the login");
2177
2178        let rendered = format!("{err:#}");
2179        // The real message names both servers and says why, which is more than
2180        // the word "issuer" — assert on the substance, and on BOTH names, so a
2181        // generic failure (TLS, 404, parse) cannot satisfy this.
2182        let lower = rendered.to_ascii_lowercase();
2183        assert!(
2184            lower.contains("different authorization server")
2185                && rendered.contains("as-evil.test")
2186                && rendered.contains("as-e2e.test"),
2187            "refused, but not by the mix-up check: {rendered}",
2188        );
2189        let seen = log.lock().unwrap().join("\n");
2190        assert!(
2191            !seen.contains("POST /token"),
2192            "the authorization code was posted to a server the user never \
2193             approved:\n{seen}",
2194        );
2195    }
2196
2197    /// **The honest path over the same real TLS server completes.**
2198    ///
2199    /// Without this, the test above is satisfied by a `complete` that refuses
2200    /// everything — including every real login.
2201    #[tokio::test]
2202    async fn a_well_formed_discovery_over_tls_reaches_the_token_endpoint() {
2203        let cookie = flow::new_binding_token();
2204        let (addr, log) = crate::net::spawn_tls(|addr| {
2205            let port = addr.port();
2206            let pds = format!("https://pds-e2e.test:{port}");
2207            let issuer = format!("https://as-e2e.test:{port}");
2208            let mut r = discovery_routes(&pds, &issuer);
2209            // The exchange itself fails; what is asserted is that it was REACHED.
2210            r.insert(
2211                "/token".to_string(),
2212                vec![crate::net::TestResponse::json(
2213                    400,
2214                    "{\"error\":\"invalid_grant\"}",
2215                )],
2216            );
2217            r
2218        })
2219        .await;
2220        for h in ["pds-e2e.test", "as-e2e.test"] {
2221            crate::net::test_host_override(h, addr);
2222        }
2223
2224        let port = addr.port();
2225        let pds = format!("https://pds-e2e.test:{port}");
2226        let issuer = format!("https://as-e2e.test:{port}");
2227        let pool = pending_against(&pds, &issuer, &cookie).await;
2228        let runtime = runtime_at("https://feather-reader.com");
2229        let mut params = callback_params();
2230        params.iss = Some(issuer.clone());
2231
2232        let _ = complete(
2233            &runtime,
2234            &reqwest::Client::builder().build().unwrap(),
2235            &pool,
2236            &params,
2237            Some(&cookie),
2238            1_700_000_000,
2239        )
2240        .await;
2241
2242        let seen = log.lock().unwrap().join("\n");
2243        assert!(
2244            seen.contains("/.well-known/oauth-protected-resource"),
2245            "discovery never fetched the protected-resource document:\n{seen}",
2246        );
2247        assert!(
2248            seen.contains("POST /token"),
2249            "a well-formed discovery never reached the token endpoint — the \
2250             refusal test above would pass for the wrong reason:\n{seen}",
2251        );
2252    }
2253
2254    /// The DPoP proof JWT from a recorded request.
2255    fn dpop_proof(raw: &str) -> String {
2256        raw.lines()
2257            .find(|l| l.to_ascii_lowercase().starts_with("dpop:"))
2258            .expect("no DPoP header on the request")[5..]
2259            .trim()
2260            .to_string()
2261    }
2262
2263    /// Decode one base64url segment of a JWT as JSON.
2264    fn jwt_part(jwt: &str, idx: usize) -> serde_json::Value {
2265        use base64::Engine as _;
2266        let seg = jwt.split('.').nth(idx).expect("malformed JWT");
2267        let raw = base64::engine::general_purpose::URL_SAFE_NO_PAD
2268            .decode(seg)
2269            .expect("JWT segment is not base64url");
2270        serde_json::from_slice(&raw).expect("JWT segment is not JSON")
2271    }
2272
2273    /// Pull the `jwk` out of a recorded request's DPoP proof header.
2274    fn dpop_jwk_from(raw: &str) -> String {
2275        use base64::Engine as _;
2276        let line = raw
2277            .lines()
2278            .find(|l| l.to_ascii_lowercase().starts_with("dpop:"))
2279            .expect("no DPoP header on the token request");
2280        let jwt = line[5..].trim();
2281        let header_b64 = jwt.split('.').next().expect("malformed DPoP proof");
2282        let json = base64::engine::general_purpose::URL_SAFE_NO_PAD
2283            .decode(header_b64)
2284            .expect("DPoP header is not base64url");
2285        let v: serde_json::Value = serde_json::from_slice(&json).expect("DPoP header is not JSON");
2286        v.get("jwk")
2287            .expect("DPoP header carries no jwk")
2288            .to_string()
2289    }
2290
2291    /// **The grant is signed under the PENDING ROW's key — checked on the wire.**
2292    ///
2293    /// The adapter could substitute a freshly generated key and every stub-driven
2294    /// test stayed green, because the stub is handed the key rather than the
2295    /// proof. Here the real proof reaches a real server, and its embedded public
2296    /// key is compared against the key the authorization request was bound to.
2297    ///
2298    /// A substituted key also produces a valid-looking proof, so only comparing
2299    /// thumbprints catches it.
2300    #[tokio::test]
2301    async fn the_token_request_is_signed_under_the_pending_rows_key() {
2302        let cookie = flow::new_binding_token();
2303        let (addr, log) = crate::net::spawn_tls(|addr| {
2304            let port = addr.port();
2305            let mut r = discovery_routes(
2306                &format!("https://pds-e2e.test:{port}"),
2307                &format!("https://as-e2e.test:{port}"),
2308            );
2309            r.insert(
2310                "/token".to_string(),
2311                vec![crate::net::TestResponse::json(
2312                    400,
2313                    "{\"error\":\"invalid_grant\"}",
2314                )],
2315            );
2316            r
2317        })
2318        .await;
2319        for h in ["pds-e2e.test", "as-e2e.test"] {
2320            crate::net::test_host_override(h, addr);
2321        }
2322        let port = addr.port();
2323        let issuer = format!("https://as-e2e.test:{port}");
2324        let pool = pending_against(&format!("https://pds-e2e.test:{port}"), &issuer, &cookie).await;
2325        let runtime = runtime_at("https://feather-reader.com");
2326        let mut params = callback_params();
2327        params.iss = Some(issuer);
2328
2329        let _ = complete(
2330            &runtime,
2331            &reqwest::Client::builder().build().unwrap(),
2332            &pool,
2333            &params,
2334            Some(&cookie),
2335            1_700_000_000,
2336        )
2337        .await;
2338
2339        let token_req = log
2340            .lock()
2341            .unwrap()
2342            .iter()
2343            .find(|r| r.starts_with("POST /token"))
2344            .cloned()
2345            .expect("the token endpoint was never reached");
2346        let on_the_wire =
2347            keys::SigningKey::public_thumbprint_of(&dpop_jwk_from(&token_req)).unwrap();
2348        assert_eq!(
2349            on_the_wire,
2350            fixture_thumbprint(),
2351            "the token request was signed under a key the authorization request \
2352             was never bound to",
2353        );
2354    }
2355
2356    /// **A `use_dpop_nonce` challenge is retried — observed as a second request.**
2357    ///
2358    /// `Retry::Allowed` is chosen in `complete` and forwarded by the adapter;
2359    /// neither was pinned, and getting it wrong is not hypothetical — the
2360    /// comment on `Retry::Forbidden` records that this exact mistake once cost a
2361    /// real login against a live PDS, because servers rotate their nonce between
2362    /// PAR and the callback.
2363    ///
2364    /// The server challenges once, then answers. Two hits on `/token` means the
2365    /// retry happened; one means it did not.
2366    #[tokio::test]
2367    async fn a_nonce_challenge_on_the_token_endpoint_is_retried() {
2368        let cookie = flow::new_binding_token();
2369        let (addr, log) = crate::net::spawn_tls(|addr| {
2370            let port = addr.port();
2371            let mut r = discovery_routes(
2372                &format!("https://pds-e2e.test:{port}"),
2373                &format!("https://as-e2e.test:{port}"),
2374            );
2375            r.insert(
2376                "/token".to_string(),
2377                vec![
2378                    crate::net::TestResponse::json(400, "{\"error\":\"use_dpop_nonce\"}")
2379                        .with_header("DPoP-Nonce", "nonce-from-the-server"),
2380                    crate::net::TestResponse::json(400, "{\"error\":\"invalid_grant\"}"),
2381                ],
2382            );
2383            r
2384        })
2385        .await;
2386        for h in ["pds-e2e.test", "as-e2e.test"] {
2387            crate::net::test_host_override(h, addr);
2388        }
2389        let port = addr.port();
2390        let issuer = format!("https://as-e2e.test:{port}");
2391        let pool = pending_against(&format!("https://pds-e2e.test:{port}"), &issuer, &cookie).await;
2392        let runtime = runtime_at("https://feather-reader.com");
2393        let mut params = callback_params();
2394        params.iss = Some(issuer);
2395
2396        let _ = complete(
2397            &runtime,
2398            &reqwest::Client::builder().build().unwrap(),
2399            &pool,
2400            &params,
2401            Some(&cookie),
2402            1_700_000_000,
2403        )
2404        .await;
2405
2406        let reqs = log.lock().unwrap().clone();
2407        let token_hits = reqs.iter().filter(|r| r.starts_with("POST /token")).count();
2408        assert_eq!(
2409            token_hits, 2,
2410            "a use_dpop_nonce challenge was not retried; the exchange is marked \
2411             non-retryable somewhere between complete and the wire",
2412        );
2413        let second = reqs
2414            .iter()
2415            .filter(|r| r.starts_with("POST /token"))
2416            .nth(1)
2417            .unwrap();
2418        // The nonce rides INSIDE the DPoP proof's claims, not as plaintext in
2419        // the request — asserting on the raw bytes would have passed for a retry
2420        // that ignored the challenge entirely.
2421        let claims = jwt_part(&dpop_proof(second), 1);
2422        assert_eq!(
2423            claims.get("nonce").and_then(|v| v.as_str()),
2424            Some("nonce-from-the-server"),
2425            "the retry did not carry the server's nonce; it would be challenged \
2426             again forever",
2427        );
2428    }
2429
2430    /// **The token request uses the auth method the login was STARTED under.**
2431    ///
2432    /// A nine-line comment in `complete` explains why this must come from the
2433    /// pending row rather than the live runtime: a deploy that flipped
2434    /// dev/production between the push and the callback would otherwise present
2435    /// credentials that do not match the ones PAR was authenticated with, and the
2436    /// exchange fails for a reason nothing in the logs explains.
2437    ///
2438    /// Nothing tested it. Swapping `auth_method` for `runtime.auth_method` in
2439    /// `token_exchange_params` passed the whole suite, because every fixture had
2440    /// the two agreeing. Here they disagree: the login was started under `none`
2441    /// while the runtime is configured for `private_key_jwt`, so a client
2442    /// assertion appearing in the token params can only have come from the
2443    /// runtime.
2444    #[tokio::test]
2445    async fn the_exchange_uses_the_auth_method_the_login_started_under() {
2446        let cookie = flow::new_binding_token();
2447        let pool = empty_pool().await;
2448        let codec = crate::oauth::crypto::Codec::new(Some(TEST_KEY)).unwrap();
2449        let mut pending = pending_auth(&flow::binding_hash(&cookie));
2450        pending.auth_method = "none".into();
2451        crate::oauth::store::put_pending(&pool, &codec, &pending)
2452            .await
2453            .unwrap();
2454
2455        // The runtime, by contrast, is a private_key_jwt client.
2456        let runtime = runtime_at("https://feather-reader.com");
2457        assert_eq!(
2458            runtime.auth_method.as_str(),
2459            "private_key_jwt",
2460            "fixture: the runtime must DISAGREE with the pending row",
2461        );
2462
2463        let (out, log) = drive(
2464            &pool,
2465            &runtime,
2466            &callback_params(),
2467            Some(&cookie),
2468            PENDING_ISSUER,
2469            200,
2470            token_body(PENDING_DID),
2471        )
2472        .await;
2473        assert!(out.is_ok(), "the exchange should complete: {out:?}");
2474
2475        let calls = log.lock().unwrap();
2476        assert_eq!(
2477            calls.discovered[0].2, "none",
2478            "discovery was told the runtime's method, not the one PAR was pushed \
2479             under",
2480        );
2481        let params = &calls.posted[0].params;
2482        assert!(
2483            !params.iter().any(|(k, _)| *k == "client_assertion"),
2484            "a private_key_jwt assertion was sent for a login started under \
2485             `none`: {params:?}",
2486        );
2487    }
2488
2489    /// **The client assertion's `aud`, and the session's, are the right hosts.**
2490    ///
2491    /// Two more values `complete` decides that nothing checked: swapping the
2492    /// assertion's audience from `pending.issuer` to `pending.pds_url`, and the
2493    /// stored session's `aud` the other way, each left the whole suite green
2494    /// despite being genuinely different hosts in the fixture.
2495    ///
2496    /// `aud` is the assertion's anti-replay binding — an assertion minted for one
2497    /// audience must not be accepted by another — and the session's `aud` is the
2498    /// audience every later DPoP-bound PDS call uses. The earlier tests inspected
2499    /// only whether `client_assertion` was PRESENT, never what was in it.
2500    #[tokio::test]
2501    async fn the_assertion_and_session_audiences_are_distinct_and_correct() {
2502        let cookie = flow::new_binding_token();
2503        let pool = pending_login(&flow::binding_hash(&cookie)).await;
2504        let runtime = runtime_at("https://feather-reader.com");
2505        let (out, log) = drive(
2506            &pool,
2507            &runtime,
2508            &callback_params(),
2509            Some(&cookie),
2510            PENDING_ISSUER,
2511            200,
2512            token_body(PENDING_DID),
2513        )
2514        .await;
2515        assert!(out.is_ok());
2516
2517        // The fixture's issuer and PDS are deliberately different hosts, so
2518        // these two assertions cannot both be satisfied by one value.
2519        let assertion = {
2520            let calls = log.lock().unwrap();
2521            calls.posted[0]
2522                .params
2523                .iter()
2524                .find(|(k, _)| *k == "client_assertion")
2525                .map(|(_, v)| v.clone())
2526                .expect("no client_assertion for a private_key_jwt login")
2527        };
2528        // Decoded inline rather than via a shared helper: this file is merged
2529        // into another branch that defines one, and two definitions would clash.
2530        let claims: serde_json::Value = {
2531            use base64::Engine as _;
2532            let seg = assertion.split('.').nth(1).expect("malformed assertion");
2533            let raw = base64::engine::general_purpose::URL_SAFE_NO_PAD
2534                .decode(seg)
2535                .expect("assertion payload is not base64url");
2536            serde_json::from_slice(&raw).expect("assertion payload is not JSON")
2537        };
2538        assert_eq!(
2539            claims.get("aud").and_then(|v| v.as_str()),
2540            Some(PENDING_ISSUER),
2541            "the assertion was minted for the wrong audience; its anti-replay \
2542             binding names a server it was not sent to",
2543        );
2544
2545        let codec = crate::oauth::crypto::Codec::new(Some(TEST_KEY)).unwrap();
2546        let stored = crate::oauth::store::get_session(&pool, &codec, PENDING_DID)
2547            .await
2548            .unwrap()
2549            .expect("session");
2550        assert_eq!(
2551            stored.aud, "https://pds.example.com",
2552            "the session's audience is not the PDS; every later DPoP-bound call \
2553             would carry the wrong `htu`/`aud`",
2554        );
2555    }
2556
2557    /// **An expired pending row is refused — `now` really reaches the sweep.**
2558    ///
2559    /// `complete` passes `now` into `flow::complete_callback`, which is what lets
2560    /// `take_pending` prune expired rows. Passing `0` instead honoured a pending
2561    /// row long past its expiry — a sealed DPoP key and PKCE verifier that should
2562    /// have been swept — and the whole suite stayed green, because every fixture
2563    /// used an `expires_at` far in the future.
2564    ///
2565    /// `MAX_PENDING_SECS`'s COMPUTATION is pinned elsewhere; this pins that the
2566    /// bound is honoured.
2567    #[tokio::test]
2568    async fn an_expired_pending_row_is_refused() {
2569        let cookie = flow::new_binding_token();
2570        let pool = empty_pool().await;
2571        let codec = crate::oauth::crypto::Codec::new(Some(TEST_KEY)).unwrap();
2572        let mut pending = pending_auth(&flow::binding_hash(&cookie));
2573        // Expired an hour before the callback arrives.
2574        pending.expires_at = 1_700_000_000 - 3600;
2575        crate::oauth::store::put_pending(&pool, &codec, &pending)
2576            .await
2577            .unwrap();
2578
2579        let runtime = runtime_at("https://feather-reader.com");
2580        let (out, log) = drive(
2581            &pool,
2582            &runtime,
2583            &callback_params(),
2584            Some(&cookie),
2585            PENDING_ISSUER,
2586            200,
2587            token_body(PENDING_DID),
2588        )
2589        .await;
2590
2591        assert!(out.is_err(), "an expired pending row completed a login");
2592        assert!(
2593            log.lock().unwrap().posted.is_empty(),
2594            "the authorization code was posted for an expired pending row",
2595        );
2596    }
2597}