allowthem_core/
api_tokens.rs1use 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
9fn 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 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 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 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 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 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 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 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}