1#[derive(Debug)]
6pub struct Credential {
7 pub token_hash: String,
8 pub label: Option<String>,
9 pub created_at: i64,
10 pub expires_at: Option<i64>,
11 pub revoked: bool,
12 pub grants: Vec<String>,
13}
14
15impl Credential {
16 pub fn short_hash(&self) -> &str {
17 short_hash(&self.token_hash)
18 }
19}
20
21pub fn short_hash(token_hash: &str) -> &str {
23 &token_hash[..token_hash.len().min(SHORT_HASH_LENGTH)]
24}
25
26const SHORT_HASH_LENGTH: usize = 12;
27const SECONDS_PER_DAY: i64 = 86_400;
28
29pub async fn resolve_hash(
31 connection: &libsql::Connection,
32 token_or_hash: &str,
33) -> anyhow::Result<String> {
34 if token_or_hash.starts_with(crate::auth::TOKEN_PREFIX) {
35 return Ok(crate::auth::hash_token(token_or_hash));
36 }
37
38 let mut rows = connection
39 .query(
40 "SELECT token_hash FROM tokens WHERE token_hash LIKE ?1 || '%'",
41 libsql::params![token_or_hash],
42 )
43 .await
44 .map_err(|e| anyhow::anyhow!("looking up credential: {e}"))?;
45
46 let mut matches: Vec<String> = Vec::new();
47 while let Some(row) = rows
48 .next()
49 .await
50 .map_err(|e| anyhow::anyhow!("reading credential row: {e}"))?
51 {
52 matches.push(row.get::<String>(0).unwrap_or_default());
53 }
54 match matches.len() {
55 0 => anyhow::bail!("no credential matches {token_or_hash:?}"),
56 1 => Ok(matches.remove(0)),
57 count => anyhow::bail!("{token_or_hash:?} matches {count} credentials — use the full hash"),
58 }
59}
60
61pub async fn mint(
63 connection: &libsql::Connection,
64 label: Option<&str>,
65 grants: &[String],
66 expires_days: Option<i64>,
67) -> anyhow::Result<String> {
68 for directory in grants {
69 check_grantable(directory)?;
70 }
71
72 let token = crate::auth::mint_token();
73 let hash = crate::auth::hash_token(&token);
74 let created_at = crate::auth::now();
75 let expires_at = expires_days.map(|days| created_at + days * SECONDS_PER_DAY);
76
77 connection
78 .execute(
79 "INSERT INTO tokens (token_hash, label, created_at, expires_at, revoked)
80 VALUES (?1, ?2, ?3, ?4, 0)",
81 libsql::params![hash.clone(), label, created_at, expires_at],
82 )
83 .await
84 .map_err(|e| anyhow::anyhow!("storing credential: {e}"))?;
85
86 for directory in grants {
87 insert_grant(connection, &hash, directory).await?;
88 }
89 Ok(token)
90}
91
92pub async fn grant(
94 connection: &libsql::Connection,
95 token_or_hash: &str,
96 directory: &str,
97) -> anyhow::Result<String> {
98 check_grantable(directory)?;
99 let hash = resolve_hash(connection, token_or_hash).await?;
100 insert_grant(connection, &hash, directory).await?;
101 Ok(hash)
102}
103
104pub async fn ungrant(
106 connection: &libsql::Connection,
107 token_or_hash: &str,
108 directory: &str,
109) -> anyhow::Result<String> {
110 let hash = resolve_hash(connection, token_or_hash).await?;
111 connection
112 .execute(
113 "DELETE FROM grants WHERE token_hash = ?1 AND directory = ?2",
114 libsql::params![hash.clone(), directory],
115 )
116 .await
117 .map_err(|e| anyhow::anyhow!("removing grant: {e}"))?;
118 Ok(hash)
119}
120
121pub async fn revoke(
123 connection: &libsql::Connection,
124 token_or_hash: &str,
125) -> anyhow::Result<String> {
126 let hash = resolve_hash(connection, token_or_hash).await?;
127 connection
128 .execute(
129 "UPDATE tokens SET revoked = 1 WHERE token_hash = ?1",
130 libsql::params![hash.clone()],
131 )
132 .await
133 .map_err(|e| anyhow::anyhow!("revoking credential: {e}"))?;
134 Ok(hash)
135}
136
137pub async fn list(connection: &libsql::Connection) -> anyhow::Result<Vec<Credential>> {
139 let mut rows = connection
140 .query(
141 "SELECT t.token_hash, t.label, t.created_at, t.expires_at, t.revoked, g.directory
142 FROM tokens t
143 LEFT JOIN grants g ON g.token_hash = t.token_hash
144 ORDER BY t.created_at DESC, t.token_hash, g.directory",
145 (),
146 )
147 .await
148 .map_err(|e| anyhow::anyhow!("listing credentials: {e}"))?;
149
150 let mut credentials: Vec<Credential> = Vec::new();
151 while let Some(row) = rows
152 .next()
153 .await
154 .map_err(|e| anyhow::anyhow!("reading credential row: {e}"))?
155 {
156 let token_hash = row.get::<String>(0).unwrap_or_default();
157 let is_new_credential = credentials
158 .last()
159 .is_none_or(|credential| credential.token_hash != token_hash);
160 if is_new_credential {
161 credentials.push(Credential {
162 token_hash,
163 label: row.get::<Option<String>>(1).unwrap_or_default(),
164 created_at: row.get::<i64>(2).unwrap_or_default(),
165 expires_at: row.get::<Option<i64>>(3).unwrap_or_default(),
166 revoked: row.get::<i64>(4).unwrap_or_default() != 0,
167 grants: Vec::new(),
168 });
169 }
170 if let (Some(credential), Ok(Some(directory))) =
171 (credentials.last_mut(), row.get::<Option<String>>(5))
172 {
173 credential.grants.push(directory);
174 }
175 }
176 Ok(credentials)
177}
178
179async fn insert_grant(
180 connection: &libsql::Connection,
181 hash: &str,
182 directory: &str,
183) -> anyhow::Result<()> {
184 connection
185 .execute(
186 "INSERT OR IGNORE INTO grants (token_hash, directory) VALUES (?1, ?2)",
187 libsql::params![hash, directory],
188 )
189 .await
190 .map_err(|e| anyhow::anyhow!("storing grant: {e}"))?;
191 Ok(())
192}
193
194fn check_grantable(directory: &str) -> anyhow::Result<()> {
196 if directory == pacenotes_shared::PUBLIC_DIR {
197 anyhow::bail!(
198 "{:?} is visible to everyone and cannot be granted",
199 pacenotes_shared::PUBLIC_DIR
200 );
201 }
202 if !pacenotes_shared::valid_id(directory) {
203 anyhow::bail!("{directory:?} is not a valid directory name [a-z0-9_-]+");
204 }
205 Ok(())
206}
207
208#[cfg(test)]
209mod tests {
210 async fn open_database() -> libsql::Connection {
211 crate::db::open(":memory:").await.unwrap()
212 }
213
214 #[tokio::test]
215 async fn mint_stores_only_the_hash() {
216 let connection = open_database().await;
217 let token = super::mint(&connection, Some("Jan"), &["beta".into()], None)
218 .await
219 .unwrap();
220 assert!(token.starts_with(crate::auth::TOKEN_PREFIX));
221
222 let credentials = super::list(&connection).await.unwrap();
223 assert_eq!(credentials.len(), 1);
224 assert_eq!(credentials[0].token_hash, crate::auth::hash_token(&token));
225 assert_eq!(credentials[0].label.as_deref(), Some("Jan"));
226 assert_eq!(credentials[0].grants, vec!["beta".to_string()]);
227 assert!(!credentials[0].revoked);
228 assert!(credentials[0].expires_at.is_none());
229
230 let looked_up_by_raw_token = crate::auth::load_token(&connection, &token).await.unwrap();
231 assert!(looked_up_by_raw_token.is_none());
232 }
233
234 #[tokio::test]
235 async fn expires_days_becomes_an_absolute_deadline() {
236 let connection = open_database().await;
237 let before = crate::auth::now();
238 super::mint(&connection, None, &[], Some(7)).await.unwrap();
239 let expires_at = super::list(&connection).await.unwrap()[0]
240 .expires_at
241 .unwrap();
242 let clock_slack = 2;
243 assert!((expires_at - before - 7 * super::SECONDS_PER_DAY).abs() <= clock_slack);
244 }
245
246 #[tokio::test]
247 async fn grant_and_ungrant_round_trip_by_token_or_hash() {
248 let connection = open_database().await;
249 let token = super::mint(&connection, None, &[], None).await.unwrap();
250 let hash = crate::auth::hash_token(&token);
251
252 super::grant(&connection, &token, "beta").await.unwrap();
253 super::grant(&connection, &hash, "premium-voices")
254 .await
255 .unwrap();
256 super::grant(
257 &connection,
258 &hash[..super::SHORT_HASH_LENGTH],
259 "early-access",
260 )
261 .await
262 .unwrap();
263 super::grant(&connection, &token, "beta").await.unwrap();
264
265 let mut grants = super::list(&connection).await.unwrap()[0].grants.clone();
266 grants.sort();
267 assert_eq!(grants, vec!["beta", "early-access", "premium-voices"]);
268
269 super::ungrant(&connection, &token, "beta").await.unwrap();
270 let grants = super::list(&connection).await.unwrap()[0].grants.clone();
271 assert!(!grants.contains(&"beta".to_string()), "{grants:?}");
272
273 super::ungrant(&connection, &token, "beta").await.unwrap();
274 }
275
276 #[tokio::test]
277 async fn public_and_invalid_directories_cannot_be_granted() {
278 let connection = open_database().await;
279 let token = super::mint(&connection, None, &[], None).await.unwrap();
280
281 let error = super::grant(&connection, &token, pacenotes_shared::PUBLIC_DIR)
282 .await
283 .unwrap_err()
284 .to_string();
285 assert!(error.contains("everyone"), "{error}");
286
287 let error = super::grant(&connection, &token, "Premium Voices")
288 .await
289 .unwrap_err()
290 .to_string();
291 assert!(error.contains("not a valid directory name"), "{error}");
292
293 let error = super::mint(&connection, None, &["public".into()], None)
294 .await
295 .unwrap_err()
296 .to_string();
297 assert!(error.contains("everyone"), "{error}");
298 assert_eq!(super::list(&connection).await.unwrap().len(), 1);
299 }
300
301 #[tokio::test]
302 async fn revoke_marks_the_row_and_decide_refuses_it() {
303 let connection = open_database().await;
304 let token = super::mint(&connection, None, &["beta".into()], None)
305 .await
306 .unwrap();
307 super::revoke(&connection, &token).await.unwrap();
308
309 assert!(super::list(&connection).await.unwrap()[0].revoked);
310 let loaded = crate::auth::load_token(&connection, &crate::auth::hash_token(&token))
311 .await
312 .unwrap()
313 .unwrap();
314 assert!(matches!(
315 crate::auth::decide(Some(&loaded), "beta", crate::auth::now()),
316 crate::auth::Decision::Unauthorized
317 ));
318 }
319
320 #[tokio::test]
321 async fn unknown_and_ambiguous_references_are_refused() {
322 let connection = open_database().await;
323 let error = super::resolve_hash(&connection, "deadbeef")
324 .await
325 .unwrap_err()
326 .to_string();
327 assert!(error.contains("no credential matches"), "{error}");
328
329 super::mint(&connection, None, &[], None).await.unwrap();
330 super::mint(&connection, None, &[], None).await.unwrap();
331 let prefix_of_every_hash = "";
332 let error = super::resolve_hash(&connection, prefix_of_every_hash)
333 .await
334 .unwrap_err()
335 .to_string();
336 assert!(error.contains("use the full hash"), "{error}");
337 }
338}