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, 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}