Skip to main content

allowthem_core/
api_tokens.rs

1use chrono::{DateTime, Utc};
2use sha2::{Digest, Sha256};
3
4use crate::db::Db;
5use crate::error::AuthError;
6use crate::sessions::generate_token;
7use crate::types::{ApiTokenId, ApiTokenInfo, TokenHash, UserId};
8
9/// Hash a raw API token string with SHA-256.
10///
11/// Returns the hex-encoded digest as a `TokenHash`. This is a standalone
12/// function rather than reusing `sessions::hash_token` to avoid coupling
13/// through the `SessionToken` type.
14fn hash_api_token(raw: &str) -> TokenHash {
15    let digest = Sha256::digest(raw.as_bytes());
16    TokenHash::new_unchecked(format!("{digest:x}"))
17}
18
19impl Db {
20    /// Generate and store a new API token for the user.
21    ///
22    /// Returns the raw token string (shown once, never stored) and
23    /// `ApiTokenInfo` metadata. The caller must present the raw token to the
24    /// user — it cannot be retrieved again.
25    pub async fn create_api_token(
26        &self,
27        user_id: UserId,
28        name: &str,
29        expires_at: Option<DateTime<Utc>>,
30        metadata: Option<&str>,
31    ) -> Result<(String, ApiTokenInfo), AuthError> {
32        let id = ApiTokenId::new();
33        let raw_session_token = generate_token();
34        let raw = raw_session_token.as_str().to_string();
35        let token_hash = hash_api_token(&raw);
36        let expires_str = expires_at.map(|t| t.format("%Y-%m-%dT%H:%M:%S%.3fZ").to_string());
37
38        let info = sqlx::query_as::<_, ApiTokenInfo>(
39            "INSERT INTO allowthem_api_tokens (id, user_id, name, token_hash, expires_at, metadata)
40             VALUES (?, ?, ?, ?, ?, ?)
41             RETURNING id, user_id, name, metadata, expires_at, created_at",
42        )
43        .bind(id)
44        .bind(user_id)
45        .bind(name)
46        .bind(token_hash)
47        .bind(expires_str)
48        .bind(metadata)
49        .fetch_one(self.pool())
50        .await
51        .map_err(AuthError::Database)?;
52
53        Ok((raw, info))
54    }
55
56    /// Validate a raw bearer token.
57    ///
58    /// Hashes the token and queries by hash. Tokens with a past `expires_at`
59    /// are excluded. Returns `Some((UserId, ApiTokenInfo))` if valid, `None` otherwise.
60    pub async fn validate_api_token(
61        &self,
62        raw_token: &str,
63    ) -> Result<Option<(UserId, ApiTokenInfo)>, AuthError> {
64        let hash = hash_api_token(raw_token);
65        let now = Utc::now().format("%Y-%m-%dT%H:%M:%S%.3fZ").to_string();
66        let info = sqlx::query_as::<_, ApiTokenInfo>(
67            "SELECT id, user_id, name, metadata, expires_at, created_at
68             FROM allowthem_api_tokens
69             WHERE token_hash = ? AND (expires_at IS NULL OR expires_at > ?)",
70        )
71        .bind(hash)
72        .bind(now)
73        .fetch_optional(self.pool())
74        .await
75        .map_err(AuthError::Database)?;
76
77        Ok(info.map(|i| (i.user_id, i)))
78    }
79
80    /// List all API tokens for a user (metadata only, no hashes).
81    pub async fn list_api_tokens(&self, user_id: UserId) -> Result<Vec<ApiTokenInfo>, AuthError> {
82        sqlx::query_as::<_, ApiTokenInfo>(
83            "SELECT id, user_id, name, metadata, expires_at, created_at
84             FROM allowthem_api_tokens
85             WHERE user_id = ?
86             ORDER BY created_at DESC",
87        )
88        .bind(user_id)
89        .fetch_all(self.pool())
90        .await
91        .map_err(AuthError::Database)
92    }
93
94    /// Delete a single API token by ID.
95    ///
96    /// Returns `true` if a token was found and deleted.
97    pub async fn delete_api_token(&self, id: ApiTokenId) -> Result<bool, AuthError> {
98        let result = sqlx::query("DELETE FROM allowthem_api_tokens WHERE id = ?")
99            .bind(id)
100            .execute(self.pool())
101            .await
102            .map_err(AuthError::Database)?;
103        Ok(result.rows_affected() > 0)
104    }
105
106    /// Delete all API tokens for a user.
107    ///
108    /// Returns the number of tokens deleted.
109    pub async fn delete_user_api_tokens(&self, user_id: UserId) -> Result<u64, AuthError> {
110        let result = sqlx::query("DELETE FROM allowthem_api_tokens WHERE user_id = ?")
111            .bind(user_id)
112            .execute(self.pool())
113            .await
114            .map_err(AuthError::Database)?;
115        Ok(result.rows_affected())
116    }
117}
118
119#[cfg(test)]
120mod tests {
121    use chrono::{Duration, Utc};
122
123    use crate::db::Db;
124    use crate::types::{Email, UserId};
125
126    async fn test_db() -> Db {
127        Db::connect("sqlite::memory:")
128            .await
129            .expect("in-memory test db")
130    }
131
132    async fn create_test_user(db: &Db) -> UserId {
133        let email = Email::new(format!("user_{}@example.com", uuid::Uuid::now_v7())).unwrap();
134        let user = db
135            .create_user(email, "password123", None, None)
136            .await
137            .unwrap();
138        user.id
139    }
140
141    #[tokio::test]
142    async fn test_create_and_validate_api_token() {
143        let db = test_db().await;
144        let user_id = create_test_user(&db).await;
145
146        let (raw, info) = db
147            .create_api_token(user_id, "my-token", None, None)
148            .await
149            .unwrap();
150
151        assert_eq!(info.user_id, user_id);
152        assert_eq!(info.name, "my-token");
153        assert!(info.expires_at.is_none());
154        assert!(info.metadata.is_none());
155
156        let result = db.validate_api_token(&raw).await.unwrap();
157        let (uid, _token_info) = result.expect("token must be valid");
158        assert_eq!(uid, user_id);
159    }
160
161    #[tokio::test]
162    async fn test_expired_api_token_rejected() {
163        let db = test_db().await;
164        let user_id = create_test_user(&db).await;
165
166        let past = Utc::now() - Duration::hours(1);
167        let (raw, _) = db
168            .create_api_token(user_id, "expired-token", Some(past), None)
169            .await
170            .unwrap();
171
172        let result = db.validate_api_token(&raw).await.unwrap();
173        assert!(result.is_none(), "expired token must be rejected");
174    }
175
176    #[tokio::test]
177    async fn test_deleted_api_token_rejected() {
178        let db = test_db().await;
179        let user_id = create_test_user(&db).await;
180
181        let (raw, info) = db
182            .create_api_token(user_id, "delete-me", None, None)
183            .await
184            .unwrap();
185
186        let deleted = db.delete_api_token(info.id).await.unwrap();
187        assert!(deleted);
188
189        let result = db.validate_api_token(&raw).await.unwrap();
190        assert!(result.is_none(), "deleted token must be rejected");
191    }
192
193    #[tokio::test]
194    async fn test_list_api_tokens() {
195        let db = test_db().await;
196        let user_id = create_test_user(&db).await;
197
198        db.create_api_token(user_id, "token-a", None, None)
199            .await
200            .unwrap();
201        db.create_api_token(user_id, "token-b", None, None)
202            .await
203            .unwrap();
204
205        let tokens = db.list_api_tokens(user_id).await.unwrap();
206        assert_eq!(tokens.len(), 2);
207        // token_hash is not present in ApiTokenInfo — verify by checking names only
208        let names: Vec<&str> = tokens.iter().map(|t| t.name.as_str()).collect();
209        assert!(names.contains(&"token-a"));
210        assert!(names.contains(&"token-b"));
211    }
212
213    #[tokio::test]
214    async fn test_cascade_delete_removes_api_tokens() {
215        let db = test_db().await;
216        let user_id = create_test_user(&db).await;
217
218        db.create_api_token(user_id, "to-be-cascaded", None, None)
219            .await
220            .unwrap();
221
222        // Delete the user — token should cascade
223        db.delete_user(user_id).await.unwrap();
224
225        let token_count: i64 =
226            sqlx::query_scalar("SELECT COUNT(*) FROM allowthem_api_tokens WHERE user_id = ?")
227                .bind(user_id)
228                .fetch_one(db.pool())
229                .await
230                .unwrap();
231
232        assert_eq!(token_count, 0, "api tokens must cascade-delete with user");
233    }
234
235    #[tokio::test]
236    async fn test_create_with_metadata() {
237        let db = test_db().await;
238        let user_id = create_test_user(&db).await;
239
240        let (raw, info) = db
241            .create_api_token(user_id, "meta-token", None, Some("key=value"))
242            .await
243            .unwrap();
244
245        assert_eq!(info.metadata.as_deref(), Some("key=value"));
246
247        // validate returns correct metadata
248        let (uid, token_info) = db
249            .validate_api_token(&raw)
250            .await
251            .unwrap()
252            .expect("token must be valid");
253        assert_eq!(uid, user_id);
254        assert_eq!(token_info.metadata.as_deref(), Some("key=value"));
255
256        // list also returns correct metadata
257        let tokens = db.list_api_tokens(user_id).await.unwrap();
258        assert_eq!(tokens.len(), 1);
259        assert_eq!(tokens[0].metadata.as_deref(), Some("key=value"));
260    }
261}