anvilsign in

collin/anvil

main / crates / anvil-core / src / api_tokens.rs
1//! Personal access tokens: scoped, least-privilege bearer credentials for
2//! non-browser API clients.
3//!
4//! Tokens look like `anvil_pat_<64 hex>`. Only their SHA-256 hash is persisted;
5//! the plaintext is returned once at creation and never recoverable. Scopes
6//! bound what a token may do — today only [`READ`], which the web layer honors
7//! by accepting bearer auth on safe (GET/HEAD) requests only.
8
9use sha2::{
10 Digest,
11 Sha256,
12};
13
14use crate::{
15 error::Result,
16 models::{
17 ApiToken,
18 User,
19 },
20 users,
21};
22
23/// Read-only scope: authenticates safe requests, can never mutate.
24pub const READ: &str = "read";
25
26/// The token plaintext prefix, so a token is recognizable on sight and bearer
27/// auth can cheaply reject non-PAT strings.
28const PREFIX: &str = "anvil_pat_";
29
30/// Lowercase hex SHA-256 of a token's plaintext — its lookup key.
31fn hash(token: &str) -> String {
32 Sha256::digest(token.as_bytes())
33 .iter()
34 .map(|b| format!("{b:02x}"))
35 .collect()
36}
37
38/// Whether `token` grants `scope`.
39pub fn has_scope(token: &ApiToken, scope: &str) -> bool {
40 token.scopes.split(',').any(|s| s.trim() == scope)
41}
42
43/// Mint a token for `user_id` with the given comma-separated `scopes`,
44/// returning the stored row and the one-time plaintext to show the user.
45pub async fn create(
46 db: &toasty::Db,
47 user_id: i64,
48 name: &str,
49 scopes: &str,
50) -> Result<(ApiToken, String)> {
51 use rand::{
52 Rng,
53 rand_core::UnwrapErr,
54 rngs::SysRng,
55 };
56 let mut raw = [0u8; 32];
57 UnwrapErr(SysRng).fill_bytes(&mut raw);
58 let plaintext = format!(
59 "{PREFIX}{}",
60 raw.iter().map(|b| format!("{b:02x}")).collect::<String>()
61 );
62
63 let mut conn = db.clone();
64 let row = toasty::create!(ApiToken {
65 user_id: user_id,
66 name: name,
67 token_hash: hash(&plaintext),
68 scopes: scopes,
69 created_at: crate::now(),
70 })
71 .exec(&mut conn)
72 .await?;
73 Ok((row, plaintext))
74}
75
76/// Look up the token row for a presented plaintext, if any.
77pub async fn lookup(db: &toasty::Db, token: &str) -> Result<Option<ApiToken>> {
78 if !token.starts_with(PREFIX) {
79 return Ok(None);
80 }
81 let mut conn = db.clone();
82 let row = ApiToken::filter(ApiToken::fields().token_hash().eq(hash(token)))
83 .first()
84 .exec(&mut conn)
85 .await?;
86 Ok(row)
87}
88
89/// Resolve the [`User`] a token authenticates, if the token is valid.
90pub async fn lookup_user(db: &toasty::Db, token: &str) -> Result<Option<User>> {
91 match lookup(db, token).await? {
92 Some(row) => users::find_by_id(db, row.user_id).await,
93 None => Ok(None),
94 }
95}
96
97/// A user's tokens, newest first (hashes only — plaintext is unrecoverable).
98pub async fn list(db: &toasty::Db, user_id: i64) -> Result<Vec<ApiToken>> {
99 let mut conn = db.clone();
100 let rows = ApiToken::filter(ApiToken::fields().user_id().eq(user_id))
101 .order_by(ApiToken::fields().id().desc())
102 .exec(&mut conn)
103 .await?;
104 Ok(rows)
105}
106
107/// Revoke (delete) a token by id. Returns whether a row was removed.
108pub async fn revoke(db: &toasty::Db, id: i64) -> Result<bool> {
109 let mut conn = db.clone();
110 let Some(row) = ApiToken::filter(ApiToken::fields().id().eq(id))
111 .first()
112 .exec(&mut conn)
113 .await?
114 else {
115 return Ok(false);
116 };
117 row.delete().exec(&mut conn).await?;
118 Ok(true)
119}
120
121#[cfg(test)]
122mod tests {
123 use super::*;
124
125 #[test]
126 fn non_pat_strings_are_rejected_before_any_lookup() {
127 // lookup() must short-circuit non-prefixed input — assert via has_scope
128 // and hashing being deterministic here; the prefix guard is in lookup().
129 assert!(!"some-session-token".starts_with(PREFIX));
130 }
131
132 #[test]
133 fn scope_membership() {
134 let tok = ApiToken {
135 id: 1,
136 user_id: 1,
137 name: "t".into(),
138 token_hash: "h".into(),
139 scopes: "read".into(),
140 created_at: 0,
141 };
142 assert!(has_scope(&tok, READ));
143 assert!(!has_scope(&tok, "write"));
144 }
145}