Skip to main content

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}