anvilsign in

collin/anvil

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 argon2::password_hash::rand_core::{
52 OsRng,
53 RngCore,
54 };
55 let mut raw = [0u8; 32];
56 OsRng.fill_bytes(&mut raw);
57 let plaintext = format!(
58 "{PREFIX}{}",
59 raw.iter().map(|b| format!("{b:02x}")).collect::<String>()
60 );
61
62 let mut conn = db.clone();
63 let row = toasty::create!(ApiToken {
64 user_id: user_id,
65 name: name,
66 token_hash: hash(&plaintext),
67 scopes: scopes,
68 created_at: crate::now(),
69 })
70 .exec(&mut conn)
71 .await?;
72 Ok((row, plaintext))
73}
74
75/// Look up the token row for a presented plaintext, if any.
76pub async fn lookup(db: &toasty::Db, token: &str) -> Result<Option<ApiToken>> {
77 if !token.starts_with(PREFIX) {
78 return Ok(None);
79 }
80 let mut conn = db.clone();
81 let row = ApiToken::filter(ApiToken::fields().token_hash().eq(hash(token)))
82 .first()
83 .exec(&mut conn)
84 .await?;
85 Ok(row)
86}
87
88/// Resolve the [`User`] a token authenticates, if the token is valid.
89pub async fn lookup_user(db: &toasty::Db, token: &str) -> Result<Option<User>> {
90 match lookup(db, token).await? {
91 Some(row) => users::find_by_id(db, row.user_id).await,
92 None => Ok(None),
93 }
94}
95
96/// A user's tokens, newest first (hashes only — plaintext is unrecoverable).
97pub async fn list(db: &toasty::Db, user_id: i64) -> Result<Vec<ApiToken>> {
98 let mut conn = db.clone();
99 let rows = ApiToken::filter(ApiToken::fields().user_id().eq(user_id))
100 .order_by(ApiToken::fields().id().desc())
101 .exec(&mut conn)
102 .await?;
103 Ok(rows)
104}
105
106/// Revoke (delete) a token by id. Returns whether a row was removed.
107pub async fn revoke(db: &toasty::Db, id: i64) -> Result<bool> {
108 let mut conn = db.clone();
109 let Some(row) = ApiToken::filter(ApiToken::fields().id().eq(id))
110 .first()
111 .exec(&mut conn)
112 .await?
113 else {
114 return Ok(false);
115 };
116 row.delete().exec(&mut conn).await?;
117 Ok(true)
118}
119
120#[cfg(test)]
121mod tests {
122 use super::*;
123
124 #[test]
125 fn non_pat_strings_are_rejected_before_any_lookup() {
126 // lookup() must short-circuit non-prefixed input — assert via has_scope
127 // and hashing being deterministic here; the prefix guard is in lookup().
128 assert!(!"some-session-token".starts_with(PREFIX));
129 }
130
131 #[test]
132 fn scope_membership() {
133 let tok = ApiToken {
134 id: 1,
135 user_id: 1,
136 name: "t".into(),
137 token_hash: "h".into(),
138 scopes: "read".into(),
139 created_at: 0,
140 };
141 assert!(has_scope(&tok, READ));
142 assert!(!has_scope(&tok, "write"));
143 }
144}