rustlavel_oauth_provider/
code.rs1use crate::store::{StoreFuture, digest};
34use rustlavel_oauth::{ChallengeMethod, Scopes};
35use std::collections::HashMap;
36use std::sync::Mutex;
37
38pub const DEFAULT_TTL: u64 = 60;
41
42pub const DEFAULT_RETENTION: u64 = 600;
44
45#[derive(Clone)]
50pub struct AuthorizationCode {
51 hash: String,
52 pub client_id: String,
53 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 pub family: Option<String>,
67}
68
69impl AuthorizationCode {
70 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 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 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
150impl 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#[derive(Debug, Clone)]
169pub enum Consumption {
170 Fresh(Box<AuthorizationCode>),
172 Replayed { family: Option<String> },
174 Expired,
176 Unknown,
178}
179
180pub trait CodeStore: Send + Sync + 'static {
182 fn store<'a>(&'a self, code: AuthorizationCode) -> StoreFuture<'a, ()>;
183
184 fn consume<'a>(
194 &'a self,
195 hash: &'a str,
196 family: &'a str,
197 now: u64,
198 ) -> StoreFuture<'a, Consumption>;
199
200 fn purge<'a>(&'a self, now: u64) -> StoreFuture<'a, usize>;
206}
207
208pub 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 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 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 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 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 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 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 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 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}