Skip to main content

rustlavel_oauth_provider/
code.rs

1//! Authorisation codes: single-use, short-lived, and bound to everything.
2//!
3//! A code is the only part of the flow that travels through the user's browser,
4//! which is the least trustworthy leg there is — it lands in the address bar,
5//! in browser history, in a `Referer` header, and in whatever proxy the network
6//! runs. So a code is worth as little as it can be made worth:
7//!
8//! * **Short-lived.** Sixty seconds, which is what RFC 6749 §4.1.2 recommends
9//!   and far longer than the redirect it has to survive.
10//! * **Single-use.** Enforced atomically, so two simultaneous redemptions
11//!   cannot both win.
12//! * **Bound** to the client it was issued to, the redirect URI it was issued
13//!   for, the user it represents, and the PKCE challenge that was committed to.
14//!   Every one of those is checked again at the token endpoint.
15//! * **Hashed at rest**, like every other credential here.
16//!
17//! # Replay is a signal, not just an error
18//!
19//! RFC 6749 §4.1.2 and §10.5 both say the same thing: if a code is presented
20//! twice, the server MUST refuse the second attempt and SHOULD revoke the
21//! tokens issued from the first. The reasoning is that a code presented twice
22//! is a code that leaked — the legitimate client redeems it exactly once, so a
23//! second presentation means somebody else has it, and whichever of the two
24//! redemptions was the attacker's, tokens now exist that should not. Refusing
25//! the replay alone leaves the attacker holding whatever the first exchange
26//! produced.
27//!
28//! Detecting that requires keeping the record after the code is spent, so a
29//! consumed code stays in the store for [`DEFAULT_RETENTION`] before it is
30//! purged. A store that deleted the row on use would answer "unknown code" to
31//! the replay and revoke nothing.
32
33use crate::store::{StoreFuture, digest};
34use rustlavel_oauth::{ChallengeMethod, Scopes};
35use std::collections::HashMap;
36use std::sync::Mutex;
37
38/// How long a code is valid. RFC 6749 §4.1.2: "a maximum authorization code
39/// lifetime of 10 minutes is RECOMMENDED"; a minute is all a redirect needs.
40pub const DEFAULT_TTL: u64 = 60;
41
42/// How long a spent code is kept so a replay can still be recognised.
43pub const DEFAULT_RETENTION: u64 = 600;
44
45/// One issued authorisation code.
46///
47/// The code itself is not here — only its digest. The plaintext exists once, in
48/// the redirect that carries it.
49#[derive(Clone)]
50pub struct AuthorizationCode {
51    hash: String,
52    pub client_id: String,
53    /// The exact redirect URI this code was issued for. The token endpoint
54    /// requires the same string again, which is what stops a code obtained
55    /// through one registered URI being redeemed as though it came from
56    /// another.
57    pub redirect_uri: String,
58    pub user_id: String,
59    pub scopes: Scopes,
60    pub challenge: String,
61    pub challenge_method: ChallengeMethod,
62    pub issued_at: u64,
63    pub expires_at: u64,
64    /// Set when the code is spent: the token family the exchange produced, so a
65    /// later replay knows what to revoke.
66    pub family: Option<String>,
67}
68
69impl AuthorizationCode {
70    /// Build a record for a code that is about to be handed out.
71    pub fn issued(code: &str, now: u64, ttl: u64) -> AuthorizationCode {
72        AuthorizationCode {
73            hash: digest(code),
74            client_id: String::new(),
75            redirect_uri: String::new(),
76            user_id: String::new(),
77            scopes: Scopes::new(),
78            challenge: String::new(),
79            challenge_method: ChallengeMethod::S256,
80            issued_at: now,
81            expires_at: now.saturating_add(ttl),
82            family: None,
83        }
84    }
85
86    pub fn for_client(mut self, client_id: impl Into<String>) -> AuthorizationCode {
87        self.client_id = client_id.into();
88        self
89    }
90
91    pub fn for_user(mut self, user_id: impl Into<String>) -> AuthorizationCode {
92        self.user_id = user_id.into();
93        self
94    }
95
96    pub fn redirecting_to(mut self, uri: impl Into<String>) -> AuthorizationCode {
97        self.redirect_uri = uri.into();
98        self
99    }
100
101    pub fn granting(mut self, scopes: Scopes) -> AuthorizationCode {
102        self.scopes = scopes;
103        self
104    }
105
106    pub fn challenged(
107        mut self,
108        challenge: impl Into<String>,
109        method: ChallengeMethod,
110    ) -> AuthorizationCode {
111        self.challenge = challenge.into();
112        self.challenge_method = method;
113        self
114    }
115
116    /// The digest this record is stored under.
117    /// Rebuild from a stored digest, for a [`CodeStore`] backed by a table.
118    ///
119    /// The plaintext code is not needed and must not be available. Every other
120    /// field is public and is set by the caller from the same row.
121    pub fn from_hash(hash: impl Into<String>) -> AuthorizationCode {
122        AuthorizationCode {
123            hash: hash.into(),
124            client_id: String::new(),
125            redirect_uri: String::new(),
126            user_id: String::new(),
127            scopes: Scopes::new(),
128            challenge: String::new(),
129            challenge_method: ChallengeMethod::Plain,
130            issued_at: 0,
131            expires_at: 0,
132            family: None,
133        }
134    }
135
136    pub fn hash(&self) -> &str {
137        &self.hash
138    }
139
140    /// Whether the code was spent, whatever the outcome of that exchange.
141    pub fn is_consumed(&self) -> bool {
142        self.family.is_some()
143    }
144
145    pub fn is_expired(&self, now: u64) -> bool {
146        now >= self.expires_at
147    }
148}
149
150/// `Debug` prints no digest: it is the key a stolen log would need in order to
151/// recognise a code it also captured.
152impl std::fmt::Debug for AuthorizationCode {
153    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
154        f.debug_struct("AuthorizationCode")
155            .field("hash", &"<redacted>")
156            .field("client_id", &self.client_id)
157            .field("redirect_uri", &self.redirect_uri)
158            .field("user_id", &self.user_id)
159            .field("scopes", &self.scopes)
160            .field("challenge_method", &self.challenge_method)
161            .field("expires_at", &self.expires_at)
162            .field("family", &self.family)
163            .finish()
164    }
165}
166
167/// What happened when a code was presented.
168#[derive(Debug, Clone)]
169pub enum Consumption {
170    /// The code was live and is now spent. Carry on with the exchange.
171    Fresh(Box<AuthorizationCode>),
172    /// Already spent. The caller must revoke `family` before refusing.
173    Replayed { family: Option<String> },
174    /// Still unspent, but past its lifetime.
175    Expired,
176    /// No such code.
177    Unknown,
178}
179
180/// Where live and recently-spent authorisation codes are kept.
181pub trait CodeStore: Send + Sync + 'static {
182    fn store<'a>(&'a self, code: AuthorizationCode) -> StoreFuture<'a, ()>;
183
184    /// Spend a code, exactly once.
185    ///
186    /// Implementations **must** make the check-and-mark atomic: with two
187    /// requests carrying the same code in flight, exactly one may receive
188    /// [`Consumption::Fresh`]. A read followed by a separate write leaves a
189    /// window in which both do, which is the whole attack this is here to stop.
190    ///
191    /// `family` is the token family the caller is about to issue into, recorded
192    /// on the code so a replay knows what to revoke.
193    fn consume<'a>(
194        &'a self,
195        hash: &'a str,
196        family: &'a str,
197        now: u64,
198    ) -> StoreFuture<'a, Consumption>;
199
200    /// Drop records whose retention window has passed, returning how many.
201    ///
202    /// Housekeeping rather than security — [`CodeStore::consume`] already
203    /// refuses an expired code — but a busy server accumulates one dead record
204    /// per login, forever, without it.
205    fn purge<'a>(&'a self, now: u64) -> StoreFuture<'a, usize>;
206}
207
208/// Codes held in this process's memory.
209pub struct MemoryCodeStore {
210    codes: Mutex<HashMap<String, AuthorizationCode>>,
211    retention: u64,
212}
213
214impl MemoryCodeStore {
215    pub fn new() -> MemoryCodeStore {
216        MemoryCodeStore::with_retention(DEFAULT_RETENTION)
217    }
218
219    /// How long a spent code is kept for replay detection. Shortening this
220    /// shortens the window in which a stolen code's replay is noticed.
221    pub fn with_retention(retention: u64) -> MemoryCodeStore {
222        MemoryCodeStore { codes: Mutex::new(HashMap::new()), retention }
223    }
224
225    pub fn len(&self) -> usize {
226        self.codes.lock().expect("code store lock poisoned").len()
227    }
228
229    pub fn is_empty(&self) -> bool {
230        self.len() == 0
231    }
232}
233
234impl Default for MemoryCodeStore {
235    fn default() -> MemoryCodeStore {
236        MemoryCodeStore::new()
237    }
238}
239
240impl CodeStore for MemoryCodeStore {
241    fn store<'a>(&'a self, code: AuthorizationCode) -> StoreFuture<'a, ()> {
242        Box::pin(async move {
243            self.codes
244                .lock()
245                .expect("code store lock poisoned")
246                .insert(code.hash.clone(), code);
247            Ok(())
248        })
249    }
250
251    fn consume<'a>(
252        &'a self,
253        hash: &'a str,
254        family: &'a str,
255        now: u64,
256    ) -> StoreFuture<'a, Consumption> {
257        Box::pin(async move {
258            // One lock for the read and the write: this is the atomicity the
259            // trait requires, and the reason `consume` is not `find` + `mark`.
260            let mut codes = self.codes.lock().expect("code store lock poisoned");
261            let Some(code) = codes.get_mut(hash) else { return Ok(Consumption::Unknown) };
262
263            // Spent is checked before expired, deliberately. A code replayed
264            // after its lifetime is still a code that leaked, and reporting it
265            // as merely expired would skip the revocation.
266            if code.is_consumed() {
267                return Ok(Consumption::Replayed { family: code.family.clone() });
268            }
269            if code.is_expired(now) {
270                return Ok(Consumption::Expired);
271            }
272
273            code.family = Some(family.to_string());
274            Ok(Consumption::Fresh(Box::new(code.clone())))
275        })
276    }
277
278    fn purge<'a>(&'a self, now: u64) -> StoreFuture<'a, usize> {
279        Box::pin(async move {
280            let mut codes = self.codes.lock().expect("code store lock poisoned");
281            let before = codes.len();
282            codes.retain(|_, code| now < code.expires_at.saturating_add(self.retention));
283            Ok(before - codes.len())
284        })
285    }
286}
287
288#[cfg(test)]
289mod tests {
290    use super::*;
291
292    const NOW: u64 = 1_800_000_000;
293
294    fn issue(code: &str) -> AuthorizationCode {
295        AuthorizationCode::issued(code, NOW, DEFAULT_TTL)
296            .for_client("web")
297            .for_user("7")
298            .redirecting_to("https://a.test/cb")
299            .granting(Scopes::of(["read"]))
300            .challenged("challenge", ChallengeMethod::S256)
301    }
302
303    async fn stored(code: &str) -> MemoryCodeStore {
304        let store = MemoryCodeStore::new();
305        store.store(issue(code)).await.expect("stored");
306        store
307    }
308
309    #[test]
310    fn the_code_itself_is_never_kept() {
311        let record = issue("the-code");
312
313        assert_ne!(record.hash(), "the-code");
314        assert_eq!(record.hash(), digest("the-code"));
315        assert!(!format!("{record:?}").contains(record.hash()));
316    }
317
318    #[tokio::test]
319    async fn a_fresh_code_is_spent_once_and_carries_its_bindings() {
320        let store = stored("the-code").await;
321
322        let Consumption::Fresh(code) = store
323            .consume(&digest("the-code"), "family-1", NOW)
324            .await
325            .expect("consumed")
326        else {
327            panic!("the first use must succeed");
328        };
329
330        assert_eq!(code.client_id, "web");
331        assert_eq!(code.user_id, "7");
332        assert_eq!(code.redirect_uri, "https://a.test/cb");
333        assert_eq!(code.challenge, "challenge");
334        assert_eq!(code.family.as_deref(), Some("family-1"));
335    }
336
337    #[tokio::test]
338    async fn replaying_a_code_reports_the_family_to_revoke() {
339        let store = stored("the-code").await;
340        store.consume(&digest("the-code"), "family-1", NOW).await.expect("first use");
341
342        let second = store
343            .consume(&digest("the-code"), "family-2", NOW)
344            .await
345            .expect("second use");
346
347        // Not merely refused: the caller is handed the family the first
348        // exchange produced, because those tokens are now in play too.
349        match second {
350            Consumption::Replayed { family } => assert_eq!(family.as_deref(), Some("family-1")),
351            other => panic!("expected a replay, got {other:?}"),
352        }
353    }
354
355    #[tokio::test]
356    async fn a_replay_after_the_lifetime_is_still_a_replay() {
357        // If expiry were checked first, a code stolen and replayed a minute
358        // late would look like an ordinary timeout and revoke nothing.
359        let store = stored("the-code").await;
360        store.consume(&digest("the-code"), "family-1", NOW).await.expect("first use");
361
362        let late = store
363            .consume(&digest("the-code"), "family-2", NOW + DEFAULT_TTL + 1)
364            .await
365            .expect("late use");
366
367        assert!(matches!(late, Consumption::Replayed { .. }), "got {late:?}");
368    }
369
370    #[tokio::test]
371    async fn an_expired_code_is_refused_without_being_spent() {
372        let store = stored("the-code").await;
373
374        let outcome = store
375            .consume(&digest("the-code"), "family-1", NOW + DEFAULT_TTL)
376            .await
377            .expect("consumed");
378
379        assert!(matches!(outcome, Consumption::Expired), "got {outcome:?}");
380        // And it did not become "replayed" on the next attempt, which would
381        // have revoked a family that never existed.
382        let again = store
383            .consume(&digest("the-code"), "family-1", NOW + DEFAULT_TTL)
384            .await
385            .expect("consumed");
386        assert!(matches!(again, Consumption::Expired), "got {again:?}");
387    }
388
389    #[tokio::test]
390    async fn a_code_expires_the_second_its_lifetime_is_up() {
391        let store = stored("the-code").await;
392
393        let inside = store.consume(&digest("the-code"), "f", NOW + DEFAULT_TTL - 1).await;
394        assert!(matches!(inside.expect("consumed"), Consumption::Fresh(_)));
395    }
396
397    #[tokio::test]
398    async fn an_unknown_code_is_unknown() {
399        let store = stored("the-code").await;
400
401        let outcome = store.consume(&digest("other"), "family-1", NOW).await.expect("consumed");
402        assert!(matches!(outcome, Consumption::Unknown), "got {outcome:?}");
403    }
404
405    #[tokio::test]
406    async fn the_plaintext_code_is_not_a_lookup_key() {
407        // Guards against a store that was handed the code rather than its
408        // digest, which would make the database a set of live codes.
409        let store = stored("the-code").await;
410
411        let outcome = store.consume("the-code", "family-1", NOW).await.expect("consumed");
412        assert!(matches!(outcome, Consumption::Unknown), "got {outcome:?}");
413    }
414
415    #[tokio::test]
416    async fn purging_keeps_a_spent_code_for_its_retention_window() {
417        let store = MemoryCodeStore::with_retention(600);
418        store.store(issue("the-code")).await.expect("stored");
419        store.consume(&digest("the-code"), "family-1", NOW).await.expect("first use");
420
421        assert_eq!(store.purge(NOW + DEFAULT_TTL + 599).await.expect("purged"), 0);
422        // Still detectable as a replay right up to the edge.
423        let replay = store.consume(&digest("the-code"), "f", NOW + 599).await.expect("replay");
424        assert!(matches!(replay, Consumption::Replayed { .. }));
425
426        assert_eq!(store.purge(NOW + DEFAULT_TTL + 600).await.expect("purged"), 1);
427        assert!(store.is_empty());
428    }
429}