collin/anvil · 91f548f3
feat: read-only personal access tokens (PATs) for API fetches
Collin Richards · 2026-06-10 14:27 UTC · 91f548f3896bf4aa6e26dd840b646683c4157526 · parent 680a0d93 · browse files
modifiedCLAUDE.md+18 −0
| ⋯ 16 unchanged lines | |||
| 17 | 17 | `cargo sort-derives` (`cargo install cargo-sort-derives`), and | |
| 18 | 18 | `cargo clippy --workspace --all-targets -- -D warnings`. | |
| 19 | 19 | ||
| 20 | + | ## Viewing attachments referenced in tasks | |
| 21 | + | ||
| 22 | + | TODO items and tickets may embed an uploaded image as | |
| 23 | + | ``. These bytes live outside git on | |
| 24 | + | the anvil instance, so to actually *see* one, fetch it from the instance and | |
| 25 | + | `Read` the file. With `ANVIL_BASE_URL` (the instance URL) and `ANVIL_TOKEN` (a | |
| 26 | + | read-only PAT — mint one with `anvild user token create <user>`) set: | |
| 27 | + | ||
| 28 | + | ``` | |
| 29 | + | curl -fsS -H "Authorization: Bearer $ANVIL_TOKEN" \ | |
| 30 | + | "$ANVIL_BASE_URL/{owner}/{repo}/-/attachments/{hash}" -o /tmp/att.png | |
| 31 | + | ``` | |
| 32 | + | ||
| 33 | + | Then `Read /tmp/att.png` to view it. The PAT is read-only (it authenticates | |
| 34 | + | GET/HEAD only), so it's safe to hold; public-repo attachments need no token. | |
| 35 | + | If those env vars aren't set, ask the user for the instance URL and a token | |
| 36 | + | rather than guessing. | |
| 37 | + | ||
| 20 | 38 | Current status, resume notes, the agreed next steps (a/b/c), and the roadmap live | |
| 21 | 39 | in the TODO. Read it first: | |
| 22 | 40 | ||
| ⋯ 1 unchanged line | |||
modifiedTODO.md+11 −0
| ⋯ 58 unchanged lines | |||
| 59 | 59 | - [ ] maybe: per-repo drill-down, and a cheap cached/periodic variant if the | |
| 60 | 60 | on-demand disk walk gets slow on large instances. | |
| 61 | 61 | ||
| 62 | + | ## API tokens (read-only PATs) | |
| 63 | + | ||
| 64 | + | - [x] `ApiToken` model + `anvil-core::api_tokens` (create/list/revoke, SHA-256 | |
| 65 | + | hashed, scoped). CLI `anvild user token create|list|revoke`. | |
| 66 | + | - [x] bearer auth: `CurrentUser` also accepts `Authorization: Bearer <pat>` on | |
| 67 | + | GET/HEAD only — least-privilege read-only (writes need a session CSRF a bearer | |
| 68 | + | lacks). Lets tooling (and Claude) fetch private-repo attachments over HTTP. | |
| 69 | + | See the recipe in `CLAUDE.md`. | |
| 70 | + | - [ ] maybe later: a `write` scope (would need CSRF-exempt write paths), token | |
| 71 | + | management in the web UI, and `last_used_at` tracking. | |
| 72 | + | ||
| 62 | 73 | ## UI polish (done) | |
| 63 | 74 | ||
| 64 | 75 | - [x] less vertical padding at the top of the screen (`main` top padding 24→12px) | |
| ⋯ 5 unchanged lines | |||
modifiedcrates/anvil-cli/src/main.rs+56 −0
| ⋯ 2 unchanged lines | |||
| 3 | 3 | use anvil_core::{ | |
| 4 | 4 | App, | |
| 5 | 5 | Config, | |
| 6 | + | api_tokens, | |
| 6 | 7 | repos, | |
| 7 | 8 | ssh_keys, | |
| 8 | 9 | users, | |
| ⋯ 64 unchanged lines | |||
| 73 | 74 | #[arg(long, default_value = "")] | |
| 74 | 75 | title: String, | |
| 75 | 76 | }, | |
| 77 | + | /// Manage personal access tokens (read-only API bearer credentials). | |
| 78 | + | Token { | |
| 79 | + | #[command(subcommand)] | |
| 80 | + | command: TokenCommand, | |
| 81 | + | }, | |
| 82 | + | } | |
| 83 | + | ||
| 84 | + | #[derive(Subcommand)] | |
| 85 | + | enum TokenCommand { | |
| 86 | + | /// Mint a token for a user. The plaintext is printed once — store it now. | |
| 87 | + | Create { | |
| 88 | + | username: String, | |
| 89 | + | /// Human label for the token (shown when listing). | |
| 90 | + | #[arg(long, default_value = "api")] | |
| 91 | + | name: String, | |
| 92 | + | }, | |
| 93 | + | /// List a user's tokens (id, name, created — never the secret). | |
| 94 | + | List { username: String }, | |
| 95 | + | /// Revoke a token by id. | |
| 96 | + | Revoke { id: i64 }, | |
| 76 | 97 | } | |
| 77 | 98 | ||
| 78 | 99 | #[derive(Subcommand)] | |
| ⋯ 97 unchanged lines | |||
| 176 | 197 | user.username, saved.fingerprint | |
| 177 | 198 | ); | |
| 178 | 199 | } | |
| 200 | + | UserCommand::Token { command } => token(&app, command).await?, | |
| 201 | + | } | |
| 202 | + | Ok(()) | |
| 203 | + | } | |
| 204 | + | ||
| 205 | + | async fn token(app: &App, command: TokenCommand) -> Result<()> { | |
| 206 | + | match command { | |
| 207 | + | TokenCommand::Create { username, name } => { | |
| 208 | + | let user = users::find_by_username(&app.db, &username) | |
| 209 | + | .await? | |
| 210 | + | .with_context(|| format!("no such user: {username}"))?; | |
| 211 | + | let (_, plaintext) = | |
| 212 | + | api_tokens::create(&app.db, user.id, &name, api_tokens::READ).await?; | |
| 213 | + | println!("created read-only token '{name}' for {username}."); | |
| 214 | + | println!("store this now — it won't be shown again:\n\n {plaintext}\n"); | |
| 215 | + | } | |
| 216 | + | TokenCommand::List { username } => { | |
| 217 | + | let user = users::find_by_username(&app.db, &username) | |
| 218 | + | .await? | |
| 219 | + | .with_context(|| format!("no such user: {username}"))?; | |
| 220 | + | let tokens = api_tokens::list(&app.db, user.id).await?; | |
| 221 | + | if tokens.is_empty() { | |
| 222 | + | println!("{username} has no tokens."); | |
| 223 | + | } | |
| 224 | + | for t in tokens { | |
| 225 | + | println!("#{} {} [{}]", t.id, t.name, t.scopes); | |
| 226 | + | } | |
| 227 | + | } | |
| 228 | + | TokenCommand::Revoke { id } => { | |
| 229 | + | if api_tokens::revoke(&app.db, id).await? { | |
| 230 | + | println!("revoked token #{id}."); | |
| 231 | + | } else { | |
| 232 | + | anyhow::bail!("no token with id {id}"); | |
| 233 | + | } | |
| 234 | + | } | |
| 179 | 235 | } | |
| 180 | 236 | Ok(()) | |
| 181 | 237 | } | |
| ⋯ 40 unchanged lines | |||
addedcrates/anvil-core/src/api_tokens.rs+144 −0
| 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 | + | } |
modifiedcrates/anvil-core/src/db.rs+21 −2
| ⋯ 4 unchanged lines | |||
| 5 | 5 | use crate::{ | |
| 6 | 6 | error::Result, | |
| 7 | 7 | models::{ | |
| 8 | + | ApiToken, | |
| 8 | 9 | Attachment, | |
| 9 | 10 | CiArtifact, | |
| 10 | 11 | CiRun, | |
| ⋯ 30 unchanged lines | |||
| 41 | 42 | CiArtifact, | |
| 42 | 43 | Issue, | |
| 43 | 44 | IssueComment, | |
| 44 | - | Attachment | |
| 45 | + | Attachment, | |
| 46 | + | ApiToken | |
| 45 | 47 | )) | |
| 46 | 48 | .connect(&url) | |
| 47 | 49 | .await?; | |
| ⋯ 20 unchanged lines | |||
| 68 | 70 | r#"CREATE INDEX IF NOT EXISTS "index_issue_comments_by_issue_id" ON "issue_comments" ("issue_id")"#, | |
| 69 | 71 | ATTACHMENTS_DDL, | |
| 70 | 72 | r#"CREATE INDEX IF NOT EXISTS "index_attachments_by_repo_id" ON "attachments" ("repo_id")"#, | |
| 73 | + | API_TOKENS_DDL, | |
| 74 | + | r#"CREATE INDEX IF NOT EXISTS "index_api_tokens_by_user_id" ON "api_tokens" ("user_id")"#, | |
| 75 | + | r#"CREATE UNIQUE INDEX IF NOT EXISTS "index_api_tokens_by_token_hash" ON "api_tokens" ("token_hash")"#, | |
| 71 | 76 | ]; | |
| 72 | 77 | ||
| 73 | 78 | const CI_ARTIFACTS_DDL: &str = r#"CREATE TABLE IF NOT EXISTS "ci_artifacts" ( | |
| ⋯ 35 unchanged lines | |||
| 109 | 114 | "uploader_id" BIGINT NOT NULL, | |
| 110 | 115 | "created_at" BIGINT NOT NULL )"#; | |
| 111 | 116 | ||
| 117 | + | const API_TOKENS_DDL: &str = r#"CREATE TABLE IF NOT EXISTS "api_tokens" ( | |
| 118 | + | "id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, | |
| 119 | + | "user_id" BIGINT NOT NULL, | |
| 120 | + | "name" TEXT NOT NULL, | |
| 121 | + | "token_hash" TEXT NOT NULL, | |
| 122 | + | "scopes" TEXT NOT NULL, | |
| 123 | + | "created_at" BIGINT NOT NULL )"#; | |
| 124 | + | ||
| 112 | 125 | /// Columns added to existing tables after deployment, applied as | |
| 113 | 126 | /// `ALTER TABLE … ADD COLUMN` when missing (SQLite has no `IF NOT EXISTS` | |
| 114 | 127 | /// for columns, so presence is checked via `pragma_table_info`). The model | |
| ⋯ 36 unchanged lines | |||
| 151 | 164 | use super::*; | |
| 152 | 165 | ||
| 153 | 166 | /// Tables created by shims (i.e. added after the first deployment). | |
| 154 | - | const SHIMMED_TABLES: &[&str] = &["ci_artifacts", "issues", "issue_comments", "attachments"]; | |
| 167 | + | const SHIMMED_TABLES: &[&str] = &[ | |
| 168 | + | "ci_artifacts", | |
| 169 | + | "issues", | |
| 170 | + | "issue_comments", | |
| 171 | + | "attachments", | |
| 172 | + | "api_tokens", | |
| 173 | + | ]; | |
| 155 | 174 | ||
| 156 | 175 | /// Every schema object (table + indexes) for `table`, normalized. | |
| 157 | 176 | fn schema_objects(path: &Path, table: &str) -> Vec<String> { | |
| ⋯ 117 unchanged lines | |||
modifiedcrates/anvil-core/src/lib.rs+2 −0
| ⋯ 5 unchanged lines | |||
| 6 | 6 | //! `anvil-git`) build on top of it. | |
| 7 | 7 | ||
| 8 | 8 | pub mod access; | |
| 9 | + | pub mod api_tokens; | |
| 9 | 10 | pub mod attachments; | |
| 10 | 11 | pub mod ci; | |
| 11 | 12 | pub mod config; | |
| ⋯ 14 unchanged lines | |||
| 26 | 27 | Result, | |
| 27 | 28 | }; | |
| 28 | 29 | pub use models::{ | |
| 30 | + | ApiToken, | |
| 29 | 31 | Attachment, | |
| 30 | 32 | CiArtifact, | |
| 31 | 33 | CiRun, | |
| ⋯ 101 unchanged lines | |||
modifiedcrates/anvil-core/src/models.rs+23 −0
| ⋯ 168 unchanged lines | |||
| 169 | 169 | pub created_at: i64, | |
| 170 | 170 | } | |
| 171 | 171 | ||
| 172 | + | /// A personal access token: a long-lived, scoped bearer credential for | |
| 173 | + | /// non-browser API clients (e.g. tooling that fetches attachments). Only the | |
| 174 | + | /// SHA-256 hash of the token is stored; the plaintext is shown once at | |
| 175 | + | /// creation. A PAT is least-privilege by design — its `scopes` bound what it | |
| 176 | + | /// can do, and the only scope today (`read`) authenticates safe (GET/HEAD) | |
| 177 | + | /// requests only, so a leaked token can never mutate. | |
| 178 | + | #[derive(Clone, Debug, toasty::Model)] | |
| 179 | + | pub struct ApiToken { | |
| 180 | + | #[key] | |
| 181 | + | #[auto] | |
| 182 | + | pub id: i64, | |
| 183 | + | #[index] | |
| 184 | + | pub user_id: i64, | |
| 185 | + | /// A human label for the token (e.g. "claude"), for listing/revoking. | |
| 186 | + | pub name: String, | |
| 187 | + | /// Lowercase hex SHA-256 of the token; the lookup key. | |
| 188 | + | #[unique] | |
| 189 | + | pub token_hash: String, | |
| 190 | + | /// Comma-separated scopes granted to this token (e.g. `read`). | |
| 191 | + | pub scopes: String, | |
| 192 | + | pub created_at: i64, | |
| 193 | + | } | |
| 194 | + | ||
| 172 | 195 | /// A registered SSH public key, used to authenticate git-over-SSH connections. | |
| 173 | 196 | #[derive(Debug, toasty::Model)] | |
| 174 | 197 | pub struct SshKey { | |
| ⋯ 13 unchanged lines | |||
modifiedcrates/anvil-web/src/auth.rs+33 −3
| ⋯ 5 unchanged lines | |||
| 6 | 6 | use anvil_core::{ | |
| 7 | 7 | App, | |
| 8 | 8 | User, | |
| 9 | + | api_tokens, | |
| 9 | 10 | sessions, | |
| 10 | 11 | users, | |
| 11 | 12 | }; | |
| ⋯ 5 unchanged lines | |||
| 17 | 18 | State, | |
| 18 | 19 | }, | |
| 19 | 20 | http::{ | |
| 21 | + | Method, | |
| 20 | 22 | StatusCode, | |
| 21 | 23 | request::Parts, | |
| 22 | 24 | }, | |
| ⋯ 44 unchanged lines | |||
| 67 | 69 | CSRF_TOKEN.scope(token, next.run(req)).await | |
| 68 | 70 | } | |
| 69 | 71 | ||
| 70 | - | /// Extractor yielding the logged-in user, if any, from the session cookie. | |
| 71 | - | /// Never fails — absence of a valid session simply yields `None`. | |
| 72 | + | /// Extractor yielding the logged-in user, if any. Authenticates from the | |
| 73 | + | /// session cookie, or — on safe (GET/HEAD) requests only — from a | |
| 74 | + | /// `Authorization: Bearer <pat>` personal access token. Never fails: absence | |
| 75 | + | /// of a valid credential simply yields `None`. | |
| 76 | + | /// | |
| 77 | + | /// PAT auth is deliberately confined to read methods: a token grants the | |
| 78 | + | /// `read` scope and nothing more, so a leaked token can never mutate (and | |
| 79 | + | /// mutating handlers also require a session-bound CSRF token a bearer lacks). | |
| 72 | 80 | pub struct CurrentUser(pub Option<User>); | |
| 73 | 81 | ||
| 74 | 82 | impl FromRequestParts<App> for CurrentUser { | |
| ⋯ 1 unchanged line | |||
| 76 | 84 | ||
| 77 | 85 | async fn from_request_parts(parts: &mut Parts, app: &App) -> Result<Self, Infallible> { | |
| 78 | 86 | let jar = CookieJar::from_headers(&parts.headers); | |
| 79 | - | let user = match jar.get(SESSION_COOKIE) { | |
| 87 | + | let mut user = match jar.get(SESSION_COOKIE) { | |
| 80 | 88 | Some(cookie) => sessions::lookup_user(&app.db, cookie.value()) | |
| 81 | 89 | .await | |
| 82 | 90 | .ok() | |
| 83 | 91 | .flatten(), | |
| 84 | 92 | None => None, | |
| 85 | 93 | }; | |
| 94 | + | ||
| 95 | + | // Read-only PAT fallback for API clients (no session cookie). | |
| 96 | + | let safe = parts.method == Method::GET || parts.method == Method::HEAD; | |
| 97 | + | if user.is_none() | |
| 98 | + | && safe | |
| 99 | + | && let Some(token) = bearer_token(&parts.headers) | |
| 100 | + | && let Ok(Some(tok)) = api_tokens::lookup(&app.db, token).await | |
| 101 | + | && api_tokens::has_scope(&tok, api_tokens::READ) | |
| 102 | + | { | |
| 103 | + | user = users::find_by_id(&app.db, tok.user_id).await.ok().flatten(); | |
| 104 | + | } | |
| 105 | + | ||
| 86 | 106 | Ok(CurrentUser(user)) | |
| 87 | 107 | } | |
| 88 | 108 | } | |
| 89 | 109 | ||
| 110 | + | /// Extract the credential from an `Authorization: Bearer <token>` header. | |
| 111 | + | fn bearer_token(headers: &axum::http::HeaderMap) -> Option<&str> { | |
| 112 | + | headers | |
| 113 | + | .get(axum::http::header::AUTHORIZATION)? | |
| 114 | + | .to_str() | |
| 115 | + | .ok()? | |
| 116 | + | .strip_prefix("Bearer ") | |
| 117 | + | .map(str::trim) | |
| 118 | + | } | |
| 119 | + | ||
| 90 | 120 | /// Extractor yielding the CSRF token bound to the caller's session, or an empty | |
| 91 | 121 | /// string when unauthenticated. Embed it in forms via [`crate::ui::csrf_input`] | |
| 92 | 122 | /// and verify mutating POSTs with [`verify_csrf`]. | |
| ⋯ 151 unchanged lines | |||