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};
42//! use modo::auth::guard::require_scope;
43//! use axum::{Router, routing::get};
44//! # fn example(db: modo::db::Database) -> modo::Result<()> {
45//!
46//! // Build the store from config + database
47//! let store = ApiKeyStore::new(db, ApiKeyConfig::default())?;
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//! # Ok(())
55//! # }
56//! ```
57
58mod backend;
59mod config;
60mod extractor;
61mod middleware;
62pub(crate) mod sqlite;
63mod store;
64mod token;
65mod types;
66
67pub use backend::ApiKeyBackend;
68pub use config::ApiKeyConfig;
69pub use middleware::ApiKeyLayer;
70pub use store::ApiKeyStore;
71pub use types::{ApiKeyCreated, ApiKeyMeta, ApiKeyRecord, CreateKeyRequest};
72
73/// Test helpers for the API key module.
74///
75/// Available when running tests or when the `test-helpers` feature is enabled.
76#[cfg_attr(not(any(test, feature = "test-helpers")), allow(dead_code))]
77pub mod test {
78 use std::future::Future;
79 use std::pin::Pin;
80 use std::sync::Mutex;
81
82 use crate::error::Result;
83
84 use super::backend::ApiKeyBackend;
85 use super::types::ApiKeyRecord;
86
87 /// In-memory backend for unit tests.
88 pub struct InMemoryBackend {
89 records: Mutex<Vec<ApiKeyRecord>>,
90 }
91
92 impl Default for InMemoryBackend {
93 fn default() -> Self {
94 Self::new()
95 }
96 }
97
98 impl InMemoryBackend {
99 /// Create an empty in-memory backend.
100 pub fn new() -> Self {
101 Self {
102 records: Mutex::new(Vec::new()),
103 }
104 }
105 }
106
107 impl ApiKeyBackend for InMemoryBackend {
108 fn store(
109 &self,
110 record: &ApiKeyRecord,
111 ) -> Pin<Box<dyn Future<Output = Result<()>> + Send + '_>> {
112 self.records.lock().unwrap().push(record.clone());
113 Box::pin(async { Ok(()) })
114 }
115
116 fn lookup(
117 &self,
118 key_id: &str,
119 ) -> Pin<Box<dyn Future<Output = Result<Option<ApiKeyRecord>>> + Send + '_>> {
120 let found = self
121 .records
122 .lock()
123 .unwrap()
124 .iter()
125 .find(|r| r.id == key_id)
126 .cloned();
127 Box::pin(async { Ok(found) })
128 }
129
130 fn revoke(
131 &self,
132 key_id: &str,
133 revoked_at: &str,
134 ) -> Pin<Box<dyn Future<Output = Result<()>> + Send + '_>> {
135 let revoked_at = revoked_at.to_owned();
136 if let Some(r) = self
137 .records
138 .lock()
139 .unwrap()
140 .iter_mut()
141 .find(|r| r.id == key_id)
142 {
143 r.revoked_at = Some(revoked_at);
144 }
145 Box::pin(async { Ok(()) })
146 }
147
148 fn list(
149 &self,
150 tenant_id: &str,
151 ) -> Pin<Box<dyn Future<Output = Result<Vec<ApiKeyRecord>>> + Send + '_>> {
152 let records: Vec<ApiKeyRecord> = self
153 .records
154 .lock()
155 .unwrap()
156 .iter()
157 .filter(|r| r.tenant_id == tenant_id && r.revoked_at.is_none())
158 .cloned()
159 .collect();
160 Box::pin(async { Ok(records) })
161 }
162
163 fn update_last_used(
164 &self,
165 key_id: &str,
166 timestamp: &str,
167 ) -> Pin<Box<dyn Future<Output = Result<()>> + Send + '_>> {
168 let timestamp = timestamp.to_owned();
169 if let Some(r) = self
170 .records
171 .lock()
172 .unwrap()
173 .iter_mut()
174 .find(|r| r.id == key_id)
175 {
176 r.last_used_at = Some(timestamp);
177 }
178 Box::pin(async { Ok(()) })
179 }
180
181 fn update_expires_at(
182 &self,
183 key_id: &str,
184 expires_at: Option<&str>,
185 ) -> Pin<Box<dyn Future<Output = Result<()>> + Send + '_>> {
186 let expires_at = expires_at.map(|s| s.to_owned());
187 if let Some(r) = self
188 .records
189 .lock()
190 .unwrap()
191 .iter_mut()
192 .find(|r| r.id == key_id)
193 {
194 r.expires_at = expires_at;
195 }
196 Box::pin(async { Ok(()) })
197 }
198 }
199}