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    ) -> Result<(String, ApiTokenInfo), AuthError> {
31        let id = ApiTokenId::new();
32        let raw_session_token = generate_token();
33        let raw = raw_session_token.as_str().to_string();
34        let token_hash = hash_api_token(&raw);
35        let expires_str = expires_at.map(|t| t.format("%Y-%m-%dT%H:%M:%S%.3fZ").to_string());
36
37        let info = sqlx::query_as::<_, ApiTokenInfo>(
38            "INSERT INTO allowthem_api_tokens (id, user_id, name, token_hash, expires_at)
39             VALUES (?, ?, ?, ?, ?)
40             RETURNING id, user_id, name, expires_at, created_at",
41        )
42        .bind(id)
43        .bind(user_id)
44        .bind(name)
45        .bind(token_hash)
46        .bind(expires_str)
47        .fetch_one(self.pool())
48        .await
49        .map_err(AuthError::Database)?;
50
51        Ok((raw, info))
52    }
53
54    /// Validate a raw bearer token.
55    ///
56    /// Hashes the token and queries by hash. Tokens with a past `expires_at`
57    /// are excluded. Returns `Some(UserId)` if valid, `None` otherwise.
58    pub async fn validate_api_token(&self, raw_token: &str) -> Result<Option<UserId>, AuthError> {
59        let hash = hash_api_token(raw_token);
60        let now = Utc::now().format("%Y-%m-%dT%H:%M:%S%.3fZ").to_string();
61        sqlx::query_scalar::<_, UserId>(
62            "SELECT user_id FROM allowthem_api_tokens
63             WHERE token_hash = ? AND (expires_at IS NULL OR expires_at > ?)",
64        )
65        .bind(hash)
66        .bind(now)
67        .fetch_optional(self.pool())
68        .await
69        .map_err(AuthError::Database)
70    }
71
72    /// List all API tokens for a user (metadata only, no hashes).
73    pub async fn list_api_tokens(&self, user_id: UserId) -> Result<Vec<ApiTokenInfo>, AuthError> {
74        sqlx::query_as::<_, ApiTokenInfo>(
75            "SELECT id, user_id, name, expires_at, created_at
76             FROM allowthem_api_tokens
77             WHERE user_id = ?
78             ORDER BY created_at DESC",
79        )
80        .bind(user_id)
81        .fetch_all(self.pool())
82        .await
83        .map_err(AuthError::Database)
84    }
85
86    /// Delete a single API token by ID.
87    ///
88    /// Returns `true` if a token was found and deleted.
89    pub async fn delete_api_token(&self, id: ApiTokenId) -> Result<bool, AuthError> {
90        let result = sqlx::query("DELETE FROM allowthem_api_tokens WHERE id = ?")
91            .bind(id)
92            .execute(self.pool())
93            .await
94            .map_err(AuthError::Database)?;
95        Ok(result.rows_affected() > 0)
96    }
97
98    /// Delete all API tokens for a user.
99    ///
100    /// Returns the number of tokens deleted.
101    pub async fn delete_user_api_tokens(&self, user_id: UserId) -> Result<u64, AuthError> {
102        let result = sqlx::query("DELETE FROM allowthem_api_tokens WHERE user_id = ?")
103            .bind(user_id)
104            .execute(self.pool())
105            .await
106            .map_err(AuthError::Database)?;
107        Ok(result.rows_affected())
108    }
109}
110
111#[cfg(test)]
112mod tests {
113    use chrono::{Duration, Utc};
114
115    use crate::db::Db;
116    use crate::types::{Email, UserId};
117
118    async fn test_db() -> Db {
119        Db::connect("sqlite::memory:")
120            .await
121            .expect("in-memory test db")
122    }
123
124    async fn create_test_user(db: &Db) -> UserId {
125        let email = Email::new(format!("user_{}@example.com", uuid::Uuid::now_v7())).unwrap();
126        let user = db.create_user(email, "password123", None).await.unwrap();
127        user.id
128    }
129
130    #[tokio::test]
131    async fn test_create_and_validate_api_token() {
132        let db = test_db().await;
133        let user_id = create_test_user(&db).await;
134
135        let (raw, info) = db
136            .create_api_token(user_id, "my-token", None)
137            .await
138            .unwrap();
139
140        assert_eq!(info.user_id, user_id);
141        assert_eq!(info.name, "my-token");
142        assert!(info.expires_at.is_none());
143
144        let result = db.validate_api_token(&raw).await.unwrap();
145        assert_eq!(result, Some(user_id));
146    }
147
148    #[tokio::test]
149    async fn test_expired_api_token_rejected() {
150        let db = test_db().await;
151        let user_id = create_test_user(&db).await;
152
153        let past = Utc::now() - Duration::hours(1);
154        let (raw, _) = db
155            .create_api_token(user_id, "expired-token", Some(past))
156            .await
157            .unwrap();
158
159        let result = db.validate_api_token(&raw).await.unwrap();
160        assert!(result.is_none(), "expired token must be rejected");
161    }
162
163    #[tokio::test]
164    async fn test_deleted_api_token_rejected() {
165        let db = test_db().await;
166        let user_id = create_test_user(&db).await;
167
168        let (raw, info) = db
169            .create_api_token(user_id, "delete-me", None)
170            .await
171            .unwrap();
172
173        let deleted = db.delete_api_token(info.id).await.unwrap();
174        assert!(deleted);
175
176        let result = db.validate_api_token(&raw).await.unwrap();
177        assert!(result.is_none(), "deleted token must be rejected");
178    }
179
180    #[tokio::test]
181    async fn test_list_api_tokens() {
182        let db = test_db().await;
183        let user_id = create_test_user(&db).await;
184
185        db.create_api_token(user_id, "token-a", None).await.unwrap();
186        db.create_api_token(user_id, "token-b", None).await.unwrap();
187
188        let tokens = db.list_api_tokens(user_id).await.unwrap();
189        assert_eq!(tokens.len(), 2);
190        // token_hash is not present in ApiTokenInfo — verify by checking names only
191        let names: Vec<&str> = tokens.iter().map(|t| t.name.as_str()).collect();
192        assert!(names.contains(&"token-a"));
193        assert!(names.contains(&"token-b"));
194    }
195
196    #[tokio::test]
197    async fn test_cascade_delete_removes_api_tokens() {
198        let db = test_db().await;
199        let user_id = create_test_user(&db).await;
200
201        db.create_api_token(user_id, "to-be-cascaded", None)
202            .await
203            .unwrap();
204
205        // Delete the user — token should cascade
206        db.delete_user(user_id).await.unwrap();
207
208        let token_count: i64 =
209            sqlx::query_scalar("SELECT COUNT(*) FROM allowthem_api_tokens WHERE user_id = ?")
210                .bind(user_id)
211                .fetch_one(db.pool())
212                .await
213                .unwrap();
214
215        assert_eq!(token_count, 0, "api tokens must cascade-delete with user");
216    }
217}