Skip to main content

didbot_serve/oauth/
consent.rs

1//! The consent seam.
2//!
3//! `plan/oauth.md`'s hard part: OAuth's `authorize` step assumes a human at a
4//! browser, and an agent has none. What stands in for the person is the
5//! agent's own daemon: `POST /oauth/par` mints a decision record and a
6//! one-time [`ConsentReference`] for it, the daemon reads the decision
7//! through `bot.did.listPendingAuthorizations` and answers it with
8//! `bot.did.approveAuthorization`, carrying that reference under
9//! `Credential::AccountSelf`. The DID doing the approving is therefore the one
10//! the presented agent token authenticates as, never a value in the body.
11//!
12//! What this module builds is everything behind that route:
13//! [`ConsentReference`] minting, [`ConsentStore`], and [`confirm`] — the
14//! function the route reaches, doing two checks:
15//!
16//! 1. the reference is live and unused, and
17//! 2. the account named in the request is the authenticated agent.
18//!
19//! **Policy is not one of them.** The sign-in was judged once, at PAR
20//! ([`crate::oauth::par`]), and the answer is in the record: a record whose
21//! verdict is a denial carries no reference, so there is nothing here to
22//! confirm. Evaluating again at this point could only contradict a record
23//! that already told the agent `allow`.
24//!
25//! # The invariant
26//!
27//! **Approval happens because the named agent confirmed, never because
28//! there was nobody to say no.** [`confirm`] takes the authenticated DID as
29//! an explicit argument rather than trusting anything in the request body,
30//! so a caller cannot self-attest its own identity.
31
32use std::collections::HashMap;
33use std::sync::RwLock;
34
35use rand::rngs::OsRng;
36use rand::TryRngCore;
37use time::{Duration, OffsetDateTime};
38
39use didbot_pds::oauth::ClientAuthKey;
40
41use crate::oauth::dpop_seam::DpopThumbprint;
42use crate::oauth::scope::ScopeSet;
43
44/// How long a minted consent reference stays live before it must be reissued.
45///
46/// Short: this is the window between the headless browser reading the
47/// `authorize` page and the agent's confirming tool call landing, not a
48/// session lifetime.
49pub const CONSENT_TTL: Duration = Duration::minutes(5);
50
51/// Random bytes a reference is drawn from.
52const REFERENCE_BYTES: usize = 24;
53
54/// A one-time reference minted when `authorize` is ready for confirmation.
55#[derive(Debug, Clone, PartialEq, Eq, Hash)]
56pub struct ConsentReference(pub String);
57
58impl ConsentReference {
59    fn mint() -> Self {
60        let mut bytes = [0u8; REFERENCE_BYTES];
61        OsRng
62            .try_fill_bytes(&mut bytes)
63            .expect("the OS random source is available");
64        Self(hex::encode(bytes))
65    }
66}
67
68/// What a pending consent decision is about.
69#[derive(Debug, Clone)]
70pub struct PendingConsent {
71    /// The `request_uri` this consent will confirm, so [`confirm`] can hand
72    /// the caller back to `authorize`'s own PAR-backed request state.
73    pub request_uri: String,
74    /// The account the pushed request named — `login_hint`, resolved to a
75    /// DID by whatever resolves at-identifiers. This is "the account named
76    /// in the request" that check 2 compares the stamp against.
77    pub did: String,
78    /// The client asking, carried through for logging.
79    pub client_id: String,
80    /// What the decision record granted of the client's request — the set
81    /// the agent is shown and approves. The code carries it, and what a
82    /// token may do is this as the ceiling grants it at each use — see
83    /// `crate::oauth::token`.
84    pub granted_scope: ScopeSet,
85    /// Where the authorization code must be delivered — checked again at
86    /// `token`, per RFC 6749 §4.1.3.
87    pub redirect_uri: String,
88    /// What the client sent as `state`, to be handed back on the redirect.
89    /// A client checks it against what it generated, which is how it tells
90    /// its own flow from one somebody else started in its name.
91    pub state: Option<String>,
92    /// PKCE fields, carried through so `token` can verify `code_verifier`
93    /// after the pushed request has expired.
94    pub code_challenge: String,
95    /// The PKCE method the challenge was computed with; always `S256`.
96    pub code_challenge_method: String,
97    /// The key a confidential client authenticated with at PAR. The code,
98    /// and the login it becomes, stay bound to it. `None` for a public
99    /// client.
100    pub client_auth_key: Option<ClientAuthKey>,
101    /// The thumbprint of the DPoP key the push was signed with, carried to
102    /// the code, which is exchanged only under the same key.
103    pub dpop_jkt: DpopThumbprint,
104    expires_at: OffsetDateTime,
105    consumed: bool,
106}
107
108/// Why [`confirm`] refused.
109#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
110pub enum ConsentError {
111    /// Check 1: the reference does not name a pending consent, has expired,
112    /// or has already been used.
113    #[error("consent reference is not live: {0}")]
114    NotLive(&'static str),
115    /// Check 2: the stamped DID is not the account the request named.
116    #[error("the stamped identity does not match the account this request is for")]
117    IdentityMismatch,
118}
119
120/// Where pending consent decisions live between `authorize` minting a
121/// reference and a stamped call confirming it.
122pub trait ConsentStore: Send + Sync {
123    /// Mints and stores a fresh reference for `pending`.
124    fn mint(&self, pending: PendingConsent) -> ConsentReference;
125    /// Marks the reference consumed and returns what it was pending, if it
126    /// was live. Consuming happens whether [`confirm`] goes on to approve or
127    /// refuse: a reference is single-use either way, so a caller cannot
128    /// retry a refused reference hoping for a different stamped identity.
129    fn consume(&self, reference: &ConsentReference) -> Option<PendingConsent>;
130    /// Removes a reference [`ConsentStore::mint`] minted moments earlier,
131    /// because the decision record it was for was not, after all, stored.
132    ///
133    /// `oauth::par::push_authorization_request` calls this on both of its
134    /// undo branches: [`crate::oauth::consent::begin`] runs before
135    /// `DecisionStore::put` — a token has to name the record it approves,
136    /// so it cannot be minted after — and a bound that refuses the record
137    /// must not leave the reference `begin` already minted for it
138    /// behind. Unlike [`ConsentStore::consume`], this does not require the
139    /// reference still be live: nothing will ever present a reference for
140    /// a push that was undone, live or not, so there is nothing to check.
141    fn discard(&self, reference: &ConsentReference);
142
143    /// Drops every reference this store holds for `client_id`, or for
144    /// `client_id` on `did` alone. Answers how many went.
145    ///
146    /// An operator ending an app's logins ends what is in flight too: a
147    /// reference left here is one stamped call short of an authorization
148    /// code, which is one exchange short of a token.
149    fn revoke_client(&self, client_id: &str, did: Option<&str>) -> usize;
150}
151
152/// The in-process consent store.
153#[derive(Debug, Default)]
154pub struct MemoryConsentStore {
155    entries: RwLock<HashMap<String, PendingConsent>>,
156}
157
158impl MemoryConsentStore {
159    /// An empty store.
160    pub fn new() -> Self {
161        Self::default()
162    }
163
164    /// How many references this store still holds — consumed or discarded
165    /// ones are gone, expired-but-never-presented ones are gone once
166    /// something has called [`ConsentStore::mint`] since they expired. Test
167    /// use only, crate-visible so `oauth::par`'s own tests can reach it too.
168    #[cfg(test)]
169    pub(crate) fn len(&self) -> usize {
170        self.entries.read().unwrap_or_else(|p| p.into_inner()).len()
171    }
172}
173
174impl ConsentStore for MemoryConsentStore {
175    fn mint(&self, mut pending: PendingConsent) -> ConsentReference {
176        let reference = ConsentReference::mint();
177        let now = OffsetDateTime::now_utc();
178        pending.expires_at = now + CONSENT_TTL;
179        pending.consumed = false;
180        let mut entries = self.entries.write().unwrap_or_else(|p| p.into_inner());
181        // `consume` and `discard` both remove the one reference they are
182        // given, but nothing removes a reference *nobody ever presents* —
183        // an abandoned `authorize` page, a decision that expired unread.
184        // Swept here, the same way `MemoryDecisionStore::put` sweeps its
185        // own map on every write, so this is not the one store in the
186        // module an unpresented reference outlives forever.
187        entries.retain(|_, pending| !pending.consumed && now < pending.expires_at);
188        entries.insert(reference.0.clone(), pending);
189        reference
190    }
191
192    fn consume(&self, reference: &ConsentReference) -> Option<PendingConsent> {
193        let mut entries = self.entries.write().unwrap_or_else(|p| p.into_inner());
194        let pending = entries.remove(&reference.0)?;
195        if pending.consumed || OffsetDateTime::now_utc() >= pending.expires_at {
196            return None;
197        }
198        Some(pending)
199    }
200
201    fn discard(&self, reference: &ConsentReference) {
202        self.entries
203            .write()
204            .unwrap_or_else(|p| p.into_inner())
205            .remove(&reference.0);
206    }
207
208    fn revoke_client(&self, client_id: &str, did: Option<&str>) -> usize {
209        let mut entries = self.entries.write().unwrap_or_else(|p| p.into_inner());
210        let before = entries.len();
211        entries.retain(|_, pending| {
212            !(pending.client_id == client_id && did.is_none_or(|did| pending.did == did))
213        });
214        before - entries.len()
215    }
216}
217
218/// Mints a pending consent, for `authorize` to hand back as the reference the
219/// agent's confirming tool call must carry.
220///
221/// `expires_at` and `consumed` on the passed [`PendingConsent`] are ignored
222/// and overwritten by the store — callers construct one with any
223/// placeholder for both.
224#[allow(clippy::too_many_arguments)]
225pub fn begin(
226    store: &dyn ConsentStore,
227    request_uri: String,
228    did: String,
229    client_id: String,
230    granted_scope: ScopeSet,
231    redirect_uri: String,
232    state: Option<String>,
233    code_challenge: String,
234    code_challenge_method: String,
235    client_auth_key: Option<ClientAuthKey>,
236    dpop_jkt: DpopThumbprint,
237) -> ConsentReference {
238    store.mint(PendingConsent {
239        request_uri,
240        did,
241        client_id,
242        granted_scope,
243        redirect_uri,
244        state,
245        code_challenge,
246        code_challenge_method,
247        client_auth_key,
248        dpop_jkt,
249        expires_at: OffsetDateTime::now_utc(),
250        consumed: true,
251    })
252}
253
254/// **This is the seam.** A future `PreToolUse`-stamped tool call reaches this
255/// function (through whatever HTTP or in-process boundary the harness that
256/// drives it uses — not decided here) with `reference` from the tool call's
257/// arguments and `stamped_did` from the rewrite, not from the caller.
258///
259/// Runs the two checks in order, so the error always names the first one
260/// that failed:
261///
262/// 1. `store.consume(reference)` — live and unused.
263/// 2. `stamped_did == pending.did` — the account named in the request is the
264///    agent that was stamped.
265///
266/// There is no policy check here, and the reference's own existence is why:
267/// a reference is minted only for a decision PAR did not deny.
268///
269/// Consuming the reference happens as check 1 regardless of what check 2
270/// decides: a reference is spent by the attempt, not by success, so a
271/// refused attempt cannot be retried with a different claimed identity.
272pub fn confirm(
273    store: &dyn ConsentStore,
274    reference: &ConsentReference,
275    stamped_did: &str,
276) -> Result<PendingConsent, ConsentError> {
277    let pending = store
278        .consume(reference)
279        .ok_or(ConsentError::NotLive("unknown, expired, or already used"))?;
280    if pending.did != stamped_did {
281        return Err(ConsentError::IdentityMismatch);
282    }
283    Ok(pending)
284}
285
286#[cfg(test)]
287mod tests {
288    use super::*;
289
290    fn begin_test(store: &dyn ConsentStore) -> ConsentReference {
291        begin(
292            store,
293            "urn:...:abc".to_owned(),
294            "did:web:agent".to_owned(),
295            "https://c".to_owned(),
296            ScopeSet::parse("atproto").unwrap(),
297            "https://c/cb".to_owned(),
298            None,
299            "challenge".to_owned(),
300            "S256".to_owned(),
301            None,
302            DpopThumbprint("the-pushs-own-key".to_owned()),
303        )
304    }
305
306    #[test]
307    fn a_reference_is_single_use() {
308        let store = MemoryConsentStore::new();
309        let reference = begin_test(&store);
310        assert!(confirm(&store, &reference, "did:web:agent").is_ok());
311        let err = confirm(&store, &reference, "did:web:agent").unwrap_err();
312        assert!(matches!(err, ConsentError::NotLive(_)));
313    }
314
315    #[test]
316    fn a_mismatched_stamped_identity_is_refused_and_still_consumes() {
317        let store = MemoryConsentStore::new();
318        let reference = begin_test(&store);
319        let err = confirm(&store, &reference, "did:web:someone-else").unwrap_err();
320        assert!(matches!(err, ConsentError::IdentityMismatch));
321        // Spent by the attempt, not by success.
322        let err2 = confirm(&store, &reference, "did:web:agent").unwrap_err();
323        assert!(matches!(err2, ConsentError::NotLive(_)));
324    }
325
326    #[test]
327    fn an_unknown_reference_is_refused() {
328        let store = MemoryConsentStore::new();
329        let err = confirm(
330            &store,
331            &ConsentReference("nope".to_owned()),
332            "did:web:agent",
333        )
334        .unwrap_err();
335        assert!(matches!(err, ConsentError::NotLive(_)));
336    }
337
338    /// A reference nobody ever presents — an abandoned `authorize` page, a
339    /// decision that expired unread — is not removed by anything else in
340    /// this module: `consume` only fires on presentation, and `discard`
341    /// only on a push `oauth::par` undoes. Without a sweep this is the one
342    /// store in the module an entry outlives forever. Constructed directly
343    /// rather than through `begin`, which always sets its own fresh expiry
344    /// — the point here is a reference `begin` could never mint.
345    #[test]
346    fn an_unpresented_reference_is_swept_once_it_has_expired() {
347        let store = MemoryConsentStore::new();
348        let stale = ConsentReference::mint();
349        store.entries.write().unwrap().insert(
350            stale.0.clone(),
351            PendingConsent {
352                request_uri: "urn:...:stale".to_owned(),
353                did: "did:web:agent".to_owned(),
354                client_id: "https://c".to_owned(),
355                granted_scope: ScopeSet::parse("atproto").unwrap(),
356                redirect_uri: "https://c/cb".to_owned(),
357                state: None,
358                code_challenge: "challenge".to_owned(),
359                code_challenge_method: "S256".to_owned(),
360                client_auth_key: None,
361                dpop_jkt: DpopThumbprint("the-pushs-own-key".to_owned()),
362                expires_at: OffsetDateTime::now_utc() - Duration::seconds(1),
363                consumed: false,
364            },
365        );
366        assert_eq!(store.len(), 1);
367
368        // Minting an unrelated, fresh reference is the only trigger a
369        // sweep needs — see `MemoryConsentStore::mint`'s own doc.
370        let _ = begin_test(&store);
371        assert_eq!(
372            store.len(),
373            1,
374            "the stale reference is gone, the fresh one is the only one left"
375        );
376        assert!(store.entries.read().unwrap().get(&stale.0).is_none());
377    }
378}