Skip to main content

rskit_auth/apikey/
manager.rs

1//! API key manager: issuing and validating keys.
2
3use chrono::{DateTime, Utc};
4use rskit_errors::{AppError, ErrorCode};
5
6use super::{GenerateResult, Hasher, Key, Store, split_key, validate};
7
8/// Specification of a new API key to issue.
9#[derive(Debug, Clone, Default)]
10pub struct KeySpec {
11    /// Key identifier.
12    pub key_id: String,
13    /// Key owner.
14    pub owner_id: String,
15    /// Display name.
16    pub name: String,
17    /// Key prefix.
18    pub prefix: String,
19    /// Granted scopes.
20    pub scopes: Vec<String>,
21    /// Optional expiry.
22    pub expires_at: Option<DateTime<Utc>>,
23}
24
25/// Manager for issuing, validating, and rotating API keys.
26pub struct Manager<S> {
27    pub(super) store: S,
28    pub(super) hasher: Hasher,
29}
30
31impl<S> Manager<S> {
32    /// Construct a manager.
33    #[must_use]
34    pub const fn new(store: S, hasher: Hasher) -> Self {
35        Self { store, hasher }
36    }
37
38    /// Access the configured store.
39    #[must_use]
40    pub const fn store(&self) -> &S {
41        &self.store
42    }
43
44    /// Access the configured hasher.
45    #[must_use]
46    pub const fn hasher(&self) -> &Hasher {
47        &self.hasher
48    }
49}
50
51impl<S: Store> Manager<S> {
52    /// Issue and persist a new key.
53    pub async fn issue_key(&self, spec: KeySpec) -> Result<(GenerateResult, Key), AppError> {
54        let issued = self.hasher.generate_key(&spec.prefix)?;
55        let record = Key {
56            id: spec.key_id,
57            owner_id: spec.owner_id,
58            name: spec.name,
59            key_prefix: issued.key_prefix.clone(),
60            key_digest: issued.key_digest.clone(),
61            scopes: spec.scopes,
62            is_active: true,
63            expires_at: spec.expires_at,
64            grace_ends_at: None,
65            rotated_by_id: None,
66            last_used_at: None,
67            created_at: Utc::now(),
68        };
69        self.store.create(record.clone()).await?;
70        Ok((issued, record))
71    }
72
73    /// Validate a plaintext key.
74    pub async fn validate_key_with_scopes(
75        &self,
76        plain_key: &str,
77        required_scopes: &[String],
78    ) -> Result<Key, AppError> {
79        let (key_prefix, _secret) = split_key(plain_key)?;
80        let candidates = self.store.list_by_prefix(&key_prefix).await?;
81
82        let mut matched: Option<Key> = None;
83        for candidate in candidates {
84            let digest_matches = self.hasher.compare(plain_key, &candidate.key_digest);
85            if digest_matches && matched.is_none() {
86                matched = Some(candidate);
87            }
88        }
89
90        let mut matched = matched.ok_or_else(AppError::invalid_token)?;
91        validate(&matched).map_err(|_| AppError::invalid_token())?;
92        if required_scopes
93            .iter()
94            .any(|scope| !matched.scopes.iter().any(|granted| granted == scope))
95        {
96            return Err(AppError::new(
97                ErrorCode::Forbidden,
98                String::from("insufficient API key scope"),
99            ));
100        }
101
102        let used_at = Utc::now();
103        self.store.update_last_used(&matched.id, used_at).await?;
104        matched.last_used_at = Some(used_at);
105        Ok(matched)
106    }
107}
108
109#[async_trait::async_trait]
110impl<S: Store> super::KeyValidator for Manager<S> {
111    async fn validate_key(&self, plain_key: &str) -> Result<Key, AppError> {
112        self.validate_key_with_scopes(plain_key, &[]).await
113    }
114}
115
116#[cfg(test)]
117mod tests {
118    use super::KeySpec;
119    use crate::apikey::test_support::manager;
120
121    #[tokio::test]
122    async fn issue_persists_key_and_validates_with_required_scopes() {
123        let manager = manager();
124        let (issued, record) = manager
125            .issue_key(KeySpec {
126                key_id: String::from("key-1"),
127                owner_id: String::from("user-1"),
128                name: String::from("primary"),
129                prefix: String::from("pk"),
130                scopes: vec![String::from("read")],
131                expires_at: None,
132            })
133            .await
134            .unwrap();
135        assert_eq!(record.key_prefix, "pk");
136
137        let validated = manager
138            .validate_key_with_scopes(&issued.plain_key, &[String::from("read")])
139            .await
140            .unwrap();
141        assert_eq!(validated.owner_id, "user-1");
142        assert!(validated.last_used_at.is_some());
143    }
144
145    #[tokio::test]
146    async fn validate_rejects_missing_required_scope() {
147        let manager = manager();
148        let (issued, _record) = manager
149            .issue_key(KeySpec {
150                key_id: String::from("key-1"),
151                owner_id: String::from("user-1"),
152                name: String::from("primary"),
153                prefix: String::from("pk"),
154                scopes: vec![String::from("read")],
155                expires_at: None,
156            })
157            .await
158            .unwrap();
159
160        let error = manager
161            .validate_key_with_scopes(&issued.plain_key, &[String::from("write")])
162            .await
163            .unwrap_err();
164        assert_eq!(error.code(), rskit_errors::ErrorCode::Forbidden);
165    }
166}