Skip to main content

didbot_serve/oauth/
token.rs

1//! `POST /oauth/token`: authorization code exchange and refresh rotation.
2//!
3//! Issued tokens are DPoP-bound (RFC 9449) and opaque bearer strings — a
4//! signature scheme and claims format nothing here needs, since a token
5//! authenticates by lookup rather than by being verified on its own. A
6//! client presents one as `Authorization: DPoP <token>`, which the repo
7//! write routes accept through `crate::auth::require_agent_token_or_dpop`. A token names the client it
8//! was issued to, and every write it presents is judged with that client.
9//!
10//! # A login keeps what the agent approved, and the ceiling is asked every time
11//!
12//! A login stores what the decision record granted — the set the agent was
13//! shown and answered, which is the client's request as the ceiling stood at
14//! PAR. What a token may do is that set as the ceiling in force grants it
15//! now ([`in_force`]), asked at exchange, at every refresh, and at every
16//! write. A ceiling tightened since sign-in narrows a login at once. A
17//! ceiling loosened afterwards changes nothing for a live login: an atom the
18//! record named as `cut` was never approved, so a wider ceiling cannot hand
19//! it back without asking the agent again.
20//!
21//! # A login has an end
22//!
23//! [`REFRESH_TTL`] is measured from the moment a login was minted and a
24//! refresh does not move it, so an approval an agent gave once is spent
25//! within one bounded window rather than renewed indefinitely by a client
26//! that keeps refreshing.
27//!
28//! # A confidential client's login keeps its key
29//!
30//! A login a confidential client began is bound to the key it signed its
31//! client assertion with at PAR ([`ClientAuthKey`]). The code, and every
32//! refresh after it, must come with an assertion signed by that same key,
33//! found in the key set the client publishes now. A request with no
34//! assertion is `invalid_client`. One signed with another key, or one that
35//! brings an assertion to a public client's login, is `invalid_grant`, and
36//! nothing rotates. See [`crate::oauth::client_auth`].
37//!
38//! The lifetimes below are the same for both kinds of client. The atproto
39//! profile limits a public client's login to two weeks and a confidential
40//! client's refresh token to 180 days, and fourteen days is inside both.
41//!
42//! # Access token lifetime
43//!
44//! [`ACCESS_TTL`] is 5 minutes. See `plan/oauth.md` for the reasoning; in
45//! short, this deployment has no revocation endpoint by design (withdrawal
46//! is refusal at the write path) and no per-token introspection, so by the
47//! atproto profile's own rule ("5 minutes is recommended" when a server
48//! lacks individual revocation) this is the number, not a compromise toward
49//! it.
50
51use std::sync::Arc;
52
53use didbot_pds::oauth::{
54    ClientAuthKey, GrantError, GrantRequest, Lifetimes, MemoryOAuthGrantStore, MintedGrant,
55    OAuthGrantStore, Rotation, Unkept,
56};
57use didbot_pds::policy::ClientId;
58use time::{Duration, OffsetDateTime};
59
60use crate::oauth::authorize::ScopePolicy;
61use crate::oauth::client_auth::AuthenticatedClient;
62use crate::oauth::consent::{self, ConsentError, ConsentReference, ConsentStore};
63use crate::oauth::decision::{verdict_for, CEILING_RULE};
64use crate::oauth::dpop_seam::{DpopError, DpopThumbprint, DpopVerifier, VerifiedProof};
65use crate::oauth::scope::ScopeSet;
66
67/// How long a minted access token is good for. See the module doc.
68pub const ACCESS_TTL: Duration = Duration::minutes(5);
69
70/// How long a login lasts, from the moment its first pair was minted.
71///
72/// A login's absolute end, not the current refresh token's: rotation mints a
73/// fresh refresh token and leaves this deadline where it was
74/// (`didbot_pds::oauth::Lifetimes::refresh`), so fourteen days is the most an
75/// agent's one approval can be spent for. A client that wants longer signs in
76/// again, which puts the whole request back in front of the agent.
77///
78/// Long relative to [`ACCESS_TTL`], by design — the whole point of the split
79/// is that a client spends the refresh token rarely.
80pub const REFRESH_TTL: Duration = Duration::days(14);
81
82/// How long an authorization code may be exchanged before it expires.
83/// RFC 6749 §4.1.2 asks for "a maximum authorization code lifetime of 10
84/// minutes"; this is well inside that, since the code is minted only after
85/// consent has already been confirmed and exchange should follow
86/// immediately.
87pub const AUTHORIZATION_CODE_TTL: Duration = Duration::minutes(2);
88
89/// Random bytes an authorization code is drawn from.
90const CODE_BYTES: usize = 32;
91
92/// A confirmed grant, ready to be redeemed at `token` — minted the instant
93/// [`consent::confirm`] approves, which is why it carries everything `token`
94/// needs without a second lookup into anything PAR- or authorize-owned;
95/// both of those have already one-shot-consumed their own state by this
96/// point.
97#[derive(Debug, Clone)]
98struct AuthorizationCode {
99    did: String,
100    client_id: String,
101    scope: ScopeSet,
102    redirect_uri: String,
103    code_challenge: String,
104    code_challenge_method: String,
105    client_auth_key: Option<ClientAuthKey>,
106    dpop_jkt: DpopThumbprint,
107    expires_at: OffsetDateTime,
108}
109
110/// Where authorization codes live between consent confirmation and
111/// exchange.
112pub trait AuthorizationCodeStore: Send + Sync {
113    /// Mints and stores a code for a confirmed grant.
114    fn issue(&self, code: AuthorizationCodeRecord) -> String;
115    /// Redeems a code. One-shot: RFC 6749 §4.1.2 requires a code be usable
116    /// once, and a second presentation of an already-redeemed code is
117    /// itself a signal the server treats as `invalid_grant` rather than as
118    /// "return the same tokens again."
119    fn redeem(&self, code: &str) -> Option<AuthorizationCodeRecord>;
120    /// Drops every unredeemed code this store holds for `did`.
121    ///
122    /// A code is one hop short of a token: redeeming one mints the grant
123    /// [`OAuthTokenStore::revoke_account`] would otherwise have to end a
124    /// moment later. Dropping the codes at the same time a deletion ends the
125    /// grants closes that hop, so nothing outstanding at deletion can turn
126    /// into a credential afterwards.
127    fn revoke_account(&self, did: &str);
128
129    /// Drops every unredeemed code this store holds for `client_id`, or
130    /// for `client_id` on `did` alone. Answers how many went.
131    ///
132    /// What an operator ending an app's logins calls, for the same reason
133    /// [`Self::revoke_account`] exists: a code left behind would turn into
134    /// a fresh token a moment after the grants ended.
135    fn revoke_client(&self, client_id: &str, did: Option<&str>) -> usize;
136}
137
138/// The fields [`AuthorizationCodeStore::issue`] takes — the same shape as
139/// `AuthorizationCode`, public because a caller assembling one from a
140/// confirmed [`consent::PendingConsent`] needs to name every field.
141pub struct AuthorizationCodeRecord {
142    /// The account this code grants access to.
143    pub did: String,
144    /// The client this code was issued to.
145    pub client_id: String,
146    /// What the decision record granted, which is what the agent approved;
147    /// see this module's doc.
148    pub scope: ScopeSet,
149    /// Where this code must be redirected back to; checked again at
150    /// exchange.
151    pub redirect_uri: String,
152    /// The PKCE challenge `code_verifier` must hash to at exchange.
153    pub code_challenge: String,
154    /// The PKCE method the challenge was computed with; always `S256`.
155    pub code_challenge_method: String,
156    /// The key a confidential client authenticated with at PAR, which the
157    /// exchange must be signed with too. `None` for a public client.
158    pub client_auth_key: Option<ClientAuthKey>,
159    /// The thumbprint of the DPoP key the sign-in's push was signed with.
160    /// The exchange's proof must be signed with the same key (RFC 9449 §10).
161    pub dpop_jkt: DpopThumbprint,
162}
163
164impl From<consent::PendingConsent> for AuthorizationCodeRecord {
165    fn from(pending: consent::PendingConsent) -> Self {
166        Self {
167            did: pending.did,
168            client_id: pending.client_id,
169            scope: pending.granted_scope,
170            redirect_uri: pending.redirect_uri,
171            code_challenge: pending.code_challenge,
172            code_challenge_method: pending.code_challenge_method,
173            client_auth_key: pending.client_auth_key,
174            dpop_jkt: pending.dpop_jkt,
175        }
176    }
177}
178
179/// The in-process authorization code store.
180#[derive(Debug, Default)]
181pub struct MemoryAuthorizationCodeStore {
182    entries: std::sync::RwLock<std::collections::HashMap<String, AuthorizationCode>>,
183}
184
185impl MemoryAuthorizationCodeStore {
186    /// An empty store.
187    pub fn new() -> Self {
188        Self::default()
189    }
190
191    /// How many codes this store still holds. Test use only.
192    #[cfg(test)]
193    pub(crate) fn len(&self) -> usize {
194        self.entries.read().unwrap_or_else(|p| p.into_inner()).len()
195    }
196}
197
198fn random_code() -> String {
199    use rand::rngs::OsRng;
200    use rand::TryRngCore;
201    let mut bytes = [0u8; CODE_BYTES];
202    OsRng
203        .try_fill_bytes(&mut bytes)
204        .expect("the OS random source is available");
205    hex::encode(bytes)
206}
207
208impl AuthorizationCodeStore for MemoryAuthorizationCodeStore {
209    fn issue(&self, record: AuthorizationCodeRecord) -> String {
210        let code = random_code();
211        let now = OffsetDateTime::now_utc();
212        let mut entries = self.entries.write().unwrap_or_else(|p| p.into_inner());
213        // `redeem` removes only the code it is handed, so one a client never
214        // exchanges is swept here once it has expired.
215        entries.retain(|_, entry| now < entry.expires_at);
216        entries.insert(
217            code.clone(),
218            AuthorizationCode {
219                did: record.did,
220                client_id: record.client_id,
221                scope: record.scope,
222                redirect_uri: record.redirect_uri,
223                code_challenge: record.code_challenge,
224                code_challenge_method: record.code_challenge_method,
225                client_auth_key: record.client_auth_key,
226                dpop_jkt: record.dpop_jkt,
227                expires_at: now + AUTHORIZATION_CODE_TTL,
228            },
229        );
230        code
231    }
232
233    fn redeem(&self, code: &str) -> Option<AuthorizationCodeRecord> {
234        let mut entries = self.entries.write().unwrap_or_else(|p| p.into_inner());
235        let record = entries.remove(code)?;
236        if OffsetDateTime::now_utc() >= record.expires_at {
237            return None;
238        }
239        Some(AuthorizationCodeRecord {
240            did: record.did,
241            client_id: record.client_id,
242            scope: record.scope,
243            redirect_uri: record.redirect_uri,
244            code_challenge: record.code_challenge,
245            code_challenge_method: record.code_challenge_method,
246            client_auth_key: record.client_auth_key,
247            dpop_jkt: record.dpop_jkt,
248        })
249    }
250
251    fn revoke_account(&self, did: &str) {
252        self.entries
253            .write()
254            .unwrap_or_else(|p| p.into_inner())
255            .retain(|_, entry| entry.did != did);
256    }
257
258    fn revoke_client(&self, client_id: &str, did: Option<&str>) -> usize {
259        let mut entries = self.entries.write().unwrap_or_else(|p| p.into_inner());
260        let before = entries.len();
261        entries.retain(|_, entry| {
262            !(entry.client_id == client_id && did.is_none_or(|did| entry.did == did))
263        });
264        before - entries.len()
265    }
266}
267
268/// Confirms a consent reference and, on success, issues the authorization
269/// code — the combined operation a future stamped tool call reaches, so that
270/// wiring it in is a single call rather than two.
271///
272/// Runs [`consent::confirm`]'s two checks first; a code is minted only if
273/// both pass.
274pub fn confirm_and_issue_code(
275    consent_store: &dyn ConsentStore,
276    code_store: &dyn AuthorizationCodeStore,
277    reference: &ConsentReference,
278    stamped_did: &str,
279) -> Result<String, ConsentError> {
280    let pending = consent::confirm(consent_store, reference, stamped_did)?;
281    Ok(code_store.issue(pending.into()))
282}
283
284/// One minted access/refresh pair.
285#[derive(Debug, Clone, PartialEq, Eq)]
286pub struct OAuthTokens {
287    /// The account these tokens authenticate as.
288    pub did: String,
289    /// The client these tokens were issued to.
290    pub client_id: String,
291    /// What these tokens may do as they are issued. See [`in_force`].
292    pub scope: ScopeSet,
293    /// The bearer string a request presents as `Authorization: DPoP <..>`.
294    pub access_token: String,
295    /// The bearer string `token`'s `refresh_token` grant takes instead.
296    pub refresh_token: String,
297    /// When `access_token` stops working.
298    pub access_expires_at: OffsetDateTime,
299    /// When `refresh_token` stops working.
300    pub refresh_expires_at: OffsetDateTime,
301}
302
303/// Why token issuance or refresh was refused.
304#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
305pub enum TokenError {
306    /// `grant_type` named something other than `authorization_code` or
307    /// `refresh_token`.
308    #[error("grant_type `{0}` is not supported")]
309    UnsupportedGrantType(String),
310    /// A required parameter for the requested grant type was absent.
311    #[error("`{0}` is a required parameter")]
312    MissingParameter(&'static str),
313    /// `code` is unknown, expired, or was already redeemed.
314    #[error("code is unknown, expired, or already redeemed")]
315    InvalidCode,
316    /// `redirect_uri` does not match the one the code was issued for.
317    #[error("redirect_uri does not match the one this code was issued for")]
318    RedirectUriMismatch,
319    /// `code_verifier` does not hash to `code_challenge`.
320    #[error("code_verifier does not match code_challenge")]
321    PkceFailed,
322    /// The exchange's DPoP proof was signed with a key other than the one
323    /// the sign-in's push was signed with (RFC 9449 §10).
324    #[error("this code was issued to a sign-in that began under another DPoP key")]
325    CodeKeyMismatch,
326    /// `client_id` does not match the one the code was issued to.
327    #[error("client_id does not match the one this code was issued to")]
328    ClientMismatch,
329    /// This code or login belongs to a confidential client, and the request
330    /// carries no client assertion.
331    #[error(
332        "this login belongs to a confidential client, and the request carries no client_assertion"
333    )]
334    ClientAssertionRequired,
335    /// The request authenticated its client differently from how the login
336    /// began: an assertion signed with another key, or one brought to a
337    /// public client's login.
338    #[error("the client authenticated differently from how this login began")]
339    ClientAuthenticationChanged,
340    /// The DPoP proof presented was refused; see [`DpopError`].
341    #[error("DPoP proof was not accepted: {0}")]
342    Dpop(#[from] DpopError),
343    /// `refresh_token` is unknown or has expired.
344    #[error("refresh_token is unknown or expired")]
345    InvalidRefreshToken,
346    /// `refresh_token` had already been rotated away; its whole family has
347    /// been revoked as a result of this call.
348    #[error("refresh_token was already used; its whole family has been revoked")]
349    RefreshReused,
350    /// The DPoP proof presented a different key than the one this token
351    /// family was minted bound to.
352    #[error("the DPoP key presented does not match the one this token family is bound to")]
353    DpopKeyMismatch,
354    /// This server's policy refuses the client a token for this account.
355    /// Nothing is revoked: if the policy lifts, the same refresh token
356    /// works again.
357    #[error("this server's policy refuses this client a token")]
358    ClientRefused,
359    /// The grant could not be written down, so no pair was issued. A pair
360    /// this deployment will not remember is one that stops working at the
361    /// next restart with nothing saying why.
362    #[error("this server could not record the grant, so none was issued")]
363    NotKept,
364}
365
366/// What an OAuth access token authenticates: an account, through the
367/// application the token was issued to.
368#[derive(Debug, Clone, PartialEq, Eq)]
369pub struct AccessGrant {
370    /// The account.
371    pub did: String,
372    /// The application.
373    pub client_id: ClientId,
374    /// What the token may do now: what its login was approved at, as the
375    /// ceiling in force grants it. See [`in_force`].
376    pub scope: ScopeSet,
377}
378
379/// Asked before any token is issued: whether `client_id` may hold one for
380/// `did`, carrying `scope`, right now.
381pub trait TokenAdmission {
382    /// `true` to issue the token.
383    fn admits(&self, did: &str, client_id: &ClientId, scope: &ScopeSet) -> bool;
384}
385
386/// [`TokenAdmission`] by `plan/policy.md`'s gate: the token is judged as a
387/// `didbot_policy::Subject::Token`, within the account's chain of principals,
388/// the way a write by that account is.
389pub struct GateAdmission<'a> {
390    /// The gate every write and sign-in is judged by.
391    pub gate: &'a dyn didbot_pds::policy::PolicyGate,
392    /// Where the account, and the principals above it, are read from.
393    pub registry: &'a dyn didbot_pds::Registry,
394    /// This deployment's own DID.
395    pub pds_did: &'a str,
396    /// This deployment's own hostname.
397    pub pds_hostname: &'a str,
398}
399
400impl TokenAdmission for GateAdmission<'_> {
401    /// An account this server does not hold is never admitted: there is
402    /// nothing for the token to authenticate as.
403    fn admits(&self, did: &str, client_id: &ClientId, scope: &ScopeSet) -> bool {
404        let Some(account) = self.registry.account(did) else {
405            return false;
406        };
407        let atoms: Vec<String> = scope.0.iter().map(ToString::to_string).collect();
408        let scopes: Vec<&str> = atoms.iter().map(String::as_str).collect();
409        let subject = didbot_pds::policy::token_subject(
410            account.did.as_str(),
411            account.kind,
412            account.handle.as_deref(),
413            self.pds_did,
414            self.pds_hostname,
415            client_id,
416            &scopes,
417        );
418        let boundary = didbot_pds::policy_bindings::Boundary::of(
419            self.registry
420                .boundary(did)
421                .unwrap_or_else(|_| vec![did.to_owned()]),
422        );
423        let outcome = self.gate.judge_within(&subject, &boundary);
424        let admitted = matches!(outcome, didbot_pds::policy::Outcome::Allow);
425        if !admitted {
426            tracing::info!(account = did, client = %client_id, "policy refused a client a token");
427        }
428        admitted
429    }
430}
431
432/// What a login approved at `approved` may do now: that set as `ceiling`
433/// grants it for this account and client, by the same verdict
434/// `POST /oauth/par` reached. Empty when the ceiling grants none of it.
435///
436/// Never wider than `approved`: a narrowing projects the request onto the
437/// ceiling, so a ceiling loosened after sign-in answers `approved` itself
438/// rather than anything past it.
439pub fn in_force(
440    approved: &ScopeSet,
441    ceiling: &dyn ScopePolicy,
442    did: &str,
443    client_id: &ClientId,
444) -> ScopeSet {
445    let ceiling = ceiling.ceiling(did, client_id.as_str());
446    verdict_for(approved, &ceiling, CEILING_RULE).granted(approved)
447}
448
449/// Where one live login stands right now. Neither field is stored; both
450/// are asked of the ceiling and the gate as they are at this moment.
451#[derive(Debug, Clone, PartialEq, Eq)]
452pub struct Standing {
453    /// What the login may do now: what it was approved at, as the ceiling
454    /// in force grants it. See [`in_force`].
455    pub in_force: ScopeSet,
456    /// Whether this server's policy would issue this client a token for
457    /// this account now.
458    pub admitted: bool,
459}
460
461/// Judges one live grant the way `POST /oauth/token` would judge it if the
462/// client presented its refresh token right now.
463///
464/// A grant whose stored `client_id` no longer parses may do nothing and is
465/// not admitted, which is the same answer every other reader of a stored
466/// client id gets; see `stored_client_id`.
467#[must_use]
468pub fn standing(
469    grant: &didbot_pds::oauth::Grant,
470    ceiling: &dyn ScopePolicy,
471    admission: &dyn TokenAdmission,
472) -> Standing {
473    let Some(client_id) = stored_client_id(&grant.client_id) else {
474        return Standing {
475            in_force: ScopeSet::default(),
476            admitted: false,
477        };
478    };
479    let in_force = in_force(&approved(grant), ceiling, &grant.did, &client_id);
480    Standing {
481        admitted: admission.admits(&grant.did, &client_id, &in_force),
482        in_force,
483    }
484}
485
486/// The stored client id of a grant or code, as the one spelling policy
487/// matches. One that does not parse is refused rather than judged, since no
488/// rule could be trusted to match it.
489fn stored_client_id(raw: &str) -> Option<ClientId> {
490    let parsed = ClientId::parse(raw);
491    if let Err(error) = &parsed {
492        tracing::warn!(client_id = raw, %error, "refusing an OAuth grant whose client_id policy cannot match");
493    }
494    parsed.ok()
495}
496
497/// What a stored login was approved at. A string that will not parse grants
498/// nothing, which is the safe direction for a value this crate rendered with
499/// [`ScopeSet`]'s own `Display`.
500///
501/// The empty set authorizes nothing, so a grant that stops parsing — after a
502/// grammar change, say — is a login that has quietly gone inert. It says so,
503/// the way `stored_client_id` says so about the other half of the same
504/// stored row.
505fn approved(grant: &didbot_pds::oauth::Grant) -> ScopeSet {
506    match ScopeSet::parse_grant(&grant.scope) {
507        Ok(scope) => scope,
508        Err(error) => {
509            tracing::warn!(
510                client_id = grant.client_id,
511                %error,
512                "an OAuth grant's stored scope no longer parses; this login authorizes nothing"
513            );
514            ScopeSet::default()
515        }
516    }
517}
518
519/// Whether `presented` may spend a code or login `client_id` holds, bound to
520/// `bound`: no assertion for a public client's, and an assertion from that
521/// client, signed with the key it began with, for a confidential one's.
522fn authenticated_as(
523    client_id: &str,
524    bound: Option<&ClientAuthKey>,
525    presented: Option<&AuthenticatedClient>,
526) -> Result<(), TokenError> {
527    match (bound, presented) {
528        (None, None) => Ok(()),
529        (Some(_), None) => Err(TokenError::ClientAssertionRequired),
530        (None, Some(_)) => Err(TokenError::ClientAuthenticationChanged),
531        (Some(_), Some(presented)) if presented.client_id != client_id => {
532            Err(TokenError::ClientMismatch)
533        }
534        (Some(bound), Some(presented)) if presented.key != *bound => {
535            Err(TokenError::ClientAuthenticationChanged)
536        }
537        (Some(_), Some(_)) => Ok(()),
538    }
539}
540
541/// Verifies a PKCE `code_verifier` against the `code_challenge` an
542/// authorization code was issued with. `S256` only — this server refuses
543/// `plain` at PAR, so nothing here needs to handle it.
544fn verify_pkce(code_verifier: &str, code_challenge: &str) -> bool {
545    use base64::engine::general_purpose::URL_SAFE_NO_PAD;
546    use base64::Engine;
547    use sha2::{Digest, Sha256};
548    let digest = Sha256::digest(code_verifier.as_bytes());
549    let computed = URL_SAFE_NO_PAD.encode(digest);
550    // Constant-time is not needed here the way a password comparison needs
551    // it: `code_verifier` is single-use, bound to a code that is itself
552    // single-use, and a timing side channel on it does not shorten a brute
553    // force enough to matter within `AUTHORIZATION_CODE_TTL`.
554    computed == code_challenge
555}
556
557/// OAuth access/refresh token issuance and rotation.
558///
559/// The family bookkeeping — which token digests are live, whose account they
560/// name, when they stop working, and the reuse rule that ends a family — is
561/// [`didbot_pds::oauth`]'s, so that a deployment with a `--data` directory
562/// carries its grants in the same write-ahead log its accounts and agent
563/// tokens already live in. What this type holds is the part that is this
564/// crate's: PKCE, DPoP proof verification, and the scope grammar.
565pub struct OAuthTokenStore {
566    grants: Arc<dyn OAuthGrantStore>,
567}
568
569impl Default for OAuthTokenStore {
570    fn default() -> Self {
571        Self::new()
572    }
573}
574
575impl OAuthTokenStore {
576    /// An empty in-process store, minting nothing yet.
577    pub fn new() -> Self {
578        Self::with_grants(Arc::new(MemoryOAuthGrantStore::new()))
579    }
580
581    /// A store over the grants a deployment supplied — the durable one for a
582    /// `--data` run; see `didbot_pds::Durable::oauth_grants`.
583    pub fn with_grants(grants: Arc<dyn OAuthGrantStore>) -> Self {
584        Self { grants }
585    }
586
587    /// This module's own [`ACCESS_TTL`] and [`REFRESH_TTL`], as the grant
588    /// store takes them.
589    fn lifetimes() -> Lifetimes {
590        Lifetimes {
591            access: ACCESS_TTL,
592            refresh: REFRESH_TTL,
593        }
594    }
595
596    /// The wire shape of a minted pair, carrying `scope`: what the pair may
597    /// do as it is issued.
598    fn tokens(minted: MintedGrant, scope: ScopeSet) -> OAuthTokens {
599        OAuthTokens {
600            did: minted.grant.did,
601            client_id: minted.grant.client_id,
602            scope,
603            access_token: minted.access_token,
604            refresh_token: minted.refresh_token,
605            access_expires_at: minted.grant.access_expires_at,
606            refresh_expires_at: minted.grant.refresh_expires_at,
607        }
608    }
609
610    /// Exchanges an authorization code for a fresh access/refresh pair,
611    /// binding both to the DPoP key `proof` proved possession of.
612    ///
613    /// `proof` is the request's DPoP proof, already verified: the route
614    /// checks it before anything in the body is spent, so a nonce challenge
615    /// leaves the code for the client's retry. It costs `dpop` a
616    /// replay-window entry once the code checks out. Its key must be the one
617    /// the sign-in's push was signed with, or the code is spent and nothing
618    /// is issued, as the reference provider orders it.
619    ///
620    /// `client` is the confidential client the request authenticated as,
621    /// if it carried an assertion; see this module's doc for what a code
622    /// needs of it.
623    ///
624    /// `admission` and `ceiling` are asked last, once the caller has proven
625    /// it holds the code and the key. A client admission refuses, or one the
626    /// ceiling now grants nothing, gets no token, and the code is spent
627    /// either way.
628    #[allow(clippy::too_many_arguments)]
629    pub fn exchange_code(
630        &self,
631        code_store: &dyn AuthorizationCodeStore,
632        dpop: &dyn DpopVerifier,
633        admission: &dyn TokenAdmission,
634        ceiling: &dyn ScopePolicy,
635        code: &str,
636        redirect_uri: &str,
637        code_verifier: &str,
638        client_id: &str,
639        client: Option<&AuthenticatedClient>,
640        proof: &VerifiedProof,
641    ) -> Result<OAuthTokens, TokenError> {
642        let record = code_store.redeem(code).ok_or(TokenError::InvalidCode)?;
643        if record.client_id != client_id {
644            return Err(TokenError::ClientMismatch);
645        }
646        if proof.thumbprint != record.dpop_jkt {
647            return Err(TokenError::CodeKeyMismatch);
648        }
649        authenticated_as(&record.client_id, record.client_auth_key.as_ref(), client)?;
650        if record.redirect_uri != redirect_uri {
651            return Err(TokenError::RedirectUriMismatch);
652        }
653        if record.code_challenge_method != "S256"
654            || !verify_pkce(code_verifier, &record.code_challenge)
655        {
656            return Err(TokenError::PkceFailed);
657        }
658        // The code and its PKCE verifier have already checked out, so this
659        // proof is one an authenticated caller presented and recording its
660        // `jti` is not something a stranger can make this server do.
661        dpop.accept(proof)?;
662        let thumbprint = proof.thumbprint.clone();
663        let client = stored_client_id(&record.client_id).ok_or(TokenError::ClientRefused)?;
664        let scope = in_force(&record.scope, ceiling, &record.did, &client);
665        if scope.0.is_empty() || !admission.admits(&record.did, &client, &scope) {
666            return Err(TokenError::ClientRefused);
667        }
668        let minted = self
669            .grants
670            .mint(
671                GrantRequest {
672                    did: record.did,
673                    client_id: record.client_id,
674                    scope: record.scope.to_string(),
675                    dpop_thumbprint: thumbprint.0,
676                    client_auth_key: record.client_auth_key,
677                },
678                Self::lifetimes(),
679            )
680            .map_err(|_| TokenError::NotKept)?;
681        Ok(Self::tokens(minted, scope))
682    }
683
684    /// Rotates a refresh token. A refresh token presented after it was
685    /// already rotated away revokes its whole family: a replayed refresh
686    /// token is indistinguishable from a race
687    /// with the legitimate client, and the safe response to either is to
688    /// stop trusting the family.
689    ///
690    /// `admission`, `ceiling` and `client` are asked only about a refresh
691    /// token that would rotate, so a replayed one still ends its family. A
692    /// client admission refuses, or one the ceiling now grants nothing, or
693    /// one that did not authenticate the way its login began (see this
694    /// module's doc), keeps its family, unrotated: the refresh token expires
695    /// on its own schedule, and works again if the refusal lifts first.
696    ///
697    /// `proof` is the request's DPoP proof, already verified, as
698    /// [`OAuthTokenStore::exchange_code`] takes one.
699    pub fn refresh(
700        &self,
701        dpop: &dyn DpopVerifier,
702        admission: &dyn TokenAdmission,
703        ceiling: &dyn ScopePolicy,
704        refresh_token: &str,
705        client: Option<&AuthenticatedClient>,
706        proof: &VerifiedProof,
707    ) -> Result<OAuthTokens, TokenError> {
708        let thumbprint = proof.thumbprint.clone();
709        if let Some(grant) = self.grants.resolve_refresh(refresh_token, &thumbprint.0) {
710            // The refresh token resolved against this proof's own key, so
711            // the caller holds a credential and the proof costs a
712            // replay-window entry. A refresh grant is reachable with any
713            // freshly generated key, which is why this is not spent before
714            // the token resolves.
715            dpop.accept(proof)?;
716            authenticated_as(&grant.client_id, grant.client_auth_key.as_ref(), client)?;
717            let Some(client) = stored_client_id(&grant.client_id) else {
718                return Err(TokenError::InvalidRefreshToken);
719            };
720            let scope = in_force(&approved(&grant), ceiling, &grant.did, &client);
721            if scope.0.is_empty() || !admission.admits(&grant.did, &client, &scope) {
722                return Err(TokenError::ClientRefused);
723            }
724        }
725        match self
726            .grants
727            .rotate(refresh_token, &thumbprint.0, Self::lifetimes())
728        {
729            Rotation::Rotated(minted) => {
730                let scope = stored_client_id(&minted.grant.client_id)
731                    .map(|client| {
732                        in_force(
733                            &approved(&minted.grant),
734                            ceiling,
735                            &minted.grant.did,
736                            &client,
737                        )
738                    })
739                    .unwrap_or_default();
740                Ok(Self::tokens(*minted, scope))
741            }
742            Rotation::Revoked {
743                error: GrantError::Reused,
744                ..
745            } => Err(TokenError::RefreshReused),
746            Rotation::Revoked { .. } | Rotation::Refused(GrantError::Unknown) => {
747                Err(TokenError::InvalidRefreshToken)
748            }
749            Rotation::Refused(GrantError::KeyMismatch) => Err(TokenError::DpopKeyMismatch),
750            Rotation::Refused(_) => Err(TokenError::InvalidRefreshToken),
751        }
752    }
753
754    /// The account an OAuth access token authenticates as, and the client
755    /// it was issued to, or `None`.
756    ///
757    /// `thumbprint` is not optional: it is the RFC 7638 thumbprint of the
758    /// key a caller has already proven possession of — by verifying a fresh
759    /// DPoP proof against the request this token accompanies, the same way
760    /// [`OAuthTokenStore::exchange_code`] and [`OAuthTokenStore::refresh`]
761    /// take a [`VerifiedProof`] rather than a bare parameter. A caller
762    /// cannot get a thumbprint any other way than verifying a proof, so
763    /// this signature makes "authenticate a bare
764    /// token string with no proof of possession" a type error rather than a
765    /// documented trap — see `plan/adversarial.md`'s DPoP-binding findings,
766    /// which this signature exists to close.
767    ///
768    /// Refuses (returns `None`) on an unknown, stale, revoked, or
769    /// already-rotated-away access token — and whenever `thumbprint` does
770    /// not match the key this token's family was minted bound to, which is
771    /// the entire point of DPoP: a copied bearer string is worthless to
772    /// whoever copied it, because their proof is signed by a different key
773    /// with a different thumbprint. Also `None` for a grant whose stored
774    /// client id policy could not match; see `stored_client_id`.
775    ///
776    /// The scope answered is [`in_force`] under `ceiling`, never what was
777    /// granted when the token was issued.
778    pub fn validate_access(
779        &self,
780        access_token: &str,
781        thumbprint: &DpopThumbprint,
782        ceiling: &dyn ScopePolicy,
783    ) -> Option<AccessGrant> {
784        let grant = self.grants.resolve_access(access_token, &thumbprint.0)?;
785        let client_id = stored_client_id(&grant.client_id)?;
786        Some(AccessGrant {
787            scope: in_force(&approved(&grant), ceiling, &grant.did, &client_id),
788            client_id,
789            did: grant.did,
790        })
791    }
792
793    /// Ends every grant this store holds for `did`, immediately.
794    ///
795    /// What a deletion calls, through
796    /// [`didbot_pds::Registry::set_account_revoke_hook`]: an OAuth grant
797    /// names an account, and a grant that answers for an account this
798    /// deployment no longer has is a credential with nothing behind it.
799    ///
800    /// A family is ended the same way a detected refresh reuse ends one — so
801    /// an access token for a deleted account stops validating on the next
802    /// call and its refresh token has nothing left to rotate into — and a
803    /// durable store writes the revocation down before this returns. Answers
804    /// how many families went.
805    ///
806    /// # Errors
807    ///
808    /// [`Unkept`] when the grant store could not write the ending down. The
809    /// logins end in this process either way, and a restart may bring them
810    /// back.
811    pub fn revoke_account(&self, did: &str) -> Result<usize, Unkept> {
812        let revoked = self.grants.revoke_account(did)?;
813        if !revoked.is_empty() {
814            tracing::info!(
815                did,
816                grants = revoked.len(),
817                "revoked an account's OAuth grants"
818            );
819        }
820        Ok(revoked.len())
821    }
822
823    /// Every live grant family this store holds, for the operations
824    /// dashboard's per-app view; see
825    /// [`didbot_pds::oauth::OAuthGrantStore::live`].
826    #[must_use]
827    pub fn live(&self) -> Vec<didbot_pds::oauth::Grant> {
828        self.grants.live()
829    }
830
831    /// Ends every grant this store holds for `client_id`, or for
832    /// `client_id` on `did` alone, immediately. Answers the logins that
833    /// went, each naming its account.
834    ///
835    /// What an operator reaches for when an application's keys or tokens
836    /// leak: the logins it holds end for good, and the application itself
837    /// stays as allowed as it was. Denying it is a separate, reversible
838    /// policy edit.
839    ///
840    /// # Errors
841    ///
842    /// [`Unkept`] when the grant store could not write the ending down. The
843    /// logins end in this process either way, and a restart may bring them
844    /// back.
845    pub fn revoke_client(
846        &self,
847        client_id: &str,
848        did: Option<&str>,
849    ) -> Result<Vec<didbot_pds::oauth::Grant>, Unkept> {
850        self.grants.revoke_client(client_id, did)
851    }
852
853    /// Ends every grant this store holds, immediately — what the e-stop's
854    /// Revoke setting throws.
855    pub fn revoke_all(&self) {
856        let revoked = self.grants.revoke_all();
857        if !revoked.is_empty() {
858            tracing::info!(grants = revoked.len(), "revoked every OAuth grant");
859        }
860    }
861}
862
863#[cfg(test)]
864mod tests {
865    use super::*;
866    use crate::oauth::authorize::GrantAnyScope;
867
868    /// A replay window that records nothing, for tests about token issuance
869    /// rather than DPoP itself. The grants take a proof the route already
870    /// verified, so this verifies nothing either; `oauth::dpop` has the real
871    /// checks.
872    struct NoReplay;
873    impl DpopVerifier for NoReplay {
874        fn verify(
875            &self,
876            _proof: &str,
877            _method: &str,
878            _uri: &str,
879            _access_token: Option<&str>,
880        ) -> Result<VerifiedProof, DpopError> {
881            Err(DpopError::Refused("the grants verify nothing".to_owned()))
882        }
883    }
884
885    /// A proof of possession of the key `thumbprint` names, as the token
886    /// route hands a grant one.
887    fn proof(thumbprint: &str) -> VerifiedProof {
888        VerifiedProof::for_key(thumbprint)
889    }
890
891    /// Admits every client, for tests about issuance rather than policy.
892    struct AdmitAll;
893    impl TokenAdmission for AdmitAll {
894        fn admits(&self, _did: &str, _client_id: &ClientId, _scope: &ScopeSet) -> bool {
895            true
896        }
897    }
898
899    /// Refuses every client.
900    struct RefuseAll;
901    impl TokenAdmission for RefuseAll {
902        fn admits(&self, _did: &str, _client_id: &ClientId, _scope: &ScopeSet) -> bool {
903            false
904        }
905    }
906
907    fn code_verifier_and_challenge() -> (String, String) {
908        use base64::engine::general_purpose::URL_SAFE_NO_PAD;
909        use base64::Engine;
910        use sha2::{Digest, Sha256};
911        let verifier = "a-fixed-code-verifier-at-least-43-characters-long".to_owned();
912        let challenge = URL_SAFE_NO_PAD.encode(Sha256::digest(verifier.as_bytes()));
913        (verifier, challenge)
914    }
915
916    fn issue_code(code_store: &dyn AuthorizationCodeStore, code_challenge: &str) -> String {
917        code_store.issue(AuthorizationCodeRecord {
918            did: "did:web:agent.example".to_owned(),
919            client_id: "https://client.example/id.json".to_owned(),
920            scope: ScopeSet::parse("atproto").unwrap(),
921            redirect_uri: "https://client.example/cb".to_owned(),
922            code_challenge: code_challenge.to_owned(),
923            code_challenge_method: "S256".to_owned(),
924            client_auth_key: None,
925            dpop_jkt: DpopThumbprint("thumb-a".to_owned()),
926        })
927    }
928
929    #[test]
930    fn exchanging_a_code_with_the_right_verifier_mints_a_bound_pair() {
931        let (verifier, challenge) = code_verifier_and_challenge();
932        let codes = MemoryAuthorizationCodeStore::new();
933        let code = issue_code(&codes, &challenge);
934        let store = OAuthTokenStore::new();
935        let tokens = store
936            .exchange_code(
937                &codes,
938                &NoReplay,
939                &AdmitAll,
940                &GrantAnyScope,
941                &code,
942                "https://client.example/cb",
943                &verifier,
944                "https://client.example/id.json",
945                None,
946                &proof("thumb-a"),
947            )
948            .unwrap();
949        assert_eq!(tokens.did, "did:web:agent.example");
950        assert_eq!(
951            store
952                .validate_access(
953                    &tokens.access_token,
954                    &DpopThumbprint("thumb-a".to_owned()),
955                    &GrantAnyScope
956                )
957                .map(|grant| grant.did),
958            Some("did:web:agent.example".to_owned())
959        );
960    }
961
962    /// A bare token string is not a credential on its own: a caller must
963    /// also present the thumbprint of a key it has proven possession of
964    /// (via a freshly verified DPoP proof), and a token minted bound to one
965    /// key authenticates for nobody presenting a different one — the same
966    /// property a stolen bearer string must fail.
967    #[test]
968    fn validate_access_refuses_the_right_token_with_the_wrong_thumbprint() {
969        let (verifier, challenge) = code_verifier_and_challenge();
970        let codes = MemoryAuthorizationCodeStore::new();
971        let code = issue_code(&codes, &challenge);
972        let store = OAuthTokenStore::new();
973        let tokens = store
974            .exchange_code(
975                &codes,
976                &NoReplay,
977                &AdmitAll,
978                &GrantAnyScope,
979                &code,
980                "https://client.example/cb",
981                &verifier,
982                "https://client.example/id.json",
983                None,
984                &proof("thumb-a"),
985            )
986            .unwrap();
987        assert_eq!(
988            store.validate_access(&tokens.access_token, &DpopThumbprint("thumb-b".to_owned()), &GrantAnyScope).map(|grant| grant.did),
989            None,
990            "a stolen access token presented with a different key's thumbprint must not authenticate"
991        );
992    }
993
994    /// A code a client never exchanges is swept by the next issue once it
995    /// has expired.
996    #[test]
997    fn an_unredeemed_code_is_swept_once_it_has_expired() {
998        let (_, challenge) = code_verifier_and_challenge();
999        let codes = MemoryAuthorizationCodeStore::new();
1000        let abandoned = issue_code(&codes, &challenge);
1001        codes
1002            .entries
1003            .write()
1004            .unwrap()
1005            .get_mut(&abandoned)
1006            .unwrap()
1007            .expires_at = OffsetDateTime::now_utc() - time::Duration::seconds(1);
1008
1009        let fresh = issue_code(&codes, &challenge);
1010        assert_eq!(codes.len(), 1);
1011        assert!(codes.redeem(&fresh).is_some());
1012        assert_eq!(codes.len(), 0);
1013    }
1014
1015    #[test]
1016    fn a_code_is_one_shot() {
1017        let (verifier, challenge) = code_verifier_and_challenge();
1018        let codes = MemoryAuthorizationCodeStore::new();
1019        let code = issue_code(&codes, &challenge);
1020        let store = OAuthTokenStore::new();
1021        let exchange = |c: &str| {
1022            store.exchange_code(
1023                &codes,
1024                &NoReplay,
1025                &AdmitAll,
1026                &GrantAnyScope,
1027                c,
1028                "https://client.example/cb",
1029                &verifier,
1030                "https://client.example/id.json",
1031                None,
1032                &proof("thumb-a"),
1033            )
1034        };
1035        assert!(exchange(&code).is_ok());
1036        assert_eq!(exchange(&code).unwrap_err(), TokenError::InvalidCode);
1037    }
1038
1039    #[test]
1040    fn a_wrong_code_verifier_fails_pkce() {
1041        let (_verifier, challenge) = code_verifier_and_challenge();
1042        let codes = MemoryAuthorizationCodeStore::new();
1043        let code = issue_code(&codes, &challenge);
1044        let store = OAuthTokenStore::new();
1045        let err = store
1046            .exchange_code(
1047                &codes,
1048                &NoReplay,
1049                &AdmitAll,
1050                &GrantAnyScope,
1051                &code,
1052                "https://client.example/cb",
1053                "wrong-verifier",
1054                "https://client.example/id.json",
1055                None,
1056                &proof("thumb-a"),
1057            )
1058            .unwrap_err();
1059        assert_eq!(err, TokenError::PkceFailed);
1060    }
1061
1062    /// RFC 9449 §10: a code is exchanged only under the key its sign-in's
1063    /// push was signed with. Any other key spends the code and mints
1064    /// nothing.
1065    #[test]
1066    fn a_code_is_refused_under_another_key() {
1067        let (verifier, challenge) = code_verifier_and_challenge();
1068        let codes = MemoryAuthorizationCodeStore::new();
1069        let code = issue_code(&codes, &challenge);
1070        let store = OAuthTokenStore::new();
1071        let exchange = |thumbprint: &str| {
1072            store.exchange_code(
1073                &codes,
1074                &NoReplay,
1075                &AdmitAll,
1076                &GrantAnyScope,
1077                &code,
1078                "https://client.example/cb",
1079                &verifier,
1080                "https://client.example/id.json",
1081                None,
1082                &proof(thumbprint),
1083            )
1084        };
1085        assert_eq!(
1086            exchange("thumb-b").unwrap_err(),
1087            TokenError::CodeKeyMismatch
1088        );
1089        assert_eq!(exchange("thumb-a").unwrap_err(), TokenError::InvalidCode);
1090    }
1091
1092    #[test]
1093    fn a_mismatched_redirect_uri_is_refused() {
1094        let (verifier, challenge) = code_verifier_and_challenge();
1095        let codes = MemoryAuthorizationCodeStore::new();
1096        let code = issue_code(&codes, &challenge);
1097        let store = OAuthTokenStore::new();
1098        let err = store
1099            .exchange_code(
1100                &codes,
1101                &NoReplay,
1102                &AdmitAll,
1103                &GrantAnyScope,
1104                &code,
1105                "https://client.example/somewhere-else",
1106                &verifier,
1107                "https://client.example/id.json",
1108                None,
1109                &proof("thumb-a"),
1110            )
1111            .unwrap_err();
1112        assert_eq!(err, TokenError::RedirectUriMismatch);
1113    }
1114
1115    #[test]
1116    fn refresh_rotates_both_tokens_and_the_old_access_token_stops_working() {
1117        let (verifier, challenge) = code_verifier_and_challenge();
1118        let codes = MemoryAuthorizationCodeStore::new();
1119        let code = issue_code(&codes, &challenge);
1120        let store = OAuthTokenStore::new();
1121        let first = store
1122            .exchange_code(
1123                &codes,
1124                &NoReplay,
1125                &AdmitAll,
1126                &GrantAnyScope,
1127                &code,
1128                "https://client.example/cb",
1129                &verifier,
1130                "https://client.example/id.json",
1131                None,
1132                &proof("thumb-a"),
1133            )
1134            .unwrap();
1135        let second = store
1136            .refresh(
1137                &NoReplay,
1138                &AdmitAll,
1139                &GrantAnyScope,
1140                &first.refresh_token,
1141                None,
1142                &proof("thumb-a"),
1143            )
1144            .unwrap();
1145        assert_ne!(first.access_token, second.access_token);
1146        assert_ne!(first.refresh_token, second.refresh_token);
1147        assert_eq!(
1148            store
1149                .validate_access(
1150                    &first.access_token,
1151                    &DpopThumbprint("thumb-a".to_owned()),
1152                    &GrantAnyScope
1153                )
1154                .map(|grant| grant.did),
1155            None
1156        );
1157        assert_eq!(
1158            store
1159                .validate_access(
1160                    &second.access_token,
1161                    &DpopThumbprint("thumb-a".to_owned()),
1162                    &GrantAnyScope
1163                )
1164                .map(|grant| grant.did),
1165            Some("did:web:agent.example".to_owned())
1166        );
1167    }
1168
1169    /// A deleted account's OAuth grant stops answering, and its refresh
1170    /// token has nothing left to rotate into.
1171    #[test]
1172    fn revoking_an_account_ends_its_grant_and_its_refresh() {
1173        let (verifier, challenge) = code_verifier_and_challenge();
1174        let codes = MemoryAuthorizationCodeStore::new();
1175        let code = issue_code(&codes, &challenge);
1176        let store = OAuthTokenStore::new();
1177        let tokens = store
1178            .exchange_code(
1179                &codes,
1180                &NoReplay,
1181                &AdmitAll,
1182                &GrantAnyScope,
1183                &code,
1184                "https://client.example/cb",
1185                &verifier,
1186                "https://client.example/id.json",
1187                None,
1188                &proof("thumb-a"),
1189            )
1190            .unwrap();
1191        assert_eq!(
1192            store
1193                .validate_access(
1194                    &tokens.access_token,
1195                    &DpopThumbprint("thumb-a".to_owned()),
1196                    &GrantAnyScope
1197                )
1198                .map(|grant| grant.did),
1199            Some("did:web:agent.example".to_owned())
1200        );
1201
1202        assert_eq!(
1203            store.revoke_account("did:web:agent.example").ok(),
1204            Some(1),
1205            "a memory store keeps every ending"
1206        );
1207
1208        assert_eq!(
1209            store
1210                .validate_access(
1211                    &tokens.access_token,
1212                    &DpopThumbprint("thumb-a".to_owned()),
1213                    &GrantAnyScope
1214                )
1215                .map(|grant| grant.did),
1216            None
1217        );
1218        assert!(store
1219            .refresh(
1220                &NoReplay,
1221                &AdmitAll,
1222                &GrantAnyScope,
1223                &tokens.refresh_token,
1224                None,
1225                &proof("thumb-a"),
1226            )
1227            .is_err());
1228    }
1229
1230    /// An unredeemed code is one hop short of a grant, so a deletion drops
1231    /// it rather than leaving it to mint one.
1232    #[test]
1233    fn revoking_an_account_drops_its_unredeemed_codes() {
1234        let (_verifier, challenge) = code_verifier_and_challenge();
1235        let codes = MemoryAuthorizationCodeStore::new();
1236        let code = issue_code(&codes, &challenge);
1237
1238        codes.revoke_account("did:web:agent.example");
1239
1240        assert!(codes.redeem(&code).is_none());
1241    }
1242
1243    #[test]
1244    fn reusing_a_spent_refresh_token_revokes_the_family() {
1245        let (verifier, challenge) = code_verifier_and_challenge();
1246        let codes = MemoryAuthorizationCodeStore::new();
1247        let code = issue_code(&codes, &challenge);
1248        let store = OAuthTokenStore::new();
1249        let first = store
1250            .exchange_code(
1251                &codes,
1252                &NoReplay,
1253                &AdmitAll,
1254                &GrantAnyScope,
1255                &code,
1256                "https://client.example/cb",
1257                &verifier,
1258                "https://client.example/id.json",
1259                None,
1260                &proof("thumb-a"),
1261            )
1262            .unwrap();
1263        let second = store
1264            .refresh(
1265                &NoReplay,
1266                &AdmitAll,
1267                &GrantAnyScope,
1268                &first.refresh_token,
1269                None,
1270                &proof("thumb-a"),
1271            )
1272            .unwrap();
1273        let err = store
1274            .refresh(
1275                &NoReplay,
1276                &AdmitAll,
1277                &GrantAnyScope,
1278                &first.refresh_token,
1279                None,
1280                &proof("thumb-a"),
1281            )
1282            .unwrap_err();
1283        assert_eq!(err, TokenError::RefreshReused);
1284        assert_eq!(
1285            store
1286                .validate_access(
1287                    &second.access_token,
1288                    &DpopThumbprint("thumb-a".to_owned()),
1289                    &GrantAnyScope
1290                )
1291                .map(|grant| grant.did),
1292            None
1293        );
1294    }
1295
1296    #[test]
1297    fn refreshing_with_a_different_dpop_key_is_refused() {
1298        let (verifier, challenge) = code_verifier_and_challenge();
1299        let codes = MemoryAuthorizationCodeStore::new();
1300        let code = issue_code(&codes, &challenge);
1301        let store = OAuthTokenStore::new();
1302        let first = store
1303            .exchange_code(
1304                &codes,
1305                &NoReplay,
1306                &AdmitAll,
1307                &GrantAnyScope,
1308                &code,
1309                "https://client.example/cb",
1310                &verifier,
1311                "https://client.example/id.json",
1312                None,
1313                &proof("thumb-a"),
1314            )
1315            .unwrap();
1316        let err = store
1317            .refresh(
1318                &NoReplay,
1319                &AdmitAll,
1320                &GrantAnyScope,
1321                &first.refresh_token,
1322                None,
1323                &proof("thumb-b"),
1324            )
1325            .unwrap_err();
1326        assert_eq!(err, TokenError::DpopKeyMismatch);
1327    }
1328
1329    /// Signs a client in with `admission`, the way every test above does by
1330    /// hand.
1331    fn sign_in(
1332        store: &OAuthTokenStore,
1333        admission: &dyn TokenAdmission,
1334    ) -> Result<OAuthTokens, TokenError> {
1335        let (verifier, challenge) = code_verifier_and_challenge();
1336        let codes = MemoryAuthorizationCodeStore::new();
1337        let code = issue_code(&codes, &challenge);
1338        store.exchange_code(
1339            &codes,
1340            &NoReplay,
1341            admission,
1342            &GrantAnyScope,
1343            &code,
1344            "https://client.example/cb",
1345            &verifier,
1346            "https://client.example/id.json",
1347            None,
1348            &proof("thumb-a"),
1349        )
1350    }
1351
1352    fn refresh_with(
1353        store: &OAuthTokenStore,
1354        admission: &dyn TokenAdmission,
1355        refresh_token: &str,
1356    ) -> Result<OAuthTokens, TokenError> {
1357        store.refresh(
1358            &NoReplay,
1359            admission,
1360            &GrantAnyScope,
1361            refresh_token,
1362            None,
1363            &proof("thumb-a"),
1364        )
1365    }
1366
1367    #[test]
1368    fn a_refused_client_gets_no_token_for_its_code() {
1369        let store = OAuthTokenStore::new();
1370        assert_eq!(
1371            sign_in(&store, &RefuseAll).unwrap_err(),
1372            TokenError::ClientRefused
1373        );
1374    }
1375
1376    /// A refusal ends nothing. The refresh token is not rotated, so it still
1377    /// expires on schedule, and it works again once the policy lifts.
1378    #[test]
1379    fn a_refused_refresh_keeps_its_family() {
1380        let store = OAuthTokenStore::new();
1381        let tokens = sign_in(&store, &AdmitAll).unwrap();
1382        assert_eq!(
1383            refresh_with(&store, &RefuseAll, &tokens.refresh_token).unwrap_err(),
1384            TokenError::ClientRefused
1385        );
1386        assert!(refresh_with(&store, &AdmitAll, &tokens.refresh_token).is_ok());
1387    }
1388
1389    /// A replayed refresh token ends its family whatever policy says, so a
1390    /// refusal cannot shield a stolen token from the reuse rule.
1391    #[test]
1392    fn a_replay_ends_its_family_even_under_a_refusal() {
1393        let store = OAuthTokenStore::new();
1394        let first = sign_in(&store, &AdmitAll).unwrap();
1395        let second = refresh_with(&store, &AdmitAll, &first.refresh_token).unwrap();
1396        assert_eq!(
1397            refresh_with(&store, &RefuseAll, &first.refresh_token).unwrap_err(),
1398            TokenError::RefreshReused
1399        );
1400        assert!(
1401            refresh_with(&store, &AdmitAll, &second.refresh_token).is_err(),
1402            "the family is over"
1403        );
1404    }
1405
1406    /// The key a confidential client's login is bound to in these tests.
1407    fn client_key(kid: &str) -> ClientAuthKey {
1408        ClientAuthKey {
1409            kid: kid.to_owned(),
1410            alg: "ES256".to_owned(),
1411            jkt: format!("{kid}-thumbprint"),
1412        }
1413    }
1414
1415    /// The confidential client these tests sign in, authenticated with the
1416    /// key named `kid`.
1417    fn authenticated(kid: &str) -> AuthenticatedClient {
1418        AuthenticatedClient {
1419            client_id: "https://client.example/id.json".to_owned(),
1420            key: client_key(kid),
1421        }
1422    }
1423
1424    /// Exchanges a fresh code a confidential client pushed with
1425    /// `client-key-1`, the request authenticated as `presented`.
1426    fn exchange_confidential(
1427        store: &OAuthTokenStore,
1428        presented: Option<&AuthenticatedClient>,
1429    ) -> Result<OAuthTokens, TokenError> {
1430        let (verifier, challenge) = code_verifier_and_challenge();
1431        let codes = MemoryAuthorizationCodeStore::new();
1432        let code = codes.issue(AuthorizationCodeRecord {
1433            did: "did:web:agent.example".to_owned(),
1434            client_id: "https://client.example/id.json".to_owned(),
1435            scope: ScopeSet::parse("atproto").unwrap(),
1436            redirect_uri: "https://client.example/cb".to_owned(),
1437            code_challenge: challenge,
1438            code_challenge_method: "S256".to_owned(),
1439            client_auth_key: Some(client_key("client-key-1")),
1440            dpop_jkt: DpopThumbprint("thumb-a".to_owned()),
1441        });
1442        store.exchange_code(
1443            &codes,
1444            &NoReplay,
1445            &AdmitAll,
1446            &GrantAnyScope,
1447            &code,
1448            "https://client.example/cb",
1449            &verifier,
1450            "https://client.example/id.json",
1451            presented,
1452            &proof("thumb-a"),
1453        )
1454    }
1455
1456    fn refresh_as(
1457        store: &OAuthTokenStore,
1458        refresh_token: &str,
1459        presented: Option<&AuthenticatedClient>,
1460    ) -> Result<OAuthTokens, TokenError> {
1461        store.refresh(
1462            &NoReplay,
1463            &AdmitAll,
1464            &GrantAnyScope,
1465            refresh_token,
1466            presented,
1467            &proof("thumb-a"),
1468        )
1469    }
1470
1471    /// A confidential client's code is exchanged only with an assertion
1472    /// signed by the key it pushed with, and the login it becomes is bound
1473    /// to that key.
1474    #[test]
1475    fn a_confidential_code_is_exchanged_only_with_the_key_it_was_pushed_with() {
1476        let store = OAuthTokenStore::new();
1477        assert_eq!(
1478            exchange_confidential(&store, None).unwrap_err(),
1479            TokenError::ClientAssertionRequired
1480        );
1481        assert_eq!(
1482            exchange_confidential(&store, Some(&authenticated("client-key-2"))).unwrap_err(),
1483            TokenError::ClientAuthenticationChanged
1484        );
1485        exchange_confidential(&store, Some(&authenticated("client-key-1")))
1486            .expect("the key the code was pushed with exchanges it");
1487        let live = store.live();
1488        assert_eq!(
1489            live.len(),
1490            1,
1491            "only the authenticated exchange minted a login"
1492        );
1493        assert_eq!(live[0].client_auth_key, Some(client_key("client-key-1")));
1494    }
1495
1496    /// Every refresh of a confidential client's login is signed with the key
1497    /// it began with. A refresh signed otherwise, or not at all, is refused
1498    /// and rotates nothing, so the right key still refreshes afterwards.
1499    #[test]
1500    fn a_confidential_login_refreshes_only_with_the_key_it_began_with() {
1501        let store = OAuthTokenStore::new();
1502        let tokens = exchange_confidential(&store, Some(&authenticated("client-key-1")))
1503            .expect("the login begins");
1504        assert_eq!(
1505            refresh_as(&store, &tokens.refresh_token, None).unwrap_err(),
1506            TokenError::ClientAssertionRequired
1507        );
1508        assert_eq!(
1509            refresh_as(
1510                &store,
1511                &tokens.refresh_token,
1512                Some(&authenticated("client-key-2"))
1513            )
1514            .unwrap_err(),
1515            TokenError::ClientAuthenticationChanged
1516        );
1517        let elsewhere = AuthenticatedClient {
1518            client_id: "https://elsewhere.example/id.json".to_owned(),
1519            ..authenticated("client-key-1")
1520        };
1521        assert_eq!(
1522            refresh_as(&store, &tokens.refresh_token, Some(&elsewhere)).unwrap_err(),
1523            TokenError::ClientMismatch
1524        );
1525        refresh_as(
1526            &store,
1527            &tokens.refresh_token,
1528            Some(&authenticated("client-key-1")),
1529        )
1530        .expect("the key the login began with refreshes it");
1531    }
1532
1533    /// A public client's login stays a public client's: an assertion brought
1534    /// to it is refused, and the refresh token still works without one.
1535    #[test]
1536    fn a_public_login_is_refreshed_without_an_assertion() {
1537        let store = OAuthTokenStore::new();
1538        let tokens = sign_in(&store, &AdmitAll).unwrap();
1539        assert_eq!(
1540            refresh_as(
1541                &store,
1542                &tokens.refresh_token,
1543                Some(&authenticated("client-key-1"))
1544            )
1545            .unwrap_err(),
1546            TokenError::ClientAuthenticationChanged
1547        );
1548        refresh_as(&store, &tokens.refresh_token, None).expect("the refusal rotated nothing");
1549    }
1550
1551    /// The whole seam, over a real directory: a client that exchanged a code
1552    /// before a restart still authenticates after one, with the scope it was
1553    /// granted, and can still rotate.
1554    ///
1555    /// Lives here rather than in `didbot-pds`'s own durability suite because
1556    /// what it pins is this crate's half — the scope set crossing the crate
1557    /// boundary as the string the log holds and coming back as the same
1558    /// `ScopeSet` — which that suite cannot see.
1559    #[test]
1560    fn a_grant_survives_a_restart_with_the_scope_it_was_granted() {
1561        let dir = std::env::temp_dir().join(format!(
1562            "didbot-oauth-durable-{}-{:?}",
1563            std::process::id(),
1564            std::thread::current().id()
1565        ));
1566        let _ = std::fs::remove_dir_all(&dir);
1567        let granted = ScopeSet::parse("atproto repo:com.example.thing?action=create").unwrap();
1568
1569        let issued = {
1570            let durable =
1571                didbot_pds::Durable::open(&dir, Duration::days(30)).expect("the log opens");
1572            let store = OAuthTokenStore::with_grants(durable.oauth_grants());
1573            let (verifier, challenge) = code_verifier_and_challenge();
1574            let codes = MemoryAuthorizationCodeStore::new();
1575            let code = codes.issue(AuthorizationCodeRecord {
1576                did: "did:web:agent.example".to_owned(),
1577                client_id: "https://client.example/id.json".to_owned(),
1578                scope: granted.clone(),
1579                redirect_uri: "https://client.example/cb".to_owned(),
1580                code_challenge: challenge,
1581                code_challenge_method: "S256".to_owned(),
1582                client_auth_key: None,
1583                dpop_jkt: DpopThumbprint("thumb-a".to_owned()),
1584            });
1585            let issued = store
1586                .exchange_code(
1587                    &codes,
1588                    &NoReplay,
1589                    &AdmitAll,
1590                    &GrantAnyScope,
1591                    &code,
1592                    "https://client.example/cb",
1593                    &verifier,
1594                    "https://client.example/id.json",
1595                    None,
1596                    &proof("thumb-a"),
1597                )
1598                .expect("the exchange succeeds");
1599            assert_eq!(issued.scope, granted);
1600            durable.wal().sync().expect("the log syncs");
1601            issued
1602        };
1603
1604        let durable = didbot_pds::Durable::open(&dir, Duration::days(30)).expect("the log replays");
1605        let store = OAuthTokenStore::with_grants(durable.oauth_grants());
1606        assert_eq!(
1607            store
1608                .validate_access(
1609                    &issued.access_token,
1610                    &DpopThumbprint("thumb-a".to_owned()),
1611                    &GrantAnyScope
1612                )
1613                .map(|grant| grant.did),
1614            Some("did:web:agent.example".to_owned())
1615        );
1616        assert_eq!(
1617            store
1618                .validate_access(
1619                    &issued.access_token,
1620                    &DpopThumbprint("thumb-b".to_owned()),
1621                    &GrantAnyScope
1622                )
1623                .map(|grant| grant.did),
1624            None,
1625            "a restart must not loosen the DPoP binding"
1626        );
1627        let rotated = store
1628            .refresh(
1629                &NoReplay,
1630                &AdmitAll,
1631                &GrantAnyScope,
1632                &issued.refresh_token,
1633                None,
1634                &proof("thumb-a"),
1635            )
1636            .expect("rotation still works after a restart");
1637        assert_eq!(rotated.scope, granted);
1638        assert_eq!(rotated.did, "did:web:agent.example");
1639        assert_eq!(
1640            store.refresh(
1641                &NoReplay,
1642                &AdmitAll,
1643                &GrantAnyScope,
1644                &issued.refresh_token,
1645                None,
1646                &proof("thumb-a"),
1647            ),
1648            Err(TokenError::RefreshReused),
1649            "the reuse rule survives the restart too"
1650        );
1651
1652        drop(durable);
1653        let _ = std::fs::remove_dir_all(&dir);
1654    }
1655}