use std::collections::HashMap;
use std::sync::Mutex;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ApiKey {
pub id: String,
pub user_id: String,
pub name: String,
pub prefix: String,
pub secret_hash: String,
pub scopes: Option<String>,
pub expires_at: Option<u64>,
pub last_used_at: Option<u64>,
pub created_at: u64,
}
pub trait ApiKeyBackend: Send + Sync {
fn put(&self, key: &ApiKey);
fn get(&self, id: &str) -> Option<ApiKey>;
fn delete(&self, id: &str) -> bool;
fn list_for_user(&self, user_id: &str) -> Vec<ApiKey>;
fn touch(&self, id: &str, now: u64);
}
pub struct InMemoryApiKeyBackend {
keys: Mutex<HashMap<String, ApiKey>>,
}
impl InMemoryApiKeyBackend {
pub fn new() -> Self {
Self {
keys: Mutex::new(HashMap::new()),
}
}
}
impl Default for InMemoryApiKeyBackend {
fn default() -> Self {
Self::new()
}
}
impl ApiKeyBackend for InMemoryApiKeyBackend {
fn put(&self, key: &ApiKey) {
self.keys
.lock()
.unwrap()
.insert(key.id.clone(), key.clone());
}
fn get(&self, id: &str) -> Option<ApiKey> {
self.keys.lock().unwrap().get(id).cloned()
}
fn delete(&self, id: &str) -> bool {
self.keys.lock().unwrap().remove(id).is_some()
}
fn list_for_user(&self, user_id: &str) -> Vec<ApiKey> {
self.keys
.lock()
.unwrap()
.values()
.filter(|k| k.user_id == user_id)
.cloned()
.collect()
}
fn touch(&self, id: &str, now: u64) {
if let Some(k) = self.keys.lock().unwrap().get_mut(id) {
k.last_used_at = Some(now);
}
}
}
pub struct ApiKeyStore {
backend: Box<dyn ApiKeyBackend>,
}
impl Default for ApiKeyStore {
fn default() -> Self {
Self::new()
}
}
#[derive(Debug, Clone)]
pub enum ApiKeyVerifyError {
Malformed,
NotFound,
BadSecret,
Expired,
}
impl std::fmt::Display for ApiKeyVerifyError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Malformed => f.write_str("API key is malformed"),
Self::NotFound => f.write_str("API key not found"),
Self::BadSecret => f.write_str("API key secret mismatch"),
Self::Expired => f.write_str("API key has expired"),
}
}
}
impl ApiKeyStore {
pub fn new() -> Self {
Self::with_backend(Box::new(InMemoryApiKeyBackend::new()))
}
pub fn with_backend(backend: Box<dyn ApiKeyBackend>) -> Self {
Self { backend }
}
pub fn create(
&self,
user_id: String,
name: String,
scopes: Option<String>,
expires_at: Option<u64>,
) -> (String, ApiKey) {
let id = format!("key_{}", random_token(24));
let secret = random_token(32);
let plaintext = format!("pk.{id}.{secret}");
let prefix: String = plaintext.chars().take(16).collect();
let key = ApiKey {
id: id.clone(),
user_id,
name,
prefix,
secret_hash: hash_secret(&secret),
scopes,
expires_at,
last_used_at: None,
created_at: now_secs(),
};
self.backend.put(&key);
(plaintext, key)
}
pub fn verify(&self, token: &str) -> Result<ApiKey, ApiKeyVerifyError> {
let (id, secret) = parse_token(token).ok_or(ApiKeyVerifyError::Malformed)?;
let key = self.backend.get(&id).ok_or(ApiKeyVerifyError::NotFound)?;
if let Some(exp) = key.expires_at {
if exp <= now_secs() {
return Err(ApiKeyVerifyError::Expired);
}
}
let expected = hash_secret(&secret);
if !crate::constant_time_eq(expected.as_bytes(), key.secret_hash.as_bytes()) {
return Err(ApiKeyVerifyError::BadSecret);
}
let now = now_secs();
if key.last_used_at.map(|t| now - t > 60).unwrap_or(true) {
self.backend.touch(&key.id, now);
}
Ok(key)
}
pub fn revoke(&self, id: &str) -> bool {
self.backend.delete(id)
}
pub fn list_for_user(&self, user_id: &str) -> Vec<ApiKey> {
self.backend.list_for_user(user_id)
}
}
fn parse_token(token: &str) -> Option<(String, String)> {
let rest = token.strip_prefix("pk.")?;
let mut parts = rest.split('.');
let id_part = parts.next()?;
let secret = parts.next()?;
if parts.next().is_some() {
return None;
}
if !id_part.starts_with("key_") {
return None;
}
let id_body = &id_part[4..]; if id_body.len() != 32 || secret.len() != 43 {
return None;
}
if !is_base64url(id_body) || !is_base64url(secret) {
return None;
}
Some((id_part.to_string(), secret.to_string()))
}
fn is_base64url(s: &str) -> bool {
s.bytes().all(|b| {
matches!(b,
b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_')
})
}
fn random_token(n_bytes: usize) -> String {
use rand::RngCore;
let mut bytes = vec![0u8; n_bytes];
rand::thread_rng().fill_bytes(&mut bytes);
use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine};
URL_SAFE_NO_PAD.encode(bytes)
}
fn hash_secret(secret: &str) -> String {
use hmac::{Hmac, Mac};
use sha2::Sha256;
type HmacSha256 = Hmac<Sha256>;
let pepper = std::env::var("PYLON_API_KEY_PEPPER")
.unwrap_or_else(|_| "pylon-dev-api-key-pepper-not-for-production".into());
let mut mac =
HmacSha256::new_from_slice(pepper.as_bytes()).expect("HMAC accepts any key length");
mac.update(secret.as_bytes());
let out = mac.finalize().into_bytes();
use std::fmt::Write;
let mut s = String::with_capacity(64);
for b in out {
let _ = write!(s, "{b:02x}");
}
s
}
fn now_secs() -> u64 {
use std::time::{SystemTime, UNIX_EPOCH};
SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_secs()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn create_and_verify_roundtrip() {
let store = ApiKeyStore::new();
let (plaintext, key) = store.create(
"user_1".into(),
"test".into(),
Some("read,write".into()),
None,
);
assert!(plaintext.starts_with("pk.key_"));
let verified = store.verify(&plaintext).expect("verify");
assert_eq!(verified.id, key.id);
assert_eq!(verified.user_id, "user_1");
assert_eq!(verified.scopes.as_deref(), Some("read,write"));
}
#[test]
fn malformed_token_rejected() {
let store = ApiKeyStore::new();
let err = store.verify("not_a_real_key").unwrap_err();
assert!(matches!(err, ApiKeyVerifyError::Malformed));
}
#[test]
fn unknown_id_returns_not_found() {
let store = ApiKeyStore::new();
let token = format!("pk.key_{}.{}", "z".repeat(32), "y".repeat(43));
let err = store.verify(&token).unwrap_err();
assert!(matches!(err, ApiKeyVerifyError::NotFound), "got: {err}");
}
#[test]
fn wrong_secret_rejected() {
let store = ApiKeyStore::new();
let (plaintext, key) = store.create("u".into(), "n".into(), None, None);
let mut bad = plaintext;
bad.pop();
bad.push('X');
let err = store.verify(&bad).unwrap_err();
assert!(matches!(err, ApiKeyVerifyError::BadSecret), "got: {err}");
let _ = key.id;
}
#[test]
fn expired_key_rejected() {
let store = ApiKeyStore::new();
let (plaintext, _) = store.create("u".into(), "n".into(), None, Some(now_secs() - 1));
let err = store.verify(&plaintext).unwrap_err();
assert!(matches!(err, ApiKeyVerifyError::Expired));
}
#[test]
fn revoke_removes_key() {
let store = ApiKeyStore::new();
let (plaintext, key) = store.create("u".into(), "n".into(), None, None);
assert!(store.revoke(&key.id));
let err = store.verify(&plaintext).unwrap_err();
assert!(matches!(err, ApiKeyVerifyError::NotFound));
}
#[test]
fn touch_updates_last_used_at() {
let store = ApiKeyStore::new();
let (plaintext, key) = store.create("u".into(), "n".into(), None, None);
assert!(key.last_used_at.is_none());
let _ = store.verify(&plaintext);
let after = store.list_for_user("u")[0].clone();
assert!(after.last_used_at.is_some(), "touch should refresh");
}
#[test]
fn list_for_user_only_returns_owned() {
let store = ApiKeyStore::new();
let _ = store.create("alice".into(), "k1".into(), None, None);
let _ = store.create("alice".into(), "k2".into(), None, None);
let _ = store.create("bob".into(), "k3".into(), None, None);
assert_eq!(store.list_for_user("alice").len(), 2);
assert_eq!(store.list_for_user("bob").len(), 1);
}
#[test]
fn parse_token_accepts_well_formed() {
let id_body = "a".repeat(32);
let secret = "b".repeat(43);
let token = format!("pk.key_{id_body}.{secret}");
let parsed = parse_token(&token).unwrap();
assert_eq!(parsed.0, format!("key_{id_body}"));
assert_eq!(parsed.1, secret);
}
#[test]
fn parse_token_rejects_malformed() {
assert!(parse_token("pk.key_abc.").is_none());
assert!(parse_token("pk.key_abc").is_none());
assert!(parse_token(&format!("pk.abc.{}", "b".repeat(43))).is_none());
assert!(parse_token(&format!("xy.key_{}.{}", "a".repeat(32), "b".repeat(43))).is_none());
assert!(parse_token(&format!("pk.key_{}.{}", "a".repeat(31), "b".repeat(43))).is_none());
assert!(parse_token(&format!("pk.key_{}.{}", "a".repeat(32), "b".repeat(42))).is_none());
assert!(parse_token(&format!("pk.key_{}.{}", "@".repeat(32), "b".repeat(43))).is_none());
assert!(parse_token(&format!(
"pk.key_{}.{}.junk",
"a".repeat(32),
"b".repeat(43)
))
.is_none());
}
#[test]
fn random_keys_with_underscores_round_trip() {
let store = ApiKeyStore::new();
for _ in 0..20 {
let (plaintext, key) = store.create("u".into(), "n".into(), None, None);
let verified = store
.verify(&plaintext)
.expect("base64url body must verify");
assert_eq!(verified.id, key.id);
}
}
}