fabrica

hanna/fabrica

feat(auth): argon2id, sessions, HS256 JWTs, and the access model

1f61684 · hanna committed on 2026-07-25

Implement phase 4. `crates/auth` is I/O-free and splits into four concerns:

- password: Argon2id PHC hashing; verification runs full hashing work on the
  missing/malformed-hash paths so account existence never leaks via timing.
- session: 32-byte OsRng tokens, base64url in the cookie, only their SHA-256
  kept server-side as the sessions.id.
- token: HS256 JWTs minted/verified over hmac+sha2 (no `ring`, keeping the
  license set clean); fixed header rejects alg-confusion; revocation stays a
  store concern.
- scope + access: the authorization vocabulary and the single `access()`
  function, with the five ordered rules and an exhaustive viewer × visibility ×
  collaborator × anonymous matrix test.

docs/decisions.md records the hand-rolled JWT and the widened access() signature.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed by hannaSSH key fingerprint: SHA256:4g9cWhkAAw8gwqhJUcVbSnGEvdGwklW+1Aa/fMUu59k
10 files changed · +1097 −3UnifiedSplit
Cargo.lock +87 −2
@@ -69,6 +69,18 @@ name = "api"
69version = "0.1.0"69version = "0.1.0"
7070
71[[package]]71[[package]]
72name = "argon2"
73version = "0.5.3"
74source = "registry+https://github.com/rust-lang/crates.io-index"
75checksum = "3c3610892ee6e0cbce8ae2700349fcf8f98adb0dbfbee85aec3c9179d29cc072"
76dependencies = [
77 "base64ct",
78 "blake2",
79 "cpufeatures 0.2.17",
80 "password-hash",
81]
82
83[[package]]
72name = "atoi"84name = "atoi"
73version = "2.0.0"85version = "2.0.0"
74source = "registry+https://github.com/rust-lang/crates.io-index"86source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -89,6 +101,17 @@ dependencies = [
89[[package]]101[[package]]
90name = "auth"102name = "auth"
91version = "0.1.0"103version = "0.1.0"
104dependencies = [
105 "argon2",
106 "base64",
107 "hmac 0.12.1",
108 "model",
109 "rand_core 0.6.4",
110 "serde",
111 "serde_json",
112 "sha2 0.10.9",
113 "thiserror",
114]
92115
93[[package]]116[[package]]
94name = "autocfg"117name = "autocfg"
@@ -103,6 +126,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
103checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"126checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"
104127
105[[package]]128[[package]]
129name = "base64ct"
130version = "1.8.3"
131source = "registry+https://github.com/rust-lang/crates.io-index"
132checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06"
133
134[[package]]
106name = "bitflags"135name = "bitflags"
107version = "2.13.1"136version = "2.13.1"
108source = "registry+https://github.com/rust-lang/crates.io-index"137source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -112,6 +141,15 @@ dependencies = [
112]141]
113142
114[[package]]143[[package]]
144name = "blake2"
145version = "0.10.6"
146source = "registry+https://github.com/rust-lang/crates.io-index"
147checksum = "46502ad458c9a52b69d4d4d32775c788b7a1b85e8bc9d482d92250fc0e3f8efe"
148dependencies = [
149 "digest 0.10.7",
150]
151
152[[package]]
115name = "block-buffer"153name = "block-buffer"
116version = "0.10.4"154version = "0.10.4"
117source = "registry+https://github.com/rust-lang/crates.io-index"155source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -346,6 +384,7 @@ checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292"
346dependencies = [384dependencies = [
347 "block-buffer 0.10.4",385 "block-buffer 0.10.4",
348 "crypto-common 0.1.6",386 "crypto-common 0.1.6",
387 "subtle",
349]388]
350389
351[[package]]390[[package]]
@@ -566,6 +605,17 @@ dependencies = [
566605
567[[package]]606[[package]]
568name = "getrandom"607name = "getrandom"
608version = "0.2.17"
609source = "registry+https://github.com/rust-lang/crates.io-index"
610checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0"
611dependencies = [
612 "cfg-if",
613 "libc",
614 "wasi",
615]
616
617[[package]]
618name = "getrandom"
569version = "0.3.4"619version = "0.3.4"
570source = "registry+https://github.com/rust-lang/crates.io-index"620source = "registry+https://github.com/rust-lang/crates.io-index"
571checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd"621checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd"
@@ -640,7 +690,16 @@ version = "0.13.0"
640source = "registry+https://github.com/rust-lang/crates.io-index"690source = "registry+https://github.com/rust-lang/crates.io-index"
641checksum = "4aaa26c720c68b866f2c96ef5c1264b3e6f473fe5d4ce61cd44bbe913e553018"691checksum = "4aaa26c720c68b866f2c96ef5c1264b3e6f473fe5d4ce61cd44bbe913e553018"
642dependencies = [692dependencies = [
643 "hmac",693 "hmac 0.13.0",
694]
695
696[[package]]
697name = "hmac"
698version = "0.12.1"
699source = "registry+https://github.com/rust-lang/crates.io-index"
700checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e"
701dependencies = [
702 "digest 0.10.7",
644]703]
645704
646[[package]]705[[package]]
@@ -935,6 +994,17 @@ dependencies = [
935]994]
936995
937[[package]]996[[package]]
997name = "password-hash"
998version = "0.5.0"
999source = "registry+https://github.com/rust-lang/crates.io-index"
1000checksum = "346f04948ba92c43e8469c1ee6736c7563d71012b17d40745260fe106aac2166"
1001dependencies = [
1002 "base64ct",
1003 "rand_core 0.6.4",
1004 "subtle",
1005]
1006
1007[[package]]
938name = "pear"1008name = "pear"
939version = "0.2.9"1009version = "0.2.9"
940source = "registry+https://github.com/rust-lang/crates.io-index"1010source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -1069,6 +1139,15 @@ dependencies = [
10691139
1070[[package]]1140[[package]]
1071name = "rand_core"1141name = "rand_core"
1142version = "0.6.4"
1143source = "registry+https://github.com/rust-lang/crates.io-index"
1144checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c"
1145dependencies = [
1146 "getrandom 0.2.17",
1147]
1148
1149[[package]]
1150name = "rand_core"
1072version = "0.9.5"1151version = "0.9.5"
1073source = "registry+https://github.com/rust-lang/crates.io-index"1152source = "registry+https://github.com/rust-lang/crates.io-index"
1074checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c"1153checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c"
@@ -1370,7 +1449,7 @@ dependencies = [
1370 "futures-util",1449 "futures-util",
1371 "hex",1450 "hex",
1372 "hkdf",1451 "hkdf",
1373 "hmac",1452 "hmac 0.13.0",
1374 "itoa",1453 "itoa",
1375 "log",1454 "log",
1376 "md-5",1455 "md-5",
@@ -1451,6 +1530,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
1451checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"1530checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"
14521531
1453[[package]]1532[[package]]
1533name = "subtle"
1534version = "2.6.1"
1535source = "registry+https://github.com/rust-lang/crates.io-index"
1536checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292"
1537
1538[[package]]
1454name = "syn"1539name = "syn"
1455version = "2.0.119"1540version = "2.0.119"
1456source = "registry+https://github.com/rust-lang/crates.io-index"1541source = "registry+https://github.com/rust-lang/crates.io-index"
Cargo.toml +11 −0
@@ -72,6 +72,17 @@ sqlx = { version = "0.9", default-features = false, features = [
72ulid = "1"72ulid = "1"
73tokio = { version = "1", features = ["macros", "rt-multi-thread"] }73tokio = { version = "1", features = ["macros", "rt-multi-thread"] }
74tempfile = "3"74tempfile = "3"
75# Authentication primitives. Argon2id password hashing; SHA-256 for session and
76# invite token digests; HMAC-SHA256 for hand-rolled HS256 JWTs (avoiding a `ring`
77# dependency, whose license set is not in our allow-list — see docs/decisions.md);
78# base64url for token encoding; OsRng for salts and session bytes.
79argon2 = { version = "0.5", features = ["std"] }
80sha2 = "0.10"
81hmac = "0.12"
82base64 = "0.22"
83rand_core = { version = "0.6", features = ["getrandom"] }
84# Interactive, non-echoing password entry for the CLI.
85rpassword = "7"
7586
76[workspace.lints.rust]87[workspace.lints.rust]
77unsafe_code = "forbid"88unsafe_code = "forbid"
crates/auth/Cargo.toml +9 −0
@@ -10,6 +10,15 @@ repository.workspace = true
10publish.workspace = true10publish.workspace = true
1111
12[dependencies]12[dependencies]
13model = { workspace = true }
14argon2 = { workspace = true }
15sha2 = { workspace = true }
16hmac = { workspace = true }
17base64 = { workspace = true }
18rand_core = { workspace = true }
19serde = { workspace = true }
20serde_json = { workspace = true }
21thiserror = { workspace = true }
1322
14[lints]23[lints]
15workspace = true24workspace = true
crates/auth/src/access.rs +304 −0
@@ -0,0 +1,304 @@
1// This Source Code Form is subject to the terms of the Mozilla Public
2// License, v. 2.0. If a copy of the MPL was not distributed with this
3// file, You can obtain one at https://mozilla.org/MPL/2.0/.
4
5//! The permission model: one function, [`access`], that every route funnels
6//! through.
7//!
8//! Keeping authorization in a single pure function is what makes it testable to
9//! exhaustion (see the matrix test below) and impossible for a route to get
10//! subtly wrong. The function takes everything it needs by value or reference and
11//! returns a verdict; the *consequences* of that verdict — in particular that
12//! [`Access::None`] on a private repo renders `404`, never `403`, so existence
13//! does not leak — belong to the web and API layers.
14
15use model::Repo;
16
17/// The access level a viewer has to a repository, in increasing order of power.
18#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
19pub enum Access {
20 /// No access. On a private repo the caller MUST render `404`, not `403`.
21 None,
22 /// May read: browse, clone, and read-only API.
23 Read,
24 /// May read and push.
25 Write,
26 /// May read, push, and administer (settings, collaborators, deletion).
27 Admin,
28}
29
30/// A stored collaborator permission — the `repo_collaborators.permission` column.
31///
32/// Distinct from [`Access`] because a collaborator row can never say "no access"
33/// (its absence does); the two convert one way, [`Permission`] into [`Access`].
34#[derive(Debug, Clone, Copy, PartialEq, Eq)]
35pub enum Permission {
36 /// Read-only collaborator.
37 Read,
38 /// Read-write collaborator.
39 Write,
40 /// Administrative collaborator.
41 Admin,
42}
43
44impl Permission {
45 /// The wire/database spelling (`read` | `write` | `admin`).
46 #[must_use]
47 pub fn as_str(self) -> &'static str {
48 match self {
49 Self::Read => "read",
50 Self::Write => "write",
51 Self::Admin => "admin",
52 }
53 }
54
55 /// Parse a stored permission string, returning `None` for anything
56 /// unrecognized (a corrupt row grants nothing rather than panicking).
57 #[must_use]
58 pub fn parse(s: &str) -> Option<Self> {
59 match s {
60 "read" => Some(Self::Read),
61 "write" => Some(Self::Write),
62 "admin" => Some(Self::Admin),
63 _ => None,
64 }
65 }
66}
67
68impl From<Permission> for Access {
69 fn from(permission: Permission) -> Self {
70 match permission {
71 Permission::Read => Self::Read,
72 Permission::Write => Self::Write,
73 Permission::Admin => Self::Admin,
74 }
75 }
76}
77
78/// The authenticated identity making a request, as far as authorization cares.
79///
80/// Anonymous requests pass `None` in place of a `Viewer`. A disabled account must
81/// never be turned into a `Viewer` — that check belongs to the authentication
82/// layer, upstream of every call here.
83#[derive(Debug, Clone, PartialEq, Eq)]
84pub struct Viewer {
85 /// The viewer's user id (`users.id`).
86 pub id: String,
87 /// Whether the viewer is a site administrator.
88 pub is_admin: bool,
89}
90
91/// Resolve the access `viewer` has to `repo`.
92///
93/// The rules are applied strictly in order; the first that matches wins:
94///
95/// 1. a site admin gets [`Access::Admin`];
96/// 2. the repo's owner gets [`Access::Admin`];
97/// 3. an explicit collaborator gets exactly their stored permission;
98/// 4. a public repo, when the instance allows anonymous access, grants
99/// [`Access::Read`] to anyone;
100/// 5. otherwise [`Access::None`].
101///
102/// `collaborator` is the viewer's row in `repo_collaborators` for this repo, if
103/// any; the caller looks it up. `allow_anonymous` is `instance.allow_anonymous`.
104/// Note rule 4 is gated on that flag for *every* viewer, so disabling anonymous
105/// access closes public repos to non-collaborators as well — matching the spec's
106/// private-by-default posture.
107#[must_use]
108pub fn access(
109 viewer: Option<&Viewer>,
110 repo: &Repo,
111 collaborator: Option<Permission>,
112 allow_anonymous: bool,
113) -> Access {
114 if let Some(v) = viewer {
115 if v.is_admin {
116 return Access::Admin;
117 }
118 if v.id == repo.owner_id {
119 return Access::Admin;
120 }
121 if let Some(permission) = collaborator {
122 return permission.into();
123 }
124 }
125 if !repo.is_private && allow_anonymous {
126 return Access::Read;
127 }
128 Access::None
129}
130
131#[cfg(test)]
132mod tests {
133 #![allow(clippy::unwrap_used)]
134
135 use super::*;
136
137 /// A repo owned by `owner`, with the given visibility. Only the fields
138 /// `access` reads are meaningful; the rest are filler.
139 fn repo(owner: &str, is_private: bool) -> Repo {
140 Repo {
141 id: "01reporeporeporeporeporepo".to_string(),
142 owner_id: owner.to_string(),
143 group_id: None,
144 name: "r".to_string(),
145 path: "r".to_string(),
146 description: None,
147 is_private,
148 default_branch: "main".to_string(),
149 archived_at: None,
150 size_bytes: 0,
151 pushed_at: None,
152 created_at: 0,
153 updated_at: 0,
154 }
155 }
156
157 fn viewer(id: &str, is_admin: bool) -> Viewer {
158 Viewer {
159 id: id.to_string(),
160 is_admin,
161 }
162 }
163
164 #[test]
165 fn permission_strings_round_trip() {
166 for p in [Permission::Read, Permission::Write, Permission::Admin] {
167 assert_eq!(Permission::parse(p.as_str()), Some(p));
168 }
169 assert_eq!(Permission::parse("owner"), None);
170 }
171
172 #[test]
173 fn access_is_ordered() {
174 assert!(Access::None < Access::Read);
175 assert!(Access::Read < Access::Write);
176 assert!(Access::Write < Access::Admin);
177 }
178
179 /// One row of the access matrix: the inputs and the verdict they must yield.
180 struct Case {
181 viewer: Option<Viewer>,
182 collaborator: Option<Permission>,
183 is_private: bool,
184 allow_anonymous: bool,
185 expect: Access,
186 }
187
188 /// The whole point of this crate: enumerate every combination of viewer kind,
189 /// visibility, collaborator permission, and the anonymous-access flag, and
190 /// assert the exact verdict. Nothing here is sampled — it is the full matrix.
191 #[test]
192 fn access_matrix_is_exhaustive() {
193 // Viewer kinds, expressed relative to a repo owned by "owner".
194 let admin = viewer("boss", true);
195 let owner = viewer("owner", false);
196 let other = viewer("stranger", false);
197
198 let mut cases = Vec::new();
199 let mut push = |viewer: Option<&Viewer>,
200 collaborator: Option<Permission>,
201 is_private: bool,
202 allow_anonymous: bool,
203 expect: Access| {
204 cases.push(Case {
205 viewer: viewer.cloned(),
206 collaborator,
207 is_private,
208 allow_anonymous,
209 expect,
210 });
211 };
212
213 // Rule 1: a site admin is Admin everywhere, regardless of every other
214 // dimension.
215 for &private in &[true, false] {
216 for &anon in &[true, false] {
217 for collab in [None, Some(Permission::Read)] {
218 push(Some(&admin), collab, private, anon, Access::Admin);
219 }
220 }
221 }
222
223 // Rule 2: the owner is Admin everywhere, even with a lesser collaborator
224 // row somehow present, and even when anonymous access is off.
225 for &private in &[true, false] {
226 for &anon in &[true, false] {
227 for collab in [None, Some(Permission::Read), Some(Permission::Write)] {
228 push(Some(&owner), collab, private, anon, Access::Admin);
229 }
230 }
231 }
232
233 // Rule 3: an explicit collaborator gets exactly their permission — this
234 // beats rule 4, so it holds even on a public repo, and is unaffected by
235 // the anonymous flag or visibility.
236 for &private in &[true, false] {
237 for &anon in &[true, false] {
238 push(
239 Some(&other),
240 Some(Permission::Read),
241 private,
242 anon,
243 Access::Read,
244 );
245 push(
246 Some(&other),
247 Some(Permission::Write),
248 private,
249 anon,
250 Access::Write,
251 );
252 push(
253 Some(&other),
254 Some(Permission::Admin),
255 private,
256 anon,
257 Access::Admin,
258 );
259 }
260 }
261
262 // Rule 4 / 5: a non-collaborator (authenticated stranger or anonymous)
263 // reads a public repo only when anonymous access is allowed; a private
264 // repo is always None; and anon-off closes public repos too.
265 for viewer in [Some(&other), None] {
266 // Public repo, anonymous allowed -> Read.
267 push(viewer, None, false, true, Access::Read);
268 // Public repo, anonymous disallowed -> None.
269 push(viewer, None, false, false, Access::None);
270 // Private repo -> None regardless of the flag.
271 push(viewer, None, true, true, Access::None);
272 push(viewer, None, true, false, Access::None);
273 }
274
275 for case in &cases {
276 let repo = repo("owner", case.is_private);
277 let got = access(
278 case.viewer.as_ref(),
279 &repo,
280 case.collaborator,
281 case.allow_anonymous,
282 );
283 assert_eq!(
284 got, case.expect,
285 "viewer={:?} collab={:?} private={} anon={}",
286 case.viewer, case.collaborator, case.is_private, case.allow_anonymous
287 );
288 }
289 }
290
291 #[test]
292 fn private_repo_never_leaks_to_a_stranger() {
293 // The security-critical invariant, called out on its own: no combination
294 // of a non-privileged stranger on a private repo yields anything but None.
295 let stranger = viewer("stranger", false);
296 for &anon in &[true, false] {
297 assert_eq!(
298 access(Some(&stranger), &repo("owner", true), None, anon),
299 Access::None
300 );
301 assert_eq!(access(None, &repo("owner", true), None, anon), Access::None);
302 }
303 }
304}
crates/auth/src/lib.rs +54 −1
@@ -2,4 +2,57 @@
2// License, v. 2.0. If a copy of the MPL was not distributed with this2// License, v. 2.0. If a copy of the MPL was not distributed with this
3// file, You can obtain one at https://mozilla.org/MPL/2.0/.3// file, You can obtain one at https://mozilla.org/MPL/2.0/.
44
5//! Authentication and authorization: argon2id, sessions, JWTs, and `access()`.5//! Authentication and authorization primitives.
6//!
7//! This crate is deliberately I/O-free: it turns bytes into decisions and never
8//! touches the database or the network. The [`store`](../store/index.html) crate
9//! persists what these functions produce (password hashes, session-id digests,
10//! token rows) and the `web`/`api`/`ssh` crates call in at request time.
11//!
12//! Four concerns, one per module:
13//!
14//! * [`password`] — Argon2id hashing and constant-time verification. A missing
15//! hash still does the work of a real verify so account existence never leaks
16//! through timing.
17//! * [`session`] — opaque session tokens: 32 bytes of OS randomness handed to the
18//! browser, only their SHA-256 kept server-side.
19//! * [`token`] — HS256 JSON Web Tokens for the API, minted and verified against
20//! the instance secret. Revocation is a database concern; this module only
21//! proves a token is authentic and unexpired.
22//! * [`scope`] and [`access`] — the authorization vocabulary. [`access::access`]
23//! is the single, exhaustively tested function every route funnels through.
24
25pub mod access;
26pub mod password;
27pub mod scope;
28pub mod session;
29pub mod token;
30
31pub use access::{Access, Permission, Viewer, access};
32pub use password::{HashParams, hash_password, verify_password};
33pub use scope::{Scope, format_scopes, parse_scopes};
34pub use session::{SESSION_TOKEN_BYTES, SessionToken, new_session_token, session_id};
35pub use token::{Claims, mint_jwt, verify_jwt};
36
37/// Errors produced by the authentication primitives.
38///
39/// Variants stay coarse on purpose: a caller authenticating a request should not
40/// be able to tell *why* a credential failed (bad signature vs. expired vs.
41/// malformed), so verification failures collapse into a single
42/// [`AuthError::InvalidToken`].
43#[derive(Debug, thiserror::Error)]
44pub enum AuthError {
45 /// A password could not be hashed, or a stored hash could not be parsed.
46 /// Carries the library's message; never contains the password itself.
47 #[error("password hashing failed: {0}")]
48 Hash(String),
49
50 /// A JWT was malformed, carried a bad signature, or had expired. One variant
51 /// for every failure mode so nothing distinguishes them to the caller.
52 #[error("invalid or expired token")]
53 InvalidToken,
54
55 /// A scope string held a token outside the known set.
56 #[error("unknown scope {0:?}")]
57 UnknownScope(String),
58}
crates/auth/src/password.rs +151 −0
@@ -0,0 +1,151 @@
1// This Source Code Form is subject to the terms of the Mozilla Public
2// License, v. 2.0. If a copy of the MPL was not distributed with this
3// file, You can obtain one at https://mozilla.org/MPL/2.0/.
4
5//! Argon2id password hashing and verification.
6//!
7//! Hashes are stored in PHC string format, which embeds the algorithm, version,
8//! and cost parameters — so a stored hash is self-describing and verification
9//! never needs the config that produced it. That matters when the operator tunes
10//! the cost parameters over time: old hashes keep verifying against the costs
11//! they were made with.
12
13use argon2::password_hash::rand_core::OsRng;
14use argon2::password_hash::{PasswordHash, PasswordHasher, PasswordVerifier, SaltString};
15use argon2::{Algorithm, Argon2, Params, Version};
16
17use crate::AuthError;
18
19/// Argon2id cost parameters, mirroring the `[auth]` config keys. Kept as a plain
20/// value type so the `auth` crate need not depend on `config`.
21#[derive(Debug, Clone, Copy, PartialEq, Eq)]
22pub struct HashParams {
23 /// Memory cost, in KiB (`auth.argon2_m_cost`).
24 pub m_cost: u32,
25 /// Time cost — the number of iterations (`auth.argon2_t_cost`).
26 pub t_cost: u32,
27 /// Degree of parallelism — lanes (`auth.argon2_p_cost`).
28 pub p_cost: u32,
29}
30
31impl Default for HashParams {
32 /// The spec's baseline: 19 MiB, two iterations, one lane.
33 fn default() -> Self {
34 Self {
35 m_cost: 19_456,
36 t_cost: 2,
37 p_cost: 1,
38 }
39 }
40}
41
42impl HashParams {
43 /// Build the concrete Argon2 hasher for these parameters.
44 fn hasher(self) -> Result<Argon2<'static>, AuthError> {
45 let params = Params::new(self.m_cost, self.t_cost, self.p_cost, None)
46 .map_err(|e| AuthError::Hash(e.to_string()))?;
47 Ok(Argon2::new(Algorithm::Argon2id, Version::V0x13, params))
48 }
49}
50
51/// Hash `password` with Argon2id, returning a PHC string ready to store.
52///
53/// A fresh random salt is drawn from the OS CSPRNG for every call, so identical
54/// passwords produce different hashes.
55///
56/// # Errors
57///
58/// Returns [`AuthError::Hash`] if the cost parameters are invalid or the hasher
59/// fails.
60pub fn hash_password(password: &str, params: HashParams) -> Result<String, AuthError> {
61 let salt = SaltString::generate(&mut OsRng);
62 let hash = params
63 .hasher()?
64 .hash_password(password.as_bytes(), &salt)
65 .map_err(|e| AuthError::Hash(e.to_string()))?;
66 Ok(hash.to_string())
67}
68
69/// Verify `password` against a stored PHC hash.
70///
71/// `stored` is [`Option`] because a user may have no password yet (the invite is
72/// still outstanding). In that case — and whenever the stored hash fails to parse
73/// — we still run a full Argon2 computation against a throwaway salt before
74/// returning `false`, so the "no such credential" path costs the same as a real
75/// verification and account state does not leak through response timing.
76///
77/// Returns `true` only when the password matches a well-formed stored hash.
78#[must_use]
79pub fn verify_password(password: &str, stored: Option<&str>) -> bool {
80 let Some(hash) = stored else {
81 dummy_verify(password);
82 return false;
83 };
84 let Ok(parsed) = PasswordHash::new(hash) else {
85 dummy_verify(password);
86 return false;
87 };
88 // Argon2id verification reads its parameters from the PHC string itself, so a
89 // default-configured verifier honours whatever costs the hash was made with.
90 Argon2::default()
91 .verify_password(password.as_bytes(), &parsed)
92 .is_ok()
93}
94
95/// Perform throwaway hashing work to equalise the timing of the failure paths in
96/// [`verify_password`]. The result is intentionally discarded.
97fn dummy_verify(password: &str) {
98 let _ = hash_password(password, HashParams::default());
99}
100
101#[cfg(test)]
102mod tests {
103 #![allow(clippy::unwrap_used)]
104
105 use super::*;
106
107 // Cheap parameters keep the hashing tests fast; production costs come from
108 // config. The minimum Argon2 memory cost is 8 KiB.
109 const FAST: HashParams = HashParams {
110 m_cost: 8,
111 t_cost: 1,
112 p_cost: 1,
113 };
114
115 #[test]
116 fn hash_is_phc_argon2id_and_verifies() {
117 let hash = hash_password("correct horse", FAST).unwrap();
118 assert!(hash.starts_with("$argon2id$"), "PHC prefix, got {hash}");
119 assert!(verify_password("correct horse", Some(&hash)));
120 assert!(!verify_password("wrong horse", Some(&hash)));
121 }
122
123 #[test]
124 fn hashing_is_salted() {
125 let a = hash_password("same", FAST).unwrap();
126 let b = hash_password("same", FAST).unwrap();
127 assert_ne!(a, b, "distinct salts should yield distinct hashes");
128 assert!(verify_password("same", Some(&a)));
129 assert!(verify_password("same", Some(&b)));
130 }
131
132 #[test]
133 fn absent_hash_never_verifies() {
134 assert!(!verify_password("anything", None));
135 }
136
137 #[test]
138 fn malformed_hash_never_verifies() {
139 assert!(!verify_password("anything", Some("not-a-phc-string")));
140 assert!(!verify_password("anything", Some("$argon2id$garbage")));
141 }
142
143 #[test]
144 fn params_round_trip_through_the_hash() {
145 // A hash made with non-default costs still verifies with the default
146 // verifier, because the costs travel inside the PHC string.
147 let hash = hash_password("pw", FAST).unwrap();
148 assert!(hash.contains("m=8"), "params embedded, got {hash}");
149 assert!(verify_password("pw", Some(&hash)));
150 }
151}
crates/auth/src/scope.rs +135 −0
@@ -0,0 +1,135 @@
1// This Source Code Form is subject to the terms of the Mozilla Public
2// License, v. 2.0. If a copy of the MPL was not distributed with this
3// file, You can obtain one at https://mozilla.org/MPL/2.0/.
4
5//! API token scopes.
6//!
7//! A token carries a comma-separated scope list, stored verbatim in
8//! `api_tokens.scopes` and echoed inside the JWT. The wire spelling
9//! (`repo:read`, …) is the single source of truth; this module is the only place
10//! that maps those strings to and from the [`Scope`] enum.
11
12use crate::AuthError;
13
14/// A capability a token may grant. The string forms match the spec exactly and
15/// are what appears in config, the CLI, and the JWT payload.
16#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
17pub enum Scope {
18 /// Read repository contents (clone, browse, read API).
19 RepoRead,
20 /// Push to repositories and mutate their contents.
21 RepoWrite,
22 /// Administer repositories (settings, collaborators, deletion).
23 RepoAdmin,
24 /// Read user and account information.
25 UserRead,
26 /// Mutate user and account information.
27 UserWrite,
28 /// Full administrative access, equivalent to a site admin.
29 Admin,
30}
31
32impl Scope {
33 /// The canonical wire spelling.
34 #[must_use]
35 pub fn as_str(self) -> &'static str {
36 match self {
37 Self::RepoRead => "repo:read",
38 Self::RepoWrite => "repo:write",
39 Self::RepoAdmin => "repo:admin",
40 Self::UserRead => "user:read",
41 Self::UserWrite => "user:write",
42 Self::Admin => "admin",
43 }
44 }
45
46 /// Parse a single scope token.
47 ///
48 /// # Errors
49 ///
50 /// Returns [`AuthError::UnknownScope`] if `s` is not a known scope. Leading
51 /// and trailing whitespace is tolerated so a hand-written `--scopes a, b`
52 /// list parses.
53 pub fn parse(s: &str) -> Result<Self, AuthError> {
54 match s.trim() {
55 "repo:read" => Ok(Self::RepoRead),
56 "repo:write" => Ok(Self::RepoWrite),
57 "repo:admin" => Ok(Self::RepoAdmin),
58 "user:read" => Ok(Self::UserRead),
59 "user:write" => Ok(Self::UserWrite),
60 "admin" => Ok(Self::Admin),
61 other => Err(AuthError::UnknownScope(other.to_string())),
62 }
63 }
64}
65
66/// Parse a comma-separated scope list. Empty entries (a trailing comma, or an
67/// entirely blank string) are skipped rather than rejected.
68///
69/// # Errors
70///
71/// Returns [`AuthError::UnknownScope`] on the first unrecognized entry.
72pub fn parse_scopes(list: &str) -> Result<Vec<Scope>, AuthError> {
73 list.split(',')
74 .map(str::trim)
75 .filter(|s| !s.is_empty())
76 .map(Scope::parse)
77 .collect()
78}
79
80/// Render scopes as the canonical comma-separated string stored in the database.
81#[must_use]
82pub fn format_scopes(scopes: &[Scope]) -> String {
83 scopes
84 .iter()
85 .map(|s| s.as_str())
86 .collect::<Vec<_>>()
87 .join(",")
88}
89
90#[cfg(test)]
91mod tests {
92 #![allow(clippy::unwrap_used)]
93
94 use super::*;
95
96 #[test]
97 fn every_scope_round_trips() {
98 for scope in [
99 Scope::RepoRead,
100 Scope::RepoWrite,
101 Scope::RepoAdmin,
102 Scope::UserRead,
103 Scope::UserWrite,
104 Scope::Admin,
105 ] {
106 assert_eq!(Scope::parse(scope.as_str()).unwrap(), scope);
107 }
108 }
109
110 #[test]
111 fn list_parses_and_reformats_canonically() {
112 let scopes = parse_scopes(" repo:read, repo:write ,admin ").unwrap();
113 assert_eq!(scopes, [Scope::RepoRead, Scope::RepoWrite, Scope::Admin]);
114 assert_eq!(format_scopes(&scopes), "repo:read,repo:write,admin");
115 }
116
117 #[test]
118 fn blank_and_trailing_commas_are_ignored() {
119 assert!(parse_scopes("").unwrap().is_empty());
120 assert_eq!(
121 parse_scopes("repo:read,,").unwrap(),
122 [Scope::RepoRead],
123 "empty entries dropped, not rejected"
124 );
125 }
126
127 #[test]
128 fn unknown_scope_is_rejected() {
129 let err = parse_scopes("repo:read,repo:destroy").unwrap_err();
130 assert!(
131 matches!(&err, AuthError::UnknownScope(s) if s == "repo:destroy"),
132 "got {err:?}"
133 );
134 }
135}
crates/auth/src/session.rs +94 −0
@@ -0,0 +1,94 @@
1// This Source Code Form is subject to the terms of the Mozilla Public
2// License, v. 2.0. If a copy of the MPL was not distributed with this
3// file, You can obtain one at https://mozilla.org/MPL/2.0/.
4
5//! Opaque session tokens.
6//!
7//! A session token is 32 bytes of OS randomness, base64url-encoded and handed to
8//! the browser in a cookie. The database stores **only** the SHA-256 of that
9//! cookie value as the session-row id, so a leak of the database does not leak
10//! usable session tokens: an attacker would still need the pre-image. Lookup
11//! hashes the presented cookie the same way and matches on the digest.
12
13use base64::Engine;
14use base64::engine::general_purpose::URL_SAFE_NO_PAD;
15use rand_core::{OsRng, RngCore};
16use sha2::{Digest, Sha256};
17
18/// Number of random bytes behind a session token, before encoding.
19pub const SESSION_TOKEN_BYTES: usize = 32;
20
21/// A freshly minted session token, split into the two halves that live in
22/// different places.
23#[derive(Debug, Clone)]
24pub struct SessionToken {
25 /// The value placed in the user's cookie. Secret — treat like a password.
26 pub cookie: String,
27 /// The SHA-256 of [`SessionToken::cookie`], stored as the `sessions.id`
28 /// primary key. Safe to persist and log.
29 pub id: String,
30}
31
32/// Mint a new session token: draw 32 bytes from the OS CSPRNG, encode them for
33/// the cookie, and derive the id the database will key on.
34#[must_use]
35pub fn new_session_token() -> SessionToken {
36 let mut bytes = [0u8; SESSION_TOKEN_BYTES];
37 OsRng.fill_bytes(&mut bytes);
38 let cookie = URL_SAFE_NO_PAD.encode(bytes);
39 let id = session_id(&cookie);
40 SessionToken { cookie, id }
41}
42
43/// Derive the `sessions.id` for a presented cookie value. This is the one place
44/// the cookie-to-id mapping is defined; login (minting) and every subsequent
45/// request (lookup) both route through it so they cannot disagree.
46#[must_use]
47pub fn session_id(cookie: &str) -> String {
48 let digest = Sha256::digest(cookie.as_bytes());
49 hex(&digest)
50}
51
52/// Lowercase hex encoding of a byte slice.
53fn hex(bytes: &[u8]) -> String {
54 use std::fmt::Write as _;
55 let mut out = String::with_capacity(bytes.len() * 2);
56 for b in bytes {
57 // Writing to a String is infallible.
58 let _ = write!(out, "{b:02x}");
59 }
60 out
61}
62
63#[cfg(test)]
64mod tests {
65 use super::*;
66
67 #[test]
68 fn tokens_are_unique_and_hex_ided() {
69 let a = new_session_token();
70 let b = new_session_token();
71 assert_ne!(a.cookie, b.cookie, "each token draws fresh randomness");
72 assert_ne!(a.id, b.id);
73 // SHA-256 hex is 64 characters, all hex digits.
74 assert_eq!(a.id.len(), 64);
75 assert!(a.id.bytes().all(|c| c.is_ascii_hexdigit()));
76 }
77
78 #[test]
79 fn id_is_a_pure_function_of_the_cookie() {
80 let token = new_session_token();
81 assert_eq!(
82 session_id(&token.cookie),
83 token.id,
84 "lookup must reproduce the stored id"
85 );
86 }
87
88 #[test]
89 fn different_cookies_hash_differently() {
90 assert_ne!(session_id("a"), session_id("b"));
91 // Known-answer: SHA-256("") has a well-known digest prefix.
92 assert!(session_id("").starts_with("e3b0c44298fc1c14"));
93 }
94}
crates/auth/src/token.rs +216 −0
@@ -0,0 +1,216 @@
1// This Source Code Form is subject to the terms of the Mozilla Public
2// License, v. 2.0. If a copy of the MPL was not distributed with this
3// file, You can obtain one at https://mozilla.org/MPL/2.0/.
4
5//! HS256 JSON Web Tokens for the API.
6//!
7//! We hand-roll the (small, well-specified) HS256 construction rather than pull
8//! in a JWT library, because the mainstream crates depend on `ring`, whose
9//! license set falls outside this project's allow-list. The surface we need is
10//! tiny: base64url of a fixed header and a JSON payload, an HMAC-SHA256 over the
11//! two, and constant-time verification of the same.
12//!
13//! Signature and expiry are all this module proves. **Revocation is not checked
14//! here** — a valid signature only means the token is authentic and unexpired.
15//! Callers MUST additionally confirm the `jti` row in `api_tokens` has
16//! `revoked_at IS NULL`; that database check is what makes revocation real.
17
18use base64::Engine;
19use base64::engine::general_purpose::URL_SAFE_NO_PAD;
20use hmac::{Hmac, Mac};
21use serde::{Deserialize, Serialize};
22use sha2::Sha256;
23
24use crate::AuthError;
25
26type HmacSha256 = Hmac<Sha256>;
27
28/// The fixed JOSE header for every token we mint: `{"alg":"HS256","typ":"JWT"}`.
29/// Pre-encoded so minting never re-serializes it, and matched exactly on verify
30/// so a token advertising any other algorithm is rejected out of hand (closing
31/// the classic "alg" confusion attack).
32const HEADER_B64: &str = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9";
33
34/// The claims carried by an API token. Field names are the JWT wire names.
35#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
36pub struct Claims {
37 /// Subject: the owning user's id (`users.id`).
38 pub sub: String,
39 /// JWT id: the `api_tokens` row id, checked against `revoked_at` on every
40 /// request to enforce revocation.
41 pub jti: String,
42 /// Comma-separated scope list (see [`crate::scope`]).
43 pub scopes: String,
44 /// Issued-at, Unix seconds.
45 pub iat: i64,
46 /// Expiry, Unix seconds. A token is rejected once `exp <= now`.
47 pub exp: i64,
48}
49
50/// Mint a signed HS256 token for `claims`, signed with the instance `secret`.
51///
52/// # Errors
53///
54/// Returns [`AuthError::Hash`] if the claims cannot be serialized (in practice
55/// only an allocation failure), reusing that variant to avoid widening the error
56/// enum for an all-but-impossible case.
57pub fn mint_jwt(secret: &str, claims: &Claims) -> Result<String, AuthError> {
58 let payload_json =
59 serde_json::to_vec(claims).map_err(|e| AuthError::Hash(format!("claims: {e}")))?;
60 let payload_b64 = URL_SAFE_NO_PAD.encode(payload_json);
61 let signing_input = format!("{HEADER_B64}.{payload_b64}");
62 let signature = sign(secret, signing_input.as_bytes());
63 Ok(format!("{signing_input}.{signature}"))
64}
65
66/// Verify a token's signature and expiry against `secret` and the current time
67/// `now` (Unix seconds), returning its claims on success.
68///
69/// Every failure — wrong shape, unexpected algorithm, bad signature, malformed
70/// payload, or expiry — collapses to [`AuthError::InvalidToken`] so nothing about
71/// *why* a token was rejected reaches the caller.
72///
73/// # Errors
74///
75/// Returns [`AuthError::InvalidToken`] on any verification failure.
76pub fn verify_jwt(secret: &str, token: &str, now: i64) -> Result<Claims, AuthError> {
77 let mut parts = token.split('.');
78 let (Some(header_b64), Some(payload_b64), Some(signature_b64), None) =
79 (parts.next(), parts.next(), parts.next(), parts.next())
80 else {
81 return Err(AuthError::InvalidToken);
82 };
83
84 // Reject anything not signed with our fixed HS256 header before spending an
85 // HMAC on it.
86 if header_b64 != HEADER_B64 {
87 return Err(AuthError::InvalidToken);
88 }
89
90 // Constant-time signature check via the MAC's own verifier.
91 let provided = URL_SAFE_NO_PAD
92 .decode(signature_b64)
93 .map_err(|_| AuthError::InvalidToken)?;
94 let signing_input = format!("{header_b64}.{payload_b64}");
95 let mut mac =
96 HmacSha256::new_from_slice(secret.as_bytes()).map_err(|_| AuthError::InvalidToken)?;
97 mac.update(signing_input.as_bytes());
98 mac.verify_slice(&provided)
99 .map_err(|_| AuthError::InvalidToken)?;
100
101 // Signature is good; now decode and check the claims.
102 let payload = URL_SAFE_NO_PAD
103 .decode(payload_b64)
104 .map_err(|_| AuthError::InvalidToken)?;
105 let claims: Claims = serde_json::from_slice(&payload).map_err(|_| AuthError::InvalidToken)?;
106 if claims.exp <= now {
107 return Err(AuthError::InvalidToken);
108 }
109 Ok(claims)
110}
111
112/// Compute the base64url HMAC-SHA256 signature of `message` under `secret`.
113fn sign(secret: &str, message: &[u8]) -> String {
114 // `new_from_slice` only fails for key sizes HMAC cannot accept, which is
115 // none — every byte length is valid — so this cannot error in practice.
116 let Ok(mut mac) = HmacSha256::new_from_slice(secret.as_bytes()) else {
117 return String::new();
118 };
119 mac.update(message);
120 URL_SAFE_NO_PAD.encode(mac.finalize().into_bytes())
121}
122
123#[cfg(test)]
124mod tests {
125 #![allow(clippy::unwrap_used)]
126
127 use super::*;
128
129 fn claims(exp: i64) -> Claims {
130 Claims {
131 sub: "01useruseruseruseruseruser".to_string(),
132 jti: "01tokentokentokentokentoken".to_string(),
133 scopes: "repo:read,repo:write".to_string(),
134 iat: 1_000,
135 exp,
136 }
137 }
138
139 #[test]
140 fn round_trips_a_valid_token() {
141 let token = mint_jwt("s3cr3t", &claims(2_000)).unwrap();
142 // Three base64url segments.
143 assert_eq!(token.split('.').count(), 3);
144 let decoded = verify_jwt("s3cr3t", &token, 1_500).unwrap();
145 assert_eq!(decoded, claims(2_000));
146 }
147
148 #[test]
149 fn header_is_the_canonical_hs256_header() {
150 let token = mint_jwt("s", &claims(2_000)).unwrap();
151 let header = token.split('.').next().unwrap();
152 let json = URL_SAFE_NO_PAD.decode(header).unwrap();
153 assert_eq!(&json, br#"{"alg":"HS256","typ":"JWT"}"#);
154 }
155
156 #[test]
157 fn wrong_secret_is_rejected() {
158 let token = mint_jwt("right", &claims(2_000)).unwrap();
159 assert!(matches!(
160 verify_jwt("wrong", &token, 1_500),
161 Err(AuthError::InvalidToken)
162 ));
163 }
164
165 #[test]
166 fn expired_token_is_rejected() {
167 let token = mint_jwt("s", &claims(2_000)).unwrap();
168 // At exactly exp the token is already expired (exp <= now).
169 assert!(matches!(
170 verify_jwt("s", &token, 2_000),
171 Err(AuthError::InvalidToken)
172 ));
173 assert!(verify_jwt("s", &token, 1_999).is_ok());
174 }
175
176 #[test]
177 fn tampered_payload_is_rejected() {
178 let token = mint_jwt("s", &claims(2_000)).unwrap();
179 let mut parts: Vec<&str> = token.split('.').collect();
180 // Re-encode a payload that escalates scopes; the signature no longer fits.
181 let forged = Claims {
182 scopes: "admin".to_string(),
183 ..claims(2_000)
184 };
185 let forged_b64 = URL_SAFE_NO_PAD.encode(serde_json::to_vec(&forged).unwrap());
186 parts[1] = &forged_b64;
187 let tampered = parts.join(".");
188 assert!(matches!(
189 verify_jwt("s", &tampered, 1_500),
190 Err(AuthError::InvalidToken)
191 ));
192 }
193
194 #[test]
195 fn malformed_shapes_are_rejected() {
196 for bad in ["", "onlyonepart", "two.parts", "a.b.c.d", "..", "x.y.z"] {
197 assert!(
198 matches!(verify_jwt("s", bad, 0), Err(AuthError::InvalidToken)),
199 "should reject {bad:?}"
200 );
201 }
202 }
203
204 #[test]
205 fn algorithm_confusion_is_rejected() {
206 // A token whose header claims "alg":"none" but is otherwise well-formed
207 // must not verify, even with an empty signature.
208 let payload = URL_SAFE_NO_PAD.encode(serde_json::to_vec(&claims(2_000)).unwrap());
209 let none_header = URL_SAFE_NO_PAD.encode(br#"{"alg":"none","typ":"JWT"}"#);
210 let forged = format!("{none_header}.{payload}.");
211 assert!(matches!(
212 verify_jwt("s", &forged, 1_500),
213 Err(AuthError::InvalidToken)
214 ));
215 }
216}
docs/decisions.md +36 −0
@@ -170,3 +170,39 @@ into `OUT_DIR`.
170break every derivation that compiles `store`. Widening the filter is the minimal,170break every derivation that compiles `store`. Widening the filter is the minimal,
171explicit fix and leaves room to add further asset globs (themes, vendored htmx)171explicit fix and leaves room to add further asset globs (themes, vendored htmx)
172the same way in later phases.172the same way in later phases.
173
174## 2026-07-24 — Hand-rolled HS256 JWTs instead of a JWT crate
175
176**Decision:** `crates/auth` implements the HS256 JSON Web Token construction
177directly (base64url of a fixed JOSE header and a serde JSON payload, an
178`hmac`/`sha2` HMAC-SHA256, constant-time verification via `Mac::verify_slice`)
179rather than depending on `jsonwebtoken` or similar.
180
181**Alternatives:** Use the `jsonwebtoken` crate.
182
183**Rationale:** The mainstream JWT crates depend on `ring`, whose license set is
184not in `deny.toml`'s allow-list (it carries OpenSSL-derived terms), so pulling
185one in would fail `cargo-deny` — the same reason sqlx is built without a TLS
186backend. The HS256 surface we need is tiny and well-specified; hand-rolling it is
187a few dozen lines over crates already in the tree (`hmac`, `sha2`, `base64`,
188`serde_json`) and lets us reject algorithm-confusion tokens explicitly. Tokens
189are minted and verified only by fabrica, so interop with other JWT producers is a
190non-goal.
191
192## 2026-07-24 — `access()` takes the collaborator row and anonymous flag explicitly
193
194**Decision:** The permission function is
195`access(viewer: Option<&Viewer>, repo: &Repo, collaborator: Option<Permission>,
196allow_anonymous: bool) -> Access`, widening the two-argument shape sketched in
197the spec.
198
199**Alternatives:** Keep the literal `access(viewer, repo)` signature and thread the
200collaborator lookup and `instance.allow_anonymous` in via `Viewer` or a context
201struct.
202
203**Rationale:** Rule 3 needs the viewer's `repo_collaborators` row and rule 4 needs
204`instance.allow_anonymous`; neither lives on `Viewer` or `Repo`. Passing them as
205explicit parameters keeps `access` a pure function of its inputs — which is what
206makes the exhaustive matrix test possible — and keeps the collaborator lookup at
207the call site (the store), where it belongs. The five ordered rules are unchanged
208from the spec.