use chrono::{DateTime, Utc};
use uuid::Uuid;
use zeroize::Zeroize;
pub const PURPOSE_OAUTH_TOKEN: &str = "oauth_token";
pub struct TokenBundle {
access_token: String,
refresh_token: Option<String>,
expires_at: DateTime<Utc>,
scope: Option<String>,
}
impl TokenBundle {
pub fn new(
access_token: String,
refresh_token: Option<String>,
expires_at: DateTime<Utc>,
scope: Option<String>,
) -> Self {
Self { access_token, refresh_token, expires_at, scope }
}
pub fn access_token(&self) -> &str {
&self.access_token
}
pub fn refresh_token(&self) -> Option<&str> {
self.refresh_token.as_deref()
}
pub fn expires_at(&self) -> DateTime<Utc> {
self.expires_at
}
pub fn scope(&self) -> Option<&str> {
self.scope.as_deref()
}
pub fn metadata(&self) -> TokenMetadata {
TokenMetadata { expires_at: self.expires_at, scope: self.scope.clone() }
}
}
impl std::fmt::Debug for TokenBundle {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("TokenBundle")
.field("access_token", &"[REDACTED]")
.field("refresh_token", &self.refresh_token.as_ref().map(|_| "[REDACTED]"))
.field("expires_at", &self.expires_at)
.field("scope", &self.scope)
.finish()
}
}
impl Drop for TokenBundle {
fn drop(&mut self) {
self.access_token.zeroize();
if let Some(ref mut refresh) = self.refresh_token {
refresh.zeroize();
}
}
}
#[derive(Debug, Clone, PartialEq)]
pub struct TokenMetadata {
pub expires_at: DateTime<Utc>,
pub scope: Option<String>,
}
#[derive(Debug, Clone, thiserror::Error)]
#[error("credential store call failed ({code}): {message}")]
pub struct OAuthCredentialFailure {
pub code: String,
pub message: String,
}
impl OAuthCredentialFailure {
pub const CODE_NOT_FOUND: &str = "not_found";
pub const CODE_NOT_ACTIVE: &str = "not_active";
pub const CODE_EXPIRED: &str = "expired";
pub const CODE_DUPLICATE_ACTIVE: &str = "duplicate_active";
pub const CODE_TRANSPORT: &str = "transport";
pub fn new(code: &str, message: impl Into<String>) -> Self {
Self { code: code.to_string(), message: message.into() }
}
pub fn not_found() -> Self {
Self::new(Self::CODE_NOT_FOUND, "no credential issued for this scope")
}
pub fn not_active(status: &str) -> Self {
Self::new(Self::CODE_NOT_ACTIVE, format!("credential is {status} (terminal)"))
}
pub fn expired() -> Self {
Self::new(Self::CODE_EXPIRED, "credential passed its honest expiry; rotate it")
}
pub fn duplicate_active() -> Self {
Self::new(Self::CODE_DUPLICATE_ACTIVE, "an active credential exists; rotate instead of issuing a second")
}
pub fn transport(message: impl Into<String>) -> Self {
Self::new(Self::CODE_TRANSPORT, message)
}
pub fn is_transport(&self) -> bool {
self.code == Self::CODE_TRANSPORT
}
}
#[async_trait::async_trait]
pub trait OAuthCredentialStore: Send + Sync {
async fn issue(
&self,
company_id: Uuid,
provider: &str,
account_ref: &str,
purpose: &str,
bundle: TokenBundle,
expires_at: DateTime<Utc>,
) -> Result<Uuid, OAuthCredentialFailure>;
async fn read_token(
&self,
company_id: Uuid,
provider: &str,
account_ref: &str,
) -> Result<TokenBundle, OAuthCredentialFailure>;
async fn rotate(
&self,
company_id: Uuid,
provider: &str,
account_ref: &str,
bundle: TokenBundle,
expires_at: DateTime<Utc>,
) -> Result<Uuid, OAuthCredentialFailure>;
async fn revoke(
&self,
company_id: Uuid,
provider: &str,
account_ref: &str,
) -> Result<(), OAuthCredentialFailure>;
}
#[cfg(test)]
mod tests {
use super::*;
fn bundle() -> TokenBundle {
TokenBundle::new(
"SECRET-ACCESS-TOKEN-0123456789".into(),
Some("SECRET-REFRESH-TOKEN-9876543210".into()),
Utc::now() + chrono::Duration::hours(24),
Some("https://mail.google.com/".into()),
)
}
#[test]
fn debug_never_contains_token_material() {
let b = bundle();
let debugged = format!("{b:?}");
assert!(!debugged.contains("SECRET-ACCESS-TOKEN"), "access token leaked into Debug: {debugged}");
assert!(!debugged.contains("SECRET-REFRESH-TOKEN"), "refresh token leaked into Debug: {debugged}");
assert!(debugged.contains("[REDACTED]"), "redaction marker missing: {debugged}");
assert!(debugged.contains("expires_at"), "expiry hidden, Debug no longer diagnostic: {debugged}");
}
#[test]
fn accessors_hand_out_references_and_metadata_only_projection() {
let b = bundle();
assert_eq!(b.access_token(), "SECRET-ACCESS-TOKEN-0123456789");
assert_eq!(b.refresh_token(), Some("SECRET-REFRESH-TOKEN-9876543210"));
let meta = b.metadata();
assert_eq!(meta.scope.as_deref(), Some("https://mail.google.com/"));
let meta_debug = format!("{meta:?}");
assert!(!meta_debug.contains("SECRET"), "metadata projection carries token material: {meta_debug}");
}
#[test]
fn failure_codes_are_stable_and_distinct() {
let codes = [
OAuthCredentialFailure::CODE_NOT_FOUND,
OAuthCredentialFailure::CODE_NOT_ACTIVE,
OAuthCredentialFailure::CODE_EXPIRED,
OAuthCredentialFailure::CODE_DUPLICATE_ACTIVE,
OAuthCredentialFailure::CODE_TRANSPORT,
];
let mut sorted = codes.to_vec();
sorted.sort_unstable();
sorted.dedup();
assert_eq!(sorted.len(), codes.len(), "failure codes must not collide");
assert!(!OAuthCredentialFailure::not_found().is_transport());
assert!(OAuthCredentialFailure::transport("store unreachable").is_transport());
assert_eq!(OAuthCredentialFailure::duplicate_active().code, "duplicate_active");
}
#[test]
fn purpose_label_matches_the_store_vocabulary() {
assert_eq!(PURPOSE_OAUTH_TOKEN, "oauth_token");
}
}