use crate::store::{StoreFuture, digest};
use rustlavel_oauth::{ChallengeMethod, Scopes};
use std::collections::HashMap;
use std::sync::Mutex;
pub const DEFAULT_TTL: u64 = 60;
pub const DEFAULT_RETENTION: u64 = 600;
#[derive(Clone)]
pub struct AuthorizationCode {
hash: String,
pub client_id: String,
pub redirect_uri: String,
pub user_id: String,
pub scopes: Scopes,
pub challenge: String,
pub challenge_method: ChallengeMethod,
pub issued_at: u64,
pub expires_at: u64,
pub family: Option<String>,
}
impl AuthorizationCode {
pub fn issued(code: &str, now: u64, ttl: u64) -> AuthorizationCode {
AuthorizationCode {
hash: digest(code),
client_id: String::new(),
redirect_uri: String::new(),
user_id: String::new(),
scopes: Scopes::new(),
challenge: String::new(),
challenge_method: ChallengeMethod::S256,
issued_at: now,
expires_at: now.saturating_add(ttl),
family: None,
}
}
pub fn for_client(mut self, client_id: impl Into<String>) -> AuthorizationCode {
self.client_id = client_id.into();
self
}
pub fn for_user(mut self, user_id: impl Into<String>) -> AuthorizationCode {
self.user_id = user_id.into();
self
}
pub fn redirecting_to(mut self, uri: impl Into<String>) -> AuthorizationCode {
self.redirect_uri = uri.into();
self
}
pub fn granting(mut self, scopes: Scopes) -> AuthorizationCode {
self.scopes = scopes;
self
}
pub fn challenged(
mut self,
challenge: impl Into<String>,
method: ChallengeMethod,
) -> AuthorizationCode {
self.challenge = challenge.into();
self.challenge_method = method;
self
}
pub fn from_hash(hash: impl Into<String>) -> AuthorizationCode {
AuthorizationCode {
hash: hash.into(),
client_id: String::new(),
redirect_uri: String::new(),
user_id: String::new(),
scopes: Scopes::new(),
challenge: String::new(),
challenge_method: ChallengeMethod::Plain,
issued_at: 0,
expires_at: 0,
family: None,
}
}
pub fn hash(&self) -> &str {
&self.hash
}
pub fn is_consumed(&self) -> bool {
self.family.is_some()
}
pub fn is_expired(&self, now: u64) -> bool {
now >= self.expires_at
}
}
impl std::fmt::Debug for AuthorizationCode {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("AuthorizationCode")
.field("hash", &"<redacted>")
.field("client_id", &self.client_id)
.field("redirect_uri", &self.redirect_uri)
.field("user_id", &self.user_id)
.field("scopes", &self.scopes)
.field("challenge_method", &self.challenge_method)
.field("expires_at", &self.expires_at)
.field("family", &self.family)
.finish()
}
}
#[derive(Debug, Clone)]
pub enum Consumption {
Fresh(Box<AuthorizationCode>),
Replayed { family: Option<String> },
Expired,
Unknown,
}
pub trait CodeStore: Send + Sync + 'static {
fn store<'a>(&'a self, code: AuthorizationCode) -> StoreFuture<'a, ()>;
fn consume<'a>(
&'a self,
hash: &'a str,
family: &'a str,
now: u64,
) -> StoreFuture<'a, Consumption>;
fn purge<'a>(&'a self, now: u64) -> StoreFuture<'a, usize>;
}
pub struct MemoryCodeStore {
codes: Mutex<HashMap<String, AuthorizationCode>>,
retention: u64,
}
impl MemoryCodeStore {
pub fn new() -> MemoryCodeStore {
MemoryCodeStore::with_retention(DEFAULT_RETENTION)
}
pub fn with_retention(retention: u64) -> MemoryCodeStore {
MemoryCodeStore { codes: Mutex::new(HashMap::new()), retention }
}
pub fn len(&self) -> usize {
self.codes.lock().expect("code store lock poisoned").len()
}
pub fn is_empty(&self) -> bool {
self.len() == 0
}
}
impl Default for MemoryCodeStore {
fn default() -> MemoryCodeStore {
MemoryCodeStore::new()
}
}
impl CodeStore for MemoryCodeStore {
fn store<'a>(&'a self, code: AuthorizationCode) -> StoreFuture<'a, ()> {
Box::pin(async move {
self.codes
.lock()
.expect("code store lock poisoned")
.insert(code.hash.clone(), code);
Ok(())
})
}
fn consume<'a>(
&'a self,
hash: &'a str,
family: &'a str,
now: u64,
) -> StoreFuture<'a, Consumption> {
Box::pin(async move {
let mut codes = self.codes.lock().expect("code store lock poisoned");
let Some(code) = codes.get_mut(hash) else { return Ok(Consumption::Unknown) };
if code.is_consumed() {
return Ok(Consumption::Replayed { family: code.family.clone() });
}
if code.is_expired(now) {
return Ok(Consumption::Expired);
}
code.family = Some(family.to_string());
Ok(Consumption::Fresh(Box::new(code.clone())))
})
}
fn purge<'a>(&'a self, now: u64) -> StoreFuture<'a, usize> {
Box::pin(async move {
let mut codes = self.codes.lock().expect("code store lock poisoned");
let before = codes.len();
codes.retain(|_, code| now < code.expires_at.saturating_add(self.retention));
Ok(before - codes.len())
})
}
}
#[cfg(test)]
mod tests {
use super::*;
const NOW: u64 = 1_800_000_000;
fn issue(code: &str) -> AuthorizationCode {
AuthorizationCode::issued(code, NOW, DEFAULT_TTL)
.for_client("web")
.for_user("7")
.redirecting_to("https://a.test/cb")
.granting(Scopes::of(["read"]))
.challenged("challenge", ChallengeMethod::S256)
}
async fn stored(code: &str) -> MemoryCodeStore {
let store = MemoryCodeStore::new();
store.store(issue(code)).await.expect("stored");
store
}
#[test]
fn the_code_itself_is_never_kept() {
let record = issue("the-code");
assert_ne!(record.hash(), "the-code");
assert_eq!(record.hash(), digest("the-code"));
assert!(!format!("{record:?}").contains(record.hash()));
}
#[tokio::test]
async fn a_fresh_code_is_spent_once_and_carries_its_bindings() {
let store = stored("the-code").await;
let Consumption::Fresh(code) = store
.consume(&digest("the-code"), "family-1", NOW)
.await
.expect("consumed")
else {
panic!("the first use must succeed");
};
assert_eq!(code.client_id, "web");
assert_eq!(code.user_id, "7");
assert_eq!(code.redirect_uri, "https://a.test/cb");
assert_eq!(code.challenge, "challenge");
assert_eq!(code.family.as_deref(), Some("family-1"));
}
#[tokio::test]
async fn replaying_a_code_reports_the_family_to_revoke() {
let store = stored("the-code").await;
store.consume(&digest("the-code"), "family-1", NOW).await.expect("first use");
let second = store
.consume(&digest("the-code"), "family-2", NOW)
.await
.expect("second use");
match second {
Consumption::Replayed { family } => assert_eq!(family.as_deref(), Some("family-1")),
other => panic!("expected a replay, got {other:?}"),
}
}
#[tokio::test]
async fn a_replay_after_the_lifetime_is_still_a_replay() {
let store = stored("the-code").await;
store.consume(&digest("the-code"), "family-1", NOW).await.expect("first use");
let late = store
.consume(&digest("the-code"), "family-2", NOW + DEFAULT_TTL + 1)
.await
.expect("late use");
assert!(matches!(late, Consumption::Replayed { .. }), "got {late:?}");
}
#[tokio::test]
async fn an_expired_code_is_refused_without_being_spent() {
let store = stored("the-code").await;
let outcome = store
.consume(&digest("the-code"), "family-1", NOW + DEFAULT_TTL)
.await
.expect("consumed");
assert!(matches!(outcome, Consumption::Expired), "got {outcome:?}");
let again = store
.consume(&digest("the-code"), "family-1", NOW + DEFAULT_TTL)
.await
.expect("consumed");
assert!(matches!(again, Consumption::Expired), "got {again:?}");
}
#[tokio::test]
async fn a_code_expires_the_second_its_lifetime_is_up() {
let store = stored("the-code").await;
let inside = store.consume(&digest("the-code"), "f", NOW + DEFAULT_TTL - 1).await;
assert!(matches!(inside.expect("consumed"), Consumption::Fresh(_)));
}
#[tokio::test]
async fn an_unknown_code_is_unknown() {
let store = stored("the-code").await;
let outcome = store.consume(&digest("other"), "family-1", NOW).await.expect("consumed");
assert!(matches!(outcome, Consumption::Unknown), "got {outcome:?}");
}
#[tokio::test]
async fn the_plaintext_code_is_not_a_lookup_key() {
let store = stored("the-code").await;
let outcome = store.consume("the-code", "family-1", NOW).await.expect("consumed");
assert!(matches!(outcome, Consumption::Unknown), "got {outcome:?}");
}
#[tokio::test]
async fn purging_keeps_a_spent_code_for_its_retention_window() {
let store = MemoryCodeStore::with_retention(600);
store.store(issue("the-code")).await.expect("stored");
store.consume(&digest("the-code"), "family-1", NOW).await.expect("first use");
assert_eq!(store.purge(NOW + DEFAULT_TTL + 599).await.expect("purged"), 0);
let replay = store.consume(&digest("the-code"), "f", NOW + 599).await.expect("replay");
assert!(matches!(replay, Consumption::Replayed { .. }));
assert_eq!(store.purge(NOW + DEFAULT_TTL + 600).await.expect("purged"), 1);
assert!(store.is_empty());
}
}