| 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 | |
| 9 | use sha2::{ |
| 10 | Digest, |
| 11 | Sha256, |
| 12 | }; |
| 13 | |
| 14 | use 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. |
| 24 | pub 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. |
| 28 | const PREFIX: &str = "anvil_pat_"; |
| 29 | |
| 30 | /// Lowercase hex SHA-256 of a token's plaintext — its lookup key. |
| 31 | fn 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`. |
| 39 | pub 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. |
| 45 | pub 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. |
| 76 | pub 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. |
| 89 | pub 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). |
| 97 | pub 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. |
| 107 | pub 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)] |
| 121 | mod 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 | } |