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}