pacenotes_server/auth/
token_lookup.rs1pub async fn load_token(
8 connection: &libsql::Connection,
9 token_hash: &str,
10) -> anyhow::Result<Option<crate::auth::TokenRow>> {
11 let mut rows = connection
12 .query(
13 "SELECT t.token_hash, t.label, t.expires_at, t.revoked, g.directory
14 FROM tokens t
15 LEFT JOIN grants g ON g.token_hash = t.token_hash
16 WHERE t.token_hash = ?1",
17 libsql::params![token_hash],
18 )
19 .await
20 .map_err(|e| anyhow::anyhow!("loading credential: {e}"))?;
21
22 let mut loaded: Option<crate::auth::TokenRow> = None;
23 while let Some(row) = rows
24 .next()
25 .await
26 .map_err(|e| anyhow::anyhow!("reading credential row: {e}"))?
27 {
28 let column = |index: i32| {
29 row.get_value(index)
30 .map_err(|e| anyhow::anyhow!("reading credential column {index}: {e}"))
31 };
32 if loaded.is_none() {
33 loaded = Some(crate::auth::TokenRow {
34 token_hash: text(column(0)?, "token_hash")?,
35 label: optional_text(column(1)?, "label")?,
36 expires_at: optional_integer(column(2)?, "expires_at")?,
37 revoked: integer(column(3)?, "revoked")? != 0,
38 grants: Vec::new(),
39 });
40 }
41 let directory = optional_text(column(4)?, "grant")?;
42 if let (Some(token), Some(directory)) = (loaded.as_mut(), directory) {
43 token.grants.push(directory);
44 }
45 }
46 Ok(loaded)
47}
48
49fn integer(value: libsql::Value, column: &str) -> anyhow::Result<i64> {
50 match value {
51 libsql::Value::Integer(integer) => Ok(integer),
52 other => anyhow::bail!("credential {column}: expected an integer, found {other:?}"),
53 }
54}
55
56fn optional_integer(value: libsql::Value, column: &str) -> anyhow::Result<Option<i64>> {
57 match value {
58 libsql::Value::Null => Ok(None),
59 other => integer(other, column).map(Some),
60 }
61}
62
63fn text(value: libsql::Value, column: &str) -> anyhow::Result<String> {
64 match value {
65 libsql::Value::Text(text) => Ok(text),
66 other => anyhow::bail!("credential {column}: expected text, found {other:?}"),
67 }
68}
69
70fn optional_text(value: libsql::Value, column: &str) -> anyhow::Result<Option<String>> {
71 match value {
72 libsql::Value::Null => Ok(None),
73 other => text(other, column).map(Some),
74 }
75}
76
77#[cfg(test)]
78mod tests {
79 #[tokio::test]
80 async fn folds_grants_into_one_row() {
81 let connection = crate::db::open(":memory:").await.unwrap();
82 connection
83 .execute_batch(
84 "INSERT INTO tokens (token_hash, label, created_at, expires_at, revoked)
85 VALUES ('h1', 'Jan', 1, 99, 0);
86 INSERT INTO grants (token_hash, directory) VALUES ('h1', 'beta');
87 INSERT INTO grants (token_hash, directory) VALUES ('h1', 'premium-voices');
88 INSERT INTO tokens (token_hash, created_at) VALUES ('h2', 1);",
89 )
90 .await
91 .unwrap();
92
93 let loaded = super::load_token(&connection, "h1").await.unwrap().unwrap();
94 assert_eq!(loaded.token_hash, "h1");
95 assert_eq!(loaded.label.as_deref(), Some("Jan"));
96 assert_eq!(loaded.expires_at, Some(99));
97 assert!(!loaded.revoked);
98 let mut grants = loaded.grants.clone();
99 grants.sort();
100 assert_eq!(grants, vec!["beta", "premium-voices"]);
101
102 let without_grants = super::load_token(&connection, "h2").await.unwrap().unwrap();
103 assert!(without_grants.grants.is_empty());
104 assert!(without_grants.label.is_none());
105 assert!(without_grants.expires_at.is_none());
106
107 assert!(super::load_token(&connection, "nope")
108 .await
109 .unwrap()
110 .is_none());
111 }
112
113 #[tokio::test]
115 async fn an_undecodable_row_fails_closed() {
116 let connection = crate::db::open(":memory:").await.unwrap();
117 connection
118 .execute(
119 "INSERT INTO tokens (token_hash, created_at, revoked) VALUES ('h', 1, 'yes')",
120 (),
121 )
122 .await
123 .unwrap();
124 assert!(super::load_token(&connection, "h").await.is_err());
125 }
126}