Skip to main content

pacenotes_server/auth/
token_lookup.rs

1/// The credential with this hash and all its grants, in one query; `None`
2/// when no such credential exists.
3///
4/// Decodes fail-closed, since defaulting `revoked` or `expires_at` after a
5/// schema drift would turn a dead key live; raw values are read because libsql
6/// 0.6's typed `row.get` panics on a type mismatch.
7pub 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    /// SQLite's dynamic typing lets text land in `revoked`, which used to decode as live.
114    #[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}