modo/auth/apikey/mod.rs
1//! # modo::auth::apikey
2//!
3//! Prefixed API key issuance, verification, scoping, and lifecycle management.
4//!
5//! ## Provides
6//!
7//! ### Core
8//!
9//! | Type | Purpose |
10//! |------|---------|
11//! | [`ApiKeyStore`] | Tenant-scoped store: create, verify, revoke, list, refresh keys |
12//! | [`ApiKeyConfig`] | YAML-deserializable configuration (prefix, secret length, touch threshold) |
13//! | [`ApiKeyBackend`] | Trait for pluggable storage backends (SQLite built-in) |
14//!
15//! ### Middleware
16//!
17//! | Type | Purpose |
18//! |------|---------|
19//! | [`ApiKeyLayer`] | Tower layer that verifies API keys on incoming requests |
20//!
21//! Route-level scope gating (`require_scope`) lives in [`crate::auth::guard`].
22//!
23//! ### Data types
24//!
25//! | Type | Purpose |
26//! |------|---------|
27//! | [`ApiKeyMeta`] | Public metadata extracted by middleware, usable as an axum extractor |
28//! | [`ApiKeyCreated`] | One-time creation result containing the raw token |
29//! | [`ApiKeyRecord`] | Full stored record used by backend implementations |
30//! | [`CreateKeyRequest`] | Input for [`ApiKeyStore::create`] |
31//!
32//! ### Testing
33//!
34//! | Type | Purpose |
35//! |------|---------|
36//! | [`test::InMemoryBackend`] | In-memory backend for unit tests (requires `test-helpers` feature) |
37//!
38//! ## Quick start
39//!
40//! ```rust,no_run
41//! use modo::auth::apikey::{ApiKeyConfig, ApiKeyStore, ApiKeyLayer, CreateKeyRequest};
42//! use modo::auth::guard::require_scope;
43//! use axum::{Router, routing::get};
44//! # fn example(db: modo::db::Database) {
45//!
46//! // Build the store from config + database
47//! let store = ApiKeyStore::new(db, ApiKeyConfig::default()).unwrap();
48//!
49//! // Protect routes with the API key middleware and optional scope checks
50//! let app: Router = Router::new()
51//! .route("/orders", get(|| async { "orders" }))
52//! .route_layer(require_scope("read:orders"))
53//! .layer(ApiKeyLayer::new(store));
54//! # }
55//! ```
56
57mod backend;
58mod config;
59mod extractor;
60mod middleware;
61pub(crate) mod sqlite;
62mod store;
63mod token;
64mod types;
65
66pub use backend::ApiKeyBackend;
67pub use config::ApiKeyConfig;
68pub use middleware::ApiKeyLayer;
69pub use store::ApiKeyStore;
70pub use types::{ApiKeyCreated, ApiKeyMeta, ApiKeyRecord, CreateKeyRequest};
71
72/// Test helpers for the API key module.
73///
74/// Available when running tests or when the `test-helpers` feature is enabled.
75#[cfg_attr(not(any(test, feature = "test-helpers")), allow(dead_code))]
76pub mod test {
77 use std::future::Future;
78 use std::pin::Pin;
79 use std::sync::Mutex;
80
81 use crate::error::Result;
82
83 use super::backend::ApiKeyBackend;
84 use super::types::ApiKeyRecord;
85
86 /// In-memory backend for unit tests.
87 pub struct InMemoryBackend {
88 records: Mutex<Vec<ApiKeyRecord>>,
89 }
90
91 impl Default for InMemoryBackend {
92 fn default() -> Self {
93 Self::new()
94 }
95 }
96
97 impl InMemoryBackend {
98 /// Create an empty in-memory backend.
99 pub fn new() -> Self {
100 Self {
101 records: Mutex::new(Vec::new()),
102 }
103 }
104 }
105
106 impl ApiKeyBackend for InMemoryBackend {
107 fn store(
108 &self,
109 record: &ApiKeyRecord,
110 ) -> Pin<Box<dyn Future<Output = Result<()>> + Send + '_>> {
111 self.records.lock().unwrap().push(record.clone());
112 Box::pin(async { Ok(()) })
113 }
114
115 fn lookup(
116 &self,
117 key_id: &str,
118 ) -> Pin<Box<dyn Future<Output = Result<Option<ApiKeyRecord>>> + Send + '_>> {
119 let found = self
120 .records
121 .lock()
122 .unwrap()
123 .iter()
124 .find(|r| r.id == key_id)
125 .cloned();
126 Box::pin(async { Ok(found) })
127 }
128
129 fn revoke(
130 &self,
131 key_id: &str,
132 revoked_at: &str,
133 ) -> Pin<Box<dyn Future<Output = Result<()>> + Send + '_>> {
134 let revoked_at = revoked_at.to_owned();
135 if let Some(r) = self
136 .records
137 .lock()
138 .unwrap()
139 .iter_mut()
140 .find(|r| r.id == key_id)
141 {
142 r.revoked_at = Some(revoked_at);
143 }
144 Box::pin(async { Ok(()) })
145 }
146
147 fn list(
148 &self,
149 tenant_id: &str,
150 ) -> Pin<Box<dyn Future<Output = Result<Vec<ApiKeyRecord>>> + Send + '_>> {
151 let records: Vec<ApiKeyRecord> = self
152 .records
153 .lock()
154 .unwrap()
155 .iter()
156 .filter(|r| r.tenant_id == tenant_id && r.revoked_at.is_none())
157 .cloned()
158 .collect();
159 Box::pin(async { Ok(records) })
160 }
161
162 fn update_last_used(
163 &self,
164 key_id: &str,
165 timestamp: &str,
166 ) -> Pin<Box<dyn Future<Output = Result<()>> + Send + '_>> {
167 let timestamp = timestamp.to_owned();
168 if let Some(r) = self
169 .records
170 .lock()
171 .unwrap()
172 .iter_mut()
173 .find(|r| r.id == key_id)
174 {
175 r.last_used_at = Some(timestamp);
176 }
177 Box::pin(async { Ok(()) })
178 }
179
180 fn update_expires_at(
181 &self,
182 key_id: &str,
183 expires_at: Option<&str>,
184 ) -> Pin<Box<dyn Future<Output = Result<()>> + Send + '_>> {
185 let expires_at = expires_at.map(|s| s.to_owned());
186 if let Some(r) = self
187 .records
188 .lock()
189 .unwrap()
190 .iter_mut()
191 .find(|r| r.id == key_id)
192 {
193 r.expires_at = expires_at;
194 }
195 Box::pin(async { Ok(()) })
196 }
197 }
198}