From 884d5211a69e8feaed4abac571161c119274e8d5 Mon Sep 17 00:00:00 2001 From: Axel Anderson Date: Tue, 11 Aug 2026 22:28:08 -0400 Subject: [PATCH 01/25] docs(auth): design GitHub authentication --- .../specs/2026-08-11-github-auth-design.md | 244 ++++++++++++++++++ 1 file changed, 244 insertions(+) create mode 100644 docs/superpowers/specs/2026-08-11-github-auth-design.md diff --git a/docs/superpowers/specs/2026-08-11-github-auth-design.md b/docs/superpowers/specs/2026-08-11-github-auth-design.md new file mode 100644 index 0000000..bbd3195 --- /dev/null +++ b/docs/superpowers/specs/2026-08-11-github-auth-design.md @@ -0,0 +1,244 @@ +# GitHub Authentication Design + +## Summary + +Interne will replace routine invite-code login with GitHub OAuth. The first release will run in closed signup mode: existing users can connect a GitHub identity, and an administrator can create invitation links for specific new users. A later configuration change can enable public signup for anyone with a GitHub account. + +Interne will not send email, request a GitHub email scope, or store GitHub access tokens. Emergency recovery remains an explicit SSH-only administrative action that produces a four-hour, single-use recovery URL. + +## Goals + +- Let an existing user sign in from a new browser through GitHub without SSH access. +- Preserve the existing user's ID, entries, visits, collections, and tags when GitHub is connected. +- Launch closed, while making public GitHub signup a configuration-only change. +- Support manually invited users without operating an email service. +- Provide a safe emergency recovery path. +- Make the legacy `invite_code` column removable after migration. + +## Non-goals + +- Password authentication. +- Email-based login, verification, invitations, or recovery. +- A general account-settings or user-administration UI. +- Persisting GitHub API access tokens. +- Self-service GitHub disconnection. +- Account merging. +- Multiple GitHub identities per Interne user. + +## Chosen approach + +Interne will implement the GitHub OAuth web application flow directly. This keeps account linking and signup policy in the application that owns the user records. A reverse-proxy authentication service would still require identity-header trust and account-linking logic, while adding another deployed service. Passkeys would require a more complex enrollment and recovery design. + +GitHub's stable numeric user ID is the external identity. The GitHub username is display and diagnostic metadata only; changing a GitHub username must not break login. + +## Configuration + +The server reads: + +- `GITHUB_CLIENT_ID`: OAuth application client ID. +- `GITHUB_CLIENT_SECRET`: OAuth application client secret. +- `PUBLIC_BASE_URL`: canonical external origin. Production uses `https://interne.honkytonk.in`. +- `GITHUB_SIGNUP_MODE`: `closed` or `public`; defaults to `closed`. + +Server startup fails with an actionable error if the OAuth credentials or public base URL are missing or invalid. CLI commands that generate URLs require `PUBLIC_BASE_URL` but do not require GitHub credentials. + +The production GitHub OAuth application uses: + +- Homepage: `https://interne.honkytonk.in` +- Callback: `https://interne.honkytonk.in/auth/github/callback` +- OAuth scopes: none + +## Data model + +### Users + +The `users` table gains: + +- `github_user_id TEXT UNIQUE NULL`: authoritative external identity, stored as an opaque decimal string. +- `github_login TEXT NULL`: most recently observed GitHub username. +- `auth_version INTEGER NOT NULL DEFAULT 1`: session-revocation generation. + +The existing `invite_code` becomes nullable. Setting it to `NULL` permanently disables legacy invite-code authentication for that user. A later migration may remove the column and legacy route without changing GitHub login or recovery. + +GitHub profile email is neither requested nor stored. A new public user's Interne name is GitHub's non-empty display name, falling back to the GitHub username. + +### Connection tokens + +A new `auth_connection_tokens` table contains: + +- token ID; +- target Interne user ID; +- SHA-256 hash of the random bearer token, uniquely indexed; +- purpose: `invite` or `recovery`; +- creation and expiration timestamps; +- nullable consumption timestamp. + +The plaintext token appears only in the generated URL printed by the CLI. It is never stored in SQLite. Tokens expire after four hours and are single-use. Issuing a token invalidates every still-active connection token for the same target user. + +An invitation token and a recovery token use the same browser flow. Their different purposes exist for auditability and user-facing copy. + +## Module boundaries + +GitHub-specific HTTP behavior lives behind a narrow OAuth client interface. The rest of the application can: + +1. construct an authorization URL; +2. exchange a callback code using a PKCE verifier; +3. fetch the authenticated GitHub profile. + +The route layer owns Interne flow state, account lookup/linking, session creation, signup policy, confirmation pages, and error responses. Database operations for consuming a connection token and linking an identity occur transactionally. + +The access token returned by GitHub exists only in memory while fetching `/user` and is then dropped. + +## OAuth security + +Every OAuth attempt uses: + +- a cryptographically random `state` value to prevent callback CSRF; +- PKCE with `S256` and a fresh verifier; +- an explicit callback URL derived from `PUBLIC_BASE_URL`; +- a server-side session record identifying the attempt's purpose. + +Only one OAuth attempt is active per browser session. Starting another replaces the prior attempt. The callback must match the stored state and have an unexpired flow record. Interne always fetches `/user` after exchanging a code; it never trusts identity values from browser input. + +OAuth attempts and pending confirmations expire after ten minutes. Restricted legacy-migration sessions expire after thirty minutes. These deadlines are stored with the flow state and checked independently of the session cookie's longer inactivity lifetime. + +For link and recovery flows, the callback stores only the fetched GitHub ID, login, and display name as a short-lived pending connection in the server-side session. An explicit confirmation POST completes the link. The confirmation page identifies the GitHub username being connected. Normal login and public signup do not require a second confirmation. + +If the returned GitHub ID is already connected to another Interne user, the operation fails without modifying either account. Interne never matches accounts by GitHub username, display name, or email. + +## User flows + +### Normal GitHub login + +The login page's primary action is **Continue with GitHub**. + +1. Interne starts a `login` OAuth attempt. +2. GitHub redirects to the callback. +3. Interne looks up `github_user_id`. +4. If found, Interne updates `github_login`, cycles the session ID, stores the user ID and current `auth_version`, and redirects home. +5. If not found and signup mode is `closed`, Interne shows: “Access isn’t open yet. Email webmaster@honkytonk.in for an invite.” +6. If not found and signup mode is `public`, Interne creates and logs in a user immediately. + +Changing `GITHUB_SIGNUP_MODE` from `closed` to `public` and restarting the container is sufficient to open registration. No code or schema deployment is required. + +### Existing-user migration + +The legacy invite-code form remains temporarily available for users whose `invite_code` is non-null and whose GitHub identity is unconnected. + +1. A valid invite code creates a restricted migration session and redirects to the GitHub connection page. +2. That session may only connect GitHub or log out; it cannot access normal application data routes. +3. Interne completes a `link` OAuth attempt and shows “Connect Interne to ``?” +4. Confirmation transactionally sets `github_user_id` and `github_login`, sets `invite_code` to `NULL`, consumes any active connection tokens, increments `auth_version`, and creates a fresh full session. + +Sessions created before the auth-version migration do not contain a version and are treated as logged out. The production rollout therefore requires the existing invite code once. This is the last routine use of that credential. + +The legacy form is rendered only while at least one unlinked user retains a legacy invite code. Once all such codes are gone, only GitHub login is shown. A later cleanup removes the route, model field, column, and deprecated command. + +### Closed-mode invitation + +`interne invite-user `: + +1. creates an unlinked Interne user with no legacy invite code; +2. invalidates any prior active connection token for that new user (normally none); +3. issues a four-hour invitation token; +4. prints the new user ID and complete connection URL. + +The administrator sends the URL manually. Opening it validates the token, establishes a short-lived recovery/linking session, and immediately redirects to a clean URL before starting GitHub OAuth. After the callback, the recipient confirms the GitHub username. Confirmation links the identity, consumes the token, cycles the session ID, and logs the user in. + +The legacy `create-user` command remains as a deprecated compatibility alias during the transition. It follows the invitation behavior rather than generating a new long-lived invite code. It can be removed with the legacy column. + +### Emergency recovery + +`interne reset-auth ` is deliberately available only through CLI access to the deployment: + +1. verify the user exists; +2. clear `github_user_id` and `github_login`; +3. set `invite_code` to `NULL`; +4. increment `auth_version`, immediately invalidating every existing session; +5. invalidate earlier connection tokens; +6. create a four-hour, single-use recovery token; +7. print the complete recovery URL. + +The recovery URL allows any GitHub identity not already attached elsewhere to be connected to the target user after explicit confirmation. Possession of the URL is therefore equivalent to recovery authority. There is no self-service unlink action. + +### Recovery URL handling + +The CLI prints a URL shaped like `https://interne.honkytonk.in/recover?token=`. The recovery/invitation endpoint validates the plaintext token by hashing it and comparing the hash to an active, unexpired database record. It then records only the token ID and purpose in the server-side session and redirects immediately to a URL without the token before OAuth begins. Responses set a restrictive referrer policy, and request logging for this endpoint must omit the query string. + +Expired, consumed, malformed, and superseded tokens all produce the same safe, actionable failure page. Confirmation rechecks the stored token record's target, expiration, and consumption state, then consumes the token and links the identity in one transaction, preventing replay and partial updates. + +## Sessions and authorization + +A full authenticated session stores both the Interne user ID and the `auth_version` observed at login. `AuthUser` accepts the session only when both values match the current user row. This keeps all existing protected route interfaces unchanged while enabling immediate global logout through a version increment. + +OAuth attempts, restricted migration sessions, and pending confirmations are separate session states and cannot satisfy `AuthUser`. Successful login or linking cycles the session ID. Logout continues to flush the session. + +The existing 30-day inactivity expiry remains unchanged. A temporary GitHub outage does not end an existing valid Interne session, but a new login requires GitHub to be available. + +## Error handling + +User-facing errors provide a next action without exposing OAuth codes, access tokens, recovery tokens, client secrets, database details, or raw GitHub responses. Cases include: + +- denied or failed GitHub authorization; +- mismatched or missing OAuth state; +- expired OAuth attempt; +- GitHub token exchange or profile lookup failure; +- GitHub identity already connected elsewhere; +- closed signup for an unknown GitHub identity; +- expired, used, malformed, or superseded connection link; +- invalid signup-mode or URL configuration. + +Provider and database failures are logged with internal context, with credentials and bearer values redacted. + +## Testing + +The GitHub client is replaceable in tests so integration tests never call GitHub. Tests cover: + +- authorization URLs include state, PKCE, callback, and no requested scopes; +- callback state and PKCE validation; +- login to an existing linked account; +- refresh of display-only `github_login`; +- closed-mode rejection with the webmaster contact; +- public-mode creation and name fallback; +- legacy login's restricted session and successful connection; +- legacy invite invalidation after connection; +- invitation and recovery confirmation; +- duplicate GitHub identity rejection; +- expired, consumed, malformed, and superseded connection tokens; +- transactional token consumption under replay attempts; +- `reset-auth` session revocation through `auth_version`; +- logout and existing protected-route behavior; +- migration preservation of existing users, entries, visits, collections, and tags. + +CLI tests verify generated URLs, four-hour expiration, token hashing, invalid user handling, and the deprecated alias. + +## Rollout + +1. Register the production GitHub OAuth application with the configured homepage and callback. +2. Add the OAuth credentials, `PUBLIC_BASE_URL`, and `GITHUB_SIGNUP_MODE=closed` to deployment configuration. +3. Back up the SQLite database. +4. Deploy the application and schema migration once. +5. Sign in with the existing invite code and connect GitHub account `axelav`. +6. Log out and verify GitHub login opens the same existing entries. +7. Verify `reset-auth` can generate a recovery URL, without executing a reset on the production user. +8. Keep closed mode until public signup is desired; then change only `GITHUB_SIGNUP_MODE=public` and restart. + +## Legacy cleanup + +After the production account is connected and the migration period has ended, a follow-up cleanup may: + +- remove invite-code request handling and its conditional login form; +- remove the deprecated `create-user` compatibility alias; +- remove `invite_code` from the Rust user model; +- rebuild the SQLite `users` table without the `invite_code` column. + +Invitation and recovery continue through `auth_connection_tokens`, so this cleanup does not alter the supported authentication model. + +## References + +- [GitHub: Authorizing OAuth apps](https://docs.github.com/en/apps/oauth-apps/building-oauth-apps/authorizing-oauth-apps) +- [GitHub: Creating an OAuth app](https://docs.github.com/en/apps/oauth-apps/building-oauth-apps/creating-an-oauth-app) + +## Future Work + +- [ ] Remove the legacy invite-code route, UI, model field, column, and deprecated `create-user` alias after all existing users have connected GitHub. From b1fe174d0250772fad982cbb17cb07b9b6cc1c2c Mon Sep 17 00:00:00 2001 From: Axel Anderson Date: Tue, 11 Aug 2026 22:42:24 -0400 Subject: [PATCH 02/25] docs(auth): plan GitHub authentication --- .../plans/2026-08-11-github-auth.md | 1453 +++++++++++++++++ 1 file changed, 1453 insertions(+) create mode 100644 docs/superpowers/plans/2026-08-11-github-auth.md diff --git a/docs/superpowers/plans/2026-08-11-github-auth.md b/docs/superpowers/plans/2026-08-11-github-auth.md new file mode 100644 index 0000000..8b9a2d4 --- /dev/null +++ b/docs/superpowers/plans/2026-08-11-github-auth.md @@ -0,0 +1,1453 @@ +# GitHub Authentication Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Replace routine invite-code login with GitHub OAuth while preserving existing accounts, supporting closed invitations and later public signup, and retaining an SSH-only recovery path. + +**Architecture:** Add a mockable GitHub provider at the application boundary, keep OAuth and connection state in server-side sessions, and identify users by GitHub's stable numeric ID. A dedicated connection-token module owns four-hour invitation and recovery credentials; protected routes continue to depend only on `AuthUser`. + +**Tech Stack:** Rust 1.90, Axum 0.8, SQLite via sqlx 0.8, Askama 0.15, tower-sessions 0.15, reqwest 0.12 with rustls, SHA-256, PKCE S256, GitHub OAuth web flow + +## Global Constraints + +- Production base URL is exactly `https://interne.honkytonk.in`. +- Production callback URL is exactly `https://interne.honkytonk.in/auth/github/callback`. +- Non-production `PUBLIC_BASE_URL` values may use HTTP only with literal loopback hosts `127.0.0.1` or `[::1]`; `localhost` is rejected. +- GitHub OAuth requests use an empty permission scope, random `state`, and PKCE S256. +- GitHub access tokens exist only long enough to fetch `/user` and are never persisted. +- GitHub's numeric user ID, stored as an opaque decimal string, is authoritative; usernames are display metadata. +- `GITHUB_SIGNUP_MODE` accepts only `closed` or `public` and defaults to `closed`. +- Unknown GitHub identities in closed mode see: “Access isn’t open yet. Email webmaster@honkytonk.in for an invite.” +- Invitation and recovery tokens are random, stored only as SHA-256 hashes, single-use, and expire after exactly four hours. +- OAuth attempts and pending confirmations expire after ten minutes; restricted legacy sessions expire after thirty minutes. +- Linking GitHub nulls the legacy invite code; accounts have no self-service unlink operation. +- `reset-auth` disconnects GitHub, revokes every existing session through `auth_version`, and issues a recovery URL. +- Public signup stores no GitHub email and uses display name with username fallback. +- Preserve all existing user IDs and related entries, visits, collections, memberships, and tags. +- Every implementation commit is signed and uses conventional commit format. + +--- + +## File Map + +- `migrations/003_github_auth.sql`: safely rebuild `users` with nullable legacy credentials and add connection-token storage. +- `src/models/user.rs`: represent GitHub identity and session generation on users. +- `src/config.rs`: parse and validate server auth configuration without exposing the client secret. +- `src/github.rs`: own GitHub authorization URLs, token exchange, and `/user` lookup behind a testable trait. +- `src/auth.rs`: own full-session, migration-session, OAuth-attempt, and pending-confirmation state. +- `src/connection_tokens.rs`: issue, validate, invalidate, and atomically consume invitation/recovery tokens. +- `src/routes/auth.rs`: coordinate login, callback, connection confirmation, recovery, and logout routes. +- `src/lib.rs`: inject auth configuration and GitHub provider into `AppState`. +- `src/main.rs`: wire production configuration and expose `invite-user` and `reset-auth` commands. +- `src/cli.rs`: retain legacy import logic and provide thin user-facing wrappers over connection-token operations. +- `templates/login.html`: make GitHub the primary login action and conditionally expose legacy migration login. +- `templates/connect_github.html`: explain the required legacy-to-GitHub migration step. +- `templates/github_confirm.html`: confirm the GitHub username before linking. +- `templates/auth_error.html`: render safe, actionable authentication errors. +- `static/style.css`: style OAuth actions and authentication status pages using existing design tokens. +- `tests/common/mod.rs`: provide a fake GitHub provider and OAuth-based test login helper. +- `tests/migrations.rs`: prove the parent-table rebuild preserves related records. +- `tests/auth.rs`: cover session generations and legacy migration behavior. +- `tests/github_auth.rs`: cover OAuth login, linking, signup, confirmation, and provider failures. +- `tests/connection_tokens.rs`: cover invitations, recovery, expiry, replay, and session revocation. +- `.env.example`, `docker-compose.yml`, `README.md`: document and expose required configuration and operations. + +--- + +### Task 1: Migrate users and add authentication storage + +**Files:** +- Create: `migrations/003_github_auth.sql` +- Create: `tests/migrations.rs` +- Modify: `src/models/user.rs` + +**Interfaces:** +- Produces: `User { invite_code: Option, github_user_id: Option, github_login: Option, auth_version: i64 }` +- Produces: `auth_connection_tokens(id, user_id, token_hash, purpose, created_at, expires_at, consumed_at)` +- Consumes: existing schema from `migrations/001_initial.sql` and timestamp normalization from `migrations/002_timestamps.sql` + +- [ ] **Step 1: Write a migration-preservation test** + +Create `tests/migrations.rs`. Build the old schema on one in-memory connection, insert a user plus one referencing row in each affected table, execute the new script, and verify both data and foreign keys: + +```rust +use sqlx::{Connection, Row, SqliteConnection}; + +#[tokio::test] +async fn github_auth_migration_preserves_users_and_related_data() { + let mut db = SqliteConnection::connect("sqlite::memory:").await.unwrap(); + sqlx::query("PRAGMA foreign_keys = ON") + .execute(&mut db) + .await + .unwrap(); + sqlx::raw_sql(include_str!("../migrations/001_initial.sql")) + .execute(&mut db) + .await + .unwrap(); + sqlx::raw_sql(include_str!("../migrations/002_timestamps.sql")) + .execute(&mut db) + .await + .unwrap(); + + sqlx::query("INSERT INTO users (id, name, invite_code) VALUES ('u1', 'Axel', 'legacy')") + .execute(&mut db) + .await + .unwrap(); + sqlx::query("INSERT INTO entries (id, user_id, url, title, duration, interval) VALUES ('e1', 'u1', 'https://example.com', 'Example', 1, 'days')") + .execute(&mut db) + .await + .unwrap(); + sqlx::query("INSERT INTO visits (id, entry_id, user_id) VALUES ('v1', 'e1', 'u1')") + .execute(&mut db) + .await + .unwrap(); + sqlx::query("INSERT INTO collections (id, owner_id, name, invite_code) VALUES ('c1', 'u1', 'Reading', 'collection')") + .execute(&mut db) + .await + .unwrap(); + sqlx::query("INSERT INTO collection_members (collection_id, user_id) VALUES ('c1', 'u1')") + .execute(&mut db) + .await + .unwrap(); + sqlx::query("INSERT INTO tags (id, name) VALUES ('t1', 'rust')") + .execute(&mut db) + .await + .unwrap(); + sqlx::query("INSERT INTO entry_tags (entry_id, tag_id) VALUES ('e1', 't1')") + .execute(&mut db) + .await + .unwrap(); + + sqlx::raw_sql(include_str!("../migrations/003_github_auth.sql")) + .execute(&mut db) + .await + .unwrap(); + + let user = sqlx::query("SELECT invite_code, github_user_id, auth_version FROM users WHERE id = 'u1'") + .fetch_one(&mut db) + .await + .unwrap(); + assert_eq!(user.get::, _>("invite_code").as_deref(), Some("legacy")); + assert_eq!(user.get::, _>("github_user_id"), None); + assert_eq!(user.get::("auth_version"), 1); + + for table in ["entries", "visits", "collections", "collection_members", "tags", "entry_tags"] { + let count: i64 = sqlx::query_scalar(&format!("SELECT COUNT(*) FROM {table}")) + .fetch_one(&mut db) + .await + .unwrap(); + assert_eq!(count, 1, "{table} rows must survive the users rebuild"); + } + + let violations = sqlx::query("PRAGMA foreign_key_check") + .fetch_all(&mut db) + .await + .unwrap(); + assert!(violations.is_empty()); +} +``` + +- [ ] **Step 2: Run the migration test and verify red** + +Run: `cargo test --test migrations github_auth_migration_preserves_users_and_related_data -- --exact` + +Expected: FAIL because `migrations/003_github_auth.sql` does not exist. + +- [ ] **Step 3: Add the no-transaction SQLite migration** + +Use SQLite's documented create-copy-drop-rename procedure. `-- no-transaction` is required because `PRAGMA foreign_keys=OFF` must execute before `BEGIN`: + +```sql +-- no-transaction +PRAGMA foreign_keys = OFF; +BEGIN IMMEDIATE; + +CREATE TABLE new_users ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + email TEXT UNIQUE, + invite_code TEXT UNIQUE, + github_user_id TEXT UNIQUE, + github_login TEXT, + auth_version INTEGER NOT NULL DEFAULT 1, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + updated_at TEXT NOT NULL DEFAULT (datetime('now')) +); + +INSERT INTO new_users ( + id, name, email, invite_code, github_user_id, github_login, + auth_version, created_at, updated_at +) +SELECT + id, name, email, invite_code, NULL, NULL, + 1, created_at, updated_at +FROM users; + +DROP TABLE users; +ALTER TABLE new_users RENAME TO users; + +CREATE TABLE auth_connection_tokens ( + id TEXT PRIMARY KEY, + user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + token_hash TEXT NOT NULL UNIQUE, + purpose TEXT NOT NULL CHECK (purpose IN ('invite', 'recovery')), + created_at TEXT NOT NULL, + expires_at TEXT NOT NULL, + consumed_at TEXT +); + +CREATE INDEX idx_auth_connection_tokens_user_id + ON auth_connection_tokens(user_id); +CREATE INDEX idx_auth_connection_tokens_active + ON auth_connection_tokens(token_hash, expires_at, consumed_at); + +COMMIT; +PRAGMA foreign_keys = ON; +``` + +- [ ] **Step 4: Update the Rust user model** + +Change `User` fields to match the migrated table exactly: + +```rust +#[derive(Debug, Clone, Serialize, Deserialize, FromRow)] +pub struct User { + pub id: String, + pub name: String, + pub email: Option, + pub invite_code: Option, + pub github_user_id: Option, + pub github_login: Option, + pub auth_version: i64, + pub created_at: String, + pub updated_at: String, +} +``` + +- [ ] **Step 5: Run migration and regression tests** + +Run: `cargo test --test migrations` + +Expected: PASS. + +Run: `cargo test` + +Expected: all existing tests PASS without changing existing invite-login behavior yet. + +- [ ] **Step 6: Commit the schema checkpoint** + +```bash +git add migrations/003_github_auth.sql src/models/user.rs tests/migrations.rs +git commit -S -m "feat(auth): add GitHub identity schema" +``` + +--- + +### Task 2: Add validated configuration and the GitHub provider boundary + +**Files:** +- Create: `src/config.rs` +- Create: `src/github.rs` +- Modify: `src/lib.rs` +- Modify: `Cargo.toml` +- Modify: `Cargo.lock` + +**Interfaces:** +- Produces: `SignupMode::{Closed, Public}` +- Produces: `AuthConfig { public_base_url: Url, signup_mode: SignupMode }` +- Produces: `ServerAuthConfig { auth: AuthConfig, github: GitHubCredentials }` +- Produces: `GitHubProvider::authorization_url(...)` and `GitHubProvider::exchange_code(...)` +- Produces: `GitHubProfile { user_id: String, login: String, name: Option }` + +- [ ] **Step 1: Write configuration parsing tests** + +Add unit tests in `src/config.rs` against a closure-based parser so tests never mutate process-global environment: + +```rust +#[test] +fn signup_mode_defaults_closed_and_rejects_unknown_values() { + let closed = ServerAuthConfig::from_lookup(|key| match key { + "GITHUB_CLIENT_ID" => Some("client".into()), + "GITHUB_CLIENT_SECRET" => Some("secret".into()), + "PUBLIC_BASE_URL" => Some("https://interne.honkytonk.in".into()), + _ => None, + }).unwrap(); + assert_eq!(closed.auth.signup_mode, SignupMode::Closed); + + let error = ServerAuthConfig::from_lookup(|key| match key { + "GITHUB_CLIENT_ID" => Some("client".into()), + "GITHUB_CLIENT_SECRET" => Some("secret".into()), + "PUBLIC_BASE_URL" => Some("https://interne.honkytonk.in".into()), + "GITHUB_SIGNUP_MODE" => Some("sometimes".into()), + _ => None, + }).unwrap_err(); + assert!(error.to_string().contains("closed or public")); +} + +#[test] +fn public_base_url_requires_https_except_for_literal_loopback() { + for invalid in [ + "http://interne.honkytonk.in", + "http://localhost:3000", + "https://interne.honkytonk.in/path", + ] { + let result = AuthConfig::new(invalid, SignupMode::Closed); + assert!(result.is_err(), "{invalid} must be rejected"); + } + assert!(AuthConfig::new("http://127.0.0.1:3000", SignupMode::Closed).is_ok()); +} +``` + +- [ ] **Step 2: Write GitHub authorization URL and PKCE tests** + +Add unit tests in `src/github.rs`: + +```rust +#[test] +fn authorization_url_has_identity_only_parameters() { + let client = GitHubOAuthClient::new("client-id", "secret").unwrap(); + let callback = Url::parse("https://interne.honkytonk.in/auth/github/callback").unwrap(); + let url = client.authorization_url(&callback, "state-value", "challenge-value").unwrap(); + let query: HashMap<_, _> = url.query_pairs().into_owned().collect(); + + assert_eq!(query.get("client_id").map(String::as_str), Some("client-id")); + assert_eq!(query.get("redirect_uri").map(String::as_str), Some(callback.as_str())); + assert_eq!(query.get("state").map(String::as_str), Some("state-value")); + assert_eq!(query.get("code_challenge").map(String::as_str), Some("challenge-value")); + assert_eq!(query.get("code_challenge_method").map(String::as_str), Some("S256")); + assert!(!query.contains_key("scope")); +} + +#[test] +fn pkce_challenge_is_base64url_sha256() { + assert_eq!( + pkce_challenge("dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk"), + "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM" + ); +} +``` + +- [ ] **Step 3: Run focused tests and verify red** + +Run: `cargo test config::tests github::tests` + +Expected: FAIL because the modules and dependencies do not exist. + +- [ ] **Step 4: Add the HTTP and cryptography dependencies** + +Add: + +```toml +async-trait = "0.1" +base64 = "0.22" +rand = "0.9" +reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] } +sha2 = "0.10" +``` + +- [ ] **Step 5: Implement configuration types** + +Use this public shape in `src/config.rs`: + +```rust +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum SignupMode { Closed, Public } + +#[derive(Clone, Debug)] +pub struct AuthConfig { + pub public_base_url: Url, + pub signup_mode: SignupMode, +} + +pub struct GitHubCredentials { + pub client_id: String, + pub client_secret: String, +} + +pub struct ServerAuthConfig { + pub auth: AuthConfig, + pub github: GitHubCredentials, +} +``` + +`AuthConfig::new` must reject credentials in the URL, query strings, fragments, and non-root paths. Require HTTPS except for HTTP URLs whose host is the literal `127.0.0.1` or `[::1]`; reject `localhost`. Normalize the root path to `/`. `ServerAuthConfig::from_lookup` returns named errors for each missing variable and accepts exactly `closed` or `public`. `from_env` delegates to `from_lookup(|key| std::env::var(key).ok())`. Do not derive `Debug` for credentials or server config. + +- [ ] **Step 6: Implement the GitHub adapter** + +Use this provider seam in `src/github.rs`: + +```rust +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct GitHubProfile { + pub user_id: String, + pub login: String, + pub name: Option, +} + +#[async_trait] +pub trait GitHubProvider: Send + Sync { + fn authorization_url( + &self, + callback_url: &Url, + state: &str, + pkce_challenge: &str, + ) -> Result; + + async fn exchange_code( + &self, + callback_url: &Url, + code: &str, + pkce_verifier: &str, + ) -> Result; +} + +impl GitHubOAuthClient { + pub fn new( + client_id: impl Into, + client_secret: impl Into, + ) -> Result; +} +``` + +`GitHubOAuthClient` owns the client ID, client secret, and a `reqwest::Client` configured with redirects disabled. `exchange_code` must: + +```text +POST https://github.com/login/oauth/access_token +Accept: application/json +body: client_id, client_secret, code, redirect_uri, code_verifier + +GET https://api.github.com/user +Accept: application/vnd.github+json +Authorization: Bearer +User-Agent: interne +X-GitHub-Api-Version: 2022-11-28 +``` + +Deserialize the numeric API `id` to `serde_json::Number` and convert it to its decimal string. Return typed errors that name only the failed stage; never include response bodies, secrets, authorization codes, or tokens in `Display` or `Debug` output. + +- [ ] **Step 7: Export modules and run tests** + +Add `pub mod config;` and `pub mod github;` to `src/lib.rs`. + +Run: `cargo test config::tests github::tests` + +Expected: PASS. + +Run: `cargo test` + +Expected: all tests PASS. + +- [ ] **Step 8: Commit the provider boundary** + +```bash +git add Cargo.toml Cargo.lock src/config.rs src/github.rs src/lib.rs +git commit -S -m "feat(auth): add GitHub OAuth provider" +``` + +--- + +### Task 3: Inject authentication services and a fake provider + +**Files:** +- Modify: `src/lib.rs` +- Modify: `src/main.rs` +- Modify: `tests/common/mod.rs` + +**Interfaces:** +- Consumes: `AuthConfig`, `GitHubCredentials`, `GitHubProvider`, `GitHubOAuthClient` +- Produces: `AuthServices { config: AuthConfig, github: Arc }` +- Produces: `build_app(pool, secure_cookies, auth_services) -> Router` +- Produces: `FakeGitHubProvider::profile_for_code(code, profile)` for integration tests + +- [ ] **Step 1: Write a fake-provider contract test** + +Add the fake to `tests/common/mod.rs`, then add this test under `#[cfg(test)]` in that module: + +```rust +#[tokio::test] +async fn fake_github_returns_the_profile_registered_for_a_code() { + let fake = FakeGitHubProvider::default(); + let expected = GitHubProfile { + user_id: "123".into(), + login: "axelav".into(), + name: Some("Axel".into()), + }; + fake.profile_for_code("good-code", expected.clone()); + + let actual = fake.exchange_code( + &Url::parse("https://interne.test/auth/github/callback").unwrap(), + "good-code", + "verifier", + ).await.unwrap(); + assert_eq!(actual, expected); +} +``` + +- [ ] **Step 2: Run the contract test and verify red** + +Run: `cargo test fake_github_returns_the_profile_registered_for_a_code` + +Expected: FAIL because the fake and injectable application services do not exist. + +- [ ] **Step 3: Add the injectable application boundary** + +In `src/lib.rs`: + +```rust +#[derive(Clone)] +pub struct AuthServices { + pub config: AuthConfig, + pub github: Arc, +} + +#[derive(Clone)] +pub struct AppState { + pub db: SqlitePool, + pub auth: AuthServices, +} + +pub async fn build_app( + pool: SqlitePool, + secure_cookies: bool, + auth: AuthServices, +) -> Router +``` + +Preserve the existing session store, static service, cache headers, trace layer, and route merges. Construct `AppState { db: pool, auth }`. + +- [ ] **Step 4: Wire production startup** + +In `src/main.rs`, parse CLI commands before OAuth configuration. Only the server path requires GitHub credentials: + +```rust +let server_auth = ServerAuthConfig::from_env().unwrap_or_else(|error| { + eprintln!("Authentication configuration error: {error}"); + std::process::exit(1); +}); +let github = GitHubOAuthClient::new( + server_auth.github.client_id, + server_auth.github.client_secret, +).unwrap_or_else(|error| { + eprintln!("GitHub client configuration error: {error}"); + std::process::exit(1); +}); +let auth = AuthServices { + config: server_auth.auth, + github: Arc::new(github), +}; +let app = interne::build_app(pool, secure, auth).await; +``` + +- [ ] **Step 5: Implement the test fake and test app configuration** + +`FakeGitHubProvider` stores `HashMap>` behind `Arc>`. Its authorization URL is `https://github.test/authorize` with the supplied state and PKCE challenge. Its code exchange returns the registered result or a safe `GitHubError::TokenExchange`. + +Give `TestApp` these fields and constructors: + +```rust +pub struct TestApp { + pub router: Router, + pub db: SqlitePool, + pub github: FakeGitHubProvider, +} + +impl TestApp { + pub async fn new() -> Self { + Self::with_signup_mode(SignupMode::Closed).await + } + + pub async fn with_signup_mode(signup_mode: SignupMode) -> Self +} +``` + +Use `AuthConfig::new("https://interne.test", signup_mode)` and inject the fake into `build_app`. + +- [ ] **Step 6: Update existing direct `build_app` callers** + +Run: `rg -n "build_app\(" src tests` + +Expected callers: `src/main.rs` and `tests/common/mod.rs`. Update both; do not add environment-variable reads to tests. + +- [ ] **Step 7: Run contract and regression tests** + +Run: `cargo test fake_github_returns_the_profile_registered_for_a_code` + +Expected: PASS. + +Run: `cargo test` + +Expected: all tests PASS. + +- [ ] **Step 8: Commit dependency injection** + +```bash +git add src/lib.rs src/main.rs tests/common/mod.rs +git commit -S -m "refactor(auth): inject GitHub provider" +``` + +--- + +### Task 4: Version authenticated sessions + +**Files:** +- Modify: `src/auth.rs` +- Modify: `src/routes/auth.rs` +- Modify: `tests/auth.rs` + +**Interfaces:** +- Consumes: `User.auth_version` +- Produces: full sessions containing `user_id` and `auth_version` +- Preserves: `AuthUser(pub User)` as the only protected-route interface + +- [ ] **Step 1: Write session-generation tests** + +Add to `tests/auth.rs`: + +```rust +#[tokio::test] +async fn changing_auth_version_revokes_an_existing_session() { + let app = TestApp::new().await; + let (user_id, invite_code) = app.create_user("Test User").await; + let cookie = app.login(&invite_code).await; + + sqlx::query("UPDATE users SET auth_version = auth_version + 1 WHERE id = ?") + .bind(&user_id) + .execute(&app.db) + .await + .unwrap(); + + let response = app.get("/", Some(&cookie)).await; + assert_redirect(&response, "/login"); +} + +#[tokio::test] +async fn missing_auth_version_rejects_a_known_user_id() { + let identity = session_identity(Some("known-user".into()), None); + assert_eq!(identity, None); +} +``` + +Put `missing_auth_version_rejects_a_known_user_id` in the `src/auth.rs` unit-test module. `session_identity(Option, Option) -> Option<(String, i64)>` is a private pure helper used by `AuthUser`, so the test distinguishes a pre-migration session from a wholly unauthenticated request. + +- [ ] **Step 2: Run the revocation test and verify red** + +Run: `cargo test --test auth changing_auth_version_revokes_an_existing_session -- --exact` + +Expected: FAIL because `AuthUser` currently checks only `user_id`. + +- [ ] **Step 3: Store and validate the generation** + +In `src/auth.rs`: + +```rust +const USER_ID_KEY: &str = "user_id"; +const AUTH_VERSION_KEY: &str = "auth_version"; + +pub async fn login_user( + session: &Session, + user: &User, +) -> Result<(), tower_sessions::session::Error> { + session.insert(USER_ID_KEY, &user.id).await?; + session.insert(AUTH_VERSION_KEY, user.auth_version).await +} +``` + +`AuthUser` reads both keys and selects with: + +```sql +SELECT * FROM users WHERE id = ? AND auth_version = ? +``` + +If either key is absent or mismatched, return `AuthRedirect`. Keep `logout_user` as `session.flush()`. + +- [ ] **Step 4: Cycle the session before every full login** + +Retain `session.cycle_id().await?` in the login route immediately before `login_user`. Do not cycle inside `login_user`, because later linking transactions need to decide when old restricted state is cleared. + +- [ ] **Step 5: Run auth and full tests** + +Run: `cargo test --test auth` + +Expected: PASS. + +Run: `cargo test` + +Expected: all tests PASS. + +- [ ] **Step 6: Commit session revocation** + +```bash +git add src/auth.rs src/routes/auth.rs tests/auth.rs +git commit -S -m "feat(auth): version authenticated sessions" +``` + +--- + +### Task 5: Implement normal GitHub login and public signup + +**Files:** +- Modify: `src/auth.rs` +- Modify: `src/routes/auth.rs` +- Modify: `templates/login.html` +- Create: `templates/auth_error.html` +- Modify: `static/style.css` +- Modify: `tests/common/mod.rs` +- Create: `tests/github_auth.rs` +- Modify: existing integration tests that use `TestApp::login` + +**Interfaces:** +- Consumes: `AuthServices`, `GitHubProvider`, `SignupMode`, versioned `login_user` +- Produces: `OAuthAttempt { state, pkce_verifier, purpose, expires_at }` +- Produces: `OAuthPurpose::Login` +- Produces routes: `GET /auth/github`, `GET /auth/github/callback` +- Produces: OAuth-based `TestApp::login(github_user_id)` helper + +- [ ] **Step 1: Write closed-login, public-signup, and callback-security tests** + +Create `tests/github_auth.rs` with helpers that begin OAuth, capture the session cookie and `state` from the fake authorization URL, register a fake profile for a callback code, and call the callback. Cover these behaviors with named tests: + +```rust +#[tokio::test] +async fn linked_github_identity_logs_into_existing_user() { + let app = TestApp::new().await; + let user_id = app.create_github_user("Axel", "100", "axelav").await; + let cookie = app.github_login("100", "axelav", Some("Axel")).await; + + let response = app.get("/", Some(&cookie)).await; + assert_eq!(response.status(), StatusCode::OK); + let authenticated_id: String = sqlx::query_scalar("SELECT id FROM users WHERE github_user_id = '100'") + .fetch_one(&app.db) + .await + .unwrap(); + assert_eq!(authenticated_id, user_id); +} + +#[tokio::test] +async fn unknown_identity_is_rejected_in_closed_mode() { + let app = TestApp::new().await; + let response = app.github_callback_response("200", "visitor", None).await; + let body = body_string(response).await; + assert!(body.contains("Access isn’t open yet")); + assert!(body.contains("webmaster@honkytonk.in")); +} + +#[tokio::test] +async fn unknown_identity_creates_account_in_public_mode() { + let app = TestApp::with_signup_mode(SignupMode::Public).await; + let response = app.github_callback_response("200", "visitor", None).await; + assert_redirect(&response, "/"); + let name: String = sqlx::query_scalar("SELECT name FROM users WHERE github_user_id = '200'") + .fetch_one(&app.db) + .await + .unwrap(); + assert_eq!(name, "visitor"); + let email: Option = sqlx::query_scalar("SELECT email FROM users WHERE github_user_id = '200'") + .fetch_one(&app.db) + .await + .unwrap(); + assert_eq!(email, None); +} +``` + +Also add exact tests named: + +```text +oauth_callback_rejects_mismatched_state +oauth_callback_rejects_replayed_state +oauth_callback_rejects_expired_attempt +github_login_refreshes_display_username +provider_failure_shows_safe_error +authorization_redirect_has_pkce_and_no_scope +``` + +- [ ] **Step 2: Run the new integration test file and verify red** + +Run: `cargo test --test github_auth` + +Expected: FAIL because the routes and OAuth session state do not exist. + +- [ ] **Step 3: Add OAuth flow state to `src/auth.rs`** + +Use serializable session types: + +```rust +#[derive(Clone, Debug, Serialize, Deserialize)] +pub enum OAuthPurpose { + Login, +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct OAuthAttempt { + pub state: String, + pub pkce_verifier: String, + pub purpose: OAuthPurpose, + pub expires_at: i64, +} +``` + +`OAuthAttempt::new(OAuthPurpose::Login, now)` generates 32 random bytes for both state and verifier, base64url-encodes without padding, and sets `expires_at = now.timestamp() + 600`. Add `pkce_challenge()` using SHA-256. `take_oauth_attempt` removes the attempt from the session before validation so a callback cannot be replayed. + +- [ ] **Step 4: Add login and callback routes** + +In `src/routes/auth.rs`, add: + +```rust +.route("/auth/github", get(github_login_start)) +.route("/auth/github/callback", get(github_callback)) +``` + +`github_login_start` stores an `OAuthAttempt`, builds the callback from `PUBLIC_BASE_URL`, and redirects to the provider URL. + +`github_callback` must, in order: + +1. remove and validate the session attempt, state, purpose, and ten-minute expiry; +2. exchange the code and fetch the GitHub profile through the provider; +3. update and load a user by `github_user_id`; +4. in closed mode, render the exact webmaster invitation copy if no user exists; +5. in public mode, insert a user with `invite_code = NULL`, no email, and display-name fallback, using `ON CONFLICT(github_user_id) DO NOTHING` before selecting the winner; +6. cycle the session ID, write the full versioned session, and redirect `/`. + +Do not include the provider's raw error in HTML or logs. + +- [ ] **Step 5: Make GitHub the primary login action** + +Update `LoginTemplate` with `legacy_login_available: bool`. Query: + +```sql +SELECT EXISTS( + SELECT 1 FROM users + WHERE invite_code IS NOT NULL AND github_user_id IS NULL +) +``` + +Render a primary **Continue with GitHub** link to `/auth/github`. Keep the existing invite form only when `legacy_login_available` is true. Add `auth_error.html` extending `base.html` with a safe `message` and a link back to `/login`. + +- [ ] **Step 6: Convert the shared test login to fake GitHub OAuth** + +Add `create_github_user`, `begin_github_login`, `github_callback_response`, and `github_login` to `TestApp`. `github_login` must perform the actual start and callback requests, carry the OAuth-attempt cookie, and return the post-cycle cookie. + +Change `TestApp::create_user` to insert a linked GitHub user and return `(user_id, github_user_id)`. Keep the return shape so feature tests need only rename local variables when useful. Add `create_legacy_user` for legacy-specific tests. Change `TestApp::login` to delegate to `github_login` using the supplied GitHub ID. + +- [ ] **Step 7: Update existing tests to use the new helper semantics** + +Run: `rg -n "invite_code = app.create_user|app.login\(&invite_code\)" tests` + +Replace misleading local names with `github_user_id` in each touched test. Do not alter collection invitation tests; collection invite codes are unrelated and remain unchanged. + +- [ ] **Step 8: Run OAuth, auth, and full tests** + +Run: `cargo test --test github_auth` + +Expected: PASS. + +Run: `cargo test --test auth` + +Expected: PASS. + +Run: `cargo test` + +Expected: all tests PASS. + +- [ ] **Step 9: Commit normal GitHub login** + +```bash +git add src/auth.rs src/routes/auth.rs templates/login.html templates/auth_error.html static/style.css tests +git commit -S -m "feat(auth): sign in with GitHub" +``` + +--- + +### Task 6: Turn legacy invite login into a one-time GitHub bridge + +**Files:** +- Modify: `src/auth.rs` +- Modify: `src/routes/auth.rs` +- Create: `templates/connect_github.html` +- Create: `templates/github_confirm.html` +- Modify: `tests/auth.rs` +- Modify: `tests/github_auth.rs` + +**Interfaces:** +- Extends: `OAuthPurpose::Link { user_id: String }` +- Produces: `MigrationSession { user_id, expires_at }` +- Produces: `PendingConnection { user_id, github_profile, proof, expires_at }` +- Produces routes: `GET /auth/connect`, `POST /auth/github/connect`, `GET/POST /auth/github/confirm` +- Consumes: legacy `users.invite_code` + +- [ ] **Step 1: Replace legacy-login expectations with bridge tests** + +Change the valid-invite test and add restricted-session coverage: + +```rust +#[tokio::test] +async fn valid_legacy_code_redirects_to_github_connection() { + let app = TestApp::new().await; + let (_user_id, invite_code) = app.create_legacy_user("Test User").await; + let response = app.post_form("/login", &format!("invite_code={invite_code}"), None).await; + assert_redirect(&response, "/auth/connect"); +} + +#[tokio::test] +async fn legacy_session_cannot_access_application_data() { + let app = TestApp::new().await; + let (_user_id, invite_code) = app.create_legacy_user("Test User").await; + let cookie = app.legacy_login(&invite_code).await; + let response = app.get("/", Some(&cookie)).await; + assert_redirect(&response, "/login"); +} +``` + +Add GitHub-flow tests named: + +```text +legacy_user_confirms_github_and_keeps_the_same_user_id +link_confirmation_nulls_legacy_invite_code +link_confirmation_rejects_github_id_owned_by_another_user +link_callback_requires_a_live_migration_session +expired_migration_session_cannot_start_linking +pending_confirmation_expires_after_ten_minutes +``` + +- [ ] **Step 2: Run focused tests and verify red** + +Run: `cargo test --test auth valid_legacy_code_redirects_to_github_connection -- --exact` + +Expected: FAIL because valid invite codes still create full sessions. + +- [ ] **Step 3: Add restricted and pending session types** + +In `src/auth.rs`: + +```rust +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct MigrationSession { + pub user_id: String, + pub expires_at: i64, +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +pub enum ConnectionProof { + LegacyInvite, +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct PendingConnection { + pub user_id: String, + pub github_profile: GitHubProfile, + pub proof: ConnectionProof, + pub expires_at: i64, +} +``` + +Provide session helpers that insert, read, and remove these values. Migration sessions expire at `now + 1_800`; pending connections expire at `now + 600`. Neither helper may populate the full-session keys used by `AuthUser`. + +- [ ] **Step 4: Restrict legacy login** + +Change the invite query to: + +```sql +SELECT * FROM users +WHERE invite_code = ? AND github_user_id IS NULL +``` + +On success, flush the prior session, cycle its ID, store only `MigrationSession`, and redirect `/auth/connect`. Invalid, null, and already-linked credentials all return the existing generic “Invalid invite code” message. + +- [ ] **Step 5: Add connect, callback, and confirmation handling** + +`GET /auth/connect` validates the migration session and renders an explanation plus a POST form. `POST /auth/github/connect` creates `OAuthPurpose::Link { user_id }` only when the live migration session targets the same user. + +Extend the callback: a successful link-purpose exchange stores `PendingConnection` and redirects `/auth/github/confirm`; it does not mutate the user yet. + +The confirmation POST opens a transaction and executes: + +```sql +UPDATE users +SET github_user_id = ?, + github_login = ?, + invite_code = NULL, + auth_version = auth_version + 1, + updated_at = ? +WHERE id = ? + AND invite_code IS NOT NULL + AND github_user_id IS NULL +``` + +Reject zero updated rows. Convert a unique `github_user_id` violation into the safe duplicate-identity error. Mark all active connection tokens for the target user consumed, commit, reload the user, flush and cycle the session, write the full session with the incremented generation, and redirect `/`. + +- [ ] **Step 6: Add connection and confirmation templates** + +`connect_github.html` must explain that GitHub will replace the legacy code. `github_confirm.html` must display only the escaped GitHub username and POST without accepting identity fields from the browser. + +- [ ] **Step 7: Run legacy, OAuth, and regression tests** + +Run: `cargo test --test auth` + +Expected: PASS. + +Run: `cargo test --test github_auth` + +Expected: PASS. + +Run: `cargo test` + +Expected: all tests PASS. + +- [ ] **Step 8: Commit the one-time bridge** + +```bash +git add src/auth.rs src/routes/auth.rs templates/connect_github.html templates/github_confirm.html tests/auth.rs tests/github_auth.rs +git commit -S -m "feat(auth): connect legacy users to GitHub" +``` + +--- + +### Task 7: Add four-hour invitation and recovery commands + +**Files:** +- Create: `src/connection_tokens.rs` +- Modify: `src/lib.rs` +- Modify: `src/cli.rs` +- Modify: `src/main.rs` +- Create: `tests/connection_tokens.rs` + +**Interfaces:** +- Produces: `ConnectionPurpose::{Invite, Recovery}` +- Produces: `IssuedConnection { user_id, plaintext_token, expires_at }` +- Produces: `ConnectionClaim { token_id, user_id, purpose }` +- Produces: `issue_invitation`, `reset_auth`, `validate_token`, `consume_and_link`, `connection_url` +- Consumes: `AuthConfig.public_base_url`, `GitHubProfile`, and the migrated token table + +- [ ] **Step 1: Write token-service tests** + +Create `tests/connection_tokens.rs` with a migrated in-memory database. Add exact tests: + +```rust +#[tokio::test] +async fn invitation_is_hashed_single_use_and_expires_in_four_hours() { + let app = TestApp::new().await; + let now = Utc::now(); + let issued = issue_invitation(&app.db, "Guest", now).await.unwrap(); + + assert_eq!(issued.expires_at, now + chrono::Duration::hours(4)); + let stored: String = sqlx::query_scalar( + "SELECT token_hash FROM auth_connection_tokens WHERE user_id = ?" + ).bind(&issued.user_id).fetch_one(&app.db).await.unwrap(); + assert_ne!(stored, issued.plaintext_token); + + let claim = validate_token(&app.db, &issued.plaintext_token, now).await.unwrap(); + assert_eq!(claim.user_id, issued.user_id); +} +``` + +Add tests named: + +```text +new_invitation_supersedes_an_older_token +expired_token_is_rejected +malformed_token_is_rejected_like_an_expired_token +reset_auth_disconnects_github_and_increments_auth_version +reset_auth_rejects_an_unknown_user +connection_url_uses_public_base_and_percent_encoding +plaintext_token_is_never_written_to_sqlite +``` + +- [ ] **Step 2: Run token tests and verify red** + +Run: `cargo test --test connection_tokens` + +Expected: FAIL because `connection_tokens` does not exist. + +- [ ] **Step 3: Implement token generation, hashing, validation, and URLs** + +In `src/connection_tokens.rs`, expose: + +```rust +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum ConnectionPurpose { Invite, Recovery } + +pub struct IssuedConnection { + pub user_id: String, + pub plaintext_token: String, + pub expires_at: DateTime, +} + +pub struct ConnectionClaim { + pub token_id: String, + pub user_id: String, + pub purpose: ConnectionPurpose, +} + +pub async fn issue_invitation( + pool: &SqlitePool, + name: &str, + now: DateTime, +) -> Result; + +pub async fn reset_auth( + pool: &SqlitePool, + user_id: &str, + now: DateTime, +) -> Result; + +pub async fn validate_token( + pool: &SqlitePool, + plaintext: &str, + now: DateTime, +) -> Result; + +pub fn connection_url(base: &Url, plaintext: &str) -> Url; +``` + +Generate 32 random bytes and base64url-encode without padding. Store `base64url(SHA-256(plaintext))`. Reject plaintext outside the exact generated length before querying. In one transaction, invalidate existing tokens with `consumed_at = now`, then insert the replacement expiring at `now + Duration::hours(4)`. + +`issue_invitation` creates a UUID user with `invite_code = NULL`, no email, and no GitHub identity. `reset_auth` requires an existing user, clears both GitHub fields, nulls the invite code, increments `auth_version`, updates `updated_at`, and then issues a recovery token in the same transaction. + +- [ ] **Step 4: Add CLI-facing wrappers** + +In `src/cli.rs` add functions that call the service with `Utc::now()` and return printable records rather than printing secrets internally: + +```rust +pub async fn invite_user( + pool: &SqlitePool, + name: &str, + public_base_url: &Url, +) -> Result<(String, Url), Box>; + +pub async fn reset_user_auth( + pool: &SqlitePool, + user_id: &str, + public_base_url: &Url, +) -> Result>; +``` + +The main binary prints the URL exactly once. Never log it through `tracing`. + +- [ ] **Step 5: Add commands and deprecate `create-user`** + +Parse `PUBLIC_BASE_URL` for URL-generating CLI commands without requiring GitHub credentials. Add: + +```text +interne invite-user +interne reset-auth +``` + +Keep `create-user [email]` as a deprecated alias for `invite-user `. Print a warning that email is ignored and the command will be removed. Update `help` with exact argument names and four-hour expiry. + +- [ ] **Step 6: Run token, CLI-unit, and full tests** + +Run: `cargo test --test connection_tokens` + +Expected: PASS. + +Run: `cargo test cli::tests` + +Expected: PASS. + +Run: `cargo test` + +Expected: all tests PASS. + +- [ ] **Step 7: Commit CLI recovery primitives** + +```bash +git add src/connection_tokens.rs src/lib.rs src/cli.rs src/main.rs tests/connection_tokens.rs +git commit -S -m "feat(auth): issue invitation and recovery links" +``` + +--- + +### Task 8: Complete invitation and recovery in the browser + +**Files:** +- Modify: `src/auth.rs` +- Modify: `src/connection_tokens.rs` +- Modify: `src/routes/auth.rs` +- Modify: `src/lib.rs` +- Modify: `templates/github_confirm.html` +- Modify: `templates/auth_error.html` +- Modify: `tests/connection_tokens.rs` +- Modify: `tests/github_auth.rs` + +**Interfaces:** +- Extends: `OAuthPurpose::ConnectionToken { token_id, user_id, purpose }` +- Extends: `ConnectionProof::Token { token_id, purpose }` +- Produces routes: `GET /recover`, `GET /auth/github/recover` +- Produces: `consume_and_link(pool, claim, profile, now) -> User` + +- [ ] **Step 1: Write complete recovery-flow tests** + +Add tests that exercise HTTP start, OAuth callback, confirmation, and replay: + +```rust +#[tokio::test] +async fn recovery_url_connects_confirmed_github_identity_and_logs_in() { + let app = TestApp::new().await; + let (user_id, github_id) = app.create_user("Axel").await; + let old_cookie = app.login(&github_id).await; + let issued = reset_auth(&app.db, &user_id, Utc::now()).await.unwrap(); + + let response = app.get( + &format!("/recover?token={}", issued.plaintext_token), + None, + ).await; + assert_redirect(&response, "/auth/github/recover"); + assert_eq!(response.headers()["referrer-policy"], "no-referrer"); + assert!(!response.headers()["location"].to_str().unwrap().contains(&issued.plaintext_token)); + + let new_cookie = app.finish_recovery_oauth(response, "900", "axelav").await; + assert_eq!(app.get("/", Some(&new_cookie)).await.status(), StatusCode::OK); + assert_redirect(&app.get("/", Some(&old_cookie)).await, "/login"); +} +``` + +Add exact tests named: + +```text +invitation_url_connects_the_precreated_user +recovery_token_is_not_consumed_before_confirmation +successful_confirmation_consumes_token +used_recovery_url_cannot_be_replayed +superseded_recovery_url_is_rejected +expired_recovery_url_is_rejected +token_confirmation_rechecks_expiration +token_confirmation_rejects_duplicate_github_identity +token_query_is_removed_before_redirecting_to_github +request_log_path_excludes_recovery_query_string +``` + +- [ ] **Step 2: Run recovery tests and verify red** + +Run: `cargo test --test connection_tokens recovery_url_connects_confirmed_github_identity_and_logs_in -- --exact` + +Expected: FAIL because `/recover` is not routed. + +- [ ] **Step 3: Add token-backed session purpose** + +Extend the serializable enums: + +```rust +pub enum OAuthPurpose { + Login, + Link { user_id: String }, + ConnectionToken { + token_id: String, + user_id: String, + purpose: ConnectionPurpose, + }, +} + +pub enum ConnectionProof { + LegacyInvite, + Token { + token_id: String, + purpose: ConnectionPurpose, + }, +} +``` + +Derive `Serialize` and `Deserialize` for `ConnectionPurpose`. Never store the plaintext token in session state. + +- [ ] **Step 4: Add the two-step clean recovery redirect** + +`GET /recover?token=...` must: + +1. hash and validate the token at the current time; +2. store the `ConnectionClaim` in the server-side session; +3. return `Referrer-Policy: no-referrer`; +4. redirect to `/auth/github/recover`, with no token in the location. + +`GET /auth/github/recover` removes the stored claim, revalidates the database token record, creates a token-purpose OAuth attempt, and redirects to GitHub. This clean intermediate URL ensures the token is absent from the GitHub request's referrer and from subsequent browser history entries. + +- [ ] **Step 5: Extend callback and confirmation** + +The callback stores a `PendingConnection` carrying only token ID, target user ID, purpose, fetched profile, and ten-minute expiration. + +Add to `src/connection_tokens.rs`: + +```rust +pub async fn consume_and_link( + pool: &SqlitePool, + claim: &ConnectionClaim, + profile: &GitHubProfile, + now: DateTime, +) -> Result; +``` + +In one transaction, reselect the token by ID and target user, require `consumed_at IS NULL` and `expires_at > now`, reject a GitHub ID owned by another user, update the target user's GitHub fields, null the legacy invite, increment `auth_version`, mark every active token for the target consumed, and reload the user. Require exactly one target token row to transition from unconsumed to consumed. On success, flush and cycle the session and call `login_user` with the new generation. + +- [ ] **Step 6: Sanitize request tracing** + +Replace the default trace span's URI field with path-only data for all routes. Add `fn trace_path(uri: &Uri) -> &str { uri.path() }` and test it with `/recover?token=secret` before wiring it into: + +```rust +.make_span_with(|request: &axum::http::Request<_>| { + tracing::info_span!( + "http_request", + method = %request.method(), + path = %request.uri().path(), + ) +}) +``` + +Keep the existing request and response log levels. The pure helper test must assert that `/recover?token=secret` is represented as `/recover`. + +- [ ] **Step 7: Render purpose-specific confirmation and safe errors** + +Use “Accept invitation” for invite tokens, “Recover account” for recovery tokens, and “Connect GitHub” for legacy migration. All invalid/expired/used/superseded token conditions render the same message and link back to login. Never render a token, token hash, OAuth code, provider body, or database error. + +- [ ] **Step 8: Run recovery, OAuth, and full tests** + +Run: `cargo test --test connection_tokens` + +Expected: PASS. + +Run: `cargo test --test github_auth` + +Expected: PASS. + +Run: `cargo test` + +Expected: all tests PASS. + +- [ ] **Step 9: Commit complete invitation and recovery flows** + +```bash +git add src/auth.rs src/connection_tokens.rs src/routes/auth.rs src/lib.rs templates/github_confirm.html templates/auth_error.html tests/connection_tokens.rs tests/github_auth.rs +git commit -S -m "feat(auth): recover accounts through GitHub" +``` + +--- + +### Task 9: Document configuration, rollout, and legacy cleanup + +**Files:** +- Modify: `.env.example` +- Modify: `docker-compose.yml` +- Modify: `README.md` +- Modify: `static/style.css` + +**Interfaces:** +- Documents: production OAuth registration, server configuration, closed/public modes, invitations, recovery, and legacy cleanup +- Preserves: no secrets committed to Git + +- [ ] **Step 1: Write documentation acceptance checks** + +Run these before editing and confirm at least one fails: + +```bash +rg -n "GITHUB_CLIENT_ID|GITHUB_CLIENT_SECRET|PUBLIC_BASE_URL|GITHUB_SIGNUP_MODE" .env.example README.md docker-compose.yml +rg -n "invite-user|reset-auth|webmaster@honkytonk.in" README.md +``` + +Expected: required configuration and commands are incomplete or absent. + +- [ ] **Step 2: Update example environment and local Compose** + +Add non-secret placeholders: + +```dotenv +GITHUB_CLIENT_ID=replace-with-github-oauth-client-id +GITHUB_CLIENT_SECRET=replace-with-github-oauth-client-secret +PUBLIC_BASE_URL=https://interne.example.com +GITHUB_SIGNUP_MODE=closed +``` + +Expose the same variable names in `docker-compose.yml`; use Compose substitution for credentials so no real secret enters the file. Keep `SECURE_COOKIES=false` as an explicitly documented local-only override when testing over HTTP. + +- [ ] **Step 3: Update README setup and operations** + +Document: + +```text +Production homepage: https://interne.honkytonk.in +Production callback: https://interne.honkytonk.in/auth/github/callback +Scopes: leave blank +Closed invitation: interne invite-user "Name" +Emergency recovery: interne reset-auth +Open signup: set GITHUB_SIGNUP_MODE=public and restart +``` + +Explain that invitation/recovery URLs last four hours, work once, and must be sent manually. Explain that `reset-auth` immediately logs out every session and allows the holder of the URL to attach a different GitHub identity. Document the exact closed-mode contact copy. For local OAuth testing, document a separate GitHub OAuth app using `http://127.0.0.1:3000/auth/github/callback`; GitHub OAuth apps accept only one configured callback URL. + +Add a rollout checklist: back up SQLite, configure the OAuth app, deploy in closed mode, use the existing invite code once to connect `axelav`, log out, verify the same entries after GitHub login, and retain `reset-auth` for emergencies. + +- [ ] **Step 4: Finish auth-page styling** + +Use existing `--black`, `--gray-*`, `--border`, and `--radius` tokens. Verify `.oauth-button`, `.auth-divider`, `.auth-status`, and confirmation forms work at 320px width and do not introduce external icons, fonts, or JavaScript. + +- [ ] **Step 5: Run documentation checks** + +Run: + +```bash +rg -n "GITHUB_CLIENT_ID|GITHUB_CLIENT_SECRET|PUBLIC_BASE_URL|GITHUB_SIGNUP_MODE" .env.example README.md docker-compose.yml +rg -n "invite-user|reset-auth|webmaster@honkytonk.in" README.md +``` + +Expected: every required term is present in the appropriate setup or operations section. + +- [ ] **Step 6: Run final automated verification** + +Run: `cargo fmt --check` + +Expected: PASS. + +Run: `cargo clippy --all-targets --all-features -- -D warnings` + +Expected: PASS. + +Run: `cargo test` + +Expected: all tests PASS. + +Run: `git diff --check` + +Expected: no output. + +- [ ] **Step 7: Perform local browser smoke test** + +With test OAuth credentials and a callback registered for the local environment, verify: + +```text +1. /login shows Continue with GitHub first. +2. A linked account reaches its existing entries. +3. An unknown account in closed mode sees the webmaster invitation message. +4. A legacy code cannot open entries and can connect only after confirmation. +5. A generated invitation URL loses its token before leaving Interne. +6. A used URL fails safely. +7. Logout redirects to /login. +``` + +- [ ] **Step 8: Commit documentation and final styling** + +```bash +git add .env.example docker-compose.yml README.md static/style.css +git commit -S -m "docs(auth): document GitHub authentication" +``` + +- [ ] **Step 9: Review the complete branch** + +Run: `git log --oneline main..HEAD` + +Expected: the signed, incremental commits from Tasks 1–9 in dependency order. + +Run: `git status --short --branch` + +Expected: clean `feat/github-auth` worktree. + +Invoke `superpowers:requesting-code-review` and review the complete diff against `docs/superpowers/specs/2026-08-11-github-auth-design.md` before pushing. + +## Out-of-repository deployment prerequisite + +Before production deployment, update the `interne` service in the `honkytonk-infra` repository—on its own worktree and signed commit—to pass `GITHUB_CLIENT_ID`, `GITHUB_CLIENT_SECRET`, `PUBLIC_BASE_URL=https://interne.honkytonk.in`, and `GITHUB_SIGNUP_MODE=closed`. Store the client secret in that deployment's existing secret/environment mechanism; never commit it. This repository's branch does not modify a sibling repository. + +## Future Work + +- [ ] Remove the legacy invite-code route, conditional form, Rust model field, SQLite column, and deprecated `create-user` alias after every existing user has connected GitHub. + +## Implementation References + +- [GitHub OAuth web application flow](https://docs.github.com/en/apps/oauth-apps/building-oauth-apps/authorizing-oauth-apps) +- [GitHub OAuth app registration](https://docs.github.com/en/apps/oauth-apps/building-oauth-apps/creating-an-oauth-app) +- [SQLite generalized ALTER TABLE procedure](https://sqlite.org/lang_altertable.html#making_other_kinds_of_table_schema_changes) +- [SQLite foreign-key behavior for DROP TABLE](https://www.sqlite.org/foreignkeys.html#fk_schemacommands) From 3e99b93465c61d34cb355efea4a207f3029e3ff0 Mon Sep 17 00:00:00 2001 From: Axel Anderson Date: Tue, 11 Aug 2026 23:26:38 -0400 Subject: [PATCH 03/25] docs(auth): record SQLx migration requirement --- .../2026-08-11-sqlx-no-transaction.md | 45 +++++++++++++++++++ .../plans/2026-08-11-github-auth.md | 13 +++++- 2 files changed, 56 insertions(+), 2 deletions(-) create mode 100644 docs/research/2026-08-11-sqlx-no-transaction.md diff --git a/docs/research/2026-08-11-sqlx-no-transaction.md b/docs/research/2026-08-11-sqlx-no-transaction.md new file mode 100644 index 0000000..5013284 --- /dev/null +++ b/docs/research/2026-08-11-sqlx-no-transaction.md @@ -0,0 +1,45 @@ +# SQLx SQLite no-transaction migration support + +## Finding + +Yes. SQLx **0.9.0** supports SQLite migrations beginning with +`-- no-transaction`. This is the first released SQLx version containing the +SQLite implementation; SQLx 0.8.6 parses the flag but does not honor it for +SQLite. + +## Evidence + +- SQLx's [0.9.0 changelog](https://github.com/launchbadge/sqlx/blob/v0.9.0/CHANGELOG.md#added) + records PR [#4015](https://github.com/launchbadge/sqlx/pull/4015), + "feat(sqlite): `no_tx` migration support." The PR explains that the feature + is needed for SQLite statements such as `PRAGMA foreign_keys = ON|OFF` and + for migrations which need their own transaction boundaries. +- The [0.9.0 SQLite migrator source](https://github.com/launchbadge/sqlx/blob/v0.9.0/sqlx-sqlite/src/migrate.rs#L157-L176) + checks `migration.no_tx`; in that path it runs the migration directly on the + connection rather than calling `self.begin()`. The normal path still opens + and commits SQLx's transaction. The matching + [revert path](https://github.com/launchbadge/sqlx/blob/v0.9.0/sqlx-sqlite/src/migrate.rs#L201-L217) + does the same. +- In contrast, the [0.8.6 SQLite migrator](https://github.com/launchbadge/sqlx/blob/v0.8.6/sqlx-sqlite/src/migrate.rs#L129-L166) + unconditionally begins a transaction. Its migration parser recognizes the + flag ([source](https://github.com/launchbadge/sqlx/blob/v0.8.6/sqlx-core/src/migrate/source.rs#L126-L135)), + but the SQLite runner does not use it. + +## Upgrade compatibility + +The smallest released upgrade containing the capability is the breaking major +upgrade to `sqlx = "0.9"`; there is no SQLx 0.8.7+ release. SQLx 0.9.0 declares +Rust 1.94.0 in its [workspace manifest](https://github.com/launchbadge/sqlx/blob/v0.9.0/Cargo.toml#L25-L44). +This worktree currently uses Rust 1.90.0, so it cannot make that upgrade +without a toolchain upgrade. The project also depends on +`tower-sessions-sqlx-store`, so its SQLx compatibility must be checked during +any upgrade. + +## Recommendation + +Do not upgrade SQLx solely to obtain this feature while the application is on +Rust 1.90. Use a custom, one-time migration runner for migration 003 or revise +the migration to avoid the out-of-transaction PRAGMA requirement. If the +toolchain is raised to Rust 1.94+, upgrade both direct SQLx dependencies to +0.9, regenerate `Cargo.lock`, and run the complete test suite before adopting +the existing `-- no-transaction` migration design. diff --git a/docs/superpowers/plans/2026-08-11-github-auth.md b/docs/superpowers/plans/2026-08-11-github-auth.md index 8b9a2d4..dd32ad2 100644 --- a/docs/superpowers/plans/2026-08-11-github-auth.md +++ b/docs/superpowers/plans/2026-08-11-github-auth.md @@ -6,7 +6,7 @@ **Architecture:** Add a mockable GitHub provider at the application boundary, keep OAuth and connection state in server-side sessions, and identify users by GitHub's stable numeric ID. A dedicated connection-token module owns four-hour invitation and recovery credentials; protected routes continue to depend only on `AuthUser`. -**Tech Stack:** Rust 1.90, Axum 0.8, SQLite via sqlx 0.8, Askama 0.15, tower-sessions 0.15, reqwest 0.12 with rustls, SHA-256, PKCE S256, GitHub OAuth web flow +**Tech Stack:** Rust 1.94, Axum 0.8, SQLite via sqlx 0.9, Askama 0.15, tower-sessions 0.15, reqwest 0.12 with rustls, SHA-256, PKCE S256, GitHub OAuth web flow ## Global Constraints @@ -59,7 +59,16 @@ **Files:** - Create: `migrations/003_github_auth.sql` - Create: `tests/migrations.rs` -- Modify: `src/models/user.rs` +- Modify: `src/models/user.rs`, `Cargo.toml`, `Cargo.lock`, and `Dockerfile` +- Add: `docs/research/2026-08-11-sqlx-no-transaction.md` + +**Compatibility upgrade checkpoint:** Upgrade both direct SQLx declarations to +0.9, raise the Docker builder to Rust 1.94, and pin +`tower-sessions-sqlx-store` to an SQLx 0.9-compatible upstream commit. SQLx +0.9 is required because its SQLite migrator honors `-- no-transaction`, which +allows migration 003 to set `PRAGMA foreign_keys = OFF` before opening its own +transaction. Regenerate `Cargo.lock` and complete the focused migration and +full regression suites after the upgrade. **Interfaces:** - Produces: `User { invite_code: Option, github_user_id: Option, github_login: Option, auth_version: i64 }` From b081230aa659bb4a213b70abbf28d49e8419d863 Mon Sep 17 00:00:00 2001 From: Axel Anderson Date: Wed, 12 Aug 2026 09:04:51 -0400 Subject: [PATCH 04/25] chore(deps): upgrade SQLx for SQLite migrations --- Cargo.lock | 1304 +++++++++++++---------------------------- Cargo.toml | 6 +- Dockerfile | 2 +- src/cli.rs | 2 +- src/db.rs | 8 +- src/routes/entries.rs | 16 +- tests/entries.rs | 6 +- 7 files changed, 432 insertions(+), 912 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 9ad0697..518c005 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -10,24 +10,18 @@ checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" [[package]] name = "android_system_properties" -version = "0.1.5" +version = "0.1.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" +checksum = "ae221649c9976a6f6c56ae1facf410f3ddb33cc661c4b7b61020a912d4237fbc" dependencies = [ "libc", ] -[[package]] -name = "anyhow" -version = "1.0.101" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5f0e0fee31ef5ed1ba1316088939cea399010ed7731dba877ed44aeb407a75ea" - [[package]] name = "askama" -version = "0.15.4" +version = "0.15.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "08e1676b346cadfec169374f949d7490fd80a24193d37d2afce0c047cf695e57" +checksum = "9b8246bcbf8eb97abef10c2d92166449680d41d55c0fc6978a91dec2e3619608" dependencies = [ "askama_macros", "itoa", @@ -38,9 +32,9 @@ dependencies = [ [[package]] name = "askama_derive" -version = "0.15.4" +version = "0.15.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7661ff56517787343f376f75db037426facd7c8d3049cef8911f1e75016f3a37" +checksum = "2f9670bc84a28bb3da91821ef74226949ab63f1265aff7c751634f1dd0e6f97c" dependencies = [ "askama_parser", "basic-toml", @@ -50,23 +44,23 @@ dependencies = [ "rustc-hash", "serde", "serde_derive", - "syn", + "syn 2.0.119", ] [[package]] name = "askama_macros" -version = "0.15.4" +version = "0.15.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "713ee4dbfd1eb719c2dab859465b01fa1d21cb566684614a713a6b7a99a4e47b" +checksum = "f0756b45480437dded0565dfc568af62ccce146fb6cfe902e808ba86e445f44f" dependencies = [ "askama_derive", ] [[package]] name = "askama_parser" -version = "0.15.4" +version = "0.15.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1d62d674238a526418b30c0def480d5beadb9d8964e7f38d635b03bf639c704c" +checksum = "5d0af3691ba3af77949c0b5a3925444b85cb58a0184cc7fec16c68ba2e7be868" dependencies = [ "rustc-hash", "serde", @@ -77,13 +71,13 @@ dependencies = [ [[package]] name = "async-trait" -version = "0.1.89" +version = "0.1.92" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" +checksum = "82f6aeea286b8eb4dd3431a1be1b59d290ace00f5bfd8e2a159bc2a05e2c1667" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 3.0.3", ] [[package]] @@ -103,15 +97,15 @@ checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" [[package]] name = "autocfg" -version = "1.5.0" +version = "1.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" [[package]] name = "axum" -version = "0.8.8" +version = "0.8.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8b52af3cb4058c895d37317bb27508dccc8e5f2d39454016b297bf4a400597b8" +checksum = "31b698c5f9a010f6573133b09e0de5408834d0c82f8d7475a89fc1867a71cd90" dependencies = [ "axum-core", "bytes", @@ -165,12 +159,6 @@ version = "0.22.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" -[[package]] -name = "base64ct" -version = "1.8.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" - [[package]] name = "basic-toml" version = "0.1.10" @@ -182,9 +170,9 @@ dependencies = [ [[package]] name = "bitflags" -version = "2.11.0" +version = "2.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "843867be96c8daad0d758b57df9392b6d8d271134fce549de6ce169ff98a92af" +checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da" dependencies = [ "serde_core", ] @@ -198,11 +186,20 @@ dependencies = [ "generic-array", ] +[[package]] +name = "block-buffer" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2f6c7dbe95a6ed67ad9f18e57daf93a2f034c524b99fd2b76d18fdfeb6660aa" +dependencies = [ + "hybrid-array", +] + [[package]] name = "bumpalo" -version = "3.19.1" +version = "3.20.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5dd9dc738b7a8311c7ade152424974d8115f2cdad61e8dab8dac9f2362298510" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" [[package]] name = "byteorder" @@ -212,15 +209,15 @@ checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" [[package]] name = "bytes" -version = "1.11.1" +version = "1.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" +checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" [[package]] name = "cc" -version = "1.2.56" +version = "1.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "aebf35691d1bfb0ac386a69bac2fde4dd276fb618cf8bf4f5318fe285e821bb2" +checksum = "5d262e149917187838d5b42777c8253bcb64500067342904e7d429499a6f277e" dependencies = [ "find-msvc-tools", "shlex", @@ -232,11 +229,22 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" +[[package]] +name = "chacha20" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d524456ba66e72eb8b115ff89e01e497f8e6d11d78b70b1aa13c0fbd97540a81" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.0", + "rand_core 0.10.1", +] + [[package]] name = "chrono" -version = "0.4.43" +version = "0.4.45" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fac4744fb15ae8337dc853fee7fb3f4e48c0fbaa23d0afe49c447b4fab126118" +checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327" dependencies = [ "iana-time-zone", "js-sys", @@ -247,25 +255,16 @@ dependencies = [ ] [[package]] -name = "concurrent-queue" -version = "2.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973" -dependencies = [ - "crossbeam-utils", -] - -[[package]] -name = "const-oid" -version = "0.9.6" +name = "cmov" +version = "0.5.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" +checksum = "0c9ea0ac24bc397ab3c98583a3c9ba74fa56b09a4449bbe172b9b1ddb016027a" [[package]] name = "cookie" -version = "0.18.1" +version = "0.18.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4ddef33a339a91ea89fb53151bd0a4689cfce27055c291dfa69945475d22c747" +checksum = "1a373e3602691c3cdea496d2f0ee5935151e6168fe87739483c463db1b2f2f87" dependencies = [ "percent-encoding", "time", @@ -287,6 +286,15 @@ dependencies = [ "libc", ] +[[package]] +name = "cpufeatures" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201" +dependencies = [ + "libc", +] + [[package]] name = "crc" version = "3.4.0" @@ -298,53 +306,59 @@ dependencies = [ [[package]] name = "crc-catalog" -version = "2.4.0" +version = "2.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "19d374276b40fb8bbdee95aef7c7fa6b5316ec764510eb64b8dd0e2ed0d7e7f5" +checksum = "217698eaf96b4a3f0bc4f3662aaa55bdf913cd54d7204591faa790070c6d0853" [[package]] name = "crossbeam-queue" -version = "0.3.12" +version = "0.3.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0f58bbc28f91df819d0aa2a2c00cd19754769c2fad90579b3592b1c9ba7a3115" +checksum = "803d13fb3b09d88be9f4dbc29062c66b19bf7170867ceb746d2a8689bf6c7a26" dependencies = [ "crossbeam-utils", ] [[package]] name = "crossbeam-utils" -version = "0.8.21" +version = "0.8.22" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" +checksum = "61803da095bee82a81bb1a452ecc25d3b2f1416d1897eb86430c6159ef717c17" [[package]] name = "crypto-common" -version = "0.1.7" +version = "0.1.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +checksum = "1bfb12502f3fc46cca1bb51ac28df9d618d813cdc3d2f25b9fe775a34af26bb3" dependencies = [ "generic-array", "typenum", ] [[package]] -name = "der" -version = "0.7.10" +name = "crypto-common" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453" +dependencies = [ + "hybrid-array", +] + +[[package]] +name = "ctutils" +version = "0.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" +checksum = "7d5515a3834141de9eafb9717ad39eea8247b5674e6066c404e8c4b365d2a29e" dependencies = [ - "const-oid", - "pem-rfc7468", - "zeroize", + "cmov", ] [[package]] name = "deranged" -version = "0.5.6" +version = "0.5.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cc3dc5ad92c2e2d1c193bbbbdf2ea477cb81331de4f3103f267ca18368b988c4" +checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" dependencies = [ - "powerfmt", "serde_core", ] @@ -354,21 +368,30 @@ version = "0.10.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" dependencies = [ - "block-buffer", - "const-oid", - "crypto-common", - "subtle", + "block-buffer 0.10.4", + "crypto-common 0.1.6", +] + +[[package]] +name = "digest" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2" +dependencies = [ + "block-buffer 0.12.1", + "crypto-common 0.2.2", + "ctutils", ] [[package]] name = "displaydoc" -version = "0.2.5" +version = "0.2.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" +checksum = "c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 3.0.3", ] [[package]] @@ -379,9 +402,9 @@ checksum = "1aaf95b3e5c8f23aa320147307562d361db0ae0d51242340f558153b4eb2439b" [[package]] name = "either" -version = "1.15.0" +version = "1.17.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" +checksum = "9e5e8f6c15a24b9a3ee5efec809ccd006d3b30e8b3bb63c39af737c7f87daa1d" dependencies = [ "serde", ] @@ -399,42 +422,40 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.61.2", + "windows-sys", ] [[package]] name = "etcetera" -version = "0.8.0" +version = "0.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "136d1b5283a1ab77bd9257427ffd09d8667ced0570b6f938942bc7568ed5b943" +checksum = "de48cc4d1c1d97a20fd819def54b890cadde72ed3ad0c614822a0a433361be96" dependencies = [ "cfg-if", - "home", - "windows-sys 0.48.0", + "windows-sys", ] [[package]] name = "event-listener" -version = "5.4.1" +version = "5.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e13b66accf52311f30a0db42147dadea9850cb48cd070028831ae5f5d4b856ab" +checksum = "5a23add41df1562121a9393cb065eab5146a1242410f23a644851e90cfd669d2" dependencies = [ - "concurrent-queue", "parking", "pin-project-lite", ] [[package]] name = "find-msvc-tools" -version = "0.1.9" +version = "0.1.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" +checksum = "26b73573e6edcd2af0cdf47bd6cb58f0b3839491263c314eaad1ccf24430e1de" [[package]] name = "flume" -version = "0.11.1" +version = "0.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "da0e4dd2a88388a1f4ccc7c9ce104604dab68d9f408dc34cd45823d5a9069095" +checksum = "5e139bc46ca777eb5efaf62df0ab8cc5fd400866427e56c68b22e414e53bd3be" dependencies = [ "futures-core", "futures-sink", @@ -443,9 +464,9 @@ dependencies = [ [[package]] name = "foldhash" -version = "0.1.5" +version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" +checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" [[package]] name = "form_urlencoded" @@ -458,9 +479,9 @@ dependencies = [ [[package]] name = "futures" -version = "0.3.32" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8b147ee9d1f6d097cef9ce628cd2ee62288d963e16fb287bd9286455b241382d" +checksum = "9a31d2a3fbaaeb2af2368bbdd904aa8e812d3c04a1ee10d3171f52d556e5d0a3" dependencies = [ "futures-channel", "futures-core", @@ -472,9 +493,9 @@ dependencies = [ [[package]] name = "futures-channel" -version = "0.3.32" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" +checksum = "b1f9e3d69d39e4862ffed03ed071a76f9a13ba1d9109d355b0f0aa6b15e393c4" dependencies = [ "futures-core", "futures-sink", @@ -482,15 +503,15 @@ dependencies = [ [[package]] name = "futures-core" -version = "0.3.32" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" +checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e" [[package]] name = "futures-executor" -version = "0.3.32" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "baf29c38818342a3b26b5b923639e7b1f4a61fc5e76102d4b1981c6dc7a7579d" +checksum = "031b47cf1a3c6cc8bc2fc76cd437f521619387907d469316e7c0bc278f1f5432" dependencies = [ "futures-core", "futures-task", @@ -510,38 +531,38 @@ dependencies = [ [[package]] name = "futures-io" -version = "0.3.32" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718" +checksum = "53c0fa8157de1303bfffdaa1cc2a673bfffb60102f76b0ef4441659124373fed" [[package]] name = "futures-macro" -version = "0.3.32" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b" +checksum = "9fb9654ba8355388abeb8dcb4fc62f511300867002afc858860463bdd9fe0c44" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 3.0.3", ] [[package]] name = "futures-sink" -version = "0.3.32" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893" +checksum = "1944426bf7d03f1d14f708785e4b33efd750b36d48a157b836b3efc15ede8e1d" [[package]] name = "futures-task" -version = "0.3.32" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" +checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd" [[package]] name = "futures-util" -version = "0.3.32" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" +checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc" dependencies = [ "futures-core", "futures-io", @@ -555,25 +576,14 @@ dependencies = [ [[package]] name = "generic-array" -version = "0.14.7" +version = "0.14.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +checksum = "4bb6743198531e02858aeaea5398fcc883e71851fcbcb5a2f773e2fb6cb1edf2" dependencies = [ "typenum", "version_check", ] -[[package]] -name = "getrandom" -version = "0.2.17" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" -dependencies = [ - "cfg-if", - "libc", - "wasi", -] - [[package]] name = "getrandom" version = "0.3.4" @@ -582,28 +592,27 @@ checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" dependencies = [ "cfg-if", "libc", - "r-efi", + "r-efi 5.3.0", "wasip2", ] [[package]] name = "getrandom" -version = "0.4.1" +version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "139ef39800118c7683f2fd3c98c1b23c09ae076556b435f8e9064ae108aaeeec" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" dependencies = [ "cfg-if", "libc", - "r-efi", - "wasip2", - "wasip3", + "r-efi 6.0.0", + "rand_core 0.10.1", ] [[package]] name = "hashbrown" -version = "0.15.5" +version = "0.16.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" dependencies = [ "allocator-api2", "equivalent", @@ -612,17 +621,17 @@ dependencies = [ [[package]] name = "hashbrown" -version = "0.16.1" +version = "0.17.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" [[package]] name = "hashlink" -version = "0.10.0" +version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7382cf6263419f2d8df38c55d7da83da5c18aef87fc7a7fc1fb1e344edfe14c1" +checksum = "824e001ac4f3012dd16a264bec811403a67ca9deb6c102fc5049b32c4574b35f" dependencies = [ - "hashbrown 0.15.5", + "hashbrown 0.16.1", ] [[package]] @@ -639,36 +648,27 @@ checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" [[package]] name = "hkdf" -version = "0.12.4" +version = "0.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7b5f8eb2ad728638ea2c7d47a21db23b7b58a72ed6a38256b8a1849f15fbbdf7" +checksum = "4aaa26c720c68b866f2c96ef5c1264b3e6f473fe5d4ce61cd44bbe913e553018" dependencies = [ "hmac", ] [[package]] name = "hmac" -version = "0.12.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" -dependencies = [ - "digest", -] - -[[package]] -name = "home" -version = "0.5.12" +version = "0.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cc627f471c528ff0c4a49e1d5e60450c8f6461dd6d10ba9dcd3a61d3dff7728d" +checksum = "6303bc9732ae41b04cb554b844a762b4115a61bfaa81e3e83050991eeb56863f" dependencies = [ - "windows-sys 0.61.2", + "digest 0.11.3", ] [[package]] name = "http" -version = "1.4.0" +version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e3ba2a386d7f85a81f119ad7498ebe444d2e22c2af0b86b069416ace48b3311a" +checksum = "918d3568bebf352712bc2ef3d46a8bcf1a75b373be6539de198e9105cbbf9ce0" dependencies = [ "bytes", "itoa", @@ -676,9 +676,9 @@ dependencies = [ [[package]] name = "http-body" -version = "1.0.1" +version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" +checksum = "ca2a8f2913ee65f60facd6a5905613afaa448497a0230cc41ce022d93290bc2c" dependencies = [ "bytes", "http", @@ -686,9 +686,9 @@ dependencies = [ [[package]] name = "http-body-util" -version = "0.1.3" +version = "0.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" +checksum = "e9f41fd6a08e4d4ec69df65976da761afd5ad5e58a9d4acb46bd1c953a9e3ff2" dependencies = [ "bytes", "futures-core", @@ -715,11 +715,20 @@ version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" +[[package]] +name = "hybrid-array" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "707114b52a152fa7bdb290cd7cd5912d9467273b6d74e21b8d81aca1f8533f6b" +dependencies = [ + "typenum", +] + [[package]] name = "hyper" -version = "1.8.1" +version = "1.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2ab2d4f250c3d7b1c9fcdff1cece94ea4e2dfbec68614f7b87cb205f24ca9d11" +checksum = "d22053281f852e11534f5198498373cbb59295120a20771d90f7ed1897490a72" dependencies = [ "atomic-waker", "bytes", @@ -731,7 +740,6 @@ dependencies = [ "httpdate", "itoa", "pin-project-lite", - "pin-utils", "smallvec", "tokio", ] @@ -777,12 +785,13 @@ dependencies = [ [[package]] name = "icu_collections" -version = "2.1.1" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4c6b649701667bbe825c3b7e6388cb521c23d88644678e83c0c4d0a621a34b43" +checksum = "2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c" dependencies = [ "displaydoc", "potential_utf", + "utf8_iter", "yoke", "zerofrom", "zerovec", @@ -790,9 +799,9 @@ dependencies = [ [[package]] name = "icu_locale_core" -version = "2.1.1" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "edba7861004dd3714265b4db54a3c390e880ab658fec5f7db895fae2046b5bb6" +checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29" dependencies = [ "displaydoc", "litemap", @@ -803,9 +812,9 @@ dependencies = [ [[package]] name = "icu_normalizer" -version = "2.1.1" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5f6c8828b67bf8908d82127b2054ea1b4427ff0230ee9141c54251934ab1b599" +checksum = "c56e5ee99d6e3d33bd91c5d85458b6005a22140021cc324cea84dd0e72cff3b4" dependencies = [ "icu_collections", "icu_normalizer_data", @@ -817,15 +826,15 @@ dependencies = [ [[package]] name = "icu_normalizer_data" -version = "2.1.1" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7aedcccd01fc5fe81e6b489c15b247b8b0690feb23304303a9e560f37efc560a" +checksum = "da3be0ae77ea334f4da67c12f149704f19f81d1adf7c51cf482943e84a2bad38" [[package]] name = "icu_properties" -version = "2.1.2" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "020bfc02fe870ec3a66d93e677ccca0562506e5872c650f893269e08615d74ec" +checksum = "bee3b67d0ea5c2cca5003417989af8996f8604e34fb9ddf96208a033901e70de" dependencies = [ "icu_collections", "icu_locale_core", @@ -837,15 +846,15 @@ dependencies = [ [[package]] name = "icu_properties_data" -version = "2.1.2" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "616c294cf8d725c6afcd8f55abc17c56464ef6211f9ed59cccffe534129c77af" +checksum = "8e2bbb201e0c04f7b4b3e14382af113e17ba4f63e2c9d2ee626b720cbce54a14" [[package]] name = "icu_provider" -version = "2.1.1" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "85962cf0ce02e1e0a629cc34e7ca3e373ce20dda4c4d7294bbd0bf1fdb59e614" +checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421" dependencies = [ "displaydoc", "icu_locale_core", @@ -856,12 +865,6 @@ dependencies = [ "zerovec", ] -[[package]] -name = "id-arena" -version = "2.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" - [[package]] name = "idna" version = "1.1.0" @@ -875,9 +878,9 @@ dependencies = [ [[package]] name = "idna_adapter" -version = "1.2.1" +version = "1.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3acae9609540aa318d1bc588455225fb2085b9ed0c4f6bd0d9d5bcd86f1a0344" +checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" dependencies = [ "icu_normalizer", "icu_properties", @@ -885,14 +888,12 @@ dependencies = [ [[package]] name = "indexmap" -version = "2.13.0" +version = "2.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7714e70437a7dc3ac8eb7e6f8df75fd8eb422675fc7678aff7364301092b1017" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" dependencies = [ "equivalent", - "hashbrown 0.16.1", - "serde", - "serde_core", + "hashbrown 0.17.1", ] [[package]] @@ -921,17 +922,18 @@ dependencies = [ [[package]] name = "itoa" -version = "1.0.17" +version = "1.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" [[package]] name = "js-sys" -version = "0.3.85" +version = "0.3.104" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8c942ebf8e95485ca0d52d97da7c5a2c387d0e7f0ba4c35e93bfcaee045955b3" +checksum = "0e0c1080212aad755ea003d18543e8768dd432c48819efd73a7bf1e39b7a5a3a" dependencies = [ - "once_cell", + "cfg-if", + "futures-util", "wasm-bindgen", ] @@ -940,44 +942,18 @@ name = "lazy_static" version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" -dependencies = [ - "spin", -] - -[[package]] -name = "leb128fmt" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" [[package]] name = "libc" -version = "0.2.182" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6800badb6cb2082ffd7b6a67e6125bb39f18782f793520caee8cb8846be06112" - -[[package]] -name = "libm" -version = "0.2.16" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" - -[[package]] -name = "libredox" -version = "0.1.12" +version = "0.2.189" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3d0b95e02c851351f877147b7deea7b1afb1df71b63aa5f8270716e0c5720616" -dependencies = [ - "bitflags", - "libc", - "redox_syscall 0.7.1", -] +checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" [[package]] name = "libsqlite3-sys" -version = "0.30.1" +version = "0.37.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2e99fb7a497b1e3339bc746195567ed8d3e24945ecd636e3619d20b9de9e9149" +checksum = "b1f111c8c41e7c61a49cd34e44c7619462967221a6443b0ec299e0ac30cfb9b1" dependencies = [ "cc", "pkg-config", @@ -986,9 +962,9 @@ dependencies = [ [[package]] name = "litemap" -version = "0.8.1" +version = "0.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6373607a59f0be73a39b6fe456b8192fcc3585f602af20751600e974dd455e77" +checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" [[package]] name = "lock_api" @@ -1001,9 +977,9 @@ dependencies = [ [[package]] name = "log" -version = "0.4.29" +version = "0.4.33" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" +checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad" [[package]] name = "matchit" @@ -1013,19 +989,19 @@ checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3" [[package]] name = "md-5" -version = "0.10.6" +version = "0.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d89e7ee0cfbedfc4da3340218492196241d89eefb6dab27de5df917a6d2e78cf" +checksum = "69b6441f590336821bb897fb28fc622898ccceb1d6cea3fde5ea86b090c4de98" dependencies = [ "cfg-if", - "digest", + "digest 0.11.3", ] [[package]] name = "memchr" -version = "2.8.0" +version = "2.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" [[package]] name = "mime" @@ -1045,13 +1021,13 @@ dependencies = [ [[package]] name = "mio" -version = "1.1.1" +version = "1.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a69bcab0ad47271a0234d9422b131806bf3968021e5dc9328caf2d4cd58557fc" +checksum = "30d65c71f1ce40ab09135ce117d742b9f8a19ff91a41a8b57ed50bc2de59c427" dependencies = [ "libc", "wasi", - "windows-sys 0.61.2", + "windows-sys", ] [[package]] @@ -1060,50 +1036,14 @@ version = "0.50.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" dependencies = [ - "windows-sys 0.61.2", -] - -[[package]] -name = "num-bigint-dig" -version = "0.8.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e661dda6640fad38e827a6d4a310ff4763082116fe217f279885c97f511bb0b7" -dependencies = [ - "lazy_static", - "libm", - "num-integer", - "num-iter", - "num-traits", - "rand 0.8.5", - "smallvec", - "zeroize", + "windows-sys", ] [[package]] name = "num-conv" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cf97ec579c3c42f953ef76dbf8d55ac91fb219dde70e49aa4a6b7d74e9919050" - -[[package]] -name = "num-integer" -version = "0.1.46" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f" -dependencies = [ - "num-traits", -] - -[[package]] -name = "num-iter" -version = "0.1.45" +version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1429034a0490724d0075ebb2bc9e875d6503c3cf69e235a8941aa757d83ef5bf" -dependencies = [ - "autocfg", - "num-integer", - "num-traits", -] +checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" [[package]] name = "num-traits" @@ -1112,14 +1052,13 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" dependencies = [ "autocfg", - "libm", ] [[package]] name = "once_cell" -version = "1.21.3" +version = "1.21.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" [[package]] name = "parking" @@ -1145,20 +1084,11 @@ checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" dependencies = [ "cfg-if", "libc", - "redox_syscall 0.5.18", + "redox_syscall", "smallvec", "windows-link", ] -[[package]] -name = "pem-rfc7468" -version = "0.7.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "88b39c9bfcfc231068454382784bb460aae594343fb030d46e9f50a645418412" -dependencies = [ - "base64ct", -] - [[package]] name = "percent-encoding" version = "2.3.2" @@ -1167,48 +1097,21 @@ checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" [[package]] name = "pin-project-lite" -version = "0.2.16" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3b3cff922bd51709b605d9ead9aa71031d81447142d828eb4a6eba76fe619f9b" - -[[package]] -name = "pin-utils" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" - -[[package]] -name = "pkcs1" -version = "0.7.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c8ffb9f10fa047879315e6625af03c164b16962a5368d724ed16323b68ace47f" -dependencies = [ - "der", - "pkcs8", - "spki", -] - -[[package]] -name = "pkcs8" -version = "0.10.2" +version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7" -dependencies = [ - "der", - "spki", -] +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" [[package]] name = "pkg-config" -version = "0.3.32" +version = "0.3.33" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c" +checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" [[package]] name = "potential_utf" -version = "0.1.4" +version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b73949432f5e2a09657003c25bca5e19a0e9c84f8058ca374f49e0ebe605af77" +checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564" dependencies = [ "zerovec", ] @@ -1228,30 +1131,20 @@ dependencies = [ "zerocopy", ] -[[package]] -name = "prettyplease" -version = "0.2.37" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" -dependencies = [ - "proc-macro2", - "syn", -] - [[package]] name = "proc-macro2" -version = "1.0.106" +version = "1.0.107" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" dependencies = [ "unicode-ident", ] [[package]] name = "quote" -version = "1.0.44" +version = "1.0.47" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "21b2ebcf727b7760c461f091f9f0f539b77b8e87f2fd88131e7f1b433b3cece4" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" dependencies = [ "proc-macro2", ] @@ -1263,34 +1156,30 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" [[package]] -name = "rand" -version = "0.8.5" +name = "r-efi" +version = "6.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404" -dependencies = [ - "libc", - "rand_chacha 0.3.1", - "rand_core 0.6.4", -] +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" [[package]] name = "rand" -version = "0.9.2" +version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1" +checksum = "b9ef1d0d795eb7d84685bca4f72f3649f064e6641543d3a8c415898726a57b41" dependencies = [ - "rand_chacha 0.9.0", + "rand_chacha", "rand_core 0.9.5", ] [[package]] -name = "rand_chacha" -version = "0.3.1" +name = "rand" +version = "0.10.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +checksum = "c7f5fa3a058cd35567ef9bfa5e75732bee0f9e4c55fa90477bef2dfcdbc4be80" dependencies = [ - "ppv-lite86", - "rand_core 0.6.4", + "chacha20", + "getrandom 0.4.3", + "rand_core 0.10.1", ] [[package]] @@ -1303,15 +1192,6 @@ dependencies = [ "rand_core 0.9.5", ] -[[package]] -name = "rand_core" -version = "0.6.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" -dependencies = [ - "getrandom 0.2.17", -] - [[package]] name = "rand_core" version = "0.9.5" @@ -1322,19 +1202,16 @@ dependencies = [ ] [[package]] -name = "redox_syscall" -version = "0.5.18" +name = "rand_core" +version = "0.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" -dependencies = [ - "bitflags", -] +checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" [[package]] name = "redox_syscall" -version = "0.7.1" +version = "0.5.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "35985aa610addc02e24fc232012c86fd11f14111180f902b67e2d5331f8ebf2b" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" dependencies = [ "bitflags", ] @@ -1358,37 +1235,17 @@ dependencies = [ "serde", ] -[[package]] -name = "rsa" -version = "0.9.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b8573f03f5883dcaebdfcf4725caa1ecb9c15b2ef50c43a07b816e06799bb12d" -dependencies = [ - "const-oid", - "digest", - "num-bigint-dig", - "num-integer", - "num-traits", - "pkcs1", - "pkcs8", - "rand_core 0.6.4", - "signature", - "spki", - "subtle", - "zeroize", -] - [[package]] name = "rustc-hash" -version = "2.1.1" +version = "2.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "357703d41365b4b27c590e3ed91eabb1b663f07c4c084095e60cbed4362dff0d" +checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d" [[package]] name = "rustversion" -version = "1.0.22" +version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" [[package]] name = "ryu" @@ -1402,17 +1259,11 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" -[[package]] -name = "semver" -version = "1.0.27" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d767eb0aabc880b29956c35734170f26ed551a859dbd361d140cdbeca61ab1e2" - [[package]] name = "serde" -version = "1.0.228" +version = "1.0.229" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" dependencies = [ "serde_core", "serde_derive", @@ -1420,29 +1271,29 @@ dependencies = [ [[package]] name = "serde_core" -version = "1.0.228" +version = "1.0.229" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" dependencies = [ "serde_derive", ] [[package]] name = "serde_derive" -version = "1.0.228" +version = "1.0.229" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 3.0.3", ] [[package]] name = "serde_json" -version = "1.0.149" +version = "1.0.151" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" +checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" dependencies = [ "itoa", "memchr", @@ -1476,13 +1327,13 @@ dependencies = [ [[package]] name = "sha1" -version = "0.10.6" +version = "0.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" +checksum = "aacc4cc499359472b4abe1bf11d0b12e688af9a805fa5e3016f9a386dc2d0214" dependencies = [ "cfg-if", - "cpufeatures", - "digest", + "cpufeatures 0.3.0", + "digest 0.11.3", ] [[package]] @@ -1492,8 +1343,19 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" dependencies = [ "cfg-if", - "cpufeatures", - "digest", + "cpufeatures 0.2.17", + "digest 0.10.7", +] + +[[package]] +name = "sha2" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "446ba717509524cb3f22f17ecc096f10f4822d76ab5c0b9822c5f9c284e825f4" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.0", + "digest 0.11.3", ] [[package]] @@ -1507,9 +1369,9 @@ dependencies = [ [[package]] name = "shlex" -version = "1.3.0" +version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" [[package]] name = "signal-hook-registry" @@ -1521,16 +1383,6 @@ dependencies = [ "libc", ] -[[package]] -name = "signature" -version = "2.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" -dependencies = [ - "digest", - "rand_core 0.6.4", -] - [[package]] name = "slab" version = "0.4.12" @@ -1539,47 +1391,37 @@ checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" [[package]] name = "smallvec" -version = "1.15.1" +version = "1.15.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" +checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" dependencies = [ "serde", ] [[package]] name = "socket2" -version = "0.6.2" +version = "0.6.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "86f4aa3ad99f2088c990dfa82d367e19cb29268ed67c574d10d0a4bfe71f07e0" +checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4" dependencies = [ "libc", - "windows-sys 0.60.2", + "windows-sys", ] [[package]] name = "spin" -version = "0.9.8" +version = "0.9.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6980e8d7511241f8acf4aebddbb1ff938df5eebe98691418c4468d0b72a96a67" +checksum = "3763264f6b73151db08c50ff20d7d8a0b8796e021cdea7ceedad07b80155fa0e" dependencies = [ "lock_api", ] -[[package]] -name = "spki" -version = "0.7.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d" -dependencies = [ - "base64ct", - "der", -] - [[package]] name = "sqlx" -version = "0.8.6" +version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1fefb893899429669dcdd979aff487bd78f4064e5e7907e4269081e0ef7d97dc" +checksum = "378620ccc25c62c89d8be1c819e76a88d59bdcc3304733330788948e619bfd71" dependencies = [ "sqlx-core", "sqlx-macros", @@ -1590,12 +1432,13 @@ dependencies = [ [[package]] name = "sqlx-core" -version = "0.8.6" +version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ee6798b1838b6a0f69c007c133b8df5866302197e404e8b6ee8ed3e3a5e68dc6" +checksum = "05b44e85bf579a8eeb4ceaa77a3a523baf2bf0e9bac7e40f405d537b5d2d5ccb" dependencies = [ "base64", "bytes", + "cfg-if", "crc", "crossbeam-queue", "either", @@ -1604,18 +1447,17 @@ dependencies = [ "futures-intrusive", "futures-io", "futures-util", - "hashbrown 0.15.5", + "hashbrown 0.16.1", "hashlink", "indexmap", "log", "memchr", - "once_cell", "percent-encoding", "serde", "serde_json", - "sha2", + "sha2 0.10.9", "smallvec", - "thiserror 2.0.18", + "thiserror", "time", "tokio", "tokio-stream", @@ -1625,90 +1467,75 @@ dependencies = [ [[package]] name = "sqlx-macros" -version = "0.8.6" +version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a2d452988ccaacfbf5e0bdbc348fb91d7c8af5bee192173ac3636b5fb6e6715d" +checksum = "bd2b84f2bc39a5705ef27ec785a11c934a41bbd4a24941e257927cddc26b60bf" dependencies = [ "proc-macro2", "quote", "sqlx-core", "sqlx-macros-core", - "syn", + "syn 2.0.119", ] [[package]] name = "sqlx-macros-core" -version = "0.8.6" +version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "19a9c1841124ac5a61741f96e1d9e2ec77424bf323962dd894bdb93f37d5219b" +checksum = "fb8d96de5fdc85a5c4ec813432b523ec637e80ba98f046555f75f7908ddac7c3" dependencies = [ + "cfg-if", "dotenvy", "either", "heck", "hex", - "once_cell", "proc-macro2", "quote", "serde", "serde_json", - "sha2", + "sha2 0.10.9", "sqlx-core", "sqlx-mysql", "sqlx-postgres", "sqlx-sqlite", - "syn", + "syn 2.0.119", + "thiserror", "tokio", "url", ] [[package]] name = "sqlx-mysql" -version = "0.8.6" +version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "aa003f0038df784eb8fecbbac13affe3da23b45194bd57dba231c8f48199c526" +checksum = "90b8020fe17c5f2c245bfa2505d7ef59c5604839527c740266ad2214acebea27" dependencies = [ - "atoi", - "base64", "bitflags", "byteorder", "bytes", "crc", - "digest", + "digest 0.11.3", "dotenvy", "either", - "futures-channel", "futures-core", - "futures-io", "futures-util", "generic-array", - "hex", - "hkdf", - "hmac", - "itoa", "log", - "md-5", - "memchr", - "once_cell", "percent-encoding", - "rand 0.8.5", - "rsa", "serde", "sha1", - "sha2", - "smallvec", + "sha2 0.11.0", "sqlx-core", - "stringprep", - "thiserror 2.0.18", + "thiserror", "time", "tracing", - "whoami", ] [[package]] name = "sqlx-postgres" -version = "0.8.6" +version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "db58fcd5a53cf07c184b154801ff91347e4c30d17a3562a635ff028ad5deda46" +checksum = "87a2bdd6e83f6b3ea525ca9fee568030508b58355a43d0b2c1674d5f79dcd65e" dependencies = [ "atoi", "base64", @@ -1723,20 +1550,18 @@ dependencies = [ "hex", "hkdf", "hmac", - "home", "itoa", "log", "md-5", "memchr", - "once_cell", - "rand 0.8.5", + "rand 0.10.2", "serde", "serde_json", - "sha2", + "sha2 0.11.0", "smallvec", "sqlx-core", "stringprep", - "thiserror 2.0.18", + "thiserror", "time", "tracing", "whoami", @@ -1744,12 +1569,13 @@ dependencies = [ [[package]] name = "sqlx-sqlite" -version = "0.8.6" +version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c2d12fe70b2c1b4401038055f90f151b78208de1f9f89a7dbfd41587a10c3eea" +checksum = "488e99c397a62007e4229aec669a179816339afc6d2620ca6fa420dbee2e982c" dependencies = [ "atoi", "flume", + "form_urlencoded", "futures-channel", "futures-core", "futures-executor", @@ -1759,9 +1585,8 @@ dependencies = [ "log", "percent-encoding", "serde", - "serde_urlencoded", "sqlx-core", - "thiserror 2.0.18", + "thiserror", "time", "tracing", "url", @@ -1785,16 +1610,21 @@ dependencies = [ ] [[package]] -name = "subtle" -version = "2.6.1" +name = "syn" +version = "2.0.119" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] [[package]] name = "syn" -version = "2.0.116" +version = "3.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3df424c70518695237746f84cede799c9c58fcb37450d7b23716568cc8bc69cb" +checksum = "53e9bae58849f64dfa4f5d5ae372c8341f7305f82a3868709269343628b659a3" dependencies = [ "proc-macro2", "quote", @@ -1815,66 +1645,45 @@ checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" dependencies = [ "proc-macro2", "quote", - "syn", -] - -[[package]] -name = "thiserror" -version = "1.0.69" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" -dependencies = [ - "thiserror-impl 1.0.69", + "syn 2.0.119", ] [[package]] name = "thiserror" -version = "2.0.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" -dependencies = [ - "thiserror-impl 2.0.18", -] - -[[package]] -name = "thiserror-impl" -version = "1.0.69" +version = "2.0.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +checksum = "ec86235f5fcc2a73650310756d2ac5b138a5780bbbdfae3eeccec992c435ba4f" dependencies = [ - "proc-macro2", - "quote", - "syn", + "thiserror-impl", ] [[package]] name = "thiserror-impl" -version = "2.0.18" +version = "2.0.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" +checksum = "bc04cd3e1236dd4a98afca4569f2deb3f120e5422a4023be2cb683f8486292af" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 3.0.3", ] [[package]] name = "thread_local" -version = "1.1.9" +version = "1.1.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f60246a4944f24f6e018aa17cdeffb7818b76356965d03b07d6a9886e8962185" +checksum = "1ad99c4c6d32803332c548b1af0540b357b3f5fc0be8f6c6bfe8b2e6ae784070" dependencies = [ "cfg-if", ] [[package]] name = "time" -version = "0.3.47" +version = "0.3.55" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "743bd48c283afc0388f9b8827b976905fb217ad9e647fae3a379a9283c4def2c" +checksum = "cdb87b95ec50ddfa440816d227a17b2ccbdda963a316a727fda0fc4334f7d134" dependencies = [ "deranged", - "itoa", "num-conv", "powerfmt", "serde_core", @@ -1884,15 +1693,15 @@ dependencies = [ [[package]] name = "time-core" -version = "0.1.8" +version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7694e1cfe791f8d31026952abf09c69ca6f6fa4e1a1229e18988f06a04a12dca" +checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109" [[package]] name = "time-macros" -version = "0.2.27" +version = "0.2.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2e70e4c5a0e0a8a4823ad65dfe1a6930e4f4d756dcd9dd7939022b5e8c501215" +checksum = "7e689342a48d2ea927c87ea50cabf8594854bf940e9310208848d680d668ed85" dependencies = [ "num-conv", "time-core", @@ -1900,9 +1709,9 @@ dependencies = [ [[package]] name = "tinystr" -version = "0.8.2" +version = "0.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "42d3e9c45c09de15d06dd8acf5f4e0e399e85927b7f00711024eb7ae10fa4869" +checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d" dependencies = [ "displaydoc", "zerovec", @@ -1910,9 +1719,9 @@ dependencies = [ [[package]] name = "tinyvec" -version = "1.10.0" +version = "1.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bfa5fdc3bce6191a1dbc8c02d5c8bffcf557bafa17c124c5264a458f1b0613fa" +checksum = "bb4ebadaa0af04fab11ae01eb5f9fdb5f9c5b875506e210e71c07873528baa7f" dependencies = [ "tinyvec_macros", ] @@ -1925,9 +1734,9 @@ checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" [[package]] name = "tokio" -version = "1.49.0" +version = "1.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "72a2903cd7736441aac9df9d7688bd0ce48edccaadf181c3b90be801e81d3d86" +checksum = "202caea871b69668250d242070849eb495be178ed697a3e98aebce5bc81a0bed" dependencies = [ "bytes", "libc", @@ -1937,25 +1746,25 @@ dependencies = [ "signal-hook-registry", "socket2", "tokio-macros", - "windows-sys 0.61.2", + "windows-sys", ] [[package]] name = "tokio-macros" -version = "2.6.0" +version = "2.7.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "af407857209536a95c8e56f8231ef2c2e2aff839b22e07a1ffcbc617e9db9fa5" +checksum = "78773a2a397f451582ce068015985c33193cf6dea8b74d2a639fe457b2f07b0e" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 3.0.3", ] [[package]] name = "tokio-stream" -version = "0.1.18" +version = "0.1.19" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32da49809aab5c3bc678af03902d4ccddea2a87d028d86392a4b1560c6906c70" +checksum = "a3d06f0b082ba57c26b79407372e57cf2a1e28124f78e9479fe80322cf53420b" dependencies = [ "futures-core", "pin-project-lite", @@ -1964,9 +1773,9 @@ dependencies = [ [[package]] name = "tokio-util" -version = "0.7.18" +version = "0.7.19" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098" +checksum = "494815d09bf52b5548659851081238f0ca39ff638363907596da739561c62c52" dependencies = [ "bytes", "futures-core", @@ -2009,9 +1818,9 @@ dependencies = [ [[package]] name = "tower-http" -version = "0.6.8" +version = "0.6.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d4e6559d53cc268e5031cd8429d05415bc4cb4aefc4aa5d6cc35fbf5b924a1f8" +checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840" dependencies = [ "bitflags", "bytes", @@ -2075,10 +1884,10 @@ dependencies = [ "futures", "http", "parking_lot", - "rand 0.9.2", + "rand 0.9.5", "serde", "serde_json", - "thiserror 2.0.18", + "thiserror", "time", "tokio", "tracing", @@ -2099,12 +1908,12 @@ dependencies = [ [[package]] name = "tower-sessions-sqlx-store" version = "0.15.0" -source = "git+https://github.com/maxcountryman/tower-sessions-stores?rev=be0f230f#be0f230fadb4ca0496d4cabc946ae7778eee37e6" +source = "git+https://github.com/maxcountryman/tower-sessions-stores?rev=d18c9bf76f1d4fb73130dbe5aa643197f14b5d2d#d18c9bf76f1d4fb73130dbe5aa643197f14b5d2d" dependencies = [ "async-trait", "rmp-serde", "sqlx", - "thiserror 1.0.69", + "thiserror", "time", "tower-sessions-core", ] @@ -2129,7 +1938,7 @@ checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.119", ] [[package]] @@ -2155,9 +1964,9 @@ dependencies = [ [[package]] name = "tracing-subscriber" -version = "0.3.22" +version = "0.3.23" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2f30143827ddab0d256fd843b7a66d164e9f271cfa0dde49142c5ca0ca291f1e" +checksum = "cb7f578e5945fb242538965c2d0b04418d38ec25c79d160cd279bf0731c8d319" dependencies = [ "nu-ansi-term", "sharded-slab", @@ -2169,9 +1978,9 @@ dependencies = [ [[package]] name = "typenum" -version = "1.19.0" +version = "1.20.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" [[package]] name = "unicase" @@ -2206,12 +2015,6 @@ version = "0.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7df058c713841ad818f1dc5d3fd88063241cc61f49f5fbea4b951e8cf5a8d71d" -[[package]] -name = "unicode-xid" -version = "0.2.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" - [[package]] name = "url" version = "2.5.8" @@ -2232,11 +2035,11 @@ checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" [[package]] name = "uuid" -version = "1.21.0" +version = "1.24.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b672338555252d43fd2240c714dc444b8c6fb0a5c5335e65a07bba7742735ddb" +checksum = "bf3923a6f5c4c6382e0b653c4117f48d631ea17f38ed86e2a828e6f7412f5239" dependencies = [ - "getrandom 0.4.1", + "getrandom 0.4.3", "js-sys", "wasm-bindgen", ] @@ -2267,33 +2070,18 @@ checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" [[package]] name = "wasip2" -version = "1.0.2+wasi-0.2.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9517f9239f02c069db75e65f174b3da828fe5f5b945c4dd26bd25d89c03ebcf5" -dependencies = [ - "wit-bindgen", -] - -[[package]] -name = "wasip3" -version = "0.4.0+wasi-0.3.0-rc-2026-01-06" +version = "1.0.4+wasi-0.2.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5" +checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487" dependencies = [ "wit-bindgen", ] -[[package]] -name = "wasite" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b8dad83b4f25e74f184f64c43b150b91efe7647395b42289f38e50566d82855b" - [[package]] name = "wasm-bindgen" -version = "0.2.108" +version = "0.2.127" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "64024a30ec1e37399cf85a7ffefebdb72205ca1c972291c51512360d90bd8566" +checksum = "1b70935747edd64d89de3efa29d73789b806c15798f8e7dca4d8ac356b50ce70" dependencies = [ "cfg-if", "once_cell", @@ -2304,9 +2092,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro" -version = "0.2.108" +version = "0.2.127" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "008b239d9c740232e71bd39e8ef6429d27097518b6b30bdf9086833bd5b6d608" +checksum = "77775f8f3f7217702089053b94958f8f54061a3f663417df76e19cbdcca29bc1" dependencies = [ "quote", "wasm-bindgen-macro-support", @@ -2314,69 +2102,31 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro-support" -version = "0.2.108" +version = "0.2.127" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5256bae2d58f54820e6490f9839c49780dff84c65aeab9e772f15d5f0e913a55" +checksum = "e11d33f857dc2fb11b8bc75aee111aa9cbeb12cd9f25efd3d4c2a3dd4e235284" dependencies = [ "bumpalo", "proc-macro2", "quote", - "syn", + "syn 2.0.119", "wasm-bindgen-shared", ] [[package]] name = "wasm-bindgen-shared" -version = "0.2.108" +version = "0.2.127" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1f01b580c9ac74c8d8f0c0e4afb04eeef2acf145458e52c03845ee9cd23e3d12" +checksum = "7ef64dbcc55df09c7e5a46182d181c2cfa3e925f3da937ea764728b4bbb9dcbf" dependencies = [ "unicode-ident", ] -[[package]] -name = "wasm-encoder" -version = "0.244.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319" -dependencies = [ - "leb128fmt", - "wasmparser", -] - -[[package]] -name = "wasm-metadata" -version = "0.244.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" -dependencies = [ - "anyhow", - "indexmap", - "wasm-encoder", - "wasmparser", -] - -[[package]] -name = "wasmparser" -version = "0.244.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" -dependencies = [ - "bitflags", - "hashbrown 0.15.5", - "indexmap", - "semver", -] - [[package]] name = "whoami" -version = "1.6.1" +version = "2.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5d4a4db5077702ca3015d3d02d74974948aba2ad9e12ab7df718ee64ccd7e97d" -dependencies = [ - "libredox", - "wasite", -] +checksum = "626c4bac6755d76ffc12cb01b2eac751db1996b9e0041de9aa02c8c211ddc82c" [[package]] name = "windows-core" @@ -2399,7 +2149,7 @@ checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.119", ] [[package]] @@ -2410,7 +2160,7 @@ checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.119", ] [[package]] @@ -2437,24 +2187,6 @@ dependencies = [ "windows-link", ] -[[package]] -name = "windows-sys" -version = "0.48.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9" -dependencies = [ - "windows-targets 0.48.5", -] - -[[package]] -name = "windows-sys" -version = "0.60.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" -dependencies = [ - "windows-targets 0.53.5", -] - [[package]] name = "windows-sys" version = "0.61.2" @@ -2464,236 +2196,32 @@ dependencies = [ "windows-link", ] -[[package]] -name = "windows-targets" -version = "0.48.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c" -dependencies = [ - "windows_aarch64_gnullvm 0.48.5", - "windows_aarch64_msvc 0.48.5", - "windows_i686_gnu 0.48.5", - "windows_i686_msvc 0.48.5", - "windows_x86_64_gnu 0.48.5", - "windows_x86_64_gnullvm 0.48.5", - "windows_x86_64_msvc 0.48.5", -] - -[[package]] -name = "windows-targets" -version = "0.53.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" -dependencies = [ - "windows-link", - "windows_aarch64_gnullvm 0.53.1", - "windows_aarch64_msvc 0.53.1", - "windows_i686_gnu 0.53.1", - "windows_i686_gnullvm", - "windows_i686_msvc 0.53.1", - "windows_x86_64_gnu 0.53.1", - "windows_x86_64_gnullvm 0.53.1", - "windows_x86_64_msvc 0.53.1", -] - -[[package]] -name = "windows_aarch64_gnullvm" -version = "0.48.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8" - -[[package]] -name = "windows_aarch64_gnullvm" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" - -[[package]] -name = "windows_aarch64_msvc" -version = "0.48.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc" - -[[package]] -name = "windows_aarch64_msvc" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" - -[[package]] -name = "windows_i686_gnu" -version = "0.48.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e" - -[[package]] -name = "windows_i686_gnu" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" - -[[package]] -name = "windows_i686_gnullvm" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" - -[[package]] -name = "windows_i686_msvc" -version = "0.48.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406" - -[[package]] -name = "windows_i686_msvc" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" - -[[package]] -name = "windows_x86_64_gnu" -version = "0.48.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e" - -[[package]] -name = "windows_x86_64_gnu" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" - -[[package]] -name = "windows_x86_64_gnullvm" -version = "0.48.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc" - -[[package]] -name = "windows_x86_64_gnullvm" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" - -[[package]] -name = "windows_x86_64_msvc" -version = "0.48.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538" - -[[package]] -name = "windows_x86_64_msvc" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" - [[package]] name = "winnow" -version = "0.7.14" +version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5a5364e9d77fcdeeaa6062ced926ee3381faa2ee02d3eb83a5c27a8825540829" +checksum = "23b97319f7b8343df12cc98938e5c3eb436064524c8d2b4e30a1d3a36eecdf81" dependencies = [ "memchr", ] [[package]] name = "wit-bindgen" -version = "0.51.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" -dependencies = [ - "wit-bindgen-rust-macro", -] - -[[package]] -name = "wit-bindgen-core" -version = "0.51.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc" -dependencies = [ - "anyhow", - "heck", - "wit-parser", -] - -[[package]] -name = "wit-bindgen-rust" -version = "0.51.0" +version = "0.57.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" -dependencies = [ - "anyhow", - "heck", - "indexmap", - "prettyplease", - "syn", - "wasm-metadata", - "wit-bindgen-core", - "wit-component", -] - -[[package]] -name = "wit-bindgen-rust-macro" -version = "0.51.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a" -dependencies = [ - "anyhow", - "prettyplease", - "proc-macro2", - "quote", - "syn", - "wit-bindgen-core", - "wit-bindgen-rust", -] - -[[package]] -name = "wit-component" -version = "0.244.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" -dependencies = [ - "anyhow", - "bitflags", - "indexmap", - "log", - "serde", - "serde_derive", - "serde_json", - "wasm-encoder", - "wasm-metadata", - "wasmparser", - "wit-parser", -] - -[[package]] -name = "wit-parser" -version = "0.244.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736" -dependencies = [ - "anyhow", - "id-arena", - "indexmap", - "log", - "semver", - "serde", - "serde_derive", - "serde_json", - "unicode-xid", - "wasmparser", -] +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" [[package]] name = "writeable" -version = "0.6.2" +version = "0.6.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9edde0db4769d2dc68579893f2306b26c6ecfbe0ef499b013d731b7b9247e0b9" +checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" [[package]] name = "yoke" -version = "0.8.1" +version = "0.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "72d6e5c6afb84d73944e5cedb052c4680d5657337201555f9f2a16b7406d4954" +checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5" dependencies = [ "stable_deref_trait", "yoke-derive", @@ -2702,68 +2230,62 @@ dependencies = [ [[package]] name = "yoke-derive" -version = "0.8.1" +version = "0.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b659052874eb698efe5b9e8cf382204678a0086ebf46982b79d6ca3182927e5d" +checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.119", "synstructure", ] [[package]] name = "zerocopy" -version = "0.8.39" +version = "0.8.56" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "db6d35d663eadb6c932438e763b262fe1a70987f9ae936e60158176d710cae4a" +checksum = "556764e583adb45a9f8d413c2a147fa7e8d821e48e12b14fd560b607998b75eb" dependencies = [ "zerocopy-derive", ] [[package]] name = "zerocopy-derive" -version = "0.8.39" +version = "0.8.56" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4122cd3169e94605190e77839c9a40d40ed048d305bfdc146e7df40ab0f3e517" +checksum = "f2ab42fc20575779bd240faa45f94a74256f755c0fa9e89f0ede20d91d0cdfc1" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.119", ] [[package]] name = "zerofrom" -version = "0.1.6" +version = "0.1.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "50cc42e0333e05660c3587f3bf9d0478688e15d870fab3346451ce7f8c9fbea5" +checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" dependencies = [ "zerofrom-derive", ] [[package]] name = "zerofrom-derive" -version = "0.1.6" +version = "0.1.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d71e5d6e06ab090c67b5e44993ec16b72dcbaabc526db883a360057678b48502" +checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.119", "synstructure", ] -[[package]] -name = "zeroize" -version = "1.8.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" - [[package]] name = "zerotrie" -version = "0.2.3" +version = "0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2a59c17a5562d507e4b54960e8569ebee33bee890c70aa3fe7b97e85a9fd7851" +checksum = "0f9152d31db0792fa83f70fb2f83148effb5c1f5b8c7686c3459e361d9bc20bf" dependencies = [ "displaydoc", "yoke", @@ -2772,9 +2294,9 @@ dependencies = [ [[package]] name = "zerovec" -version = "0.11.5" +version = "0.11.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6c28719294829477f525be0186d13efa9a3c602f7ec202ca9e353d310fb9a002" +checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239" dependencies = [ "yoke", "zerofrom", @@ -2783,17 +2305,17 @@ dependencies = [ [[package]] name = "zerovec-derive" -version = "0.11.2" +version = "0.11.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eadce39539ca5cb3985590102671f2567e659fca9666581ad3411d59207951f3" +checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.119", ] [[package]] name = "zmij" -version = "1.0.21" +version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" diff --git a/Cargo.toml b/Cargo.toml index 099f390..286a765 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -6,7 +6,7 @@ edition = "2024" [dependencies] axum = "0.8" tokio = { version = "1", features = ["full"] } -sqlx = { version = "0.8", features = ["runtime-tokio", "sqlite"] } +sqlx = { version = "0.9", features = ["runtime-tokio", "sqlite"] } askama = "0.15" serde = { version = "1", features = ["derive"] } serde_json = "1" @@ -15,7 +15,7 @@ chrono = { version = "0.4", features = ["serde"] } tower = { version = "0.5", features = ["util"] } tower-http = { version = "0.6", features = ["fs", "trace", "set-header"] } tower-sessions = "0.15" -tower-sessions-sqlx-store = { git = "https://github.com/maxcountryman/tower-sessions-stores", rev = "be0f230f", features = ["sqlite"] } +tower-sessions-sqlx-store = { git = "https://github.com/maxcountryman/tower-sessions-stores", rev = "d18c9bf76f1d4fb73130dbe5aa643197f14b5d2d", features = ["sqlite"] } dotenvy = "0.15" tracing = "0.1" tracing-subscriber = "0.3" @@ -28,4 +28,4 @@ http-body-util = "0.1" uuid = { version = "1", features = ["v4"] } chrono = "0.4" serde_json = "1" -sqlx = { version = "0.8", features = ["runtime-tokio", "sqlite"] } +sqlx = { version = "0.9", features = ["runtime-tokio", "sqlite"] } diff --git a/Dockerfile b/Dockerfile index 5b34fec..a3f25fc 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,4 +1,4 @@ -FROM rust:1.90 AS builder +FROM rust:1.94 AS builder WORKDIR /app diff --git a/src/cli.rs b/src/cli.rs index 8568407..4b59354 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -92,7 +92,7 @@ pub async fn import_data(pool: &SqlitePool, file_path: &str, user_id: &str) -> R .bind(&entry.title) .bind(&entry.description) .bind(duration) - .bind(&interval) + .bind(interval) .bind(&entry.dismissed_at) .bind(&created_at) .bind(&updated_at) diff --git a/src/db.rs b/src/db.rs index 2a969ce..86ed777 100644 --- a/src/db.rs +++ b/src/db.rs @@ -4,10 +4,10 @@ use std::str::FromStr; pub async fn init_pool(database_url: &str) -> SqlitePool { // Ensure data directory exists - if let Some(path) = database_url.strip_prefix("sqlite:") { - if let Some(parent) = Path::new(path).parent() { - std::fs::create_dir_all(parent).ok(); - } + if let Some(path) = database_url.strip_prefix("sqlite:") + && let Some(parent) = Path::new(path).parent() + { + std::fs::create_dir_all(parent).ok(); } let options = SqliteConnectOptions::from_str(database_url) diff --git a/src/routes/entries.rs b/src/routes/entries.rs index 1e29b9b..bcd9196 100644 --- a/src/routes/entries.rs +++ b/src/routes/entries.rs @@ -152,10 +152,10 @@ fn validate_entry_form(form: &EntryForm) -> HashMap { errors.insert("title".to_string(), "Title must be under 500 characters".to_string()); } - if let Some(ref desc) = form.description { - if desc.len() > 5000 { - errors.insert("description".to_string(), "Description must be under 5000 characters".to_string()); - } + if let Some(ref desc) = form.description + && desc.len() > 5000 + { + errors.insert("description".to_string(), "Description must be under 5000 characters".to_string()); } errors @@ -510,11 +510,11 @@ async fn create_entry( .bind(&id) .bind(&user.id) .bind(&collection_id) - .bind(&normalize_url(&form.url).unwrap()) + .bind(normalize_url(&form.url).unwrap()) .bind(&form.title) .bind(&form.description) .bind(form.duration) - .bind(&form.interval) + .bind(form.interval) .bind(&now) .bind(&now) .execute(&state.db) @@ -662,11 +662,11 @@ async fn update_entry( WHERE id = ? "# ) - .bind(&normalize_url(&form.url).unwrap()) + .bind(normalize_url(&form.url).unwrap()) .bind(&form.title) .bind(&form.description) .bind(form.duration) - .bind(&form.interval) + .bind(form.interval) .bind(&collection_id) .bind(&now) .bind(&id) diff --git a/tests/entries.rs b/tests/entries.rs index 5895435..db64bad 100644 --- a/tests/entries.rs +++ b/tests/entries.rs @@ -192,11 +192,9 @@ async fn edit_entry_as_owner() { assert!(html.contains("Original Title")); // POST update - let body = format!( - "url=https%3A%2F%2Fexample.com&title=Updated+Title&description=&duration=5&interval=weeks&tags=&collection_id=" - ); + let body = "url=https%3A%2F%2Fexample.com&title=Updated+Title&description=&duration=5&interval=weeks&tags=&collection_id="; let resp = app - .post_form(&format!("/entries/{}", entry_id), &body, Some(&cookie)) + .post_form(&format!("/entries/{}", entry_id), body, Some(&cookie)) .await; assert_redirect(&resp, "/"); } From 7162aa28398de81d9971f7e4d3b91036c7adb669 Mon Sep 17 00:00:00 2001 From: Axel Anderson Date: Wed, 12 Aug 2026 09:04:56 -0400 Subject: [PATCH 05/25] feat(auth): add GitHub identity schema --- migrations/003_github_auth.sql | 45 +++++++++++++++++++++ src/models/user.rs | 5 ++- tests/migrations.rs | 74 ++++++++++++++++++++++++++++++++++ 3 files changed, 123 insertions(+), 1 deletion(-) create mode 100644 migrations/003_github_auth.sql create mode 100644 tests/migrations.rs diff --git a/migrations/003_github_auth.sql b/migrations/003_github_auth.sql new file mode 100644 index 0000000..5c243b3 --- /dev/null +++ b/migrations/003_github_auth.sql @@ -0,0 +1,45 @@ +-- no-transaction +PRAGMA foreign_keys = OFF; +BEGIN IMMEDIATE; + +CREATE TABLE new_users ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + email TEXT UNIQUE, + invite_code TEXT UNIQUE, + github_user_id TEXT UNIQUE, + github_login TEXT, + auth_version INTEGER NOT NULL DEFAULT 1, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + updated_at TEXT NOT NULL DEFAULT (datetime('now')) +); + +INSERT INTO new_users ( + id, name, email, invite_code, github_user_id, github_login, + auth_version, created_at, updated_at +) +SELECT + id, name, email, invite_code, NULL, NULL, + 1, created_at, updated_at +FROM users; + +DROP TABLE users; +ALTER TABLE new_users RENAME TO users; + +CREATE TABLE auth_connection_tokens ( + id TEXT PRIMARY KEY, + user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + token_hash TEXT NOT NULL UNIQUE, + purpose TEXT NOT NULL CHECK (purpose IN ('invite', 'recovery')), + created_at TEXT NOT NULL, + expires_at TEXT NOT NULL, + consumed_at TEXT +); + +CREATE INDEX idx_auth_connection_tokens_user_id + ON auth_connection_tokens(user_id); +CREATE INDEX idx_auth_connection_tokens_active + ON auth_connection_tokens(token_hash, expires_at, consumed_at); + +COMMIT; +PRAGMA foreign_keys = ON; diff --git a/src/models/user.rs b/src/models/user.rs index 3e0c2b0..3f72f78 100644 --- a/src/models/user.rs +++ b/src/models/user.rs @@ -6,7 +6,10 @@ pub struct User { pub id: String, pub name: String, pub email: Option, - pub invite_code: String, + pub invite_code: Option, + pub github_user_id: Option, + pub github_login: Option, + pub auth_version: i64, pub created_at: String, pub updated_at: String, } diff --git a/tests/migrations.rs b/tests/migrations.rs new file mode 100644 index 0000000..c75300c --- /dev/null +++ b/tests/migrations.rs @@ -0,0 +1,74 @@ +use sqlx::{AssertSqlSafe, Connection, Row, SqliteConnection}; + +#[tokio::test] +async fn github_auth_migration_preserves_users_and_related_data() { + let mut db = SqliteConnection::connect("sqlite::memory:").await.unwrap(); + sqlx::query("PRAGMA foreign_keys = ON") + .execute(&mut db) + .await + .unwrap(); + sqlx::raw_sql(include_str!("../migrations/001_initial.sql")) + .execute(&mut db) + .await + .unwrap(); + sqlx::raw_sql(include_str!("../migrations/002_timestamps.sql")) + .execute(&mut db) + .await + .unwrap(); + + sqlx::query("INSERT INTO users (id, name, invite_code) VALUES ('u1', 'Axel', 'legacy')") + .execute(&mut db) + .await + .unwrap(); + sqlx::query("INSERT INTO entries (id, user_id, url, title, duration, interval) VALUES ('e1', 'u1', 'https://example.com', 'Example', 1, 'days')") + .execute(&mut db) + .await + .unwrap(); + sqlx::query("INSERT INTO visits (id, entry_id, user_id) VALUES ('v1', 'e1', 'u1')") + .execute(&mut db) + .await + .unwrap(); + sqlx::query("INSERT INTO collections (id, owner_id, name, invite_code) VALUES ('c1', 'u1', 'Reading', 'collection')") + .execute(&mut db) + .await + .unwrap(); + sqlx::query("INSERT INTO collection_members (collection_id, user_id) VALUES ('c1', 'u1')") + .execute(&mut db) + .await + .unwrap(); + sqlx::query("INSERT INTO tags (id, name) VALUES ('t1', 'rust')") + .execute(&mut db) + .await + .unwrap(); + sqlx::query("INSERT INTO entry_tags (entry_id, tag_id) VALUES ('e1', 't1')") + .execute(&mut db) + .await + .unwrap(); + + sqlx::raw_sql(include_str!("../migrations/003_github_auth.sql")) + .execute(&mut db) + .await + .unwrap(); + + let user = sqlx::query("SELECT invite_code, github_user_id, auth_version FROM users WHERE id = 'u1'") + .fetch_one(&mut db) + .await + .unwrap(); + assert_eq!(user.get::, _>("invite_code").as_deref(), Some("legacy")); + assert_eq!(user.get::, _>("github_user_id"), None); + assert_eq!(user.get::("auth_version"), 1); + + for table in ["entries", "visits", "collections", "collection_members", "tags", "entry_tags"] { + let count: i64 = sqlx::query_scalar(AssertSqlSafe(format!("SELECT COUNT(*) FROM {table}"))) + .fetch_one(&mut db) + .await + .unwrap(); + assert_eq!(count, 1, "{table} rows must survive the users rebuild"); + } + + let violations = sqlx::query("PRAGMA foreign_key_check") + .fetch_all(&mut db) + .await + .unwrap(); + assert!(violations.is_empty()); +} From 37e926eae2965c7167f4752a1762d593e9eab89e Mon Sep 17 00:00:00 2001 From: Axel Anderson Date: Wed, 12 Aug 2026 09:14:32 -0400 Subject: [PATCH 06/25] chore(toolchain): pin Rust 1.94 --- rust-toolchain.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/rust-toolchain.toml b/rust-toolchain.toml index 292fe49..4683c9e 100644 --- a/rust-toolchain.toml +++ b/rust-toolchain.toml @@ -1,2 +1,2 @@ [toolchain] -channel = "stable" +channel = "1.94" From 1bac47ee5f4b83e33a25764040aa18a7bad04174 Mon Sep 17 00:00:00 2001 From: Axel Anderson Date: Wed, 12 Aug 2026 09:14:40 -0400 Subject: [PATCH 07/25] test(auth): strengthen migration verification --- tests/migrations.rs | 35 +++++++++++++++++++++++++++-------- 1 file changed, 27 insertions(+), 8 deletions(-) diff --git a/tests/migrations.rs b/tests/migrations.rs index c75300c..be27a67 100644 --- a/tests/migrations.rs +++ b/tests/migrations.rs @@ -16,10 +16,12 @@ async fn github_auth_migration_preserves_users_and_related_data() { .await .unwrap(); - sqlx::query("INSERT INTO users (id, name, invite_code) VALUES ('u1', 'Axel', 'legacy')") - .execute(&mut db) - .await - .unwrap(); + sqlx::query( + "INSERT INTO users (id, name, invite_code, created_at, updated_at) VALUES ('u1', 'Axel', 'legacy', '2025-01-02T03:04:05+00:00', '2026-06-07T08:09:10+00:00')", + ) + .execute(&mut db) + .await + .unwrap(); sqlx::query("INSERT INTO entries (id, user_id, url, title, duration, interval) VALUES ('e1', 'u1', 'https://example.com', 'Example', 1, 'days')") .execute(&mut db) .await @@ -50,13 +52,17 @@ async fn github_auth_migration_preserves_users_and_related_data() { .await .unwrap(); - let user = sqlx::query("SELECT invite_code, github_user_id, auth_version FROM users WHERE id = 'u1'") - .fetch_one(&mut db) - .await - .unwrap(); + let user = sqlx::query( + "SELECT invite_code, github_user_id, auth_version, created_at, updated_at FROM users WHERE id = 'u1'", + ) + .fetch_one(&mut db) + .await + .unwrap(); assert_eq!(user.get::, _>("invite_code").as_deref(), Some("legacy")); assert_eq!(user.get::, _>("github_user_id"), None); assert_eq!(user.get::("auth_version"), 1); + assert_eq!(user.get::("created_at"), "2025-01-02T03:04:05+00:00"); + assert_eq!(user.get::("updated_at"), "2026-06-07T08:09:10+00:00"); for table in ["entries", "visits", "collections", "collection_members", "tags", "entry_tags"] { let count: i64 = sqlx::query_scalar(AssertSqlSafe(format!("SELECT COUNT(*) FROM {table}"))) @@ -71,4 +77,17 @@ async fn github_auth_migration_preserves_users_and_related_data() { .await .unwrap(); assert!(violations.is_empty()); + + let foreign_keys_enabled: i64 = sqlx::query_scalar("PRAGMA foreign_keys") + .fetch_one(&mut db) + .await + .unwrap(); + assert_eq!(foreign_keys_enabled, 1); + + let orphan_result = sqlx::query( + "INSERT INTO visits (id, entry_id, user_id) VALUES ('v-orphan', 'e1', 'missing-user')", + ) + .execute(&mut db) + .await; + assert!(orphan_result.is_err(), "foreign keys must reject orphan rows"); } From b3d621e72792e1b4072d1f8d3f5aeb2e33ad86d2 Mon Sep 17 00:00:00 2001 From: Axel Anderson Date: Wed, 12 Aug 2026 09:24:57 -0400 Subject: [PATCH 08/25] feat(auth): add GitHub OAuth provider --- Cargo.lock | 387 +++++++++++++++++++++++++++++++++++++++++++++++++- Cargo.toml | 5 + src/config.rs | 255 +++++++++++++++++++++++++++++++++ src/github.rs | 224 +++++++++++++++++++++++++++++ src/lib.rs | 2 + 5 files changed, 867 insertions(+), 6 deletions(-) create mode 100644 src/config.rs create mode 100644 src/github.rs diff --git a/Cargo.lock b/Cargo.lock index 518c005..4a8dc28 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -229,6 +229,12 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" +[[package]] +name = "cfg_aliases" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527" + [[package]] name = "chacha20" version = "0.10.1" @@ -422,7 +428,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys", + "windows-sys 0.61.2", ] [[package]] @@ -432,7 +438,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "de48cc4d1c1d97a20fd819def54b890cadde72ed3ad0c614822a0a433361be96" dependencies = [ "cfg-if", - "windows-sys", + "windows-sys 0.61.2", ] [[package]] @@ -584,6 +590,19 @@ dependencies = [ "version_check", ] +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "wasi", + "wasm-bindgen", +] + [[package]] name = "getrandom" version = "0.3.4" @@ -603,9 +622,11 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" dependencies = [ "cfg-if", + "js-sys", "libc", "r-efi 6.0.0", "rand_core 0.10.1", + "wasm-bindgen", ] [[package]] @@ -742,6 +763,23 @@ dependencies = [ "pin-project-lite", "smallvec", "tokio", + "want", +] + +[[package]] +name = "hyper-rustls" +version = "0.27.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33ca68d021ef39cf6463ab54c1d0f5daf03377b70561305bb89a8f83aab66e0f" +dependencies = [ + "http", + "hyper", + "hyper-util", + "rustls", + "tokio", + "tokio-rustls", + "tower-service", + "webpki-roots", ] [[package]] @@ -750,13 +788,21 @@ version = "0.1.20" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" dependencies = [ + "base64", "bytes", + "futures-channel", + "futures-util", "http", "http-body", "hyper", + "ipnet", + "libc", + "percent-encoding", "pin-project-lite", + "socket2", "tokio", "tower-service", + "tracing", ] [[package]] @@ -901,12 +947,17 @@ name = "interne" version = "1.0.0" dependencies = [ "askama", + "async-trait", "axum", + "base64", "chrono", "dotenvy", "http-body-util", + "rand 0.9.5", + "reqwest", "serde", "serde_json", + "sha2 0.10.9", "sqlx", "time", "tokio", @@ -920,6 +971,12 @@ dependencies = [ "uuid", ] +[[package]] +name = "ipnet" +version = "2.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a756c3fac73139e83f14c2d742155dd2b78d3ee56597b419a0579b7bdd6dd78" + [[package]] name = "itoa" version = "1.0.18" @@ -981,6 +1038,12 @@ version = "0.4.33" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad" +[[package]] +name = "lru-slab" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" + [[package]] name = "matchit" version = "0.8.4" @@ -1027,7 +1090,7 @@ checksum = "30d65c71f1ce40ab09135ce117d742b9f8a19ff91a41a8b57ed50bc2de59c427" dependencies = [ "libc", "wasi", - "windows-sys", + "windows-sys 0.61.2", ] [[package]] @@ -1036,7 +1099,7 @@ version = "0.50.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" dependencies = [ - "windows-sys", + "windows-sys 0.61.2", ] [[package]] @@ -1140,6 +1203,62 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "quinn" +version = "0.11.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c1a41e437b6bbd489372cd4971de128e85c855f56c57f283d20ff016cf7c0a8" +dependencies = [ + "bytes", + "cfg_aliases", + "pin-project-lite", + "quinn-proto", + "quinn-udp", + "rustc-hash", + "rustls", + "socket2", + "thiserror", + "tokio", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-proto" +version = "0.11.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f4bfc015262b9df63c8845072ce59068853ff5872180c2ce2f13038b970e560" +dependencies = [ + "bytes", + "getrandom 0.4.3", + "lru-slab", + "rand 0.10.2", + "rand_pcg", + "ring", + "rustc-hash", + "rustls", + "rustls-pki-types", + "slab", + "thiserror", + "tinyvec", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-udp" +version = "0.5.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35a133f956daabe89a61a685c2649f13d82d5aa4bd5d12d1277e1072a21c0694" +dependencies = [ + "cfg_aliases", + "libc", + "once_cell", + "socket2", + "tracing", + "windows-sys 0.61.2", +] + [[package]] name = "quote" version = "1.0.47" @@ -1207,6 +1326,15 @@ version = "0.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" +[[package]] +name = "rand_pcg" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "caa0f4137e1c0a72f4c651489402276c8e8e1cf081f3b0ba156d2cbeef09e86a" +dependencies = [ + "rand_core 0.10.1", +] + [[package]] name = "redox_syscall" version = "0.5.18" @@ -1216,6 +1344,58 @@ dependencies = [ "bitflags", ] +[[package]] +name = "reqwest" +version = "0.12.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" +dependencies = [ + "base64", + "bytes", + "futures-core", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-rustls", + "hyper-util", + "js-sys", + "log", + "percent-encoding", + "pin-project-lite", + "quinn", + "rustls", + "rustls-pki-types", + "serde", + "serde_json", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tokio-rustls", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "webpki-roots", +] + +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.17", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + [[package]] name = "rmp" version = "0.8.15" @@ -1241,6 +1421,41 @@ version = "2.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d" +[[package]] +name = "rustls" +version = "0.23.43" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0283386ce02abc0151e1761d08802dfe86c173b0b494af5cbc086574e453da06" +dependencies = [ + "once_cell", + "ring", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-pki-types" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f4925028c7eb5d1fcdaf196971378ed9d2c1c4efc7dc5d011256f76c99c0a96" +dependencies = [ + "web-time", + "zeroize", +] + +[[package]] +name = "rustls-webpki" +version = "0.103.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0527518605e68109d875e248ea259b6758801cf165e4b2c2733ae3b51f12535a" +dependencies = [ + "ring", + "rustls-pki-types", + "untrusted", +] + [[package]] name = "rustversion" version = "1.0.23" @@ -1405,7 +1620,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4" dependencies = [ "libc", - "windows-sys", + "windows-sys 0.61.2", ] [[package]] @@ -1609,6 +1824,12 @@ dependencies = [ "unicode-properties", ] +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + [[package]] name = "syn" version = "2.0.119" @@ -1636,6 +1857,9 @@ name = "sync_wrapper" version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" +dependencies = [ + "futures-core", +] [[package]] name = "synstructure" @@ -1746,7 +1970,7 @@ dependencies = [ "signal-hook-registry", "socket2", "tokio-macros", - "windows-sys", + "windows-sys 0.61.2", ] [[package]] @@ -1760,6 +1984,16 @@ dependencies = [ "syn 3.0.3", ] +[[package]] +name = "tokio-rustls" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" +dependencies = [ + "rustls", + "tokio", +] + [[package]] name = "tokio-stream" version = "0.1.19" @@ -1837,9 +2071,11 @@ dependencies = [ "pin-project-lite", "tokio", "tokio-util", + "tower", "tower-layer", "tower-service", "tracing", + "url", ] [[package]] @@ -1976,6 +2212,12 @@ dependencies = [ "tracing-log", ] +[[package]] +name = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + [[package]] name = "typenum" version = "1.20.1" @@ -2015,6 +2257,12 @@ version = "0.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7df058c713841ad818f1dc5d3fd88063241cc61f49f5fbea4b951e8cf5a8d71d" +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + [[package]] name = "url" version = "2.5.8" @@ -2062,6 +2310,15 @@ version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" +[[package]] +name = "want" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" +dependencies = [ + "try-lock", +] + [[package]] name = "wasi" version = "0.11.1+wasi-snapshot-preview1" @@ -2090,6 +2347,16 @@ dependencies = [ "wasm-bindgen-shared", ] +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.77" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b7777d5cc23d0e91404e53ce2d5e8ec7acae3026b16233dba62cd3246457950" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + [[package]] name = "wasm-bindgen-macro" version = "0.2.127" @@ -2122,6 +2389,35 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "web-sys" +version = "0.3.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c435338968042f4f59a557f690a253676d47ce13ceb55d70100e7facf6620a30" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "web-time" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "webpki-roots" +version = "1.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7dcd9d09a39985f5344844e66b0c530a33843579125f23e21e9f0f220850f22a" +dependencies = [ + "rustls-pki-types", +] + [[package]] name = "whoami" version = "2.1.3" @@ -2187,6 +2483,15 @@ dependencies = [ "windows-link", ] +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets", +] + [[package]] name = "windows-sys" version = "0.61.2" @@ -2196,6 +2501,70 @@ dependencies = [ "windows-link", ] +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm", + "windows_aarch64_msvc", + "windows_i686_gnu", + "windows_i686_gnullvm", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + [[package]] name = "winnow" version = "1.0.4" @@ -2281,6 +2650,12 @@ dependencies = [ "synstructure", ] +[[package]] +name = "zeroize" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" + [[package]] name = "zerotrie" version = "0.2.4" diff --git a/Cargo.toml b/Cargo.toml index 286a765..f74d240 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -4,7 +4,9 @@ version = "1.0.0" edition = "2024" [dependencies] +async-trait = "0.1" axum = "0.8" +base64 = "0.22" tokio = { version = "1", features = ["full"] } sqlx = { version = "0.9", features = ["runtime-tokio", "sqlite"] } askama = "0.15" @@ -17,6 +19,9 @@ tower-http = { version = "0.6", features = ["fs", "trace", "set-header"] } tower-sessions = "0.15" tower-sessions-sqlx-store = { git = "https://github.com/maxcountryman/tower-sessions-stores", rev = "d18c9bf76f1d4fb73130dbe5aa643197f14b5d2d", features = ["sqlite"] } dotenvy = "0.15" +rand = "0.9" +reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] } +sha2 = "0.10" tracing = "0.1" tracing-subscriber = "0.3" time = "0.3" diff --git a/src/config.rs b/src/config.rs new file mode 100644 index 0000000..af28b79 --- /dev/null +++ b/src/config.rs @@ -0,0 +1,255 @@ +use std::fmt; + +use url::Url; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum SignupMode { + Closed, + Public, +} + +#[derive(Clone, Debug)] +pub struct AuthConfig { + pub public_base_url: Url, + pub signup_mode: SignupMode, +} + +pub struct GitHubCredentials { + pub client_id: String, + pub client_secret: String, +} + +pub struct ServerAuthConfig { + pub auth: AuthConfig, + pub github: GitHubCredentials, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum ConfigError { + MissingVariable(&'static str), + InvalidPublicBaseUrl, + InvalidSignupMode, +} + +impl fmt::Display for ConfigError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::MissingVariable(name) => { + write!(formatter, "missing required environment variable {name}") + } + Self::InvalidPublicBaseUrl => write!( + formatter, + "PUBLIC_BASE_URL must be a bare HTTPS origin or an HTTP literal loopback origin" + ), + Self::InvalidSignupMode => { + write!(formatter, "GITHUB_SIGNUP_MODE must be closed or public") + } + } + } +} + +impl std::error::Error for ConfigError {} + +impl fmt::Debug for ServerAuthConfig { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("ServerAuthConfig") + .field("auth", &self.auth) + .field("github", &"") + .finish() + } +} + +impl AuthConfig { + pub fn new(public_base_url: &str, signup_mode: SignupMode) -> Result { + let original_public_base_url = public_base_url; + let mut public_base_url = + Url::parse(public_base_url).map_err(|_| ConfigError::InvalidPublicBaseUrl)?; + + let has_credentials = + !public_base_url.username().is_empty() || public_base_url.password().is_some(); + let is_bare_origin = !has_credentials + && public_base_url.host().is_some() + && public_base_url.path() == "/" + && public_base_url.query().is_none() + && public_base_url.fragment().is_none(); + if !is_bare_origin { + return Err(ConfigError::InvalidPublicBaseUrl); + } + + let uses_allowed_transport = match public_base_url.scheme() { + "https" => true, + "http" => has_literal_loopback_authority(original_public_base_url), + _ => false, + }; + if !uses_allowed_transport { + return Err(ConfigError::InvalidPublicBaseUrl); + } + + public_base_url.set_path("/"); + Ok(Self { + public_base_url, + signup_mode, + }) + } +} + +fn has_literal_loopback_authority(original: &str) -> bool { + let Some((scheme, remainder)) = original.split_once("://") else { + return false; + }; + if !scheme.eq_ignore_ascii_case("http") { + return false; + } + + let authority = remainder.split(['/', '?', '#']).next().unwrap_or_default(); + authority == "127.0.0.1" + || authority.starts_with("127.0.0.1:") + || authority == "[::1]" + || authority.starts_with("[::1]:") +} + +impl ServerAuthConfig { + pub fn from_env() -> Result { + Self::from_lookup(|key| std::env::var(key).ok()) + } + + pub fn from_lookup(mut lookup: F) -> Result + where + F: FnMut(&str) -> Option, + { + let client_id = + lookup("GITHUB_CLIENT_ID").ok_or(ConfigError::MissingVariable("GITHUB_CLIENT_ID"))?; + let client_secret = lookup("GITHUB_CLIENT_SECRET") + .ok_or(ConfigError::MissingVariable("GITHUB_CLIENT_SECRET"))?; + let public_base_url = + lookup("PUBLIC_BASE_URL").ok_or(ConfigError::MissingVariable("PUBLIC_BASE_URL"))?; + let signup_mode = match lookup("GITHUB_SIGNUP_MODE").as_deref() { + None | Some("closed") => SignupMode::Closed, + Some("public") => SignupMode::Public, + Some(_) => return Err(ConfigError::InvalidSignupMode), + }; + + Ok(Self { + auth: AuthConfig::new(&public_base_url, signup_mode)?, + github: GitHubCredentials { + client_id, + client_secret, + }, + }) + } +} + +#[cfg(test)] +mod tests { + use super::{AuthConfig, ServerAuthConfig, SignupMode}; + + #[test] + fn signup_mode_defaults_closed_and_rejects_unknown_values() { + let closed = ServerAuthConfig::from_lookup(|key| match key { + "GITHUB_CLIENT_ID" => Some("client".into()), + "GITHUB_CLIENT_SECRET" => Some("secret".into()), + "PUBLIC_BASE_URL" => Some("https://interne.honkytonk.in".into()), + _ => None, + }) + .unwrap(); + assert_eq!(closed.auth.signup_mode, SignupMode::Closed); + + let error = ServerAuthConfig::from_lookup(|key| match key { + "GITHUB_CLIENT_ID" => Some("client".into()), + "GITHUB_CLIENT_SECRET" => Some("secret".into()), + "PUBLIC_BASE_URL" => Some("https://interne.honkytonk.in".into()), + "GITHUB_SIGNUP_MODE" => Some("sometimes".into()), + _ => None, + }) + .unwrap_err(); + assert!(error.to_string().contains("closed or public")); + } + + #[test] + fn public_base_url_requires_https_except_for_literal_loopback() { + for invalid in [ + "http://interne.honkytonk.in", + "http://localhost:3000", + "https://interne.honkytonk.in/path", + ] { + let result = AuthConfig::new(invalid, SignupMode::Closed); + assert!(result.is_err(), "{invalid} must be rejected"); + } + assert!(AuthConfig::new("http://127.0.0.1:3000", SignupMode::Closed).is_ok()); + } + + #[test] + fn public_base_url_rejects_components_outside_a_bare_origin() { + for invalid in [ + "https://user@interne.honkytonk.in", + "https://interne.honkytonk.in?mode=test", + "https://interne.honkytonk.in#fragment", + "ftp://interne.honkytonk.in", + ] { + assert!( + AuthConfig::new(invalid, SignupMode::Closed).is_err(), + "{invalid} must be rejected" + ); + } + } + + #[test] + fn public_base_url_accepts_ipv6_loopback_and_normalizes_root_path() { + let config = AuthConfig::new("http://[::1]:3000", SignupMode::Closed).unwrap(); + + assert_eq!(config.public_base_url.as_str(), "http://[::1]:3000/"); + } + + #[test] + fn http_requires_the_exact_loopback_host_literal() { + for invalid in ["http://127.1:3000", "http://2130706433:3000"] { + assert!( + AuthConfig::new(invalid, SignupMode::Closed).is_err(), + "{invalid} must be rejected" + ); + } + } + + #[test] + fn configuration_names_each_missing_required_variable() { + for missing in [ + "GITHUB_CLIENT_ID", + "GITHUB_CLIENT_SECRET", + "PUBLIC_BASE_URL", + ] { + let error = ServerAuthConfig::from_lookup(|key| { + if key == missing { + None + } else { + Some( + match key { + "GITHUB_CLIENT_ID" => "client", + "GITHUB_CLIENT_SECRET" => "secret", + "PUBLIC_BASE_URL" => "https://interne.honkytonk.in", + _ => return None, + } + .into(), + ) + } + }) + .expect_err("a required variable is missing"); + + assert!(error.to_string().contains(missing)); + } + } + + #[test] + fn signup_mode_accepts_public_exactly() { + let config = ServerAuthConfig::from_lookup(|key| match key { + "GITHUB_CLIENT_ID" => Some("client".into()), + "GITHUB_CLIENT_SECRET" => Some("secret".into()), + "PUBLIC_BASE_URL" => Some("https://interne.honkytonk.in".into()), + "GITHUB_SIGNUP_MODE" => Some("public".into()), + _ => None, + }) + .unwrap(); + + assert_eq!(config.auth.signup_mode, SignupMode::Public); + } +} diff --git a/src/github.rs b/src/github.rs new file mode 100644 index 0000000..b047183 --- /dev/null +++ b/src/github.rs @@ -0,0 +1,224 @@ +use std::fmt; + +use async_trait::async_trait; +use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD}; +use reqwest::{ + Client, + header::{ACCEPT, USER_AGENT}, + redirect::Policy, +}; +use serde::{Deserialize, Serialize}; +use serde_json::Number; +use sha2::{Digest, Sha256}; +use url::Url; + +const AUTHORIZE_URL: &str = "https://github.com/login/oauth/authorize"; +const TOKEN_URL: &str = "https://github.com/login/oauth/access_token"; +const PROFILE_URL: &str = "https://api.github.com/user"; + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct GitHubProfile { + pub user_id: String, + pub login: String, + pub name: Option, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum GitHubError { + ClientConfiguration, + AuthorizationUrl, + TokenExchange, + ProfileFetch, +} + +impl fmt::Display for GitHubError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + let stage = match self { + Self::ClientConfiguration => "GitHub client configuration failed", + Self::AuthorizationUrl => "GitHub authorization URL construction failed", + Self::TokenExchange => "GitHub token exchange failed", + Self::ProfileFetch => "GitHub profile fetch failed", + }; + formatter.write_str(stage) + } +} + +impl std::error::Error for GitHubError {} + +#[async_trait] +pub trait GitHubProvider: Send + Sync { + fn authorization_url( + &self, + callback_url: &Url, + state: &str, + pkce_challenge: &str, + ) -> Result; + + async fn exchange_code( + &self, + callback_url: &Url, + code: &str, + pkce_verifier: &str, + ) -> Result; +} + +#[derive(Clone)] +pub struct GitHubOAuthClient { + client_id: String, + client_secret: String, + http: Client, +} + +impl GitHubOAuthClient { + pub fn new( + client_id: impl Into, + client_secret: impl Into, + ) -> Result { + let http = Client::builder() + .redirect(Policy::none()) + .build() + .map_err(|_| GitHubError::ClientConfiguration)?; + + Ok(Self { + client_id: client_id.into(), + client_secret: client_secret.into(), + http, + }) + } +} + +#[derive(Deserialize)] +struct AccessTokenResponse { + access_token: String, +} + +#[derive(Deserialize)] +struct GitHubUserResponse { + id: Number, + login: String, + name: Option, +} + +#[async_trait] +impl GitHubProvider for GitHubOAuthClient { + fn authorization_url( + &self, + callback_url: &Url, + state: &str, + pkce_challenge: &str, + ) -> Result { + let mut url = Url::parse(AUTHORIZE_URL).map_err(|_| GitHubError::AuthorizationUrl)?; + url.query_pairs_mut() + .append_pair("client_id", &self.client_id) + .append_pair("redirect_uri", callback_url.as_str()) + .append_pair("state", state) + .append_pair("code_challenge", pkce_challenge) + .append_pair("code_challenge_method", "S256"); + Ok(url) + } + + async fn exchange_code( + &self, + callback_url: &Url, + code: &str, + pkce_verifier: &str, + ) -> Result { + let token_response = self + .http + .post(TOKEN_URL) + .header(ACCEPT, "application/json") + .form(&[ + ("client_id", self.client_id.as_str()), + ("client_secret", self.client_secret.as_str()), + ("code", code), + ("redirect_uri", callback_url.as_str()), + ("code_verifier", pkce_verifier), + ]) + .send() + .await + .and_then(reqwest::Response::error_for_status) + .map_err(|_| GitHubError::TokenExchange)? + .json::() + .await + .map_err(|_| GitHubError::TokenExchange)?; + + let github_user = self + .http + .get(PROFILE_URL) + .header(ACCEPT, "application/vnd.github+json") + .bearer_auth(&token_response.access_token) + .header(USER_AGENT, "interne") + .header("X-GitHub-Api-Version", "2022-11-28") + .send() + .await + .and_then(reqwest::Response::error_for_status) + .map_err(|_| GitHubError::ProfileFetch)? + .json::() + .await + .map_err(|_| GitHubError::ProfileFetch)?; + + Ok(GitHubProfile { + user_id: github_user.id.to_string(), + login: github_user.login, + name: github_user.name, + }) + } +} + +pub fn pkce_challenge(verifier: &str) -> String { + URL_SAFE_NO_PAD.encode(Sha256::digest(verifier.as_bytes())) +} + +#[cfg(test)] +mod tests { + use std::collections::HashMap; + + use super::{GitHubError, GitHubOAuthClient, GitHubProvider, pkce_challenge}; + use url::Url; + + #[test] + fn authorization_url_has_identity_only_parameters() { + let client = GitHubOAuthClient::new("client-id", "secret").unwrap(); + let callback = Url::parse("https://interne.honkytonk.in/auth/github/callback").unwrap(); + let url = client + .authorization_url(&callback, "state-value", "challenge-value") + .unwrap(); + let query: HashMap<_, _> = url.query_pairs().into_owned().collect(); + + assert_eq!( + query.get("client_id").map(String::as_str), + Some("client-id") + ); + assert_eq!( + query.get("redirect_uri").map(String::as_str), + Some(callback.as_str()) + ); + assert_eq!(query.get("state").map(String::as_str), Some("state-value")); + assert_eq!( + query.get("code_challenge").map(String::as_str), + Some("challenge-value") + ); + assert_eq!( + query.get("code_challenge_method").map(String::as_str), + Some("S256") + ); + assert!(!query.contains_key("scope")); + } + + #[test] + fn pkce_challenge_is_base64url_sha256() { + assert_eq!( + pkce_challenge("dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk"), + "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM" + ); + } + + #[test] + fn provider_errors_identify_only_the_failed_stage() { + assert_eq!( + GitHubError::TokenExchange.to_string(), + "GitHub token exchange failed" + ); + assert_eq!(format!("{:?}", GitHubError::ProfileFetch), "ProfileFetch"); + } +} diff --git a/src/lib.rs b/src/lib.rs index cf177b0..08f7fd0 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,7 +1,9 @@ pub mod auth; pub mod cli; +pub mod config; pub mod db; pub mod error; +pub mod github; pub mod models; pub mod routes; From 3bd81ed8d8cceaf0f899c6d0e0143f0befc4dcb5 Mon Sep 17 00:00:00 2001 From: Axel Anderson Date: Wed, 12 Aug 2026 10:27:34 -0400 Subject: [PATCH 09/25] fix(auth): harden GitHub OAuth boundary --- src/config.rs | 27 +++++ src/github.rs | 291 +++++++++++++++++++++++++++++++++++++++++++++++++- 2 files changed, 315 insertions(+), 3 deletions(-) diff --git a/src/config.rs b/src/config.rs index af28b79..c508434 100644 --- a/src/config.rs +++ b/src/config.rs @@ -71,6 +71,7 @@ impl AuthConfig { let is_bare_origin = !has_credentials && public_base_url.host().is_some() && public_base_url.path() == "/" + && has_root_or_absent_path(original_public_base_url) && public_base_url.query().is_none() && public_base_url.fragment().is_none(); if !is_bare_origin { @@ -94,6 +95,14 @@ impl AuthConfig { } } +fn has_root_or_absent_path(original: &str) -> bool { + let Some((_, remainder)) = original.split_once("://") else { + return false; + }; + let path_and_suffix = remainder.find('/').map(|index| &remainder[index..]); + matches!(path_and_suffix, None | Some("/")) +} + fn has_literal_loopback_authority(original: &str) -> bool { let Some((scheme, remainder)) = original.split_once("://") else { return false; @@ -194,6 +203,24 @@ mod tests { } } + #[test] + fn public_base_url_rejects_paths_that_normalize_to_root() { + for invalid in [ + "https://interne.test/a/..", + "https://interne.test/./", + "https://interne.test/%2e", + "https://interne.test\\path", + ] { + assert!( + AuthConfig::new(invalid, SignupMode::Closed).is_err(), + "{invalid} must be rejected" + ); + } + + assert!(AuthConfig::new("https://interne.test", SignupMode::Closed).is_ok()); + assert!(AuthConfig::new("https://interne.test/", SignupMode::Closed).is_ok()); + } + #[test] fn public_base_url_accepts_ipv6_loopback_and_normalizes_root_path() { let config = AuthConfig::new("http://[::1]:3000", SignupMode::Closed).unwrap(); diff --git a/src/github.rs b/src/github.rs index b047183..9e8a5b6 100644 --- a/src/github.rs +++ b/src/github.rs @@ -67,12 +67,25 @@ pub struct GitHubOAuthClient { client_id: String, client_secret: String, http: Client, + token_url: Url, + profile_url: Url, } impl GitHubOAuthClient { pub fn new( client_id: impl Into, client_secret: impl Into, + ) -> Result { + let token_url = Url::parse(TOKEN_URL).map_err(|_| GitHubError::ClientConfiguration)?; + let profile_url = Url::parse(PROFILE_URL).map_err(|_| GitHubError::ClientConfiguration)?; + Self::build(client_id, client_secret, token_url, profile_url) + } + + fn build( + client_id: impl Into, + client_secret: impl Into, + token_url: Url, + profile_url: Url, ) -> Result { let http = Client::builder() .redirect(Policy::none()) @@ -83,8 +96,20 @@ impl GitHubOAuthClient { client_id: client_id.into(), client_secret: client_secret.into(), http, + token_url, + profile_url, }) } + + #[cfg(test)] + fn new_with_endpoints( + client_id: impl Into, + client_secret: impl Into, + token_url: Url, + profile_url: Url, + ) -> Result { + Self::build(client_id, client_secret, token_url, profile_url) + } } #[derive(Deserialize)] @@ -125,7 +150,7 @@ impl GitHubProvider for GitHubOAuthClient { ) -> Result { let token_response = self .http - .post(TOKEN_URL) + .post(self.token_url.clone()) .header(ACCEPT, "application/json") .form(&[ ("client_id", self.client_id.as_str()), @@ -144,7 +169,7 @@ impl GitHubProvider for GitHubOAuthClient { let github_user = self .http - .get(PROFILE_URL) + .get(self.profile_url.clone()) .header(ACCEPT, "application/vnd.github+json") .bearer_auth(&token_response.access_token) .header(USER_AGENT, "interne") @@ -171,11 +196,108 @@ pub fn pkce_challenge(verifier: &str) -> String { #[cfg(test)] mod tests { - use std::collections::HashMap; + use std::{collections::HashMap, time::Duration}; use super::{GitHubError, GitHubOAuthClient, GitHubProvider, pkce_challenge}; + use tokio::{ + io::{AsyncReadExt, AsyncWriteExt}, + net::TcpListener, + task::JoinHandle, + time::timeout, + }; use url::Url; + #[derive(Debug)] + struct CapturedRequest { + method: String, + target: String, + headers: HashMap, + body: String, + } + + async fn local_server(responses: Vec) -> (Url, JoinHandle>) { + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let base_url = Url::parse(&format!("http://{}/", listener.local_addr().unwrap())).unwrap(); + let task = tokio::spawn(async move { + let mut captured = Vec::new(); + for response in responses { + let Ok(Ok((mut socket, _))) = + timeout(Duration::from_millis(500), listener.accept()).await + else { + break; + }; + captured.push(read_request(&mut socket).await); + socket.write_all(response.as_bytes()).await.unwrap(); + } + captured + }); + (base_url, task) + } + + async fn read_request(socket: &mut tokio::net::TcpStream) -> CapturedRequest { + let mut bytes = Vec::new(); + let header_end = loop { + let mut chunk = [0; 1024]; + let read = socket.read(&mut chunk).await.unwrap(); + assert_ne!(read, 0, "request ended before headers completed"); + bytes.extend_from_slice(&chunk[..read]); + if let Some(index) = bytes.windows(4).position(|window| window == b"\r\n\r\n") { + break index + 4; + } + }; + + let header_text = std::str::from_utf8(&bytes[..header_end]).unwrap(); + let mut lines = header_text.split("\r\n"); + let mut request_line = lines.next().unwrap().split_whitespace(); + let method = request_line.next().unwrap().to_string(); + let target = request_line.next().unwrap().to_string(); + let headers: HashMap<_, _> = lines + .filter_map(|line| line.split_once(':')) + .map(|(name, value)| (name.to_ascii_lowercase(), value.trim().to_string())) + .collect(); + let content_length = headers + .get("content-length") + .map(|value| value.parse::().unwrap()) + .unwrap_or(0); + while bytes.len() < header_end + content_length { + let mut chunk = [0; 1024]; + let read = socket.read(&mut chunk).await.unwrap(); + assert_ne!(read, 0, "request ended before body completed"); + bytes.extend_from_slice(&chunk[..read]); + } + + CapturedRequest { + method, + target, + headers, + body: String::from_utf8(bytes[header_end..header_end + content_length].to_vec()) + .unwrap(), + } + } + + fn response(status: &str, extra_headers: &[(&str, &str)], body: &str) -> String { + let mut response = format!( + "HTTP/1.1 {status}\r\nContent-Length: {}\r\nConnection: close\r\n", + body.len() + ); + for (name, value) in extra_headers { + response.push_str(&format!("{name}: {value}\r\n")); + } + response.push_str("\r\n"); + response.push_str(body); + response + } + + fn local_client(base_url: &Url) -> GitHubOAuthClient { + GitHubOAuthClient::new_with_endpoints( + "client-id", + "client-secret", + base_url.join("token").unwrap(), + base_url.join("user").unwrap(), + ) + .unwrap() + } + #[test] fn authorization_url_has_identity_only_parameters() { let client = GitHubOAuthClient::new("client-id", "secret").unwrap(); @@ -221,4 +343,167 @@ mod tests { ); assert_eq!(format!("{:?}", GitHubError::ProfileFetch), "ProfileFetch"); } + + #[tokio::test] + async fn exchange_code_sends_the_required_requests_and_converts_numeric_id() { + let (base_url, server) = local_server(vec![ + response( + "200 OK", + &[("Content-Type", "application/json")], + r#"{"access_token":"temporary-token"}"#, + ), + response( + "200 OK", + &[("Content-Type", "application/json")], + r#"{"id":12345678901234567890,"login":"octocat","name":"The Octocat"}"#, + ), + ]) + .await; + let callback = Url::parse("https://interne.test/auth/github/callback").unwrap(); + + let profile = local_client(&base_url) + .exchange_code(&callback, "authorization-code", "pkce-verifier") + .await + .unwrap(); + let requests = server.await.unwrap(); + + assert_eq!( + profile, + super::GitHubProfile { + user_id: "12345678901234567890".into(), + login: "octocat".into(), + name: Some("The Octocat".into()), + } + ); + assert_eq!(requests.len(), 2); + assert_eq!(requests[0].method, "POST"); + assert_eq!(requests[0].target, "/token"); + assert_eq!( + requests[0].headers.get("accept").unwrap(), + "application/json" + ); + let form: HashMap<_, _> = url::form_urlencoded::parse(requests[0].body.as_bytes()) + .into_owned() + .collect(); + assert_eq!(form.len(), 5); + assert_eq!(form.get("client_id").unwrap(), "client-id"); + assert_eq!(form.get("client_secret").unwrap(), "client-secret"); + assert_eq!(form.get("code").unwrap(), "authorization-code"); + assert_eq!(form.get("redirect_uri").unwrap(), callback.as_str()); + assert_eq!(form.get("code_verifier").unwrap(), "pkce-verifier"); + + assert_eq!(requests[1].method, "GET"); + assert_eq!(requests[1].target, "/user"); + assert_eq!( + requests[1].headers.get("accept").unwrap(), + "application/vnd.github+json" + ); + assert_eq!( + requests[1].headers.get("authorization").unwrap(), + "Bearer temporary-token" + ); + assert_eq!(requests[1].headers.get("user-agent").unwrap(), "interne"); + assert_eq!( + requests[1].headers.get("x-github-api-version").unwrap(), + "2022-11-28" + ); + } + + #[tokio::test] + async fn token_exchange_refuses_redirects() { + let (base_url, server) = local_server(vec![ + response("302 Found", &[("Location", "/redirected")], ""), + response( + "200 OK", + &[("Content-Type", "application/json")], + r#"{"access_token":"redirected-token"}"#, + ), + ]) + .await; + + let error = local_client(&base_url) + .exchange_code( + &Url::parse("https://interne.test/auth/github/callback").unwrap(), + "code", + "verifier", + ) + .await + .unwrap_err(); + let requests = server.await.unwrap(); + + assert_eq!(error, GitHubError::TokenExchange); + assert_eq!(requests.len(), 1); + assert_eq!(requests[0].target, "/token"); + } + + #[tokio::test] + async fn token_status_and_json_failures_are_safe_stage_errors() { + for response in [ + response("502 Bad Gateway", &[], "hostile-token-status-body"), + response( + "200 OK", + &[("Content-Type", "application/json")], + "hostile-token-json-body", + ), + ] { + let (base_url, server) = local_server(vec![response]).await; + let error = local_client(&base_url) + .exchange_code( + &Url::parse("https://interne.test/auth/github/callback").unwrap(), + "secret-code", + "secret-verifier", + ) + .await + .unwrap_err(); + server.await.unwrap(); + + assert_eq!(error, GitHubError::TokenExchange); + let rendered = format!("{error} {error:?}"); + for secret in ["hostile", "secret-code", "secret-verifier"] { + assert!(!rendered.contains(secret)); + } + } + } + + #[tokio::test] + async fn profile_status_and_json_failures_are_safe_stage_errors() { + for profile_response in [ + response("502 Bad Gateway", &[], "hostile-profile-status-body"), + response( + "200 OK", + &[("Content-Type", "application/json")], + "hostile-profile-json-body", + ), + ] { + let (base_url, server) = local_server(vec![ + response( + "200 OK", + &[("Content-Type", "application/json")], + r#"{"access_token":"secret-temporary-token"}"#, + ), + profile_response, + ]) + .await; + let error = local_client(&base_url) + .exchange_code( + &Url::parse("https://interne.test/auth/github/callback").unwrap(), + "secret-code", + "secret-verifier", + ) + .await + .unwrap_err(); + server.await.unwrap(); + + assert_eq!(error, GitHubError::ProfileFetch); + let rendered = format!("{error} {error:?}"); + for secret in [ + "hostile", + "secret-temporary-token", + "secret-code", + "secret-verifier", + ] { + assert!(!rendered.contains(secret)); + } + } + } } From 7414e36ed305e67dd8f395b10f6e57ef2836154f Mon Sep 17 00:00:00 2001 From: Axel Anderson Date: Wed, 12 Aug 2026 10:34:00 -0400 Subject: [PATCH 10/25] refactor(auth): inject GitHub provider --- src/lib.rs | 24 +++++++--- src/main.rs | 26 +++++++++-- tests/common/mod.rs | 108 +++++++++++++++++++++++++++++++++++++++++--- 3 files changed, 143 insertions(+), 15 deletions(-) diff --git a/src/lib.rs b/src/lib.rs index 08f7fd0..066ebb5 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -9,23 +9,35 @@ pub mod routes; pub const STATIC_HASH: &str = env!("STATIC_HASH"); -use axum::{routing::get, Router}; +use std::sync::Arc; + +use axum::http::{HeaderValue, header}; +use axum::{Router, routing::get}; use sqlx::SqlitePool; use time::Duration; -use axum::http::{header, HeaderValue}; use tower::ServiceBuilder; use tower_http::{ services::ServeDir, set_header::SetResponseHeaderLayer, trace::{DefaultOnRequest, DefaultOnResponse, TraceLayer}, }; -use tracing::Level; -use tower_sessions::{cookie::SameSite, Expiry, SessionManagerLayer}; +use tower_sessions::{Expiry, SessionManagerLayer, cookie::SameSite}; use tower_sessions_sqlx_store::SqliteStore; +use tracing::Level; + +use crate::config::AuthConfig; +use crate::github::GitHubProvider; + +#[derive(Clone)] +pub struct AuthServices { + pub config: AuthConfig, + pub github: Arc, +} #[derive(Clone)] pub struct AppState { pub db: SqlitePool, + pub auth: AuthServices, } async fn health() -> &'static str { @@ -37,7 +49,7 @@ async fn health() -> &'static str { /// Caller is responsible for running database migrations on `pool` beforehand. /// This function sets up the session store (and migrates its table), then /// assembles all route modules, middleware, and state. -pub async fn build_app(pool: SqlitePool, secure_cookies: bool) -> Router { +pub async fn build_app(pool: SqlitePool, secure_cookies: bool, auth: AuthServices) -> Router { let session_store = SqliteStore::new(pool.clone()); session_store .migrate() @@ -50,7 +62,7 @@ pub async fn build_app(pool: SqlitePool, secure_cookies: bool) -> Router { .with_http_only(true) .with_same_site(SameSite::Lax); - let state = AppState { db: pool }; + let state = AppState { db: pool, auth }; Router::new() .route("/health", get(health)) diff --git a/src/main.rs b/src/main.rs index e0e9369..42b5962 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,5 +1,10 @@ use std::env; use std::net::SocketAddr; +use std::sync::Arc; + +use interne::AuthServices; +use interne::config::ServerAuthConfig; +use interne::github::GitHubOAuthClient; use tokio::net::TcpListener; #[tokio::main] @@ -61,10 +66,25 @@ async fn main() { } // Start web server - let secure = - env::var("SECURE_COOKIES").unwrap_or_else(|_| "true".to_string()) == "true"; + let secure = env::var("SECURE_COOKIES").unwrap_or_else(|_| "true".to_string()) == "true"; - let app = interne::build_app(pool, secure).await; + let server_auth = ServerAuthConfig::from_env().unwrap_or_else(|error| { + eprintln!("Authentication configuration error: {error}"); + std::process::exit(1); + }); + let github = GitHubOAuthClient::new( + server_auth.github.client_id, + server_auth.github.client_secret, + ) + .unwrap_or_else(|error| { + eprintln!("GitHub client configuration error: {error}"); + std::process::exit(1); + }); + let auth = AuthServices { + config: server_auth.auth, + github: Arc::new(github), + }; + let app = interne::build_app(pool, secure, auth).await; let addr = SocketAddr::from(([0, 0, 0, 0], 3000)); let listener = TcpListener::bind(addr).await.unwrap(); diff --git a/tests/common/mod.rs b/tests/common/mod.rs index 721bced..bb9b727 100644 --- a/tests/common/mod.rs +++ b/tests/common/mod.rs @@ -1,21 +1,79 @@ #![allow(dead_code)] +use axum::Router; use axum::body::Body; -use http_body_util::BodyExt; use axum::http::{Request, StatusCode}; use axum::response::Response; -use axum::Router; -use sqlx::sqlite::{SqliteConnectOptions, SqlitePoolOptions}; +use http_body_util::BodyExt; +use interne::AuthServices; +use interne::config::{AuthConfig, SignupMode}; +use interne::github::{GitHubError, GitHubProfile, GitHubProvider}; use sqlx::SqlitePool; +use sqlx::sqlite::{SqliteConnectOptions, SqlitePoolOptions}; +use std::collections::HashMap; use std::str::FromStr; +use std::sync::{Arc, Mutex}; +use url::Url; + +#[derive(Clone, Default)] +pub struct FakeGitHubProvider { + profiles: Arc>>>, +} + +impl FakeGitHubProvider { + pub fn profile_for_code(&self, code: &str, profile: GitHubProfile) { + self.profiles + .lock() + .expect("Fake GitHub profile store should not be poisoned") + .insert(code.to_string(), Ok(profile)); + } +} + +#[async_trait::async_trait] +impl GitHubProvider for FakeGitHubProvider { + fn authorization_url( + &self, + callback_url: &Url, + state: &str, + pkce_challenge: &str, + ) -> Result { + let mut url = Url::parse("https://github.test/authorize") + .map_err(|_| GitHubError::AuthorizationUrl)?; + url.query_pairs_mut() + .append_pair("redirect_uri", callback_url.as_str()) + .append_pair("state", state) + .append_pair("code_challenge", pkce_challenge) + .append_pair("code_challenge_method", "S256"); + Ok(url) + } + + async fn exchange_code( + &self, + _callback_url: &Url, + code: &str, + _pkce_verifier: &str, + ) -> Result { + self.profiles + .lock() + .map_err(|_| GitHubError::TokenExchange)? + .get(code) + .cloned() + .unwrap_or(Err(GitHubError::TokenExchange)) + } +} pub struct TestApp { pub router: Router, pub db: SqlitePool, + pub github: FakeGitHubProvider, } impl TestApp { pub async fn new() -> Self { + Self::with_signup_mode(SignupMode::Closed).await + } + + pub async fn with_signup_mode(signup_mode: SignupMode) -> Self { let options = SqliteConnectOptions::from_str("sqlite::memory:") .unwrap() .create_if_missing(true); @@ -31,9 +89,18 @@ impl TestApp { .await .expect("Failed to run migrations"); - let router = interne::build_app(pool.clone(), false).await; - - Self { router, db: pool } + let github = FakeGitHubProvider::default(); + let auth = AuthServices { + config: AuthConfig::new("https://interne.test", signup_mode).unwrap(), + github: Arc::new(github.clone()), + }; + let router = interne::build_app(pool.clone(), false, auth).await; + + Self { + router, + db: pool, + github, + } } /// Send a request through the app and return the response. @@ -153,3 +220,32 @@ pub fn assert_hx_redirect(resp: &Response, expected_location: &str) { .unwrap(); assert_eq!(hx, expected_location); } + +#[cfg(test)] +mod tests { + use interne::github::{GitHubProfile, GitHubProvider}; + use url::Url; + + use super::FakeGitHubProvider; + + #[tokio::test] + async fn fake_github_returns_the_profile_registered_for_a_code() { + let fake = FakeGitHubProvider::default(); + let expected = GitHubProfile { + user_id: "123".into(), + login: "axelav".into(), + name: Some("Axel".into()), + }; + fake.profile_for_code("good-code", expected.clone()); + + let actual = fake + .exchange_code( + &Url::parse("https://interne.test/auth/github/callback").unwrap(), + "good-code", + "verifier", + ) + .await + .unwrap(); + assert_eq!(actual, expected); + } +} From 828d5710a341a34b48f890dd4111a19a6c537650 Mon Sep 17 00:00:00 2001 From: Axel Anderson Date: Wed, 12 Aug 2026 10:39:16 -0400 Subject: [PATCH 11/25] test(auth): support fake GitHub failures --- tests/common/mod.rs | 25 ++++++++++++++++++++++++- 1 file changed, 24 insertions(+), 1 deletion(-) diff --git a/tests/common/mod.rs b/tests/common/mod.rs index bb9b727..445f718 100644 --- a/tests/common/mod.rs +++ b/tests/common/mod.rs @@ -27,6 +27,13 @@ impl FakeGitHubProvider { .expect("Fake GitHub profile store should not be poisoned") .insert(code.to_string(), Ok(profile)); } + + pub fn error_for_code(&self, code: &str, error: GitHubError) { + self.profiles + .lock() + .expect("Fake GitHub profile store should not be poisoned") + .insert(code.to_string(), Err(error)); + } } #[async_trait::async_trait] @@ -223,7 +230,7 @@ pub fn assert_hx_redirect(resp: &Response, expected_location: &str) { #[cfg(test)] mod tests { - use interne::github::{GitHubProfile, GitHubProvider}; + use interne::github::{GitHubError, GitHubProfile, GitHubProvider}; use url::Url; use super::FakeGitHubProvider; @@ -248,4 +255,20 @@ mod tests { .unwrap(); assert_eq!(actual, expected); } + + #[tokio::test] + async fn fake_github_returns_the_error_registered_for_a_code() { + let fake = FakeGitHubProvider::default(); + fake.error_for_code("failed-code", GitHubError::ProfileFetch); + + let error = fake + .exchange_code( + &Url::parse("https://interne.test/auth/github/callback").unwrap(), + "failed-code", + "verifier", + ) + .await + .unwrap_err(); + assert_eq!(error, GitHubError::ProfileFetch); + } } From 676f57bc31f675cd7261aff0feeede92c95ac890 Mon Sep 17 00:00:00 2001 From: Axel Anderson Date: Wed, 12 Aug 2026 10:44:16 -0400 Subject: [PATCH 12/25] feat(auth): version authenticated sessions --- src/auth.rs | 47 +++++++++++++++++++++++++++++++++++++---------- tests/auth.rs | 16 ++++++++++++++++ 2 files changed, 53 insertions(+), 10 deletions(-) diff --git a/src/auth.rs b/src/auth.rs index 93a5362..3fd5da9 100644 --- a/src/auth.rs +++ b/src/auth.rs @@ -5,32 +5,43 @@ use axum::{ }; use tower_sessions::Session; -use crate::models::User; use crate::AppState; +use crate::models::User; const USER_ID_KEY: &str = "user_id"; +const AUTH_VERSION_KEY: &str = "auth_version"; + +fn session_identity(user_id: Option, auth_version: Option) -> Option<(String, i64)> { + Some((user_id?, auth_version?)) +} pub struct AuthUser(pub User); impl FromRequestParts for AuthUser { type Rejection = AuthRedirect; - async fn from_request_parts(parts: &mut Parts, state: &AppState) -> Result { + async fn from_request_parts( + parts: &mut Parts, + state: &AppState, + ) -> Result { let session = Session::from_request_parts(parts, state) .await .map_err(|_| AuthRedirect)?; let user_id: Option = session.get(USER_ID_KEY).await.ok().flatten(); + let auth_version: Option = session.get(AUTH_VERSION_KEY).await.ok().flatten(); - let Some(user_id) = user_id else { + let Some((user_id, auth_version)) = session_identity(user_id, auth_version) else { return Err(AuthRedirect); }; - let user: Option = sqlx::query_as("SELECT * FROM users WHERE id = ?") - .bind(&user_id) - .fetch_optional(&state.db) - .await - .map_err(|_| AuthRedirect)?; + let user: Option = + sqlx::query_as("SELECT * FROM users WHERE id = ? AND auth_version = ?") + .bind(&user_id) + .bind(auth_version) + .fetch_optional(&state.db) + .await + .map_err(|_| AuthRedirect)?; user.map(AuthUser).ok_or(AuthRedirect) } @@ -44,10 +55,26 @@ impl IntoResponse for AuthRedirect { } } -pub async fn login_user(session: &Session, user: &User) -> Result<(), tower_sessions::session::Error> { - session.insert(USER_ID_KEY, &user.id).await +pub async fn login_user( + session: &Session, + user: &User, +) -> Result<(), tower_sessions::session::Error> { + session.insert(USER_ID_KEY, &user.id).await?; + session.insert(AUTH_VERSION_KEY, user.auth_version).await } pub async fn logout_user(session: &Session) -> Result<(), tower_sessions::session::Error> { session.flush().await } + +#[cfg(test)] +mod tests { + use super::session_identity; + + #[test] + fn missing_auth_version_rejects_a_known_user_id() { + let identity = session_identity(Some("known-user".into()), None); + + assert_eq!(identity, None); + } +} diff --git a/tests/auth.rs b/tests/auth.rs index 3d4d839..6fe8631 100644 --- a/tests/auth.rs +++ b/tests/auth.rs @@ -43,6 +43,22 @@ async fn logout_clears_session() { assert_redirect(&resp, "/login"); } +#[tokio::test] +async fn changing_auth_version_revokes_an_existing_session() { + let app = TestApp::new().await; + let (user_id, invite_code) = app.create_user("Test User").await; + let cookie = app.login(&invite_code).await; + + sqlx::query("UPDATE users SET auth_version = auth_version + 1 WHERE id = ?") + .bind(&user_id) + .execute(&app.db) + .await + .unwrap(); + + let response = app.get("/", Some(&cookie)).await; + assert_redirect(&response, "/login"); +} + #[tokio::test] async fn unauthenticated_index_redirects_to_login() { let app = TestApp::new().await; From 426bb289397acf01da8dbcba93e275277475f4b5 Mon Sep 17 00:00:00 2001 From: Axel Anderson Date: Wed, 12 Aug 2026 10:57:48 -0400 Subject: [PATCH 13/25] feat(auth): sign in with GitHub --- src/auth.rs | 52 ++++++++ src/routes/auth.rs | 177 ++++++++++++++++++++++++-- static/style.css | 29 +++++ templates/auth_error.html | 11 ++ templates/login.html | 15 ++- tests/auth.rs | 16 ++- tests/collections.rs | 48 ++++---- tests/common/mod.rs | 184 ++++++++++++++++++++++++---- tests/entries.rs | 94 +++++++------- tests/export.rs | 4 +- tests/github_auth.rs | 252 ++++++++++++++++++++++++++++++++++++++ tests/tags.rs | 32 ++--- 12 files changed, 778 insertions(+), 136 deletions(-) create mode 100644 templates/auth_error.html create mode 100644 tests/github_auth.rs diff --git a/src/auth.rs b/src/auth.rs index 3fd5da9..db74787 100644 --- a/src/auth.rs +++ b/src/auth.rs @@ -3,6 +3,10 @@ use axum::{ http::request::Parts, response::{IntoResponse, Redirect, Response}, }; +use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD}; +use chrono::{DateTime, Utc}; +use rand::RngCore; +use serde::{Deserialize, Serialize}; use tower_sessions::Session; use crate::AppState; @@ -10,6 +14,54 @@ use crate::models::User; const USER_ID_KEY: &str = "user_id"; const AUTH_VERSION_KEY: &str = "auth_version"; +const OAUTH_ATTEMPT_KEY: &str = "oauth_attempt"; + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub enum OAuthPurpose { + Login, +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct OAuthAttempt { + pub state: String, + pub pkce_verifier: String, + pub purpose: OAuthPurpose, + pub expires_at: i64, +} + +impl OAuthAttempt { + pub fn new(purpose: OAuthPurpose, now: DateTime) -> Self { + let mut state = [0_u8; 32]; + let mut pkce_verifier = [0_u8; 32]; + let mut rng = rand::rng(); + rng.fill_bytes(&mut state); + rng.fill_bytes(&mut pkce_verifier); + + Self { + state: URL_SAFE_NO_PAD.encode(state), + pkce_verifier: URL_SAFE_NO_PAD.encode(pkce_verifier), + purpose, + expires_at: now.timestamp() + 600, + } + } + + pub fn pkce_challenge(&self) -> String { + crate::github::pkce_challenge(&self.pkce_verifier) + } +} + +pub async fn store_oauth_attempt( + session: &Session, + attempt: &OAuthAttempt, +) -> Result<(), tower_sessions::session::Error> { + session.insert(OAUTH_ATTEMPT_KEY, attempt).await +} + +pub async fn take_oauth_attempt( + session: &Session, +) -> Result, tower_sessions::session::Error> { + session.remove(OAUTH_ATTEMPT_KEY).await +} fn session_identity(user_id: Option, auth_version: Option) -> Option<(String, i64)> { Some((user_id?, auth_version?)) diff --git a/src/routes/auth.rs b/src/routes/auth.rs index 6df0f8d..fa37388 100644 --- a/src/routes/auth.rs +++ b/src/routes/auth.rs @@ -1,22 +1,39 @@ use askama::Template; use axum::{ - extract::State, + Form, Router, + extract::{Query, State}, response::{Html, IntoResponse, Redirect}, routing::{get, post}, - Form, Router, }; use serde::Deserialize; use tower_sessions::Session; -use crate::auth::{login_user, logout_user}; +use crate::AppState; +use crate::auth::{ + OAuthAttempt, OAuthPurpose, login_user, logout_user, store_oauth_attempt, take_oauth_attempt, +}; +use crate::config::SignupMode; use crate::error::AppError; use crate::models::User; -use crate::AppState; + +const GITHUB_SIGN_IN_ERROR: &str = + "We couldn’t complete GitHub sign-in. Please return to login and try again."; +const CLOSED_SIGNUP_ERROR: &str = + "Access isn’t open yet. Email webmaster@honkytonk.in for an invite."; #[derive(Template)] #[template(path = "login.html")] struct LoginTemplate { error: Option, + legacy_login_available: bool, + static_hash: &'static str, + user: Option, +} + +#[derive(Template)] +#[template(path = "auth_error.html")] +struct AuthErrorTemplate<'a> { + message: &'a str, static_hash: &'static str, user: Option, } @@ -26,16 +43,25 @@ pub struct LoginForm { invite_code: String, } +#[derive(Deserialize)] +struct GitHubCallbackQuery { + code: String, + state: String, +} + pub fn router() -> Router { Router::new() .route("/login", get(login_page)) .route("/login", post(login_submit)) + .route("/auth/github", get(github_login_start)) + .route("/auth/github/callback", get(github_callback)) .route("/logout", post(logout)) } -async fn login_page() -> Result { +async fn login_page(State(state): State) -> Result { let template = LoginTemplate { error: None, + legacy_login_available: legacy_login_available(&state).await?, static_hash: crate::STATIC_HASH, user: None, }; @@ -47,12 +73,10 @@ async fn login_submit( session: Session, Form(form): Form, ) -> Result { - let user: Option = sqlx::query_as( - "SELECT * FROM users WHERE invite_code = ?" - ) - .bind(&form.invite_code) - .fetch_optional(&state.db) - .await?; + let user: Option = sqlx::query_as("SELECT * FROM users WHERE invite_code = ?") + .bind(&form.invite_code) + .fetch_optional(&state.db) + .await?; match user { Some(user) => { @@ -63,6 +87,7 @@ async fn login_submit( None => { let template = LoginTemplate { error: Some("Invalid invite code".to_string()), + legacy_login_available: legacy_login_available(&state).await?, static_hash: crate::STATIC_HASH, user: None, }; @@ -71,6 +96,136 @@ async fn login_submit( } } +async fn github_login_start( + State(state): State, + session: Session, +) -> Result { + let attempt = OAuthAttempt::new(OAuthPurpose::Login, chrono::Utc::now()); + store_oauth_attempt(&session, &attempt).await?; + let callback_url = github_callback_url(&state); + let authorization_url = match state.auth.github.authorization_url( + &callback_url, + &attempt.state, + &attempt.pkce_challenge(), + ) { + Ok(url) => url, + Err(_) => return render_auth_error(GITHUB_SIGN_IN_ERROR), + }; + + Ok(Redirect::to(authorization_url.as_str()).into_response()) +} + +async fn github_callback( + State(state): State, + session: Session, + Query(query): Query, +) -> Result { + let Some(attempt) = take_oauth_attempt(&session).await? else { + return render_auth_error(GITHUB_SIGN_IN_ERROR); + }; + let is_valid_attempt = attempt.purpose == OAuthPurpose::Login + && attempt.state == query.state + && attempt.expires_at > chrono::Utc::now().timestamp(); + if !is_valid_attempt { + return render_auth_error(GITHUB_SIGN_IN_ERROR); + } + + let profile = match state + .auth + .github + .exchange_code( + &github_callback_url(&state), + &query.code, + &attempt.pkce_verifier, + ) + .await + { + Ok(profile) => profile, + Err(_) => return render_auth_error(GITHUB_SIGN_IN_ERROR), + }; + + let now = chrono::Utc::now().to_rfc3339(); + sqlx::query("UPDATE users SET github_login = ?, updated_at = ? WHERE github_user_id = ?") + .bind(&profile.login) + .bind(&now) + .bind(&profile.user_id) + .execute(&state.db) + .await?; + + let mut user: Option = sqlx::query_as("SELECT * FROM users WHERE github_user_id = ?") + .bind(&profile.user_id) + .fetch_optional(&state.db) + .await?; + + if user.is_none() { + if state.auth.config.signup_mode == SignupMode::Closed { + return render_auth_error(CLOSED_SIGNUP_ERROR); + } + + let user_id = uuid::Uuid::new_v4().to_string(); + let display_name = profile + .name + .as_deref() + .filter(|name| !name.trim().is_empty()) + .unwrap_or(&profile.login); + sqlx::query( + "INSERT INTO users (\ + id, name, email, invite_code, github_user_id, github_login, created_at, updated_at\ + ) VALUES (?, ?, NULL, NULL, ?, ?, ?, ?) \ + ON CONFLICT(github_user_id) DO NOTHING", + ) + .bind(&user_id) + .bind(display_name) + .bind(&profile.user_id) + .bind(&profile.login) + .bind(&now) + .bind(&now) + .execute(&state.db) + .await?; + + user = sqlx::query_as("SELECT * FROM users WHERE github_user_id = ?") + .bind(&profile.user_id) + .fetch_optional(&state.db) + .await?; + } + + let Some(user) = user else { + return render_auth_error(GITHUB_SIGN_IN_ERROR); + }; + session.cycle_id().await?; + login_user(&session, &user).await?; + Ok(Redirect::to("/").into_response()) +} + +async fn legacy_login_available(state: &AppState) -> Result { + Ok(sqlx::query_scalar( + "SELECT EXISTS(\ + SELECT 1 FROM users \ + WHERE invite_code IS NOT NULL AND github_user_id IS NULL\ + )", + ) + .fetch_one(&state.db) + .await?) +} + +fn github_callback_url(state: &AppState) -> url::Url { + state + .auth + .config + .public_base_url + .join("auth/github/callback") + .expect("validated public base URL should accept an OAuth callback path") +} + +fn render_auth_error(message: &str) -> Result { + let template = AuthErrorTemplate { + message, + static_hash: crate::STATIC_HASH, + user: None, + }; + Ok(Html(template.render()?).into_response()) +} + async fn logout(session: Session) -> Result { logout_user(&session).await?; Ok(Redirect::to("/login")) diff --git a/static/style.css b/static/style.css index 50747b9..bc684cb 100644 --- a/static/style.css +++ b/static/style.css @@ -380,6 +380,35 @@ button.link-button:hover { margin: 3rem auto; } +.primary-action { + display: inline-block; + padding: 0.5rem 1.25rem; + background: var(--black); + color: var(--white); + border-radius: var(--radius); + font-size: 0.875rem; + text-decoration: none; + transition: background 0.15s ease; +} + +.primary-action:hover { + background: var(--gray-900); +} + +.login-divider { + margin: 1.5rem 0; + color: var(--gray-400); + font-size: 0.75rem; +} + +.login-error { + margin-top: 1rem; +} + +.auth-error p { + margin-bottom: 1rem; +} + /* Empty state */ .empty { text-align: center; diff --git a/templates/auth_error.html b/templates/auth_error.html new file mode 100644 index 0000000..a3929df --- /dev/null +++ b/templates/auth_error.html @@ -0,0 +1,11 @@ +{% extends "base.html" %} + +{% block title %}Sign-in error - Interne{% endblock %} + +{% block content %} + +{% endblock %} diff --git a/templates/login.html b/templates/login.html index 15278ce..34bbd7d 100644 --- a/templates/login.html +++ b/templates/login.html @@ -6,6 +6,15 @@ {% endblock %} diff --git a/tests/auth.rs b/tests/auth.rs index 6fe8631..7aa638f 100644 --- a/tests/auth.rs +++ b/tests/auth.rs @@ -1,12 +1,12 @@ mod common; use axum::http::StatusCode; -use common::{assert_redirect, body_string, TestApp}; +use common::{TestApp, assert_redirect, body_string}; #[tokio::test] async fn login_with_valid_invite_code() { let app = TestApp::new().await; - let (_user_id, invite_code) = app.create_user("Test User").await; + let (_user_id, invite_code) = app.create_legacy_user("Test User").await; let resp = app .post_form("/login", &format!("invite_code={}", invite_code), None) @@ -20,9 +20,7 @@ async fn login_with_valid_invite_code() { async fn login_with_invalid_invite_code() { let app = TestApp::new().await; - let resp = app - .post_form("/login", "invite_code=bad-code", None) - .await; + let resp = app.post_form("/login", "invite_code=bad-code", None).await; assert_eq!(resp.status(), StatusCode::OK); let body = body_string(resp).await; @@ -32,8 +30,8 @@ async fn login_with_invalid_invite_code() { #[tokio::test] async fn logout_clears_session() { let app = TestApp::new().await; - let (_user_id, invite_code) = app.create_user("Test User").await; - let cookie = app.login(&invite_code).await; + let (_user_id, github_user_id) = app.create_user("Test User").await; + let cookie = app.login(&github_user_id).await; let resp = app.post_form("/logout", "", Some(&cookie)).await; assert_redirect(&resp, "/login"); @@ -46,8 +44,8 @@ async fn logout_clears_session() { #[tokio::test] async fn changing_auth_version_revokes_an_existing_session() { let app = TestApp::new().await; - let (user_id, invite_code) = app.create_user("Test User").await; - let cookie = app.login(&invite_code).await; + let (user_id, github_user_id) = app.create_user("Test User").await; + let cookie = app.login(&github_user_id).await; sqlx::query("UPDATE users SET auth_version = auth_version + 1 WHERE id = ?") .bind(&user_id) diff --git a/tests/collections.rs b/tests/collections.rs index 429825e..ef4004a 100644 --- a/tests/collections.rs +++ b/tests/collections.rs @@ -8,8 +8,8 @@ use common::{assert_hx_redirect, assert_redirect, body_string, TestApp}; #[tokio::test] async fn create_collection() { let app = TestApp::new().await; - let (_user_id, invite_code) = app.create_user("Test User").await; - let cookie = app.login(&invite_code).await; + let (_user_id, github_user_id) = app.create_user("Test User").await; + let cookie = app.login(&github_user_id).await; let resp = app .post_form("/collections", "name=My+Collection", Some(&cookie)) @@ -25,8 +25,8 @@ async fn create_collection() { #[tokio::test] async fn create_collection_empty_name_shows_error() { let app = TestApp::new().await; - let (_user_id, invite_code) = app.create_user("Test User").await; - let cookie = app.login(&invite_code).await; + let (_user_id, github_user_id) = app.create_user("Test User").await; + let cookie = app.login(&github_user_id).await; let resp = app .post_form("/collections", "name=", Some(&cookie)) @@ -39,8 +39,8 @@ async fn create_collection_empty_name_shows_error() { #[tokio::test] async fn show_collection_as_owner() { let app = TestApp::new().await; - let (user_id, invite_code) = app.create_user("Owner").await; - let cookie = app.login(&invite_code).await; + let (user_id, github_user_id) = app.create_user("Owner").await; + let cookie = app.login(&github_user_id).await; let col_id = uuid::Uuid::new_v4().to_string(); let now = chrono::Utc::now().to_rfc3339(); @@ -70,8 +70,8 @@ async fn show_collection_as_owner() { async fn show_collection_as_non_member_redirects() { let app = TestApp::new().await; let (owner_id, _) = app.create_user("Owner").await; - let (_, outsider_invite) = app.create_user("Outsider").await; - let cookie = app.login(&outsider_invite).await; + let (_, outsider_github_user_id) = app.create_user("Outsider").await; + let cookie = app.login(&outsider_github_user_id).await; let col_id = uuid::Uuid::new_v4().to_string(); let now = chrono::Utc::now().to_rfc3339(); @@ -97,8 +97,8 @@ async fn show_collection_as_non_member_redirects() { #[tokio::test] async fn update_collection_as_owner() { let app = TestApp::new().await; - let (user_id, invite_code) = app.create_user("Owner").await; - let cookie = app.login(&invite_code).await; + let (user_id, github_user_id) = app.create_user("Owner").await; + let cookie = app.login(&github_user_id).await; let col_id = uuid::Uuid::new_v4().to_string(); let now = chrono::Utc::now().to_rfc3339(); @@ -137,8 +137,8 @@ async fn update_collection_as_owner() { async fn update_collection_as_member_does_nothing() { let app = TestApp::new().await; let (owner_id, _) = app.create_user("Owner").await; - let (member_id, member_invite) = app.create_user("Member").await; - let cookie = app.login(&member_invite).await; + let (member_id, member_github_user_id) = app.create_user("Member").await; + let cookie = app.login(&member_github_user_id).await; let col_id = uuid::Uuid::new_v4().to_string(); let now = chrono::Utc::now().to_rfc3339(); @@ -185,8 +185,8 @@ async fn update_collection_as_member_does_nothing() { #[tokio::test] async fn delete_collection_as_owner() { let app = TestApp::new().await; - let (user_id, invite_code) = app.create_user("Owner").await; - let cookie = app.login(&invite_code).await; + let (user_id, github_user_id) = app.create_user("Owner").await; + let cookie = app.login(&github_user_id).await; let col_id = uuid::Uuid::new_v4().to_string(); let now = chrono::Utc::now().to_rfc3339(); @@ -220,8 +220,8 @@ async fn delete_collection_as_owner() { async fn delete_collection_as_member_does_nothing() { let app = TestApp::new().await; let (owner_id, _) = app.create_user("Owner").await; - let (member_id, member_invite) = app.create_user("Member").await; - let cookie = app.login(&member_invite).await; + let (member_id, member_github_user_id) = app.create_user("Member").await; + let cookie = app.login(&member_github_user_id).await; let col_id = uuid::Uuid::new_v4().to_string(); let now = chrono::Utc::now().to_rfc3339(); @@ -265,8 +265,8 @@ async fn delete_collection_as_member_does_nothing() { async fn join_collection_via_invite_code() { let app = TestApp::new().await; let (owner_id, _) = app.create_user("Owner").await; - let (_, member_invite) = app.create_user("Member").await; - let cookie = app.login(&member_invite).await; + let (_, member_github_user_id) = app.create_user("Member").await; + let cookie = app.login(&member_github_user_id).await; let col_id = uuid::Uuid::new_v4().to_string(); let now = chrono::Utc::now().to_rfc3339(); @@ -305,8 +305,8 @@ async fn join_collection_via_invite_code() { #[tokio::test] async fn regenerate_invite_as_owner() { let app = TestApp::new().await; - let (user_id, invite_code) = app.create_user("Owner").await; - let cookie = app.login(&invite_code).await; + let (user_id, github_user_id) = app.create_user("Owner").await; + let cookie = app.login(&github_user_id).await; let col_id = uuid::Uuid::new_v4().to_string(); let now = chrono::Utc::now().to_rfc3339(); @@ -342,9 +342,9 @@ async fn regenerate_invite_as_owner() { #[tokio::test] async fn owner_removes_member() { let app = TestApp::new().await; - let (owner_id, owner_invite) = app.create_user("Owner").await; + let (owner_id, owner_github_user_id) = app.create_user("Owner").await; let (member_id, _) = app.create_user("Member").await; - let cookie = app.login(&owner_invite).await; + let cookie = app.login(&owner_github_user_id).await; let col_id = uuid::Uuid::new_v4().to_string(); let now = chrono::Utc::now().to_rfc3339(); @@ -392,8 +392,8 @@ async fn owner_removes_member() { async fn member_leaves_collection() { let app = TestApp::new().await; let (owner_id, _) = app.create_user("Owner").await; - let (member_id, member_invite) = app.create_user("Member").await; - let cookie = app.login(&member_invite).await; + let (member_id, member_github_user_id) = app.create_user("Member").await; + let cookie = app.login(&member_github_user_id).await; let col_id = uuid::Uuid::new_v4().to_string(); let now = chrono::Utc::now().to_rfc3339(); diff --git a/tests/common/mod.rs b/tests/common/mod.rs index 445f718..0f6f5a7 100644 --- a/tests/common/mod.rs +++ b/tests/common/mod.rs @@ -2,7 +2,7 @@ use axum::Router; use axum::body::Body; -use axum::http::{Request, StatusCode}; +use axum::http::Request; use axum::response::Response; use http_body_util::BodyExt; use interne::AuthServices; @@ -12,9 +12,14 @@ use sqlx::SqlitePool; use sqlx::sqlite::{SqliteConnectOptions, SqlitePoolOptions}; use std::collections::HashMap; use std::str::FromStr; +use std::sync::atomic::{AtomicU64, Ordering}; use std::sync::{Arc, Mutex}; +use tower_sessions::{SessionStore, session::Id}; +use tower_sessions_sqlx_store::SqliteStore; use url::Url; +static NEXT_GITHUB_USER_ID: AtomicU64 = AtomicU64::new(1_000_000); + #[derive(Clone, Default)] pub struct FakeGitHubProvider { profiles: Arc>>>, @@ -117,8 +122,46 @@ impl TestApp { .unwrap() } - /// Create a user in the database and return (user_id, invite_code). + /// Create a GitHub-linked user and return (user_id, github_user_id). pub async fn create_user(&self, name: &str) -> (String, String) { + let github_user_id = NEXT_GITHUB_USER_ID + .fetch_add(1, Ordering::Relaxed) + .to_string(); + let github_login = format!("test-{github_user_id}"); + let user_id = self + .create_github_user(name, &github_user_id, &github_login) + .await; + + (user_id, github_user_id) + } + + pub async fn create_github_user( + &self, + name: &str, + github_user_id: &str, + github_login: &str, + ) -> String { + let id = uuid::Uuid::new_v4().to_string(); + let now = chrono::Utc::now().to_rfc3339(); + + sqlx::query( + "INSERT INTO users (id, name, github_user_id, github_login, created_at, updated_at) \ + VALUES (?, ?, ?, ?, ?, ?)", + ) + .bind(&id) + .bind(name) + .bind(github_user_id) + .bind(github_login) + .bind(&now) + .bind(&now) + .execute(&self.db) + .await + .expect("Failed to create test user"); + + id + } + + pub async fn create_legacy_user(&self, name: &str) -> (String, String) { let id = uuid::Uuid::new_v4().to_string(); let invite_code = uuid::Uuid::new_v4().to_string(); let now = chrono::Utc::now().to_rfc3339(); @@ -133,32 +176,119 @@ impl TestApp { .bind(&now) .execute(&self.db) .await - .expect("Failed to create test user"); + .expect("Failed to create legacy test user"); (id, invite_code) } - /// Log in as the given user and return the session cookie string. - pub async fn login(&self, invite_code: &str) -> String { - let req = Request::builder() - .uri("/login") - .method("POST") - .header("content-type", "application/x-www-form-urlencoded") - .body(Body::from(format!("invite_code={}", invite_code))) - .unwrap(); - - let resp = self.request(req).await; - assert_eq!(resp.status(), StatusCode::SEE_OTHER); + pub async fn begin_github_login(&self) -> (String, Url) { + self.begin_github_login_with_cookie(None).await + } - resp.headers() + pub async fn begin_github_login_with_cookie(&self, cookie: Option<&str>) -> (String, Url) { + let response = self.get("/auth/github", cookie).await; + assert!( + response.status().is_redirection(), + "GitHub login start should redirect, got {}", + response.status() + ); + let authorization_url = Url::parse( + response + .headers() + .get("location") + .expect("GitHub login start should have a Location header") + .to_str() + .unwrap(), + ) + .expect("GitHub authorization redirect should be a valid URL"); + let session_cookie = response + .headers() .get("set-cookie") - .expect("Login should set a session cookie") - .to_str() - .unwrap() - .split(';') - .next() - .unwrap() - .to_string() + .map(cookie_from_response) + .or_else(|| cookie.map(str::to_owned)) + .expect("GitHub login start should establish or preserve a session cookie"); + + (session_cookie, authorization_url) + } + + pub async fn github_callback_response( + &self, + github_user_id: &str, + github_login: &str, + name: Option<&str>, + ) -> Response { + let (cookie, authorization_url) = self.begin_github_login().await; + let state = authorization_url + .query_pairs() + .find_map(|(key, value)| (key == "state").then(|| value.into_owned())) + .expect("GitHub authorization URL should contain state"); + let code = format!("code-{github_user_id}"); + self.github.profile_for_code( + &code, + GitHubProfile { + user_id: github_user_id.to_owned(), + login: github_login.to_owned(), + name: name.map(str::to_owned), + }, + ); + + self.get( + &format!("/auth/github/callback?code={code}&state={state}"), + Some(&cookie), + ) + .await + } + + pub async fn github_login( + &self, + github_user_id: &str, + github_login: &str, + name: Option<&str>, + ) -> String { + let response = self + .github_callback_response(github_user_id, github_login, name) + .await; + assert_redirect(&response, "/"); + cookie_from_response( + response + .headers() + .get("set-cookie") + .expect("GitHub callback should set a post-cycle session cookie"), + ) + } + + /// Log in as the given GitHub identity and return the session cookie string. + pub async fn login(&self, github_user_id: &str) -> String { + let (github_login, name): (String, String) = + sqlx::query_as("SELECT github_login, name FROM users WHERE github_user_id = ?") + .bind(github_user_id) + .fetch_one(&self.db) + .await + .expect("GitHub-linked test user should exist"); + + self.github_login(github_user_id, &github_login, Some(&name)) + .await + } + + pub async fn expire_oauth_attempt(&self, cookie: &str) { + let (_, encoded_id) = cookie + .split_once('=') + .expect("Session cookie should contain an ID"); + let session_id = + Id::from_str(encoded_id).expect("Session cookie should contain a valid ID"); + let store = SqliteStore::new(self.db.clone()); + let mut record = store + .load(&session_id) + .await + .expect("Session should load") + .expect("OAuth session should exist"); + record + .data + .get_mut("oauth_attempt") + .and_then(serde_json::Value::as_object_mut) + .expect("OAuth attempt should be stored in the session") + .insert("expires_at".into(), serde_json::json!(0)); + store.save(&record).await.expect("Session should save"); } /// Send a GET request with an optional session cookie. @@ -195,6 +325,16 @@ impl TestApp { } } +fn cookie_from_response(value: &axum::http::HeaderValue) -> String { + value + .to_str() + .unwrap() + .split(';') + .next() + .unwrap() + .to_string() +} + /// Read the full response body as a String. pub async fn body_string(resp: Response) -> String { let bytes = resp.into_body().collect().await.unwrap().to_bytes(); diff --git a/tests/entries.rs b/tests/entries.rs index db64bad..c04e39e 100644 --- a/tests/entries.rs +++ b/tests/entries.rs @@ -6,8 +6,8 @@ use common::{assert_hx_redirect, assert_redirect, body_string, TestApp}; #[tokio::test] async fn create_entry_with_valid_form() { let app = TestApp::new().await; - let (_user_id, invite_code) = app.create_user("Test User").await; - let cookie = app.login(&invite_code).await; + let (_user_id, github_user_id) = app.create_user("Test User").await; + let cookie = app.login(&github_user_id).await; let body = "url=https%3A%2F%2Fexample.com&title=Test+Entry&description=&duration=3&interval=days&tags=&collection_id="; let resp = app.post_form("/entries", body, Some(&cookie)).await; @@ -22,8 +22,8 @@ async fn create_entry_with_valid_form() { #[tokio::test] async fn create_entry_with_empty_title_shows_error() { let app = TestApp::new().await; - let (_user_id, invite_code) = app.create_user("Test User").await; - let cookie = app.login(&invite_code).await; + let (_user_id, github_user_id) = app.create_user("Test User").await; + let cookie = app.login(&github_user_id).await; let body = "url=https%3A%2F%2Fexample.com&title=&description=&duration=3&interval=days&tags=&collection_id="; let resp = app.post_form("/entries", body, Some(&cookie)).await; @@ -35,8 +35,8 @@ async fn create_entry_with_empty_title_shows_error() { #[tokio::test] async fn create_entry_with_bare_domain_normalizes_url() { let app = TestApp::new().await; - let (user_id, invite_code) = app.create_user("Test User").await; - let cookie = app.login(&invite_code).await; + let (user_id, github_user_id) = app.create_user("Test User").await; + let cookie = app.login(&github_user_id).await; // "yahoo.com" should be accepted and normalized to "https://yahoo.com/" let body = "url=yahoo.com&title=Yahoo&description=&duration=3&interval=days&tags=&collection_id="; @@ -54,8 +54,8 @@ async fn create_entry_with_bare_domain_normalizes_url() { #[tokio::test] async fn create_entry_with_https_url_preserves_it() { let app = TestApp::new().await; - let (user_id, invite_code) = app.create_user("Test User").await; - let cookie = app.login(&invite_code).await; + let (user_id, github_user_id) = app.create_user("Test User").await; + let cookie = app.login(&github_user_id).await; let body = "url=https%3A%2F%2Fexample.com%2Fpath&title=Example&description=&duration=3&interval=days&tags=&collection_id="; let resp = app.post_form("/entries", body, Some(&cookie)).await; @@ -72,8 +72,8 @@ async fn create_entry_with_https_url_preserves_it() { #[tokio::test] async fn create_entry_with_http_url_preserves_it() { let app = TestApp::new().await; - let (user_id, invite_code) = app.create_user("Test User").await; - let cookie = app.login(&invite_code).await; + let (user_id, github_user_id) = app.create_user("Test User").await; + let cookie = app.login(&github_user_id).await; let body = "url=http%3A%2F%2Fexample.com&title=Example&description=&duration=3&interval=days&tags=&collection_id="; let resp = app.post_form("/entries", body, Some(&cookie)).await; @@ -90,8 +90,8 @@ async fn create_entry_with_http_url_preserves_it() { #[tokio::test] async fn create_entry_with_invalid_url_shows_error() { let app = TestApp::new().await; - let (_user_id, invite_code) = app.create_user("Test User").await; - let cookie = app.login(&invite_code).await; + let (_user_id, github_user_id) = app.create_user("Test User").await; + let cookie = app.login(&github_user_id).await; // "not a url" has no valid domain structure let body = "url=not+a+url&title=Test&description=&duration=3&interval=days&tags=&collection_id="; @@ -104,8 +104,8 @@ async fn create_entry_with_invalid_url_shows_error() { #[tokio::test] async fn create_entry_with_bare_word_shows_error() { let app = TestApp::new().await; - let (_user_id, invite_code) = app.create_user("Test User").await; - let cookie = app.login(&invite_code).await; + let (_user_id, github_user_id) = app.create_user("Test User").await; + let cookie = app.login(&github_user_id).await; // "yahoo" alone is not a valid URL even after normalization let body = "url=yahoo&title=Test&description=&duration=3&interval=days&tags=&collection_id="; @@ -118,8 +118,8 @@ async fn create_entry_with_bare_word_shows_error() { #[tokio::test] async fn create_entry_with_ftp_url_shows_error() { let app = TestApp::new().await; - let (_user_id, invite_code) = app.create_user("Test User").await; - let cookie = app.login(&invite_code).await; + let (_user_id, github_user_id) = app.create_user("Test User").await; + let cookie = app.login(&github_user_id).await; let body = "url=ftp%3A%2F%2Fexample.com&title=Test&description=&duration=3&interval=days&tags=&collection_id="; let resp = app.post_form("/entries", body, Some(&cookie)).await; @@ -131,8 +131,8 @@ async fn create_entry_with_ftp_url_shows_error() { #[tokio::test] async fn create_entry_with_path_and_query_normalizes() { let app = TestApp::new().await; - let (user_id, invite_code) = app.create_user("Test User").await; - let cookie = app.login(&invite_code).await; + let (user_id, github_user_id) = app.create_user("Test User").await; + let cookie = app.login(&github_user_id).await; let body = "url=example.com%2Fpath%3Fq%3D1&title=Test&description=&duration=3&interval=days&tags=&collection_id="; let resp = app.post_form("/entries", body, Some(&cookie)).await; @@ -149,8 +149,8 @@ async fn create_entry_with_path_and_query_normalizes() { #[tokio::test] async fn create_entry_with_zero_duration_shows_error() { let app = TestApp::new().await; - let (_user_id, invite_code) = app.create_user("Test User").await; - let cookie = app.login(&invite_code).await; + let (_user_id, github_user_id) = app.create_user("Test User").await; + let cookie = app.login(&github_user_id).await; let body = "url=https%3A%2F%2Fexample.com&title=Test&description=&duration=0&interval=days&tags=&collection_id="; let resp = app.post_form("/entries", body, Some(&cookie)).await; @@ -162,8 +162,8 @@ async fn create_entry_with_zero_duration_shows_error() { #[tokio::test] async fn edit_entry_as_owner() { let app = TestApp::new().await; - let (user_id, invite_code) = app.create_user("Test User").await; - let cookie = app.login(&invite_code).await; + let (user_id, github_user_id) = app.create_user("Test User").await; + let cookie = app.login(&github_user_id).await; // Create entry directly in DB let entry_id = uuid::Uuid::new_v4().to_string(); @@ -203,8 +203,8 @@ async fn edit_entry_as_owner() { async fn edit_entry_as_non_owner_redirects() { let app = TestApp::new().await; let (owner_id, _) = app.create_user("Owner").await; - let (_, other_invite) = app.create_user("Other").await; - let cookie = app.login(&other_invite).await; + let (_, other_github_user_id) = app.create_user("Other").await; + let cookie = app.login(&other_github_user_id).await; // Create entry owned by someone else let entry_id = uuid::Uuid::new_v4().to_string(); @@ -234,8 +234,8 @@ async fn edit_entry_as_non_owner_redirects() { #[tokio::test] async fn delete_entry_as_owner() { let app = TestApp::new().await; - let (user_id, invite_code) = app.create_user("Test User").await; - let cookie = app.login(&invite_code).await; + let (user_id, github_user_id) = app.create_user("Test User").await; + let cookie = app.login(&github_user_id).await; let entry_id = uuid::Uuid::new_v4().to_string(); let now = chrono::Utc::now().to_rfc3339(); @@ -272,8 +272,8 @@ async fn delete_entry_as_owner() { async fn delete_entry_as_non_owner() { let app = TestApp::new().await; let (owner_id, _) = app.create_user("Owner").await; - let (_, other_invite) = app.create_user("Other").await; - let cookie = app.login(&other_invite).await; + let (_, other_github_user_id) = app.create_user("Other").await; + let cookie = app.login(&other_github_user_id).await; let entry_id = uuid::Uuid::new_v4().to_string(); let now = chrono::Utc::now().to_rfc3339(); @@ -310,8 +310,8 @@ async fn delete_entry_as_non_owner() { #[tokio::test] async fn visit_entry_updates_availability() { let app = TestApp::new().await; - let (user_id, invite_code) = app.create_user("Test User").await; - let cookie = app.login(&invite_code).await; + let (user_id, github_user_id) = app.create_user("Test User").await; + let cookie = app.login(&github_user_id).await; // Create an available entry (never dismissed) let entry_id = uuid::Uuid::new_v4().to_string(); @@ -373,8 +373,8 @@ async fn visit_entry_updates_availability() { #[tokio::test] async fn home_shows_only_available_entries() { let app = TestApp::new().await; - let (user_id, invite_code) = app.create_user("Test User").await; - let cookie = app.login(&invite_code).await; + let (user_id, github_user_id) = app.create_user("Test User").await; + let cookie = app.login(&github_user_id).await; let now = chrono::Utc::now(); let now_str = now.to_rfc3339(); @@ -432,8 +432,8 @@ async fn home_shows_only_available_entries() { #[tokio::test] async fn collection_member_sees_shared_entries() { let app = TestApp::new().await; - let (owner_id, _owner_invite) = app.create_user("Owner").await; - let (member_id, member_invite) = app.create_user("Member").await; + let (owner_id, _owner_github_user_id) = app.create_user("Owner").await; + let (member_id, member_github_user_id) = app.create_user("Member").await; // Create collection let collection_id = uuid::Uuid::new_v4().to_string(); @@ -481,7 +481,7 @@ async fn collection_member_sees_shared_entries() { .unwrap(); // Member should see the entry - let cookie = app.login(&member_invite).await; + let cookie = app.login(&member_github_user_id).await; let resp = app.get("/", Some(&cookie)).await; let html = body_string(resp).await; assert!(html.contains("Shared Entry")); @@ -491,7 +491,7 @@ async fn collection_member_sees_shared_entries() { async fn leaving_collection_hides_shared_entries() { let app = TestApp::new().await; let (owner_id, _) = app.create_user("Owner").await; - let (member_id, member_invite) = app.create_user("Member").await; + let (member_id, member_github_user_id) = app.create_user("Member").await; let collection_id = uuid::Uuid::new_v4().to_string(); let now = chrono::Utc::now().to_rfc3339(); @@ -535,7 +535,7 @@ async fn leaving_collection_hides_shared_entries() { .await .unwrap(); - let cookie = app.login(&member_invite).await; + let cookie = app.login(&member_github_user_id).await; // Member leaves collection let resp = app @@ -556,8 +556,8 @@ async fn leaving_collection_hides_shared_entries() { #[tokio::test] async fn create_entry_with_tags() { let app = TestApp::new().await; - let (_user_id, invite_code) = app.create_user("Test User").await; - let cookie = app.login(&invite_code).await; + let (_user_id, github_user_id) = app.create_user("Test User").await; + let cookie = app.login(&github_user_id).await; let body = "url=https%3A%2F%2Fexample.com&title=Tagged+Entry&description=&duration=3&interval=days&tags=rust%2C+web&collection_id="; let resp = app.post_form("/entries", body, Some(&cookie)).await; @@ -583,8 +583,8 @@ async fn create_entry_with_tags() { #[tokio::test] async fn update_entry_replaces_tags() { let app = TestApp::new().await; - let (user_id, invite_code) = app.create_user("Test User").await; - let cookie = app.login(&invite_code).await; + let (user_id, github_user_id) = app.create_user("Test User").await; + let cookie = app.login(&github_user_id).await; // Create entry with tags let body = "url=https%3A%2F%2Fexample.com&title=Tagged&description=&duration=3&interval=days&tags=rust%2C+web&collection_id="; @@ -620,8 +620,8 @@ async fn update_entry_replaces_tags() { #[tokio::test] async fn waiting_shows_only_not_yet_due_entries() { let app = TestApp::new().await; - let (user_id, invite_code) = app.create_user("Test User").await; - let cookie = app.login(&invite_code).await; + let (user_id, github_user_id) = app.create_user("Test User").await; + let cookie = app.login(&github_user_id).await; let now = chrono::Utc::now(); let now_str = now.to_rfc3339(); @@ -673,8 +673,8 @@ async fn waiting_shows_only_not_yet_due_entries() { #[tokio::test] async fn unseen_shows_only_unvisited_entries() { let app = TestApp::new().await; - let (user_id, invite_code) = app.create_user("Test User").await; - let cookie = app.login(&invite_code).await; + let (user_id, github_user_id) = app.create_user("Test User").await; + let cookie = app.login(&github_user_id).await; let now = chrono::Utc::now().to_rfc3339(); @@ -735,8 +735,8 @@ async fn unseen_shows_only_unvisited_entries() { #[tokio::test] async fn create_entry_with_long_description_shows_error() { let app = TestApp::new().await; - let (_user_id, invite_code) = app.create_user("Test User").await; - let cookie = app.login(&invite_code).await; + let (_user_id, github_user_id) = app.create_user("Test User").await; + let cookie = app.login(&github_user_id).await; let long_desc = "a".repeat(5001); let body = format!( diff --git a/tests/export.rs b/tests/export.rs index 7fd9c92..11fbbda 100644 --- a/tests/export.rs +++ b/tests/export.rs @@ -6,8 +6,8 @@ use common::{body_string, TestApp}; #[tokio::test] async fn export_returns_json_with_entries() { let app = TestApp::new().await; - let (user_id, invite_code) = app.create_user("Test User").await; - let cookie = app.login(&invite_code).await; + let (user_id, github_user_id) = app.create_user("Test User").await; + let cookie = app.login(&github_user_id).await; // Create an entry with tags let entry_id = uuid::Uuid::new_v4().to_string(); diff --git a/tests/github_auth.rs b/tests/github_auth.rs new file mode 100644 index 0000000..bc442eb --- /dev/null +++ b/tests/github_auth.rs @@ -0,0 +1,252 @@ +mod common; + +use std::collections::HashMap; + +use axum::http::{Response, StatusCode}; +use common::{TestApp, assert_redirect, body_string}; +use interne::config::SignupMode; +use interne::github::{GitHubError, GitHubProfile}; + +fn query_parameters(url: &url::Url) -> HashMap { + url.query_pairs() + .map(|(key, value)| (key.into_owned(), value.into_owned())) + .collect() +} + +fn callback_uri(code: &str, state: &str) -> String { + let mut url = url::Url::parse("https://interne.test/auth/github/callback").unwrap(); + url.query_pairs_mut() + .append_pair("code", code) + .append_pair("state", state); + format!("{}?{}", url.path(), url.query().unwrap()) +} + +fn register_profile(app: &TestApp, code: &str, user_id: &str, login: &str, name: Option<&str>) { + app.github.profile_for_code( + code, + GitHubProfile { + user_id: user_id.into(), + login: login.into(), + name: name.map(str::to_owned), + }, + ); +} + +async fn callback_body(response: Response) -> String { + assert_eq!(response.status(), StatusCode::OK); + body_string(response).await +} + +#[tokio::test] +async fn linked_github_identity_logs_into_existing_user() { + let app = TestApp::new().await; + let user_id = app.create_github_user("Axel", "100", "axelav").await; + let cookie = app.github_login("100", "axelav", Some("Axel")).await; + + let response = app.get("/", Some(&cookie)).await; + assert_eq!(response.status(), StatusCode::OK); + let authenticated_id: String = + sqlx::query_scalar("SELECT id FROM users WHERE github_user_id = '100'") + .fetch_one(&app.db) + .await + .unwrap(); + assert_eq!(authenticated_id, user_id); +} + +#[tokio::test] +async fn unknown_identity_is_rejected_in_closed_mode() { + let app = TestApp::new().await; + let response = app.github_callback_response("200", "visitor", None).await; + let body = callback_body(response).await; + assert!(body.contains("Access isn’t open yet")); + assert!(body.contains("webmaster@honkytonk.in")); + let count: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM users") + .fetch_one(&app.db) + .await + .unwrap(); + assert_eq!(count, 0); +} + +#[tokio::test] +async fn unknown_identity_creates_account_in_public_mode() { + let app = TestApp::with_signup_mode(SignupMode::Public).await; + let response = app.github_callback_response("200", "visitor", None).await; + assert_redirect(&response, "/"); + let (name, email, invite_code): (String, Option, Option) = + sqlx::query_as("SELECT name, email, invite_code FROM users WHERE github_user_id = '200'") + .fetch_one(&app.db) + .await + .unwrap(); + assert_eq!(name, "visitor"); + assert_eq!(email, None); + assert_eq!(invite_code, None); +} + +#[tokio::test] +async fn oauth_callback_rejects_mismatched_state() { + let app = TestApp::with_signup_mode(SignupMode::Public).await; + let (cookie, _) = app.begin_github_login().await; + register_profile(&app, "valid-code", "300", "attacker", None); + + let response = app + .get( + &callback_uri("valid-code", "mismatched-state"), + Some(&cookie), + ) + .await; + let body = callback_body(response).await; + assert!(body.contains("couldn’t complete GitHub sign-in")); + let count: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM users") + .fetch_one(&app.db) + .await + .unwrap(); + assert_eq!(count, 0); +} + +#[tokio::test] +async fn oauth_callback_rejects_replayed_state() { + let app = TestApp::with_signup_mode(SignupMode::Public).await; + let (cookie, authorization_url) = app.begin_github_login().await; + let state = query_parameters(&authorization_url)["state"].clone(); + register_profile(&app, "valid-code", "400", "replay", None); + let uri = callback_uri("valid-code", &state); + + let first = app.get(&uri, Some(&cookie)).await; + assert_redirect(&first, "/"); + let replay = app.get(&uri, Some(&cookie)).await; + let body = callback_body(replay).await; + assert!(body.contains("couldn’t complete GitHub sign-in")); + let count: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM users WHERE github_user_id = '400'") + .fetch_one(&app.db) + .await + .unwrap(); + assert_eq!(count, 1); +} + +#[tokio::test] +async fn oauth_callback_rejects_expired_attempt() { + let app = TestApp::with_signup_mode(SignupMode::Public).await; + let (cookie, authorization_url) = app.begin_github_login().await; + let state = query_parameters(&authorization_url)["state"].clone(); + app.expire_oauth_attempt(&cookie).await; + register_profile(&app, "valid-code", "500", "late", None); + + let response = app + .get(&callback_uri("valid-code", &state), Some(&cookie)) + .await; + let body = callback_body(response).await; + assert!(body.contains("couldn’t complete GitHub sign-in")); + let count: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM users") + .fetch_one(&app.db) + .await + .unwrap(); + assert_eq!(count, 0); +} + +#[tokio::test] +async fn github_login_refreshes_display_username() { + let app = TestApp::new().await; + app.create_github_user("Axel", "600", "old-login").await; + + app.github_login("600", "new-login", Some("Axel")).await; + + let github_login: String = + sqlx::query_scalar("SELECT github_login FROM users WHERE github_user_id = '600'") + .fetch_one(&app.db) + .await + .unwrap(); + assert_eq!(github_login, "new-login"); +} + +#[tokio::test] +async fn provider_failure_shows_safe_error() { + let app = TestApp::new().await; + let (cookie, authorization_url) = app.begin_github_login().await; + let state = query_parameters(&authorization_url)["state"].clone(); + app.github + .error_for_code("sensitive-code", GitHubError::ProfileFetch); + + let response = app + .get(&callback_uri("sensitive-code", &state), Some(&cookie)) + .await; + let body = callback_body(response).await; + assert!(body.contains("couldn’t complete GitHub sign-in")); + assert!(!body.contains("sensitive-code")); + assert!(!body.contains("GitHub profile fetch failed")); + assert!(!body.contains("ProfileFetch")); +} + +#[tokio::test] +async fn authorization_redirect_has_pkce_and_no_scope() { + let app = TestApp::new().await; + + let (_cookie, authorization_url) = app.begin_github_login().await; + + let query = query_parameters(&authorization_url); + assert_eq!( + authorization_url.as_str().split('?').next().unwrap(), + "https://github.test/authorize" + ); + assert_eq!( + query["redirect_uri"], + "https://interne.test/auth/github/callback" + ); + assert_eq!(query["code_challenge_method"], "S256"); + assert_eq!(query["state"].len(), 43); + assert_eq!(query["code_challenge"].len(), 43); + assert!(!query.contains_key("scope")); +} + +#[tokio::test] +async fn starting_new_oauth_attempt_replaces_previous_attempt() { + let app = TestApp::with_signup_mode(SignupMode::Public).await; + let (cookie, first_url) = app.begin_github_login().await; + let (cookie, second_url) = app.begin_github_login_with_cookie(Some(&cookie)).await; + let first_state = query_parameters(&first_url)["state"].clone(); + let second_state = query_parameters(&second_url)["state"].clone(); + assert_ne!(first_state, second_state); + register_profile(&app, "valid-code", "700", "latest", None); + + let response = app + .get(&callback_uri("valid-code", &first_state), Some(&cookie)) + .await; + let body = callback_body(response).await; + assert!(body.contains("couldn’t complete GitHub sign-in")); +} + +#[tokio::test] +async fn public_signup_ignores_blank_display_name() { + let app = TestApp::with_signup_mode(SignupMode::Public).await; + + let response = app + .github_callback_response("800", "fallback-login", Some(" \t ")) + .await; + + assert_redirect(&response, "/"); + let name: String = sqlx::query_scalar("SELECT name FROM users WHERE github_user_id = '800'") + .fetch_one(&app.db) + .await + .unwrap(); + assert_eq!(name, "fallback-login"); +} + +#[tokio::test] +async fn login_page_prioritizes_github_and_hides_unavailable_legacy_form() { + let app = TestApp::new().await; + + let body = body_string(app.get("/login", None).await).await; + + assert!(body.contains("Continue with GitHub")); + assert!(!body.contains("name=\"invite_code\"")); +} + +#[tokio::test] +async fn login_page_keeps_legacy_form_for_unlinked_users() { + let app = TestApp::new().await; + app.create_legacy_user("Legacy User").await; + + let body = body_string(app.get("/login", None).await).await; + + assert!(body.contains("Continue with GitHub")); + assert!(body.contains("name=\"invite_code\"")); +} diff --git a/tests/tags.rs b/tests/tags.rs index e874291..392edb5 100644 --- a/tests/tags.rs +++ b/tests/tags.rs @@ -13,8 +13,8 @@ async fn tags_page_requires_auth() { #[tokio::test] async fn tags_page_empty_state() { let app = TestApp::new().await; - let (_user_id, invite_code) = app.create_user("Test User").await; - let cookie = app.login(&invite_code).await; + let (_user_id, github_user_id) = app.create_user("Test User").await; + let cookie = app.login(&github_user_id).await; let resp = app.get("/tags", Some(&cookie)).await; assert_eq!(resp.status(), StatusCode::OK); @@ -25,8 +25,8 @@ async fn tags_page_empty_state() { #[tokio::test] async fn tags_page_shows_user_tags() { let app = TestApp::new().await; - let (_user_id, invite_code) = app.create_user("Test User").await; - let cookie = app.login(&invite_code).await; + let (_user_id, github_user_id) = app.create_user("Test User").await; + let cookie = app.login(&github_user_id).await; // Create entry with tags let body = "url=https%3A%2F%2Fexample.com&title=Tagged+Entry&description=&duration=3&interval=days&tags=rust%2C+music&collection_id="; @@ -42,16 +42,16 @@ async fn tags_page_shows_user_tags() { #[tokio::test] async fn tags_page_does_not_show_other_users_tags() { let app = TestApp::new().await; - let (_user1_id, invite1) = app.create_user("User 1").await; - let cookie1 = app.login(&invite1).await; + let (_user1_id, github_user_id1) = app.create_user("User 1").await; + let cookie1 = app.login(&github_user_id1).await; // User 1 creates entry with tag let body = "url=https%3A%2F%2Fexample.com&title=Entry1&description=&duration=3&interval=days&tags=secret&collection_id="; app.post_form("/entries", body, Some(&cookie1)).await; // User 2 should not see User 1's tags - let (_user2_id, invite2) = app.create_user("User 2").await; - let cookie2 = app.login(&invite2).await; + let (_user2_id, github_user_id2) = app.create_user("User 2").await; + let cookie2 = app.login(&github_user_id2).await; let resp = app.get("/tags", Some(&cookie2)).await; let html = body_string(resp).await; @@ -62,8 +62,8 @@ async fn tags_page_does_not_show_other_users_tags() { #[tokio::test] async fn tag_detail_shows_entries_for_tag() { let app = TestApp::new().await; - let (_user_id, invite_code) = app.create_user("Test User").await; - let cookie = app.login(&invite_code).await; + let (_user_id, github_user_id) = app.create_user("Test User").await; + let cookie = app.login(&github_user_id).await; // Create two entries, one with "rust" tag, one without let body = "url=https%3A%2F%2Fexample.com%2F1&title=Rust+Article&description=&duration=3&interval=days&tags=rust&collection_id="; @@ -83,8 +83,8 @@ async fn tag_detail_shows_entries_for_tag() { #[tokio::test] async fn tag_detail_shows_empty_for_nonexistent_tag() { let app = TestApp::new().await; - let (_user_id, invite_code) = app.create_user("Test User").await; - let cookie = app.login(&invite_code).await; + let (_user_id, github_user_id) = app.create_user("Test User").await; + let cookie = app.login(&github_user_id).await; let resp = app.get("/tags/nonexistent", Some(&cookie)).await; assert_eq!(resp.status(), StatusCode::OK); @@ -95,8 +95,8 @@ async fn tag_detail_shows_empty_for_nonexistent_tag() { #[tokio::test] async fn tag_detail_has_back_link() { let app = TestApp::new().await; - let (_user_id, invite_code) = app.create_user("Test User").await; - let cookie = app.login(&invite_code).await; + let (_user_id, github_user_id) = app.create_user("Test User").await; + let cookie = app.login(&github_user_id).await; let resp = app.get("/tags/anything", Some(&cookie)).await; let html = body_string(resp).await; @@ -106,8 +106,8 @@ async fn tag_detail_has_back_link() { #[tokio::test] async fn tag_cloud_has_inline_styles() { let app = TestApp::new().await; - let (_user_id, invite_code) = app.create_user("Test User").await; - let cookie = app.login(&invite_code).await; + let (_user_id, github_user_id) = app.create_user("Test User").await; + let cookie = app.login(&github_user_id).await; let body = "url=https%3A%2F%2Fexample.com&title=Entry&description=&duration=3&interval=days&tags=styled&collection_id="; app.post_form("/entries", body, Some(&cookie)).await; From e2a216aa745d6ebcb78d9c2f56c13f4584e5d7ce Mon Sep 17 00:00:00 2001 From: Axel Anderson Date: Wed, 12 Aug 2026 11:06:35 -0400 Subject: [PATCH 14/25] fix(auth): consume invalid OAuth callbacks --- src/routes/auth.rs | 79 +++++++++++++++++++++++++++----- tests/github_auth.rs | 105 +++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 173 insertions(+), 11 deletions(-) diff --git a/src/routes/auth.rs b/src/routes/auth.rs index fa37388..996bc0b 100644 --- a/src/routes/auth.rs +++ b/src/routes/auth.rs @@ -1,7 +1,9 @@ +use std::collections::HashSet; + use askama::Template; use axum::{ Form, Router, - extract::{Query, State}, + extract::{RawQuery, State}, response::{Html, IntoResponse, Redirect}, routing::{get, post}, }; @@ -43,10 +45,10 @@ pub struct LoginForm { invite_code: String, } -#[derive(Deserialize)] struct GitHubCallbackQuery { - code: String, - state: String, + code: Option, + state: Option, + provider_error: bool, } pub fn router() -> Router { @@ -118,13 +120,23 @@ async fn github_login_start( async fn github_callback( State(state): State, session: Session, - Query(query): Query, + RawQuery(raw_query): RawQuery, ) -> Result { let Some(attempt) = take_oauth_attempt(&session).await? else { return render_auth_error(GITHUB_SIGN_IN_ERROR); }; + let Some(query) = parse_github_callback_query(raw_query.as_deref()) else { + return render_auth_error(GITHUB_SIGN_IN_ERROR); + }; + if query.provider_error { + return render_auth_error(GITHUB_SIGN_IN_ERROR); + } + let (Some(code), Some(callback_state)) = (query.code.as_deref(), query.state.as_deref()) else { + return render_auth_error(GITHUB_SIGN_IN_ERROR); + }; let is_valid_attempt = attempt.purpose == OAuthPurpose::Login - && attempt.state == query.state + && !code.is_empty() + && attempt.state == callback_state && attempt.expires_at > chrono::Utc::now().timestamp(); if !is_valid_attempt { return render_auth_error(GITHUB_SIGN_IN_ERROR); @@ -133,11 +145,7 @@ async fn github_callback( let profile = match state .auth .github - .exchange_code( - &github_callback_url(&state), - &query.code, - &attempt.pkce_verifier, - ) + .exchange_code(&github_callback_url(&state), code, &attempt.pkce_verifier) .await { Ok(profile) => profile, @@ -197,6 +205,55 @@ async fn github_callback( Ok(Redirect::to("/").into_response()) } +fn parse_github_callback_query(raw_query: Option<&str>) -> Option { + let raw_query = raw_query?; + if !has_valid_percent_encoding(raw_query) { + return None; + } + + let mut seen_keys = HashSet::new(); + let mut query = GitHubCallbackQuery { + code: None, + state: None, + provider_error: false, + }; + for (key, value) in url::form_urlencoded::parse(raw_query.as_bytes()) { + if key.contains('\u{fffd}') + || value.contains('\u{fffd}') + || !seen_keys.insert(key.to_string()) + { + return None; + } + match key.as_ref() { + "code" => query.code = Some(value.into_owned()), + "state" => query.state = Some(value.into_owned()), + "error" => query.provider_error = true, + _ => {} + } + } + + Some(query) +} + +fn has_valid_percent_encoding(value: &str) -> bool { + let bytes = value.as_bytes(); + let mut index = 0; + while index < bytes.len() { + if bytes[index] == b'%' { + if index + 2 >= bytes.len() + || !bytes[index + 1].is_ascii_hexdigit() + || !bytes[index + 2].is_ascii_hexdigit() + { + return false; + } + index += 3; + } else { + index += 1; + } + } + true +} + async fn legacy_login_available(state: &AppState) -> Result { Ok(sqlx::query_scalar( "SELECT EXISTS(\ diff --git a/tests/github_auth.rs b/tests/github_auth.rs index bc442eb..9f04a05 100644 --- a/tests/github_auth.rs +++ b/tests/github_auth.rs @@ -143,6 +143,111 @@ async fn oauth_callback_rejects_expired_attempt() { assert_eq!(count, 0); } +#[tokio::test] +async fn denied_oauth_callback_consumes_attempt() { + let app = TestApp::with_signup_mode(SignupMode::Public).await; + let (cookie, authorization_url) = app.begin_github_login().await; + let state = query_parameters(&authorization_url)["state"].clone(); + + let denied = app + .get( + &format!( + "/auth/github/callback?error=access_denied&error_description=private-details&state={state}" + ), + Some(&cookie), + ) + .await; + let body = callback_body(denied).await; + assert!(body.contains("couldn’t complete GitHub sign-in")); + assert!(!body.contains("access_denied")); + assert!(!body.contains("private-details")); + + register_profile(&app, "valid-code", "510", "denied-replay", None); + let replay = app + .get(&callback_uri("valid-code", &state), Some(&cookie)) + .await; + let body = callback_body(replay).await; + assert!(body.contains("couldn’t complete GitHub sign-in")); + let count: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM users") + .fetch_one(&app.db) + .await + .unwrap(); + assert_eq!(count, 0); +} + +#[tokio::test] +async fn oauth_callback_with_missing_fields_consumes_attempt() { + for missing_state in [true, false] { + let app = TestApp::with_signup_mode(SignupMode::Public).await; + let (cookie, authorization_url) = app.begin_github_login().await; + let state = query_parameters(&authorization_url)["state"].clone(); + let invalid_uri = if missing_state { + "/auth/github/callback?code=valid-code".to_string() + } else { + format!("/auth/github/callback?state={state}") + }; + + let response = app.get(&invalid_uri, Some(&cookie)).await; + let body = callback_body(response).await; + assert!(body.contains("couldn’t complete GitHub sign-in")); + + register_profile(&app, "valid-code", "520", "missing-replay", None); + let replay = app + .get(&callback_uri("valid-code", &state), Some(&cookie)) + .await; + let body = callback_body(replay).await; + assert!(body.contains("couldn’t complete GitHub sign-in")); + } +} + +#[tokio::test] +async fn oauth_callback_with_malformed_encoding_consumes_attempt() { + let app = TestApp::with_signup_mode(SignupMode::Public).await; + let (cookie, authorization_url) = app.begin_github_login().await; + let state = query_parameters(&authorization_url)["state"].clone(); + + let response = app + .get( + &format!("/auth/github/callback?code=%FF&state={state}"), + Some(&cookie), + ) + .await; + let body = callback_body(response).await; + assert!(body.contains("couldn’t complete GitHub sign-in")); + + register_profile(&app, "valid-code", "530", "malformed-replay", None); + let replay = app + .get(&callback_uri("valid-code", &state), Some(&cookie)) + .await; + let body = callback_body(replay).await; + assert!(body.contains("couldn’t complete GitHub sign-in")); +} + +#[tokio::test] +async fn oauth_callback_rejects_duplicate_state_or_code() { + for duplicate_state in [true, false] { + let app = TestApp::with_signup_mode(SignupMode::Public).await; + let (cookie, authorization_url) = app.begin_github_login().await; + let state = query_parameters(&authorization_url)["state"].clone(); + register_profile(&app, "valid-code", "540", "duplicate", None); + let invalid_uri = if duplicate_state { + format!("/auth/github/callback?code=valid-code&state={state}&state={state}") + } else { + format!("/auth/github/callback?code=valid-code&code=valid-code&state={state}") + }; + + let response = app.get(&invalid_uri, Some(&cookie)).await; + let body = callback_body(response).await; + assert!(body.contains("couldn’t complete GitHub sign-in")); + + let replay = app + .get(&callback_uri("valid-code", &state), Some(&cookie)) + .await; + let body = callback_body(replay).await; + assert!(body.contains("couldn’t complete GitHub sign-in")); + } +} + #[tokio::test] async fn github_login_refreshes_display_username() { let app = TestApp::new().await; From 1ac8dc8db829b08d467c68bc6d2e1400de44188f Mon Sep 17 00:00:00 2001 From: Axel Anderson Date: Wed, 12 Aug 2026 11:19:02 -0400 Subject: [PATCH 15/25] feat(auth): connect legacy users to GitHub --- src/auth.rs | 144 ++++++++++++- src/routes/auth.rs | 287 +++++++++++++++++++++++-- templates/connect_github.html | 13 ++ templates/github_confirm.html | 13 ++ tests/auth.rs | 80 ++++++- tests/common/mod.rs | 73 ++++++- tests/github_auth.rs | 390 +++++++++++++++++++++++++++++++++- 7 files changed, 975 insertions(+), 25 deletions(-) create mode 100644 templates/connect_github.html create mode 100644 templates/github_confirm.html diff --git a/src/auth.rs b/src/auth.rs index db74787..f43be6b 100644 --- a/src/auth.rs +++ b/src/auth.rs @@ -10,15 +10,19 @@ use serde::{Deserialize, Serialize}; use tower_sessions::Session; use crate::AppState; +use crate::github::GitHubProfile; use crate::models::User; const USER_ID_KEY: &str = "user_id"; const AUTH_VERSION_KEY: &str = "auth_version"; const OAUTH_ATTEMPT_KEY: &str = "oauth_attempt"; +const MIGRATION_SESSION_KEY: &str = "migration_session"; +const PENDING_CONNECTION_KEY: &str = "pending_connection"; #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] pub enum OAuthPurpose { Login, + Link { user_id: String }, } #[derive(Clone, Debug, Serialize, Deserialize)] @@ -29,6 +33,50 @@ pub struct OAuthAttempt { pub expires_at: i64, } +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct MigrationSession { + pub user_id: String, + pub expires_at: i64, +} + +impl MigrationSession { + pub fn new(user_id: String, now: DateTime) -> Self { + Self { + user_id, + expires_at: now.timestamp() + 1_800, + } + } +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +pub enum ConnectionProof { + LegacyInvite, +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct PendingConnection { + pub user_id: String, + pub github_profile: GitHubProfile, + pub proof: ConnectionProof, + pub expires_at: i64, +} + +impl PendingConnection { + pub fn new( + user_id: String, + github_profile: GitHubProfile, + proof: ConnectionProof, + now: DateTime, + ) -> Self { + Self { + user_id, + github_profile, + proof, + expires_at: now.timestamp() + 600, + } + } +} + impl OAuthAttempt { pub fn new(purpose: OAuthPurpose, now: DateTime) -> Self { let mut state = [0_u8; 32]; @@ -63,6 +111,51 @@ pub async fn take_oauth_attempt( session.remove(OAUTH_ATTEMPT_KEY).await } +pub async fn store_migration_session( + session: &Session, + migration: &MigrationSession, +) -> Result<(), tower_sessions::session::Error> { + session.remove::(USER_ID_KEY).await?; + session.remove::(AUTH_VERSION_KEY).await?; + session + .remove::(PENDING_CONNECTION_KEY) + .await?; + session.insert(MIGRATION_SESSION_KEY, migration).await +} + +pub async fn get_migration_session( + session: &Session, +) -> Result, tower_sessions::session::Error> { + session.get(MIGRATION_SESSION_KEY).await +} + +pub async fn take_migration_session( + session: &Session, +) -> Result, tower_sessions::session::Error> { + session.remove(MIGRATION_SESSION_KEY).await +} + +pub async fn store_pending_connection( + session: &Session, + pending: &PendingConnection, +) -> Result<(), tower_sessions::session::Error> { + session.remove::(USER_ID_KEY).await?; + session.remove::(AUTH_VERSION_KEY).await?; + session.insert(PENDING_CONNECTION_KEY, pending).await +} + +pub async fn get_pending_connection( + session: &Session, +) -> Result, tower_sessions::session::Error> { + session.get(PENDING_CONNECTION_KEY).await +} + +pub async fn take_pending_connection( + session: &Session, +) -> Result, tower_sessions::session::Error> { + session.remove(PENDING_CONNECTION_KEY).await +} + fn session_identity(user_id: Option, auth_version: Option) -> Option<(String, i64)> { Some((user_id?, auth_version?)) } @@ -80,6 +173,18 @@ impl FromRequestParts for AuthUser { .await .map_err(|_| AuthRedirect)?; + let migration: Option = session + .get(MIGRATION_SESSION_KEY) + .await + .map_err(|_| AuthRedirect)?; + let pending: Option = session + .get(PENDING_CONNECTION_KEY) + .await + .map_err(|_| AuthRedirect)?; + if migration.is_some() || pending.is_some() { + return Err(AuthRedirect); + } + let user_id: Option = session.get(USER_ID_KEY).await.ok().flatten(); let auth_version: Option = session.get(AUTH_VERSION_KEY).await.ok().flatten(); @@ -111,6 +216,12 @@ pub async fn login_user( session: &Session, user: &User, ) -> Result<(), tower_sessions::session::Error> { + session + .remove::(MIGRATION_SESSION_KEY) + .await?; + session + .remove::(PENDING_CONNECTION_KEY) + .await?; session.insert(USER_ID_KEY, &user.id).await?; session.insert(AUTH_VERSION_KEY, user.auth_version).await } @@ -121,7 +232,10 @@ pub async fn logout_user(session: &Session) -> Result<(), tower_sessions::sessio #[cfg(test)] mod tests { - use super::session_identity; + use chrono::DateTime; + + use super::{ConnectionProof, MigrationSession, PendingConnection, session_identity}; + use crate::github::GitHubProfile; #[test] fn missing_auth_version_rejects_a_known_user_id() { @@ -129,4 +243,32 @@ mod tests { assert_eq!(identity, None); } + + #[test] + fn migration_sessions_expire_after_thirty_minutes() { + let now = DateTime::from_timestamp(1_000_000, 0).unwrap(); + + let migration = MigrationSession::new("legacy-user".into(), now); + + assert_eq!(migration.expires_at, 1_001_800); + } + + #[test] + fn pending_connections_expire_after_ten_minutes() { + let now = DateTime::from_timestamp(1_000_000, 0).unwrap(); + let profile = GitHubProfile { + user_id: "123".into(), + login: "octocat".into(), + name: None, + }; + + let pending = PendingConnection::new( + "legacy-user".into(), + profile, + ConnectionProof::LegacyInvite, + now, + ); + + assert_eq!(pending.expires_at, 1_000_600); + } } diff --git a/src/routes/auth.rs b/src/routes/auth.rs index 996bc0b..3b3e674 100644 --- a/src/routes/auth.rs +++ b/src/routes/auth.rs @@ -12,7 +12,10 @@ use tower_sessions::Session; use crate::AppState; use crate::auth::{ - OAuthAttempt, OAuthPurpose, login_user, logout_user, store_oauth_attempt, take_oauth_attempt, + ConnectionProof, MigrationSession, OAuthAttempt, OAuthPurpose, PendingConnection, + get_migration_session, get_pending_connection, login_user, logout_user, + store_migration_session, store_oauth_attempt, store_pending_connection, take_migration_session, + take_oauth_attempt, take_pending_connection, }; use crate::config::SignupMode; use crate::error::AppError; @@ -20,6 +23,9 @@ use crate::models::User; const GITHUB_SIGN_IN_ERROR: &str = "We couldn’t complete GitHub sign-in. Please return to login and try again."; +const GITHUB_LINK_ERROR: &str = + "We couldn’t connect this GitHub account. Please return to login and try again."; +const GITHUB_IDENTITY_IN_USE_ERROR: &str = "That GitHub account is already connected to another Interne account. Please return to login and try another account."; const CLOSED_SIGNUP_ERROR: &str = "Access isn’t open yet. Email webmaster@honkytonk.in for an invite."; @@ -40,6 +46,21 @@ struct AuthErrorTemplate<'a> { user: Option, } +#[derive(Template)] +#[template(path = "connect_github.html")] +struct ConnectGitHubTemplate { + static_hash: &'static str, + user: Option, +} + +#[derive(Template)] +#[template(path = "github_confirm.html")] +struct GitHubConfirmTemplate<'a> { + github_login: &'a str, + static_hash: &'static str, + user: Option, +} + #[derive(Deserialize)] pub struct LoginForm { invite_code: String, @@ -56,7 +77,13 @@ pub fn router() -> Router { .route("/login", get(login_page)) .route("/login", post(login_submit)) .route("/auth/github", get(github_login_start)) + .route("/auth/connect", get(connect_github_page)) + .route("/auth/github/connect", post(github_link_start)) .route("/auth/github/callback", get(github_callback)) + .route( + "/auth/github/confirm", + get(github_confirm_page).post(github_confirm_submit), + ) .route("/logout", post(logout)) } @@ -75,16 +102,19 @@ async fn login_submit( session: Session, Form(form): Form, ) -> Result { - let user: Option = sqlx::query_as("SELECT * FROM users WHERE invite_code = ?") - .bind(&form.invite_code) - .fetch_optional(&state.db) - .await?; + let user: Option = + sqlx::query_as("SELECT * FROM users WHERE invite_code = ? AND github_user_id IS NULL") + .bind(&form.invite_code) + .fetch_optional(&state.db) + .await?; match user { Some(user) => { + session.flush().await?; session.cycle_id().await?; - login_user(&session, &user).await?; - Ok(Redirect::to("/").into_response()) + let migration = MigrationSession::new(user.id, chrono::Utc::now()); + store_migration_session(&session, &migration).await?; + Ok(Redirect::to("/auth/connect").into_response()) } None => { let template = LoginTemplate { @@ -98,6 +128,49 @@ async fn login_submit( } } +async fn connect_github_page( + State(state): State, + session: Session, +) -> Result { + let Some(_) = live_migration_session(&state, &session).await? else { + return Ok(Redirect::to("/login").into_response()); + }; + + let template = ConnectGitHubTemplate { + static_hash: crate::STATIC_HASH, + user: None, + }; + Ok(Html(template.render()?).into_response()) +} + +async fn github_link_start( + State(state): State, + session: Session, +) -> Result { + let Some(migration) = live_migration_session(&state, &session).await? else { + return Ok(Redirect::to("/login").into_response()); + }; + + let attempt = OAuthAttempt::new( + OAuthPurpose::Link { + user_id: migration.user_id, + }, + chrono::Utc::now(), + ); + store_oauth_attempt(&session, &attempt).await?; + let callback_url = github_callback_url(&state); + let authorization_url = match state.auth.github.authorization_url( + &callback_url, + &attempt.state, + &attempt.pkce_challenge(), + ) { + Ok(url) => url, + Err(_) => return render_auth_error(GITHUB_LINK_ERROR), + }; + + Ok(Redirect::to(authorization_url.as_str()).into_response()) +} + async fn github_login_start( State(state): State, session: Session, @@ -125,21 +198,32 @@ async fn github_callback( let Some(attempt) = take_oauth_attempt(&session).await? else { return render_auth_error(GITHUB_SIGN_IN_ERROR); }; + let callback_error = match &attempt.purpose { + OAuthPurpose::Login => GITHUB_SIGN_IN_ERROR, + OAuthPurpose::Link { .. } => GITHUB_LINK_ERROR, + }; let Some(query) = parse_github_callback_query(raw_query.as_deref()) else { - return render_auth_error(GITHUB_SIGN_IN_ERROR); + return render_auth_error(callback_error); }; if query.provider_error { - return render_auth_error(GITHUB_SIGN_IN_ERROR); + return render_auth_error(callback_error); } let (Some(code), Some(callback_state)) = (query.code.as_deref(), query.state.as_deref()) else { - return render_auth_error(GITHUB_SIGN_IN_ERROR); + return render_auth_error(callback_error); }; - let is_valid_attempt = attempt.purpose == OAuthPurpose::Login - && !code.is_empty() + let is_valid_attempt = !code.is_empty() && attempt.state == callback_state && attempt.expires_at > chrono::Utc::now().timestamp(); if !is_valid_attempt { - return render_auth_error(GITHUB_SIGN_IN_ERROR); + return render_auth_error(callback_error); + } + if let OAuthPurpose::Link { user_id } = &attempt.purpose { + let Some(migration) = live_migration_session(&state, &session).await? else { + return render_auth_error(callback_error); + }; + if migration.user_id != *user_id { + return render_auth_error(callback_error); + } } let profile = match state @@ -149,9 +233,36 @@ async fn github_callback( .await { Ok(profile) => profile, - Err(_) => return render_auth_error(GITHUB_SIGN_IN_ERROR), + Err(_) => return render_auth_error(callback_error), }; + match attempt.purpose { + OAuthPurpose::Login => complete_github_login(&state, &session, profile).await, + OAuthPurpose::Link { user_id } => { + let Some(migration) = live_migration_session(&state, &session).await? else { + return render_auth_error(GITHUB_LINK_ERROR); + }; + if migration.user_id != user_id { + return render_auth_error(GITHUB_LINK_ERROR); + } + + let pending = PendingConnection::new( + user_id, + profile, + ConnectionProof::LegacyInvite, + chrono::Utc::now(), + ); + store_pending_connection(&session, &pending).await?; + Ok(Redirect::to("/auth/github/confirm").into_response()) + } + } +} + +async fn complete_github_login( + state: &AppState, + session: &Session, + profile: crate::github::GitHubProfile, +) -> Result { let now = chrono::Utc::now().to_rfc3339(); sqlx::query("UPDATE users SET github_login = ?, updated_at = ? WHERE github_user_id = ?") .bind(&profile.login) @@ -200,11 +311,159 @@ async fn github_callback( let Some(user) = user else { return render_auth_error(GITHUB_SIGN_IN_ERROR); }; + session.flush().await?; + session.cycle_id().await?; + login_user(session, &user).await?; + Ok(Redirect::to("/").into_response()) +} + +async fn github_confirm_page( + State(state): State, + session: Session, +) -> Result { + let Some(pending) = live_pending_connection(&state, &session).await? else { + return render_auth_error(GITHUB_LINK_ERROR); + }; + + let template = GitHubConfirmTemplate { + github_login: &pending.github_profile.login, + static_hash: crate::STATIC_HASH, + user: None, + }; + Ok(Html(template.render()?).into_response()) +} + +async fn github_confirm_submit( + State(state): State, + session: Session, +) -> Result { + let Some(pending) = take_pending_connection(&session).await? else { + return render_auth_error(GITHUB_LINK_ERROR); + }; + if pending.expires_at <= chrono::Utc::now().timestamp() + || !pending_matches_live_migration(&state, &session, &pending).await? + { + return render_auth_error(GITHUB_LINK_ERROR); + } + match &pending.proof { + ConnectionProof::LegacyInvite => {} + } + + let now = chrono::Utc::now().to_rfc3339(); + let mut transaction = state.db.begin().await?; + let update_result = sqlx::query( + "UPDATE users \ + SET github_user_id = ?, \ + github_login = ?, \ + invite_code = NULL, \ + auth_version = auth_version + 1, \ + updated_at = ? \ + WHERE id = ? \ + AND invite_code IS NOT NULL \ + AND github_user_id IS NULL", + ) + .bind(&pending.github_profile.user_id) + .bind(&pending.github_profile.login) + .bind(&now) + .bind(&pending.user_id) + .execute(&mut *transaction) + .await; + + let update = match update_result { + Ok(update) => update, + Err(error) if is_unique_violation(&error) => { + return render_auth_error(GITHUB_IDENTITY_IN_USE_ERROR); + } + Err(error) => return Err(error.into()), + }; + if update.rows_affected() != 1 { + return render_auth_error(GITHUB_LINK_ERROR); + } + + sqlx::query( + "UPDATE auth_connection_tokens \ + SET consumed_at = ? \ + WHERE user_id = ? \ + AND consumed_at IS NULL \ + AND expires_at > ?", + ) + .bind(&now) + .bind(&pending.user_id) + .bind(&now) + .execute(&mut *transaction) + .await?; + transaction.commit().await?; + + let user: User = sqlx::query_as("SELECT * FROM users WHERE id = ?") + .bind(&pending.user_id) + .fetch_one(&state.db) + .await?; + session.flush().await?; session.cycle_id().await?; login_user(&session, &user).await?; Ok(Redirect::to("/").into_response()) } +async fn live_migration_session( + state: &AppState, + session: &Session, +) -> Result, AppError> { + let Some(migration) = get_migration_session(session).await? else { + return Ok(None); + }; + let user_is_linkable: i64 = sqlx::query_scalar( + "SELECT EXISTS(\ + SELECT 1 FROM users \ + WHERE id = ? AND invite_code IS NOT NULL AND github_user_id IS NULL\ + )", + ) + .bind(&migration.user_id) + .fetch_one(&state.db) + .await?; + if migration.expires_at <= chrono::Utc::now().timestamp() || user_is_linkable == 0 { + take_migration_session(session).await?; + return Ok(None); + } + + Ok(Some(migration)) +} + +async fn live_pending_connection( + state: &AppState, + session: &Session, +) -> Result, AppError> { + let Some(pending) = get_pending_connection(session).await? else { + return Ok(None); + }; + if pending.expires_at <= chrono::Utc::now().timestamp() + || !pending_matches_live_migration(state, session, &pending).await? + { + take_pending_connection(session).await?; + return Ok(None); + } + + Ok(Some(pending)) +} + +async fn pending_matches_live_migration( + state: &AppState, + session: &Session, + pending: &PendingConnection, +) -> Result { + match &pending.proof { + ConnectionProof::LegacyInvite => { + let migration = live_migration_session(state, session).await?; + Ok(migration.is_some_and(|migration| migration.user_id == pending.user_id)) + } + } +} + +fn is_unique_violation(error: &sqlx::Error) -> bool { + error + .as_database_error() + .is_some_and(|error| error.is_unique_violation()) +} + fn parse_github_callback_query(raw_query: Option<&str>) -> Option { let raw_query = raw_query?; if !has_valid_percent_encoding(raw_query) { diff --git a/templates/connect_github.html b/templates/connect_github.html new file mode 100644 index 0000000..97d0314 --- /dev/null +++ b/templates/connect_github.html @@ -0,0 +1,13 @@ +{% extends "base.html" %} + +{% block title %}Connect GitHub - Interne{% endblock %} + +{% block content %} + +{% endblock %} diff --git a/templates/github_confirm.html b/templates/github_confirm.html new file mode 100644 index 0000000..ab12061 --- /dev/null +++ b/templates/github_confirm.html @@ -0,0 +1,13 @@ +{% extends "base.html" %} + +{% block title %}Confirm GitHub account - Interne{% endblock %} + +{% block content %} + +{% endblock %} diff --git a/tests/auth.rs b/tests/auth.rs index 7aa638f..8b448a5 100644 --- a/tests/auth.rs +++ b/tests/auth.rs @@ -1,19 +1,30 @@ mod common; use axum::http::StatusCode; -use common::{TestApp, assert_redirect, body_string}; +use common::{TestApp, assert_redirect, body_string, cookie_from_response}; #[tokio::test] -async fn login_with_valid_invite_code() { +async fn valid_legacy_code_redirects_to_github_connection() { let app = TestApp::new().await; let (_user_id, invite_code) = app.create_legacy_user("Test User").await; - let resp = app - .post_form("/login", &format!("invite_code={}", invite_code), None) + let response = app + .post_form("/login", &format!("invite_code={invite_code}"), None) .await; - assert_redirect(&resp, "/"); - assert!(resp.headers().get("set-cookie").is_some()); + assert_redirect(&response, "/auth/connect"); + assert!(response.headers().get("set-cookie").is_some()); +} + +#[tokio::test] +async fn legacy_session_cannot_access_application_data() { + let app = TestApp::new().await; + let (_user_id, invite_code) = app.create_legacy_user("Test User").await; + let cookie = app.legacy_login(&invite_code).await; + + let response = app.get("/", Some(&cookie)).await; + + assert_redirect(&response, "/login"); } #[tokio::test] @@ -27,6 +38,63 @@ async fn login_with_invalid_invite_code() { assert!(body.contains("Invalid invite code")); } +#[tokio::test] +async fn linked_and_retired_legacy_codes_use_the_generic_invalid_error() { + for retire_by_linking in [true, false] { + let app = TestApp::new().await; + let (user_id, invite_code) = app.create_legacy_user("Test User").await; + if retire_by_linking { + sqlx::query( + "UPDATE users SET github_user_id = 'already-linked', github_login = 'linked' \ + WHERE id = ?", + ) + .bind(user_id) + .execute(&app.db) + .await + .unwrap(); + } else { + sqlx::query("UPDATE users SET invite_code = NULL WHERE id = ?") + .bind(user_id) + .execute(&app.db) + .await + .unwrap(); + } + + let response = app + .post_form("/login", &format!("invite_code={invite_code}"), None) + .await; + + assert_eq!(response.status(), StatusCode::OK); + assert!(body_string(response).await.contains("Invalid invite code")); + } +} + +#[tokio::test] +async fn legacy_login_replaces_an_existing_full_session() { + let app = TestApp::new().await; + let (_linked_user_id, github_user_id) = app.create_user("Linked User").await; + let full_cookie = app.login(&github_user_id).await; + let (_legacy_user_id, invite_code) = app.create_legacy_user("Legacy User").await; + + let response = app + .post_form( + "/login", + &format!("invite_code={invite_code}"), + Some(&full_cookie), + ) + .await; + assert_redirect(&response, "/auth/connect"); + let migration_cookie = cookie_from_response( + response + .headers() + .get("set-cookie") + .expect("Legacy login should cycle the session"), + ); + + assert_redirect(&app.get("/", Some(&full_cookie)).await, "/login"); + assert_redirect(&app.get("/", Some(&migration_cookie)).await, "/login"); +} + #[tokio::test] async fn logout_clears_session() { let app = TestApp::new().await; diff --git a/tests/common/mod.rs b/tests/common/mod.rs index 0f6f5a7..429ab16 100644 --- a/tests/common/mod.rs +++ b/tests/common/mod.rs @@ -211,6 +211,39 @@ impl TestApp { (session_cookie, authorization_url) } + pub async fn legacy_login(&self, invite_code: &str) -> String { + let response = self + .post_form("/login", &format!("invite_code={invite_code}"), None) + .await; + assert_redirect(&response, "/auth/connect"); + cookie_from_response( + response + .headers() + .get("set-cookie") + .expect("Legacy login should establish a migration session"), + ) + } + + pub async fn begin_github_link(&self, cookie: &str) -> Url { + let response = self + .post_form("/auth/github/connect", "", Some(cookie)) + .await; + assert!( + response.status().is_redirection(), + "GitHub link start should redirect, got {}", + response.status() + ); + Url::parse( + response + .headers() + .get("location") + .expect("GitHub link start should have a Location header") + .to_str() + .unwrap(), + ) + .expect("GitHub authorization redirect should be a valid URL") + } + pub async fn github_callback_response( &self, github_user_id: &str, @@ -271,6 +304,40 @@ impl TestApp { } pub async fn expire_oauth_attempt(&self, cookie: &str) { + self.expire_session_value(cookie, "oauth_attempt").await; + } + + pub async fn expire_migration_session(&self, cookie: &str) { + self.expire_session_value(cookie, "migration_session").await; + } + + pub async fn expire_pending_connection(&self, cookie: &str) { + self.expire_session_value(cookie, "pending_connection") + .await; + } + + pub async fn retarget_migration_session(&self, cookie: &str, user_id: &str) { + let (_, encoded_id) = cookie + .split_once('=') + .expect("Session cookie should contain an ID"); + let session_id = + Id::from_str(encoded_id).expect("Session cookie should contain a valid ID"); + let store = SqliteStore::new(self.db.clone()); + let mut record = store + .load(&session_id) + .await + .expect("Session should load") + .expect("Migration session should exist"); + record + .data + .get_mut("migration_session") + .and_then(serde_json::Value::as_object_mut) + .expect("Migration session should be stored in the session") + .insert("user_id".into(), serde_json::json!(user_id)); + store.save(&record).await.expect("Session should save"); + } + + async fn expire_session_value(&self, cookie: &str, key: &str) { let (_, encoded_id) = cookie .split_once('=') .expect("Session cookie should contain an ID"); @@ -284,9 +351,9 @@ impl TestApp { .expect("OAuth session should exist"); record .data - .get_mut("oauth_attempt") + .get_mut(key) .and_then(serde_json::Value::as_object_mut) - .expect("OAuth attempt should be stored in the session") + .expect("Expiring session value should be stored in the session") .insert("expires_at".into(), serde_json::json!(0)); store.save(&record).await.expect("Session should save"); } @@ -325,7 +392,7 @@ impl TestApp { } } -fn cookie_from_response(value: &axum::http::HeaderValue) -> String { +pub fn cookie_from_response(value: &axum::http::HeaderValue) -> String { value .to_str() .unwrap() diff --git a/tests/github_auth.rs b/tests/github_auth.rs index 9f04a05..e80156f 100644 --- a/tests/github_auth.rs +++ b/tests/github_auth.rs @@ -3,7 +3,7 @@ mod common; use std::collections::HashMap; use axum::http::{Response, StatusCode}; -use common::{TestApp, assert_redirect, body_string}; +use common::{TestApp, assert_redirect, body_string, cookie_from_response}; use interne::config::SignupMode; use interne::github::{GitHubError, GitHubProfile}; @@ -37,6 +37,394 @@ async fn callback_body(response: Response) -> String { body_string(response).await } +async fn prepare_legacy_pending_connection( + app: &TestApp, + invite_code: &str, + code: &str, + github_user_id: &str, + github_login: &str, +) -> String { + let cookie = app.legacy_login(invite_code).await; + let authorization_url = app.begin_github_link(&cookie).await; + let state = query_parameters(&authorization_url)["state"].clone(); + register_profile(app, code, github_user_id, github_login, None); + + let response = app.get(&callback_uri(code, &state), Some(&cookie)).await; + assert_redirect(&response, "/auth/github/confirm"); + + cookie +} + +#[tokio::test] +async fn legacy_user_confirms_github_and_keeps_the_same_user_id() { + let app = TestApp::new().await; + let (legacy_user_id, invite_code) = app.create_legacy_user("Legacy User").await; + let cookie = + prepare_legacy_pending_connection(&app, &invite_code, "legacy-code", "901", "legacy-user") + .await; + + let response = app + .post_form("/auth/github/confirm", "", Some(&cookie)) + .await; + assert_redirect(&response, "/"); + let authenticated_cookie = cookie_from_response( + response + .headers() + .get("set-cookie") + .expect("Confirmation should establish a fresh full session"), + ); + + let home = app.get("/", Some(&authenticated_cookie)).await; + assert_eq!(home.status(), StatusCode::OK); + let linked_user_id: String = + sqlx::query_scalar("SELECT id FROM users WHERE github_user_id = '901'") + .fetch_one(&app.db) + .await + .unwrap(); + assert_eq!(linked_user_id, legacy_user_id); + let auth_version: i64 = sqlx::query_scalar("SELECT auth_version FROM users WHERE id = ?") + .bind(legacy_user_id) + .fetch_one(&app.db) + .await + .unwrap(); + assert_eq!(auth_version, 2); +} + +#[tokio::test] +async fn link_confirmation_nulls_legacy_invite_code() { + let app = TestApp::new().await; + let (user_id, invite_code) = app.create_legacy_user("Legacy User").await; + let cookie = + prepare_legacy_pending_connection(&app, &invite_code, "null-code", "902", "linked").await; + + let response = app + .post_form("/auth/github/confirm", "", Some(&cookie)) + .await; + assert_redirect(&response, "/"); + + let stored_invite_code: Option = + sqlx::query_scalar("SELECT invite_code FROM users WHERE id = ?") + .bind(user_id) + .fetch_one(&app.db) + .await + .unwrap(); + assert_eq!(stored_invite_code, None); +} + +#[tokio::test] +async fn link_confirmation_rejects_github_id_owned_by_another_user() { + let app = TestApp::new().await; + let owner_id = app + .create_github_user("Owner", "903", "existing-owner") + .await; + let (legacy_user_id, invite_code) = app.create_legacy_user("Legacy User").await; + let cookie = prepare_legacy_pending_connection( + &app, + &invite_code, + "duplicate-code", + "903", + "existing-owner", + ) + .await; + + let response = app + .post_form("/auth/github/confirm", "", Some(&cookie)) + .await; + let body = callback_body(response).await; + assert!(body.contains("already connected")); + assert!(!body.contains(&owner_id)); + assert!(!body.contains(&legacy_user_id)); + + let legacy_state: (Option, Option) = + sqlx::query_as("SELECT github_user_id, invite_code FROM users WHERE id = ?") + .bind(legacy_user_id) + .fetch_one(&app.db) + .await + .unwrap(); + assert_eq!(legacy_state.0, None); + assert_eq!(legacy_state.1.as_deref(), Some(invite_code.as_str())); +} + +#[tokio::test] +async fn link_callback_requires_a_live_migration_session() { + let app = TestApp::new().await; + let (_user_id, invite_code) = app.create_legacy_user("Legacy User").await; + let cookie = app.legacy_login(&invite_code).await; + let authorization_url = app.begin_github_link(&cookie).await; + let state = query_parameters(&authorization_url)["state"].clone(); + app.expire_migration_session(&cookie).await; + register_profile(&app, "orphan-code", "904", "orphan", None); + + let response = app + .get(&callback_uri("orphan-code", &state), Some(&cookie)) + .await; + let body = callback_body(response).await; + assert!(body.contains("couldn’t connect this GitHub account")); + let github_user_id: Option = + sqlx::query_scalar("SELECT github_user_id FROM users WHERE invite_code = ?") + .bind(invite_code) + .fetch_one(&app.db) + .await + .unwrap(); + assert_eq!(github_user_id, None); +} + +#[tokio::test] +async fn link_callback_rejects_a_migration_session_for_a_different_user() { + let app = TestApp::new().await; + let (first_user_id, first_invite_code) = app.create_legacy_user("First User").await; + let (second_user_id, _second_invite_code) = app.create_legacy_user("Second User").await; + let cookie = app.legacy_login(&first_invite_code).await; + let authorization_url = app.begin_github_link(&cookie).await; + let state = query_parameters(&authorization_url)["state"].clone(); + app.retarget_migration_session(&cookie, &second_user_id) + .await; + register_profile(&app, "retargeted-code", "912", "retargeted", None); + + let response = app + .get(&callback_uri("retargeted-code", &state), Some(&cookie)) + .await; + let body = callback_body(response).await; + assert!(body.contains("couldn’t connect this GitHub account")); + let linked_count: i64 = sqlx::query_scalar( + "SELECT COUNT(*) FROM users WHERE id IN (?, ?) AND github_user_id IS NOT NULL", + ) + .bind(first_user_id) + .bind(second_user_id) + .fetch_one(&app.db) + .await + .unwrap(); + assert_eq!(linked_count, 0); +} + +#[tokio::test] +async fn expired_migration_session_cannot_start_linking() { + let app = TestApp::new().await; + let (_user_id, invite_code) = app.create_legacy_user("Legacy User").await; + let cookie = app.legacy_login(&invite_code).await; + let page = body_string(app.get("/auth/connect", Some(&cookie)).await).await; + assert!(page.contains("replace your legacy invite code")); + app.expire_migration_session(&cookie).await; + + let page = app.get("/auth/connect", Some(&cookie)).await; + assert_redirect(&page, "/login"); + let start = app + .post_form("/auth/github/connect", "", Some(&cookie)) + .await; + assert_redirect(&start, "/login"); +} + +#[tokio::test] +async fn confirmation_rechecks_the_current_legacy_user_state() { + let app = TestApp::new().await; + let (user_id, invite_code) = app.create_legacy_user("Legacy User").await; + let cookie = prepare_legacy_pending_connection( + &app, + &invite_code, + "stale-user-code", + "909", + "stale-user", + ) + .await; + sqlx::query("UPDATE users SET invite_code = NULL WHERE id = ?") + .bind(&user_id) + .execute(&app.db) + .await + .unwrap(); + + let response = app + .post_form("/auth/github/confirm", "", Some(&cookie)) + .await; + let body = callback_body(response).await; + assert!(body.contains("couldn’t connect this GitHub account")); + let github_user_id: Option = + sqlx::query_scalar("SELECT github_user_id FROM users WHERE id = ?") + .bind(user_id) + .fetch_one(&app.db) + .await + .unwrap(); + assert_eq!(github_user_id, None); +} + +#[tokio::test] +async fn completed_confirmation_cannot_be_replayed() { + let app = TestApp::new().await; + let (_user_id, invite_code) = app.create_legacy_user("Legacy User").await; + let cookie = prepare_legacy_pending_connection( + &app, + &invite_code, + "confirm-replay-code", + "910", + "confirm-replay", + ) + .await; + + let first = app + .post_form("/auth/github/confirm", "", Some(&cookie)) + .await; + assert_redirect(&first, "/"); + let replay = app + .post_form("/auth/github/confirm", "", Some(&cookie)) + .await; + let body = callback_body(replay).await; + assert!(body.contains("couldn’t connect this GitHub account")); +} + +#[tokio::test] +async fn normal_github_login_clears_legacy_migration_state() { + let app = TestApp::new().await; + app.create_github_user("Linked User", "911", "linked-user") + .await; + let (_legacy_user_id, invite_code) = app.create_legacy_user("Legacy User").await; + let migration_cookie = app.legacy_login(&invite_code).await; + let (cookie, authorization_url) = app + .begin_github_login_with_cookie(Some(&migration_cookie)) + .await; + let state = query_parameters(&authorization_url)["state"].clone(); + register_profile(&app, "normal-code", "911", "linked-user", None); + + let response = app + .get(&callback_uri("normal-code", &state), Some(&cookie)) + .await; + assert_redirect(&response, "/"); + let full_cookie = cookie_from_response( + response + .headers() + .get("set-cookie") + .expect("Normal GitHub login should replace migration state"), + ); + assert_eq!( + app.get("/", Some(&full_cookie)).await.status(), + StatusCode::OK + ); +} + +#[tokio::test] +async fn pending_confirmation_expires_after_ten_minutes() { + let app = TestApp::new().await; + let (_user_id, invite_code) = app.create_legacy_user("Legacy User").await; + let cookie = prepare_legacy_pending_connection( + &app, + &invite_code, + "expired-pending-code", + "905", + "too-late", + ) + .await; + app.expire_pending_connection(&cookie).await; + + let page = app.get("/auth/github/confirm", Some(&cookie)).await; + let body = callback_body(page).await; + assert!(body.contains("couldn’t connect this GitHub account")); + let confirm = app + .post_form("/auth/github/confirm", "", Some(&cookie)) + .await; + let body = callback_body(confirm).await; + assert!(body.contains("couldn’t connect this GitHub account")); + + let github_user_id: Option = + sqlx::query_scalar("SELECT github_user_id FROM users WHERE invite_code = ?") + .bind(invite_code) + .fetch_one(&app.db) + .await + .unwrap(); + assert_eq!(github_user_id, None); +} + +#[tokio::test] +async fn link_confirmation_consumes_active_connection_tokens() { + let app = TestApp::new().await; + let (user_id, invite_code) = app.create_legacy_user("Legacy User").await; + let now = chrono::Utc::now(); + sqlx::query( + "INSERT INTO auth_connection_tokens \ + (id, user_id, token_hash, purpose, created_at, expires_at, consumed_at) \ + VALUES ('active-token', ?, 'hash', 'recovery', ?, ?, NULL)", + ) + .bind(&user_id) + .bind(now.to_rfc3339()) + .bind((now + chrono::Duration::hours(1)).to_rfc3339()) + .execute(&app.db) + .await + .unwrap(); + let cookie = + prepare_legacy_pending_connection(&app, &invite_code, "token-code", "906", "token-user") + .await; + + let response = app + .post_form("/auth/github/confirm", "", Some(&cookie)) + .await; + assert_redirect(&response, "/"); + let consumed_at: Option = sqlx::query_scalar( + "SELECT consumed_at FROM auth_connection_tokens WHERE id = 'active-token'", + ) + .fetch_one(&app.db) + .await + .unwrap(); + assert!(consumed_at.is_some()); +} + +#[tokio::test] +async fn github_confirmation_escapes_login_and_accepts_no_identity_fields() { + let app = TestApp::new().await; + let (_user_id, invite_code) = app.create_legacy_user("Legacy User").await; + let cookie = prepare_legacy_pending_connection( + &app, + &invite_code, + "escape-code", + "907", + "", + ) + .await; + + let body = body_string(app.get("/auth/github/confirm", Some(&cookie)).await).await; + assert!(body.contains("<script>alert(1)</script>")); + assert!(!body.contains("")); + assert!(body.contains("action=\"/auth/github/confirm\"")); + assert!(!body.contains("name=\"github")); + + let response = app + .post_form( + "/auth/github/confirm", + "github_user_id=attacker&github_login=attacker", + Some(&cookie), + ) + .await; + assert_redirect(&response, "/"); + let linked_id: String = + sqlx::query_scalar("SELECT github_user_id FROM users WHERE github_login = ?") + .bind("") + .fetch_one(&app.db) + .await + .unwrap(); + assert_eq!(linked_id, "907"); +} + +#[tokio::test] +async fn denied_link_callback_consumes_attempt() { + let app = TestApp::new().await; + let (_user_id, invite_code) = app.create_legacy_user("Legacy User").await; + let cookie = app.legacy_login(&invite_code).await; + let authorization_url = app.begin_github_link(&cookie).await; + let state = query_parameters(&authorization_url)["state"].clone(); + + let denied = app + .get( + &format!("/auth/github/callback?error=access_denied&state={state}"), + Some(&cookie), + ) + .await; + let body = callback_body(denied).await; + assert!(body.contains("couldn’t connect this GitHub account")); + + register_profile(&app, "link-replay", "908", "replay", None); + let replay = app + .get(&callback_uri("link-replay", &state), Some(&cookie)) + .await; + let body = callback_body(replay).await; + assert!(body.contains("couldn’t complete GitHub sign-in")); +} + #[tokio::test] async fn linked_github_identity_logs_into_existing_user() { let app = TestApp::new().await; From 6d0bc8e105454b6d9f74a653b1b287bb1de3f0fb Mon Sep 17 00:00:00 2001 From: Axel Anderson Date: Wed, 12 Aug 2026 11:32:52 -0400 Subject: [PATCH 16/25] feat(auth): issue invitation and recovery links --- src/cli.rs | 122 ++++++++++--- src/connection_tokens.rs | 231 +++++++++++++++++++++++++ src/lib.rs | 1 + src/main.rs | 71 +++++++- tests/connection_tokens.rs | 345 +++++++++++++++++++++++++++++++++++++ 5 files changed, 740 insertions(+), 30 deletions(-) create mode 100644 src/connection_tokens.rs create mode 100644 tests/connection_tokens.rs diff --git a/src/cli.rs b/src/cli.rs index 4b59354..bb80b53 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -1,8 +1,11 @@ use serde::{Deserialize, Deserializer}; use sqlx::SqlitePool; use std::fs; +use url::Url; use uuid::Uuid; +use crate::config::{AuthConfig, ConfigError, SignupMode}; +use crate::connection_tokens::{connection_url, issue_invitation, reset_auth}; use crate::models::Interval; // Custom deserializer to handle duration as either string or integer @@ -44,7 +47,11 @@ struct LegacyEntry { tags: Vec, } -pub async fn import_data(pool: &SqlitePool, file_path: &str, user_id: &str) -> Result<(), Box> { +pub async fn import_data( + pool: &SqlitePool, + file_path: &str, + user_id: &str, +) -> Result<(), Box> { // Verify user exists before importing let user_exists: (i64,) = sqlx::query_as("SELECT COUNT(*) FROM users WHERE id = ?") .bind(user_id) @@ -137,7 +144,7 @@ pub async fn import_data(pool: &SqlitePool, file_path: &str, user_id: &str) -> R for _ in 0..visited { let visit_id = Uuid::new_v4().to_string(); sqlx::query( - "INSERT INTO visits (id, entry_id, user_id, visited_at) VALUES (?, ?, ?, ?)" + "INSERT INTO visits (id, entry_id, user_id, visited_at) VALUES (?, ?, ?, ?)", ) .bind(&visit_id) .bind(&id) @@ -156,27 +163,96 @@ pub async fn import_data(pool: &SqlitePool, file_path: &str, user_id: &str) -> R Ok(()) } -pub async fn create_user(pool: &SqlitePool, name: &str, email: Option<&str>) -> Result<(), Box> { - let id = Uuid::new_v4().to_string(); - let invite_code = Uuid::new_v4().to_string(); - let now = chrono::Utc::now().to_rfc3339(); +pub async fn invite_user( + pool: &SqlitePool, + name: &str, + public_base_url: &Url, +) -> Result<(String, Url), Box> { + let issued = issue_invitation(pool, name, chrono::Utc::now()).await?; + let url = connection_url(public_base_url, &issued.plaintext_token); + Ok((issued.user_id, url)) +} - sqlx::query( - "INSERT INTO users (id, name, email, invite_code, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?)" - ) - .bind(&id) - .bind(name) - .bind(email) - .bind(&invite_code) - .bind(&now) - .bind(&now) - .execute(pool) - .await?; - - println!("Created user:"); - println!(" ID: {}", id); - println!(" Name: {}", name); - println!(" Invite Code: {}", invite_code); +pub async fn reset_user_auth( + pool: &SqlitePool, + user_id: &str, + public_base_url: &Url, +) -> Result> { + let issued = reset_auth(pool, user_id, chrono::Utc::now()).await?; + Ok(connection_url(public_base_url, &issued.plaintext_token)) +} - Ok(()) +pub fn connection_base_url_from_lookup(mut lookup: F) -> Result +where + F: FnMut(&str) -> Option, +{ + let public_base_url = + lookup("PUBLIC_BASE_URL").ok_or(ConfigError::MissingVariable("PUBLIC_BASE_URL"))?; + Ok(AuthConfig::new(&public_base_url, SignupMode::Closed)?.public_base_url) +} + +#[cfg(test)] +mod tests { + use std::str::FromStr; + + use sqlx::SqlitePool; + use sqlx::sqlite::{SqliteConnectOptions, SqlitePoolOptions}; + use url::Url; + + use super::{connection_base_url_from_lookup, invite_user, reset_user_auth}; + + async fn migrated_pool() -> SqlitePool { + let options = SqliteConnectOptions::from_str("sqlite::memory:") + .unwrap() + .create_if_missing(true); + let pool = SqlitePoolOptions::new() + .max_connections(1) + .connect_with(options) + .await + .unwrap(); + sqlx::migrate!("./migrations").run(&pool).await.unwrap(); + pool + } + + #[tokio::test] + async fn invite_user_returns_the_created_id_and_connection_url() { + let pool = migrated_pool().await; + let base = Url::parse("https://interne.test/").unwrap(); + + let (user_id, url) = invite_user(&pool, "Guest", &base).await.unwrap(); + + assert_eq!(url.path(), "/recover"); + assert!(url.query_pairs().any(|(key, _)| key == "token")); + let user: (String, Option, Option, Option) = sqlx::query_as( + "SELECT name, email, invite_code, github_user_id FROM users WHERE id = ?", + ) + .bind(user_id) + .fetch_one(&pool) + .await + .unwrap(); + assert_eq!(user, ("Guest".into(), None, None, None)); + } + + #[tokio::test] + async fn reset_user_auth_returns_a_recovery_url() { + let pool = migrated_pool().await; + let base = Url::parse("https://interne.test/").unwrap(); + let (user_id, _) = invite_user(&pool, "Guest", &base).await.unwrap(); + + let url = reset_user_auth(&pool, &user_id, &base).await.unwrap(); + + assert_eq!(url.path(), "/recover"); + assert!(url.query_pairs().any(|(key, _)| key == "token")); + } + + #[test] + fn connection_commands_require_only_public_base_url() { + let url = connection_base_url_from_lookup(|key| match key { + "PUBLIC_BASE_URL" => Some("https://interne.test".into()), + unexpected => panic!("must not read {unexpected}"), + }) + .unwrap(); + + assert_eq!(url.as_str(), "https://interne.test/"); + } } diff --git a/src/connection_tokens.rs b/src/connection_tokens.rs new file mode 100644 index 0000000..271b89e --- /dev/null +++ b/src/connection_tokens.rs @@ -0,0 +1,231 @@ +use std::fmt; + +use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD}; +use chrono::{DateTime, Duration, Utc}; +use rand::RngCore; +use sha2::{Digest, Sha256}; +use sqlx::{Sqlite, SqlitePool, Transaction}; +use url::Url; +use uuid::Uuid; + +const TOKEN_BYTES: usize = 32; +const TOKEN_LENGTH: usize = 43; +const TOKEN_LIFETIME_HOURS: i64 = 4; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum ConnectionPurpose { + Invite, + Recovery, +} + +impl ConnectionPurpose { + fn as_str(self) -> &'static str { + match self { + Self::Invite => "invite", + Self::Recovery => "recovery", + } + } + + fn from_str(value: &str) -> Option { + match value { + "invite" => Some(Self::Invite), + "recovery" => Some(Self::Recovery), + _ => None, + } + } +} + +pub struct IssuedConnection { + pub user_id: String, + pub plaintext_token: String, + pub expires_at: DateTime, +} + +#[derive(Debug, PartialEq, Eq)] +pub struct ConnectionClaim { + pub token_id: String, + pub user_id: String, + pub purpose: ConnectionPurpose, +} + +pub enum ConnectionTokenError { + InvalidToken, + UserNotFound, + Database(sqlx::Error), +} + +impl fmt::Debug for ConnectionTokenError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::InvalidToken => formatter.write_str("InvalidToken"), + Self::UserNotFound => formatter.write_str("UserNotFound"), + Self::Database(_) => formatter.write_str("Database"), + } + } +} + +impl fmt::Display for ConnectionTokenError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::InvalidToken => formatter.write_str("connection link is invalid or expired"), + Self::UserNotFound => formatter.write_str("user not found"), + Self::Database(_) => formatter.write_str("connection token database operation failed"), + } + } +} + +impl std::error::Error for ConnectionTokenError { + fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { + match self { + Self::Database(error) => Some(error), + Self::InvalidToken | Self::UserNotFound => None, + } + } +} + +impl From for ConnectionTokenError { + fn from(error: sqlx::Error) -> Self { + Self::Database(error) + } +} + +pub async fn issue_invitation( + pool: &SqlitePool, + name: &str, + now: DateTime, +) -> Result { + let user_id = Uuid::new_v4().to_string(); + let now_text = now.to_rfc3339(); + let mut transaction = pool.begin().await?; + + sqlx::query( + "INSERT INTO users (id, name, email, invite_code, github_user_id, github_login, created_at, updated_at) \ + VALUES (?, ?, NULL, NULL, NULL, NULL, ?, ?)", + ) + .bind(&user_id) + .bind(name) + .bind(&now_text) + .bind(&now_text) + .execute(&mut *transaction) + .await?; + + let issued = issue_token(&mut transaction, &user_id, ConnectionPurpose::Invite, now).await?; + transaction.commit().await?; + Ok(issued) +} + +pub async fn reset_auth( + pool: &SqlitePool, + user_id: &str, + now: DateTime, +) -> Result { + let now_text = now.to_rfc3339(); + let mut transaction = pool.begin().await?; + let result = sqlx::query( + "UPDATE users SET github_user_id = NULL, github_login = NULL, invite_code = NULL, \ + auth_version = auth_version + 1, updated_at = ? WHERE id = ?", + ) + .bind(&now_text) + .bind(user_id) + .execute(&mut *transaction) + .await?; + if result.rows_affected() == 0 { + return Err(ConnectionTokenError::UserNotFound); + } + + let issued = issue_token(&mut transaction, user_id, ConnectionPurpose::Recovery, now).await?; + transaction.commit().await?; + Ok(issued) +} + +pub async fn validate_token( + pool: &SqlitePool, + plaintext: &str, + now: DateTime, +) -> Result { + if plaintext.len() != TOKEN_LENGTH { + return Err(ConnectionTokenError::InvalidToken); + } + let decoded = URL_SAFE_NO_PAD + .decode(plaintext) + .map_err(|_| ConnectionTokenError::InvalidToken)?; + if decoded.len() != TOKEN_BYTES || URL_SAFE_NO_PAD.encode(&decoded) != plaintext { + return Err(ConnectionTokenError::InvalidToken); + } + + let token_hash = hash_token(plaintext); + let record: Option<(String, String, String)> = sqlx::query_as( + "SELECT id, user_id, purpose FROM auth_connection_tokens \ + WHERE token_hash = ? AND consumed_at IS NULL AND expires_at > ?", + ) + .bind(token_hash) + .bind(now.to_rfc3339()) + .fetch_optional(pool) + .await?; + + let (token_id, user_id, purpose) = record.ok_or(ConnectionTokenError::InvalidToken)?; + let purpose = + ConnectionPurpose::from_str(&purpose).ok_or(ConnectionTokenError::InvalidToken)?; + Ok(ConnectionClaim { + token_id, + user_id, + purpose, + }) +} + +pub fn connection_url(base: &Url, plaintext: &str) -> Url { + let mut url = base.clone(); + url.set_path("/recover"); + url.set_query(None); + url.set_fragment(None); + url.query_pairs_mut().append_pair("token", plaintext); + url +} + +async fn issue_token( + transaction: &mut Transaction<'_, Sqlite>, + user_id: &str, + purpose: ConnectionPurpose, + now: DateTime, +) -> Result { + let mut random = [0_u8; TOKEN_BYTES]; + rand::rng().fill_bytes(&mut random); + let plaintext_token = URL_SAFE_NO_PAD.encode(random); + let token_hash = hash_token(&plaintext_token); + let expires_at = now + Duration::hours(TOKEN_LIFETIME_HOURS); + let now_text = now.to_rfc3339(); + + sqlx::query( + "UPDATE auth_connection_tokens SET consumed_at = ? \ + WHERE user_id = ? AND consumed_at IS NULL AND expires_at > ?", + ) + .bind(&now_text) + .bind(user_id) + .bind(&now_text) + .execute(&mut **transaction) + .await?; + + sqlx::query( + "INSERT INTO auth_connection_tokens \ + (id, user_id, token_hash, purpose, created_at, expires_at, consumed_at) \ + VALUES (?, ?, ?, ?, ?, ?, NULL)", + ) + .bind(Uuid::new_v4().to_string()) + .bind(user_id) + .bind(token_hash) + .bind(purpose.as_str()) + .bind(&now_text) + .bind(expires_at.to_rfc3339()) + .execute(&mut **transaction) + .await?; + + Ok(IssuedConnection { + user_id: user_id.to_owned(), + plaintext_token, + expires_at, + }) +} + +fn hash_token(plaintext: &str) -> String { + URL_SAFE_NO_PAD.encode(Sha256::digest(plaintext.as_bytes())) +} diff --git a/src/lib.rs b/src/lib.rs index 066ebb5..355c494 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,6 +1,7 @@ pub mod auth; pub mod cli; pub mod config; +pub mod connection_tokens; pub mod db; pub mod error; pub mod github; diff --git a/src/main.rs b/src/main.rs index 42b5962..c4e4479 100644 --- a/src/main.rs +++ b/src/main.rs @@ -34,15 +34,57 @@ async fn main() { return; } "create-user" => { - if args.len() < 3 { + if !(3..=4).contains(&args.len()) { eprintln!("Usage: interne create-user [email]"); std::process::exit(1); } - let email = args.get(3).map(|s| s.as_str()); - if let Err(e) = interne::cli::create_user(&pool, &args[2], email).await { - eprintln!("Failed to create user: {}", e); + eprintln!( + "Warning: create-user is deprecated and will be removed; email is ignored." + ); + let public_base_url = connection_base_url_or_exit(); + match interne::cli::invite_user(&pool, &args[2], &public_base_url).await { + Ok((user_id, url)) => { + println!("User ID: {user_id}"); + println!("Invitation URL: {url}"); + } + Err(error) => { + eprintln!("Failed to invite user: {error}"); + std::process::exit(1); + } + } + return; + } + "invite-user" => { + if args.len() != 3 { + eprintln!("Usage: interne invite-user "); + std::process::exit(1); + } + let public_base_url = connection_base_url_or_exit(); + match interne::cli::invite_user(&pool, &args[2], &public_base_url).await { + Ok((user_id, url)) => { + println!("User ID: {user_id}"); + println!("Invitation URL: {url}"); + } + Err(error) => { + eprintln!("Failed to invite user: {error}"); + std::process::exit(1); + } + } + return; + } + "reset-auth" => { + if args.len() != 3 { + eprintln!("Usage: interne reset-auth "); std::process::exit(1); } + let public_base_url = connection_base_url_or_exit(); + match interne::cli::reset_user_auth(&pool, &args[2], &public_base_url).await { + Ok(url) => println!("Recovery URL: {url}"), + Err(error) => { + eprintln!("Failed to reset user authentication: {error}"); + std::process::exit(1); + } + } return; } "help" | "--help" | "-h" => { @@ -52,9 +94,15 @@ async fn main() { println!(); println!("Commands:"); println!(" (none) Start the web server"); - println!(" create-user Create a new user"); - println!(" import Import legacy JSON data"); - println!(" help Show this help"); + println!( + " invite-user Create an invitation URL (expires in four hours)" + ); + println!( + " reset-auth Reset auth and create a recovery URL (expires in four hours)" + ); + println!(" create-user [email] Deprecated alias for invite-user"); + println!(" import Import legacy JSON data"); + println!(" help Show this help"); return; } cmd => { @@ -92,3 +140,12 @@ async fn main() { tracing::info!("listening on {}", addr); axum::serve(listener, app).await.unwrap(); } + +fn connection_base_url_or_exit() -> url::Url { + interne::cli::connection_base_url_from_lookup(|key| env::var(key).ok()).unwrap_or_else( + |error| { + eprintln!("Authentication configuration error: {error}"); + std::process::exit(1); + }, + ) +} diff --git a/tests/connection_tokens.rs b/tests/connection_tokens.rs new file mode 100644 index 0000000..e693c70 --- /dev/null +++ b/tests/connection_tokens.rs @@ -0,0 +1,345 @@ +mod common; + +use chrono::{Duration, Utc}; +use interne::connection_tokens::{ + ConnectionPurpose, ConnectionTokenError, connection_url, issue_invitation, reset_auth, + validate_token, +}; +use std::process::Command; +use std::str::FromStr; +use url::Url; + +use common::TestApp; + +#[tokio::test] +async fn invitation_is_hashed_single_use_and_expires_in_four_hours() { + let app = TestApp::new().await; + let now = Utc::now(); + let issued = issue_invitation(&app.db, "Guest", now).await.unwrap(); + + assert_eq!(issued.expires_at, now + Duration::hours(4)); + let stored: String = + sqlx::query_scalar("SELECT token_hash FROM auth_connection_tokens WHERE user_id = ?") + .bind(&issued.user_id) + .fetch_one(&app.db) + .await + .unwrap(); + assert_ne!(stored, issued.plaintext_token); + + let claim = validate_token(&app.db, &issued.plaintext_token, now) + .await + .unwrap(); + assert_eq!(claim.user_id, issued.user_id); + assert_eq!(claim.purpose, ConnectionPurpose::Invite); +} + +#[tokio::test] +async fn new_invitation_supersedes_an_older_token() { + let app = TestApp::new().await; + let first_now = Utc::now(); + let invitation = issue_invitation(&app.db, "Guest", first_now).await.unwrap(); + + let replacement_now = first_now + Duration::minutes(5); + let replacement = reset_auth(&app.db, &invitation.user_id, replacement_now) + .await + .unwrap(); + + assert_eq!(replacement.user_id, invitation.user_id); + let consumed_at: Option = sqlx::query_scalar( + "SELECT consumed_at FROM auth_connection_tokens WHERE user_id = ? AND purpose = 'invite'", + ) + .bind(&invitation.user_id) + .fetch_one(&app.db) + .await + .unwrap(); + assert_eq!( + consumed_at.as_deref(), + Some(replacement_now.to_rfc3339().as_str()) + ); + assert!( + validate_token(&app.db, &invitation.plaintext_token, replacement_now) + .await + .is_err() + ); + assert_eq!( + validate_token(&app.db, &replacement.plaintext_token, replacement_now) + .await + .unwrap() + .purpose, + ConnectionPurpose::Recovery + ); +} + +#[tokio::test] +async fn expired_token_is_rejected() { + let app = TestApp::new().await; + let now = Utc::now(); + let issued = issue_invitation(&app.db, "Guest", now).await.unwrap(); + + let error = validate_token(&app.db, &issued.plaintext_token, now + Duration::hours(4)) + .await + .unwrap_err(); + + assert!(matches!(error, ConnectionTokenError::InvalidToken)); +} + +#[tokio::test] +async fn malformed_token_is_rejected_like_an_expired_token() { + let app = TestApp::new().await; + let now = Utc::now(); + let issued = issue_invitation(&app.db, "Guest", now).await.unwrap(); + let expired = validate_token(&app.db, &issued.plaintext_token, now + Duration::hours(4)) + .await + .unwrap_err(); + + for malformed in ["short", "!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!"] { + let error = validate_token(&app.db, malformed, now).await.unwrap_err(); + assert_eq!(error.to_string(), expired.to_string()); + assert_eq!(format!("{error:?}"), format!("{expired:?}")); + } +} + +#[tokio::test] +async fn reset_auth_disconnects_github_and_increments_auth_version() { + let app = TestApp::new().await; + let user_id = app.create_github_user("Guest", "12345", "octoguest").await; + sqlx::query("UPDATE users SET invite_code = 'legacy-code' WHERE id = ?") + .bind(&user_id) + .execute(&app.db) + .await + .unwrap(); + let now = Utc::now(); + + let issued = reset_auth(&app.db, &user_id, now).await.unwrap(); + + let user: (Option, Option, Option, i64, String) = sqlx::query_as( + "SELECT github_user_id, github_login, invite_code, auth_version, updated_at FROM users WHERE id = ?", + ) + .bind(&user_id) + .fetch_one(&app.db) + .await + .unwrap(); + assert_eq!(user, (None, None, None, 2, now.to_rfc3339())); + assert_eq!(issued.user_id, user_id); + assert_eq!(issued.expires_at, now + Duration::hours(4)); + assert_eq!( + validate_token(&app.db, &issued.plaintext_token, now) + .await + .unwrap() + .purpose, + ConnectionPurpose::Recovery + ); +} + +#[tokio::test] +async fn reset_auth_rejects_an_unknown_user() { + let app = TestApp::new().await; + + let error = match reset_auth(&app.db, "missing-user", Utc::now()).await { + Ok(_) => panic!("an unknown user must not receive a recovery token"), + Err(error) => error, + }; + + assert!(matches!(error, ConnectionTokenError::UserNotFound)); + let count: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM auth_connection_tokens") + .fetch_one(&app.db) + .await + .unwrap(); + assert_eq!(count, 0); +} + +#[test] +fn connection_url_uses_public_base_and_percent_encoding() { + let base = Url::parse("https://interne.test/").unwrap(); + + let url = connection_url(&base, "a/b? c"); + + assert_eq!( + url.as_str(), + "https://interne.test/recover?token=a%2Fb%3F+c" + ); +} + +#[tokio::test] +async fn plaintext_token_is_never_written_to_sqlite() { + let app = TestApp::new().await; + let issued = issue_invitation(&app.db, "Guest", Utc::now()) + .await + .unwrap(); + + let persisted_text: (String, String, String, String, String, Option) = + sqlx::query_as( + "SELECT id, user_id, token_hash, purpose, expires_at, consumed_at FROM auth_connection_tokens WHERE user_id = ?", + ) + .bind(&issued.user_id) + .fetch_one(&app.db) + .await + .unwrap(); + for value in [ + persisted_text.0.as_str(), + persisted_text.1.as_str(), + persisted_text.2.as_str(), + persisted_text.3.as_str(), + persisted_text.4.as_str(), + persisted_text.5.as_deref().unwrap_or_default(), + ] { + assert!(!value.contains(&issued.plaintext_token)); + } +} + +#[tokio::test] +async fn invitation_rolls_back_user_creation_when_token_insert_fails() { + let app = TestApp::new().await; + sqlx::query( + "CREATE TRIGGER reject_connection_tokens BEFORE INSERT ON auth_connection_tokens BEGIN SELECT RAISE(ABORT, 'rejected'); END", + ) + .execute(&app.db) + .await + .unwrap(); + + assert!( + issue_invitation(&app.db, "Rolled Back", Utc::now()) + .await + .is_err() + ); + + let count: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM users WHERE name = 'Rolled Back'") + .fetch_one(&app.db) + .await + .unwrap(); + assert_eq!(count, 0); +} + +#[tokio::test] +async fn reset_auth_rolls_back_disconnection_when_token_insert_fails() { + let app = TestApp::new().await; + let user_id = app.create_github_user("Guest", "12345", "octoguest").await; + sqlx::query( + "CREATE TRIGGER reject_connection_tokens BEFORE INSERT ON auth_connection_tokens BEGIN SELECT RAISE(ABORT, 'rejected'); END", + ) + .execute(&app.db) + .await + .unwrap(); + + assert!(reset_auth(&app.db, &user_id, Utc::now()).await.is_err()); + + let user: (Option, Option, i64) = + sqlx::query_as("SELECT github_user_id, github_login, auth_version FROM users WHERE id = ?") + .bind(&user_id) + .fetch_one(&app.db) + .await + .unwrap(); + assert_eq!(user, (Some("12345".into()), Some("octoguest".into()), 1)); +} + +struct CliDatabase { + path: std::path::PathBuf, +} + +impl CliDatabase { + fn new() -> Self { + Self { + path: std::env::temp_dir().join(format!("interne-cli-{}.db", uuid::Uuid::new_v4())), + } + } + + fn url(&self) -> String { + format!("sqlite:{}", self.path.display()) + } +} + +impl Drop for CliDatabase { + fn drop(&mut self) { + for path in [ + self.path.clone(), + self.path.with_extension("db-shm"), + self.path.with_extension("db-wal"), + ] { + let _ = std::fs::remove_file(path); + } + } +} + +fn run_cli(database: &CliDatabase, arguments: &[&str]) -> std::process::Output { + Command::new(env!("CARGO_BIN_EXE_interne")) + .args(arguments) + .env_clear() + .env("DATABASE_URL", database.url()) + .env("PUBLIC_BASE_URL", "https://interne.test") + .current_dir(std::env::temp_dir()) + .output() + .unwrap() +} + +#[tokio::test] +async fn invitation_and_reset_commands_do_not_require_github_credentials() { + let database = CliDatabase::new(); + let invite = run_cli(&database, &["invite-user", "CLI Guest"]); + assert!( + invite.status.success(), + "{}", + String::from_utf8_lossy(&invite.stderr) + ); + let stdout = String::from_utf8(invite.stdout).unwrap(); + assert_eq!(stdout.matches("/recover?token=").count(), 1); + let user_id = stdout + .lines() + .find_map(|line| line.strip_prefix("User ID: ")) + .expect("invite command prints the user ID"); + + let reset = run_cli(&database, &["reset-auth", user_id]); + assert!( + reset.status.success(), + "{}", + String::from_utf8_lossy(&reset.stderr) + ); + let stdout = String::from_utf8(reset.stdout).unwrap(); + assert_eq!(stdout.matches("/recover?token=").count(), 1); +} + +#[tokio::test] +async fn deprecated_create_user_alias_ignores_email_and_issues_an_invitation() { + let database = CliDatabase::new(); + + let output = run_cli( + &database, + &["create-user", "Legacy Guest", "ignored@example.com"], + ); + + assert!( + output.status.success(), + "{}", + String::from_utf8_lossy(&output.stderr) + ); + let stdout = String::from_utf8(output.stdout).unwrap(); + let stderr = String::from_utf8(output.stderr).unwrap(); + assert_eq!(stdout.matches("/recover?token=").count(), 1); + assert!(stderr.contains("deprecated")); + assert!(stderr.contains("email is ignored")); + + let options = sqlx::sqlite::SqliteConnectOptions::from_str(&database.url()).unwrap(); + let pool = sqlx::sqlite::SqlitePoolOptions::new() + .max_connections(1) + .connect_with(options) + .await + .unwrap(); + let email: Option = + sqlx::query_scalar("SELECT email FROM users WHERE name = 'Legacy Guest'") + .fetch_one(&pool) + .await + .unwrap(); + assert_eq!(email, None); +} + +#[test] +fn cli_help_names_connection_arguments_and_four_hour_expiry() { + let database = CliDatabase::new(); + + let output = run_cli(&database, &["help"]); + + assert!(output.status.success()); + let stdout = String::from_utf8(output.stdout).unwrap(); + assert!(stdout.contains("invite-user ")); + assert!(stdout.contains("reset-auth ")); + assert!(stdout.contains("four hours")); +} From 28d5eed5754bb1321d4f63ec8e5d79ba360c1209 Mon Sep 17 00:00:00 2001 From: Axel Anderson Date: Wed, 12 Aug 2026 11:43:49 -0400 Subject: [PATCH 17/25] fix(auth): canonicalize connection token timestamps --- src/connection_tokens.rs | 16 ++- src/routes/auth.rs | 2 +- tests/connection_tokens.rs | 224 ++++++++++++++++++++++++++++++------- tests/github_auth.rs | 4 +- 4 files changed, 200 insertions(+), 46 deletions(-) diff --git a/src/connection_tokens.rs b/src/connection_tokens.rs index 271b89e..8c67566 100644 --- a/src/connection_tokens.rs +++ b/src/connection_tokens.rs @@ -1,7 +1,7 @@ use std::fmt; use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD}; -use chrono::{DateTime, Duration, Utc}; +use chrono::{DateTime, Duration, SecondsFormat, Utc}; use rand::RngCore; use sha2::{Digest, Sha256}; use sqlx::{Sqlite, SqlitePool, Transaction}; @@ -95,7 +95,7 @@ pub async fn issue_invitation( now: DateTime, ) -> Result { let user_id = Uuid::new_v4().to_string(); - let now_text = now.to_rfc3339(); + let now_text = database_timestamp(now); let mut transaction = pool.begin().await?; sqlx::query( @@ -119,7 +119,7 @@ pub async fn reset_auth( user_id: &str, now: DateTime, ) -> Result { - let now_text = now.to_rfc3339(); + let now_text = database_timestamp(now); let mut transaction = pool.begin().await?; let result = sqlx::query( "UPDATE users SET github_user_id = NULL, github_login = NULL, invite_code = NULL, \ @@ -159,7 +159,7 @@ pub async fn validate_token( WHERE token_hash = ? AND consumed_at IS NULL AND expires_at > ?", ) .bind(token_hash) - .bind(now.to_rfc3339()) + .bind(database_timestamp(now)) .fetch_optional(pool) .await?; @@ -193,7 +193,7 @@ async fn issue_token( let plaintext_token = URL_SAFE_NO_PAD.encode(random); let token_hash = hash_token(&plaintext_token); let expires_at = now + Duration::hours(TOKEN_LIFETIME_HOURS); - let now_text = now.to_rfc3339(); + let now_text = database_timestamp(now); sqlx::query( "UPDATE auth_connection_tokens SET consumed_at = ? \ @@ -215,7 +215,7 @@ async fn issue_token( .bind(token_hash) .bind(purpose.as_str()) .bind(&now_text) - .bind(expires_at.to_rfc3339()) + .bind(database_timestamp(expires_at)) .execute(&mut **transaction) .await?; @@ -229,3 +229,7 @@ async fn issue_token( fn hash_token(plaintext: &str) -> String { URL_SAFE_NO_PAD.encode(Sha256::digest(plaintext.as_bytes())) } + +pub(crate) fn database_timestamp(timestamp: DateTime) -> String { + timestamp.to_rfc3339_opts(SecondsFormat::Nanos, true) +} diff --git a/src/routes/auth.rs b/src/routes/auth.rs index 3b3e674..cef30aa 100644 --- a/src/routes/auth.rs +++ b/src/routes/auth.rs @@ -349,7 +349,7 @@ async fn github_confirm_submit( ConnectionProof::LegacyInvite => {} } - let now = chrono::Utc::now().to_rfc3339(); + let now = crate::connection_tokens::database_timestamp(chrono::Utc::now()); let mut transaction = state.db.begin().await?; let update_result = sqlx::query( "UPDATE users \ diff --git a/tests/connection_tokens.rs b/tests/connection_tokens.rs index e693c70..31f045b 100644 --- a/tests/connection_tokens.rs +++ b/tests/connection_tokens.rs @@ -1,6 +1,6 @@ mod common; -use chrono::{Duration, Utc}; +use chrono::{Duration, SecondsFormat, TimeZone, Utc}; use interne::connection_tokens::{ ConnectionPurpose, ConnectionTokenError, connection_url, issue_invitation, reset_auth, validate_token, @@ -54,7 +54,11 @@ async fn new_invitation_supersedes_an_older_token() { .unwrap(); assert_eq!( consumed_at.as_deref(), - Some(replacement_now.to_rfc3339().as_str()) + Some( + replacement_now + .to_rfc3339_opts(SecondsFormat::Nanos, true) + .as_str() + ) ); assert!( validate_token(&app.db, &invitation.plaintext_token, replacement_now) @@ -83,6 +87,81 @@ async fn expired_token_is_rejected() { assert!(matches!(error, ConnectionTokenError::InvalidToken)); } +#[tokio::test] +async fn exact_second_expiry_is_active_before_and_expired_at_the_boundary() { + let app = TestApp::new().await; + let now = Utc.with_ymd_and_hms(2026, 8, 12, 12, 0, 0).unwrap(); + let issued = issue_invitation(&app.db, "Exact", now).await.unwrap(); + let (created_at, expires_at): (String, String) = sqlx::query_as( + "SELECT created_at, expires_at FROM auth_connection_tokens WHERE user_id = ?", + ) + .bind(&issued.user_id) + .fetch_one(&app.db) + .await + .unwrap(); + + assert_eq!(created_at, now.to_rfc3339_opts(SecondsFormat::Nanos, true)); + assert_eq!( + expires_at, + issued + .expires_at + .to_rfc3339_opts(SecondsFormat::Nanos, true) + ); + assert!( + validate_token( + &app.db, + &issued.plaintext_token, + issued.expires_at - Duration::nanoseconds(1), + ) + .await + .is_ok() + ); + assert!( + validate_token(&app.db, &issued.plaintext_token, issued.expires_at) + .await + .is_err() + ); +} + +#[tokio::test] +async fn fractional_expiry_is_active_before_and_expired_after_the_boundary() { + let app = TestApp::new().await; + let now = + Utc.with_ymd_and_hms(2026, 8, 12, 12, 0, 0).unwrap() + Duration::nanoseconds(1_234_000); + let issued = issue_invitation(&app.db, "Fractional", now).await.unwrap(); + let expires_at: String = + sqlx::query_scalar("SELECT expires_at FROM auth_connection_tokens WHERE user_id = ?") + .bind(&issued.user_id) + .fetch_one(&app.db) + .await + .unwrap(); + + assert_eq!( + expires_at, + issued + .expires_at + .to_rfc3339_opts(SecondsFormat::Nanos, true) + ); + assert!( + validate_token( + &app.db, + &issued.plaintext_token, + issued.expires_at - Duration::nanoseconds(1), + ) + .await + .is_ok() + ); + assert!( + validate_token( + &app.db, + &issued.plaintext_token, + issued.expires_at + Duration::nanoseconds(1), + ) + .await + .is_err() + ); +} + #[tokio::test] async fn malformed_token_is_rejected_like_an_expired_token() { let app = TestApp::new().await; @@ -119,7 +198,16 @@ async fn reset_auth_disconnects_github_and_increments_auth_version() { .fetch_one(&app.db) .await .unwrap(); - assert_eq!(user, (None, None, None, 2, now.to_rfc3339())); + assert_eq!( + user, + ( + None, + None, + None, + 2, + now.to_rfc3339_opts(SecondsFormat::Nanos, true) + ) + ); assert_eq!(issued.user_id, user_id); assert_eq!(issued.expires_at, now + Duration::hours(4)); assert_eq!( @@ -162,28 +250,53 @@ fn connection_url_uses_public_base_and_percent_encoding() { #[tokio::test] async fn plaintext_token_is_never_written_to_sqlite() { - let app = TestApp::new().await; - let issued = issue_invitation(&app.db, "Guest", Utc::now()) + let database = FileDatabase::new("token-storage"); + let options = sqlx::sqlite::SqliteConnectOptions::from_str(&database.url()) + .unwrap() + .create_if_missing(true) + .journal_mode(sqlx::sqlite::SqliteJournalMode::Wal); + let pool = sqlx::sqlite::SqlitePoolOptions::new() + .max_connections(1) + .connect_with(options) .await .unwrap(); + sqlx::migrate!("./migrations").run(&pool).await.unwrap(); + let now = Utc::now(); + let issued = issue_invitation(&pool, "Guest", now).await.unwrap(); - let persisted_text: (String, String, String, String, String, Option) = - sqlx::query_as( - "SELECT id, user_id, token_hash, purpose, expires_at, consumed_at FROM auth_connection_tokens WHERE user_id = ?", - ) - .bind(&issued.user_id) - .fetch_one(&app.db) + let stored_hash: String = + sqlx::query_scalar("SELECT token_hash FROM auth_connection_tokens WHERE user_id = ?") + .bind(&issued.user_id) + .fetch_one(&pool) + .await + .unwrap(); + assert_ne!(stored_hash, issued.plaintext_token); + assert!( + validate_token(&pool, &issued.plaintext_token, now) + .await + .is_ok() + ); + + sqlx::query("PRAGMA wal_checkpoint(TRUNCATE)") + .fetch_all(&pool) .await .unwrap(); - for value in [ - persisted_text.0.as_str(), - persisted_text.1.as_str(), - persisted_text.2.as_str(), - persisted_text.3.as_str(), - persisted_text.4.as_str(), - persisted_text.5.as_deref().unwrap_or_default(), - ] { - assert!(!value.contains(&issued.plaintext_token)); + pool.close().await; + + let plaintext = issued.plaintext_token.as_bytes(); + for artifact in database + .artifacts() + .into_iter() + .filter(|path| path.exists()) + { + let bytes = std::fs::read(&artifact).unwrap(); + assert!( + !bytes + .windows(plaintext.len()) + .any(|window| window == plaintext), + "plaintext token found in {}", + artifact.display() + ); } } @@ -213,7 +326,20 @@ async fn invitation_rolls_back_user_creation_when_token_insert_fails() { #[tokio::test] async fn reset_auth_rolls_back_disconnection_when_token_insert_fails() { let app = TestApp::new().await; - let user_id = app.create_github_user("Guest", "12345", "octoguest").await; + let issued = issue_invitation(&app.db, "Guest", Utc::now()) + .await + .unwrap(); + let user_id = issued.user_id; + let original_updated_at = "2025-02-03T04:05:06.000000000Z"; + sqlx::query( + "UPDATE users SET invite_code = 'legacy-code', github_user_id = '12345', \ + github_login = 'octoguest', auth_version = 7, updated_at = ? WHERE id = ?", + ) + .bind(original_updated_at) + .bind(&user_id) + .execute(&app.db) + .await + .unwrap(); sqlx::query( "CREATE TRIGGER reject_connection_tokens BEFORE INSERT ON auth_connection_tokens BEGIN SELECT RAISE(ABORT, 'rejected'); END", ) @@ -223,44 +349,66 @@ async fn reset_auth_rolls_back_disconnection_when_token_insert_fails() { assert!(reset_auth(&app.db, &user_id, Utc::now()).await.is_err()); - let user: (Option, Option, i64) = - sqlx::query_as("SELECT github_user_id, github_login, auth_version FROM users WHERE id = ?") + let user: (Option, Option, Option, i64, String) = sqlx::query_as( + "SELECT invite_code, github_user_id, github_login, auth_version, updated_at FROM users WHERE id = ?", + ) + .bind(&user_id) + .fetch_one(&app.db) + .await + .unwrap(); + assert_eq!( + user, + ( + Some("legacy-code".into()), + Some("12345".into()), + Some("octoguest".into()), + 7, + original_updated_at.into() + ) + ); + let predecessor_consumed_at: Option = + sqlx::query_scalar("SELECT consumed_at FROM auth_connection_tokens WHERE user_id = ?") .bind(&user_id) .fetch_one(&app.db) .await .unwrap(); - assert_eq!(user, (Some("12345".into()), Some("octoguest".into()), 1)); + assert_eq!(predecessor_consumed_at, None); } -struct CliDatabase { +struct FileDatabase { path: std::path::PathBuf, } -impl CliDatabase { - fn new() -> Self { +impl FileDatabase { + fn new(label: &str) -> Self { Self { - path: std::env::temp_dir().join(format!("interne-cli-{}.db", uuid::Uuid::new_v4())), + path: std::env::temp_dir().join(format!("interne-{label}-{}.db", uuid::Uuid::new_v4())), } } fn url(&self) -> String { format!("sqlite:{}", self.path.display()) } + + fn artifacts(&self) -> [std::path::PathBuf; 3] { + let with_suffix = |suffix: &str| { + let mut path = self.path.as_os_str().to_os_string(); + path.push(suffix); + std::path::PathBuf::from(path) + }; + [self.path.clone(), with_suffix("-wal"), with_suffix("-shm")] + } } -impl Drop for CliDatabase { +impl Drop for FileDatabase { fn drop(&mut self) { - for path in [ - self.path.clone(), - self.path.with_extension("db-shm"), - self.path.with_extension("db-wal"), - ] { + for path in self.artifacts() { let _ = std::fs::remove_file(path); } } } -fn run_cli(database: &CliDatabase, arguments: &[&str]) -> std::process::Output { +fn run_cli(database: &FileDatabase, arguments: &[&str]) -> std::process::Output { Command::new(env!("CARGO_BIN_EXE_interne")) .args(arguments) .env_clear() @@ -273,7 +421,7 @@ fn run_cli(database: &CliDatabase, arguments: &[&str]) -> std::process::Output { #[tokio::test] async fn invitation_and_reset_commands_do_not_require_github_credentials() { - let database = CliDatabase::new(); + let database = FileDatabase::new("cli"); let invite = run_cli(&database, &["invite-user", "CLI Guest"]); assert!( invite.status.success(), @@ -299,7 +447,7 @@ async fn invitation_and_reset_commands_do_not_require_github_credentials() { #[tokio::test] async fn deprecated_create_user_alias_ignores_email_and_issues_an_invitation() { - let database = CliDatabase::new(); + let database = FileDatabase::new("cli"); let output = run_cli( &database, @@ -333,7 +481,7 @@ async fn deprecated_create_user_alias_ignores_email_and_issues_an_invitation() { #[test] fn cli_help_names_connection_arguments_and_four_hour_expiry() { - let database = CliDatabase::new(); + let database = FileDatabase::new("cli"); let output = run_cli(&database, &["help"]); diff --git a/tests/github_auth.rs b/tests/github_auth.rs index e80156f..7473f5c 100644 --- a/tests/github_auth.rs +++ b/tests/github_auth.rs @@ -361,7 +361,9 @@ async fn link_confirmation_consumes_active_connection_tokens() { .fetch_one(&app.db) .await .unwrap(); - assert!(consumed_at.is_some()); + let consumed_at = consumed_at.expect("active token is consumed"); + assert_eq!(consumed_at.len(), 30); + assert!(consumed_at.ends_with('Z')); } #[tokio::test] From 3e709cbf24e5bbeaa0a458c7aa8a914d263d292c Mon Sep 17 00:00:00 2001 From: Axel Anderson Date: Wed, 12 Aug 2026 11:55:39 -0400 Subject: [PATCH 18/25] feat(auth): recover accounts through GitHub --- src/auth.rs | 40 ++- src/connection_tokens.rs | 121 ++++++++- src/lib.rs | 31 ++- src/routes/auth.rs | 262 +++++++++++++++---- templates/github_confirm.html | 2 +- tests/connection_tokens.rs | 459 +++++++++++++++++++++++++++++++++- 6 files changed, 859 insertions(+), 56 deletions(-) diff --git a/src/auth.rs b/src/auth.rs index f43be6b..e169b14 100644 --- a/src/auth.rs +++ b/src/auth.rs @@ -10,6 +10,7 @@ use serde::{Deserialize, Serialize}; use tower_sessions::Session; use crate::AppState; +use crate::connection_tokens::{ConnectionClaim, ConnectionPurpose}; use crate::github::GitHubProfile; use crate::models::User; @@ -18,11 +19,19 @@ const AUTH_VERSION_KEY: &str = "auth_version"; const OAUTH_ATTEMPT_KEY: &str = "oauth_attempt"; const MIGRATION_SESSION_KEY: &str = "migration_session"; const PENDING_CONNECTION_KEY: &str = "pending_connection"; +const CONNECTION_CLAIM_KEY: &str = "connection_claim"; #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] pub enum OAuthPurpose { Login, - Link { user_id: String }, + Link { + user_id: String, + }, + ConnectionToken { + token_id: String, + user_id: String, + purpose: ConnectionPurpose, + }, } #[derive(Clone, Debug, Serialize, Deserialize)] @@ -51,6 +60,10 @@ impl MigrationSession { #[derive(Clone, Debug, Serialize, Deserialize)] pub enum ConnectionProof { LegacyInvite, + Token { + token_id: String, + purpose: ConnectionPurpose, + }, } #[derive(Clone, Debug, Serialize, Deserialize)] @@ -144,6 +157,28 @@ pub async fn store_pending_connection( session.insert(PENDING_CONNECTION_KEY, pending).await } +pub async fn store_connection_claim( + session: &Session, + claim: &ConnectionClaim, +) -> Result<(), tower_sessions::session::Error> { + session.remove::(USER_ID_KEY).await?; + session.remove::(AUTH_VERSION_KEY).await?; + session + .remove::(MIGRATION_SESSION_KEY) + .await?; + session + .remove::(PENDING_CONNECTION_KEY) + .await?; + session.remove::(OAUTH_ATTEMPT_KEY).await?; + session.insert(CONNECTION_CLAIM_KEY, claim).await +} + +pub async fn take_connection_claim( + session: &Session, +) -> Result, tower_sessions::session::Error> { + session.remove(CONNECTION_CLAIM_KEY).await +} + pub async fn get_pending_connection( session: &Session, ) -> Result, tower_sessions::session::Error> { @@ -222,6 +257,9 @@ pub async fn login_user( session .remove::(PENDING_CONNECTION_KEY) .await?; + session + .remove::(CONNECTION_CLAIM_KEY) + .await?; session.insert(USER_ID_KEY, &user.id).await?; session.insert(AUTH_VERSION_KEY, user.auth_version).await } diff --git a/src/connection_tokens.rs b/src/connection_tokens.rs index 8c67566..e1bb7bd 100644 --- a/src/connection_tokens.rs +++ b/src/connection_tokens.rs @@ -3,16 +3,20 @@ use std::fmt; use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD}; use chrono::{DateTime, Duration, SecondsFormat, Utc}; use rand::RngCore; +use serde::{Deserialize, Serialize}; use sha2::{Digest, Sha256}; use sqlx::{Sqlite, SqlitePool, Transaction}; use url::Url; use uuid::Uuid; +use crate::github::GitHubProfile; +use crate::models::User; + const TOKEN_BYTES: usize = 32; const TOKEN_LENGTH: usize = 43; const TOKEN_LIFETIME_HOURS: i64 = 4; -#[derive(Clone, Copy, Debug, PartialEq, Eq)] +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] pub enum ConnectionPurpose { Invite, Recovery, @@ -41,7 +45,7 @@ pub struct IssuedConnection { pub expires_at: DateTime, } -#[derive(Debug, PartialEq, Eq)] +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] pub struct ConnectionClaim { pub token_id: String, pub user_id: String, @@ -50,6 +54,7 @@ pub struct ConnectionClaim { pub enum ConnectionTokenError { InvalidToken, + GitHubIdentityInUse, UserNotFound, Database(sqlx::Error), } @@ -58,6 +63,7 @@ impl fmt::Debug for ConnectionTokenError { fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { match self { Self::InvalidToken => formatter.write_str("InvalidToken"), + Self::GitHubIdentityInUse => formatter.write_str("GitHubIdentityInUse"), Self::UserNotFound => formatter.write_str("UserNotFound"), Self::Database(_) => formatter.write_str("Database"), } @@ -68,6 +74,9 @@ impl fmt::Display for ConnectionTokenError { fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { match self { Self::InvalidToken => formatter.write_str("connection link is invalid or expired"), + Self::GitHubIdentityInUse => { + formatter.write_str("GitHub identity is already connected") + } Self::UserNotFound => formatter.write_str("user not found"), Self::Database(_) => formatter.write_str("connection token database operation failed"), } @@ -78,7 +87,7 @@ impl std::error::Error for ConnectionTokenError { fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { match self { Self::Database(error) => Some(error), - Self::InvalidToken | Self::UserNotFound => None, + Self::GitHubIdentityInUse | Self::InvalidToken | Self::UserNotFound => None, } } } @@ -173,6 +182,106 @@ pub async fn validate_token( }) } +pub async fn validate_claim( + pool: &SqlitePool, + claim: &ConnectionClaim, + now: DateTime, +) -> Result<(), ConnectionTokenError> { + let is_active: i64 = sqlx::query_scalar( + "SELECT EXISTS(\ + SELECT 1 FROM auth_connection_tokens \ + WHERE id = ? AND user_id = ? AND purpose = ? \ + AND consumed_at IS NULL AND expires_at > ?\ + )", + ) + .bind(&claim.token_id) + .bind(&claim.user_id) + .bind(claim.purpose.as_str()) + .bind(database_timestamp(now)) + .fetch_one(pool) + .await?; + + if is_active == 1 { + Ok(()) + } else { + Err(ConnectionTokenError::InvalidToken) + } +} + +pub async fn consume_and_link( + pool: &SqlitePool, + claim: &ConnectionClaim, + profile: &GitHubProfile, + now: DateTime, +) -> Result { + let now_text = database_timestamp(now); + let mut transaction = pool.begin().await?; + + let target_token = sqlx::query( + "UPDATE auth_connection_tokens SET consumed_at = ? \ + WHERE id = ? AND user_id = ? AND purpose = ? \ + AND consumed_at IS NULL AND expires_at > ?", + ) + .bind(&now_text) + .bind(&claim.token_id) + .bind(&claim.user_id) + .bind(claim.purpose.as_str()) + .bind(&now_text) + .execute(&mut *transaction) + .await?; + if target_token.rows_affected() != 1 { + return Err(ConnectionTokenError::InvalidToken); + } + + let owner: Option = sqlx::query_scalar("SELECT id FROM users WHERE github_user_id = ?") + .bind(&profile.user_id) + .fetch_optional(&mut *transaction) + .await?; + if owner.is_some_and(|owner_id| owner_id != claim.user_id) { + return Err(ConnectionTokenError::GitHubIdentityInUse); + } + + let user_update = sqlx::query( + "UPDATE users \ + SET github_user_id = ?, github_login = ?, invite_code = NULL, \ + auth_version = auth_version + 1, updated_at = ? \ + WHERE id = ? AND github_user_id IS NULL", + ) + .bind(&profile.user_id) + .bind(&profile.login) + .bind(&now_text) + .bind(&claim.user_id) + .execute(&mut *transaction) + .await; + let user_update = match user_update { + Ok(update) => update, + Err(error) if is_unique_violation(&error) => { + return Err(ConnectionTokenError::GitHubIdentityInUse); + } + Err(error) => return Err(error.into()), + }; + if user_update.rows_affected() != 1 { + return Err(ConnectionTokenError::InvalidToken); + } + + sqlx::query( + "UPDATE auth_connection_tokens SET consumed_at = ? \ + WHERE user_id = ? AND consumed_at IS NULL AND expires_at > ?", + ) + .bind(&now_text) + .bind(&claim.user_id) + .bind(&now_text) + .execute(&mut *transaction) + .await?; + + let user: User = sqlx::query_as("SELECT * FROM users WHERE id = ?") + .bind(&claim.user_id) + .fetch_one(&mut *transaction) + .await?; + transaction.commit().await?; + Ok(user) +} + pub fn connection_url(base: &Url, plaintext: &str) -> Url { let mut url = base.clone(); url.set_path("/recover"); @@ -230,6 +339,12 @@ fn hash_token(plaintext: &str) -> String { URL_SAFE_NO_PAD.encode(Sha256::digest(plaintext.as_bytes())) } +fn is_unique_violation(error: &sqlx::Error) -> bool { + error + .as_database_error() + .is_some_and(|error| error.is_unique_violation()) +} + pub(crate) fn database_timestamp(timestamp: DateTime) -> String { timestamp.to_rfc3339_opts(SecondsFormat::Nanos, true) } diff --git a/src/lib.rs b/src/lib.rs index 355c494..2ec8f66 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -12,7 +12,7 @@ pub const STATIC_HASH: &str = env!("STATIC_HASH"); use std::sync::Arc; -use axum::http::{HeaderValue, header}; +use axum::http::{HeaderValue, Request, Uri, header}; use axum::{Router, routing::get}; use sqlx::SqlitePool; use time::Duration; @@ -45,6 +45,10 @@ async fn health() -> &'static str { "ok" } +fn trace_path(uri: &Uri) -> &str { + uri.path() +} + /// Build the full Axum application router. /// /// Caller is responsible for running database migrations on `pool` beforehand. @@ -82,10 +86,35 @@ pub async fn build_app(pool: SqlitePool, secure_cookies: bool, auth: AuthService .service(ServeDir::new("static")), ) .layer(session_layer) + .layer(SetResponseHeaderLayer::overriding( + header::REFERRER_POLICY, + HeaderValue::from_static("no-referrer"), + )) .layer( TraceLayer::new_for_http() + .make_span_with(|request: &Request<_>| { + tracing::info_span!( + "http_request", + method = %request.method(), + path = %trace_path(request.uri()), + ) + }) .on_request(DefaultOnRequest::new().level(Level::INFO)) .on_response(DefaultOnResponse::new().level(Level::INFO)), ) .with_state(state) } + +#[cfg(test)] +mod tests { + use axum::http::Uri; + + use super::trace_path; + + #[test] + fn request_log_path_excludes_recovery_query_string() { + let uri: Uri = "/recover?token=secret".parse().unwrap(); + + assert_eq!(trace_path(&uri), "/recover"); + } +} diff --git a/src/routes/auth.rs b/src/routes/auth.rs index cef30aa..ff1faf8 100644 --- a/src/routes/auth.rs +++ b/src/routes/auth.rs @@ -13,11 +13,15 @@ use tower_sessions::Session; use crate::AppState; use crate::auth::{ ConnectionProof, MigrationSession, OAuthAttempt, OAuthPurpose, PendingConnection, - get_migration_session, get_pending_connection, login_user, logout_user, - store_migration_session, store_oauth_attempt, store_pending_connection, take_migration_session, - take_oauth_attempt, take_pending_connection, + get_migration_session, get_pending_connection, login_user, logout_user, store_connection_claim, + store_migration_session, store_oauth_attempt, store_pending_connection, take_connection_claim, + take_migration_session, take_oauth_attempt, take_pending_connection, }; use crate::config::SignupMode; +use crate::connection_tokens::{ + ConnectionClaim, ConnectionPurpose, ConnectionTokenError, consume_and_link, validate_claim, + validate_token, +}; use crate::error::AppError; use crate::models::User; @@ -26,6 +30,8 @@ const GITHUB_SIGN_IN_ERROR: &str = const GITHUB_LINK_ERROR: &str = "We couldn’t connect this GitHub account. Please return to login and try again."; const GITHUB_IDENTITY_IN_USE_ERROR: &str = "That GitHub account is already connected to another Interne account. Please return to login and try another account."; +const CONNECTION_TOKEN_ERROR: &str = + "This invitation or recovery link is invalid or expired. Please request a new link."; const CLOSED_SIGNUP_ERROR: &str = "Access isn’t open yet. Email webmaster@honkytonk.in for an invite."; @@ -57,6 +63,7 @@ struct ConnectGitHubTemplate { #[template(path = "github_confirm.html")] struct GitHubConfirmTemplate<'a> { github_login: &'a str, + action_label: &'a str, static_hash: &'static str, user: Option, } @@ -74,9 +81,11 @@ struct GitHubCallbackQuery { pub fn router() -> Router { Router::new() + .route("/recover", get(recovery_entry)) .route("/login", get(login_page)) .route("/login", post(login_submit)) .route("/auth/github", get(github_login_start)) + .route("/auth/github/recover", get(github_recovery_start)) .route("/auth/connect", get(connect_github_page)) .route("/auth/github/connect", post(github_link_start)) .route("/auth/github/callback", get(github_callback)) @@ -87,6 +96,58 @@ pub fn router() -> Router { .route("/logout", post(logout)) } +async fn recovery_entry( + State(state): State, + session: Session, + RawQuery(raw_query): RawQuery, +) -> Result { + let Some(plaintext_token) = parse_recovery_token(raw_query.as_deref()) else { + return render_auth_error(CONNECTION_TOKEN_ERROR); + }; + let claim = match validate_token(&state.db, &plaintext_token, chrono::Utc::now()).await { + Ok(claim) => claim, + Err(_) => return render_auth_error(CONNECTION_TOKEN_ERROR), + }; + store_connection_claim(&session, &claim).await?; + Ok(Redirect::to("/auth/github/recover").into_response()) +} + +async fn github_recovery_start( + State(state): State, + session: Session, +) -> Result { + let Some(claim) = take_connection_claim(&session).await? else { + return render_auth_error(CONNECTION_TOKEN_ERROR); + }; + if validate_claim(&state.db, &claim, chrono::Utc::now()) + .await + .is_err() + { + return render_auth_error(CONNECTION_TOKEN_ERROR); + } + + let attempt = OAuthAttempt::new( + OAuthPurpose::ConnectionToken { + token_id: claim.token_id, + user_id: claim.user_id, + purpose: claim.purpose, + }, + chrono::Utc::now(), + ); + store_oauth_attempt(&session, &attempt).await?; + let callback_url = github_callback_url(&state); + let authorization_url = match state.auth.github.authorization_url( + &callback_url, + &attempt.state, + &attempt.pkce_challenge(), + ) { + Ok(url) => url, + Err(_) => return render_auth_error(CONNECTION_TOKEN_ERROR), + }; + + Ok(Redirect::to(authorization_url.as_str()).into_response()) +} + async fn login_page(State(state): State) -> Result { let template = LoginTemplate { error: None, @@ -201,6 +262,7 @@ async fn github_callback( let callback_error = match &attempt.purpose { OAuthPurpose::Login => GITHUB_SIGN_IN_ERROR, OAuthPurpose::Link { .. } => GITHUB_LINK_ERROR, + OAuthPurpose::ConnectionToken { .. } => CONNECTION_TOKEN_ERROR, }; let Some(query) = parse_github_callback_query(raw_query.as_deref()) else { return render_auth_error(callback_error); @@ -225,6 +287,24 @@ async fn github_callback( return render_auth_error(callback_error); } } + if let OAuthPurpose::ConnectionToken { + token_id, + user_id, + purpose, + } = &attempt.purpose + { + let claim = ConnectionClaim { + token_id: token_id.clone(), + user_id: user_id.clone(), + purpose: *purpose, + }; + if validate_claim(&state.db, &claim, chrono::Utc::now()) + .await + .is_err() + { + return render_auth_error(callback_error); + } + } let profile = match state .auth @@ -255,6 +335,20 @@ async fn github_callback( store_pending_connection(&session, &pending).await?; Ok(Redirect::to("/auth/github/confirm").into_response()) } + OAuthPurpose::ConnectionToken { + token_id, + user_id, + purpose, + } => { + let pending = PendingConnection::new( + user_id, + profile, + ConnectionProof::Token { token_id, purpose }, + chrono::Utc::now(), + ); + store_pending_connection(&session, &pending).await?; + Ok(Redirect::to("/auth/github/confirm").into_response()) + } } } @@ -321,12 +415,19 @@ async fn github_confirm_page( State(state): State, session: Session, ) -> Result { - let Some(pending) = live_pending_connection(&state, &session).await? else { + let Some(pending) = get_pending_connection(&session).await? else { return render_auth_error(GITHUB_LINK_ERROR); }; + if pending.expires_at <= chrono::Utc::now().timestamp() + || !pending_connection_is_live(&state, &session, &pending).await? + { + take_pending_connection(&session).await?; + return render_auth_error(connection_error(&pending.proof)); + } let template = GitHubConfirmTemplate { github_login: &pending.github_profile.login, + action_label: confirmation_action(&pending.proof), static_hash: crate::STATIC_HASH, user: None, }; @@ -341,26 +442,60 @@ async fn github_confirm_submit( return render_auth_error(GITHUB_LINK_ERROR); }; if pending.expires_at <= chrono::Utc::now().timestamp() - || !pending_matches_live_migration(&state, &session, &pending).await? + || !pending_connection_is_live(&state, &session, &pending).await? { - return render_auth_error(GITHUB_LINK_ERROR); - } - match &pending.proof { - ConnectionProof::LegacyInvite => {} + return render_auth_error(connection_error(&pending.proof)); } + let user = match &pending.proof { + ConnectionProof::LegacyInvite => match complete_legacy_connection(&state, &pending).await { + Ok(user) => user, + Err(ConnectionTokenError::GitHubIdentityInUse) => { + return render_auth_error(GITHUB_IDENTITY_IN_USE_ERROR); + } + Err(ConnectionTokenError::InvalidToken | ConnectionTokenError::UserNotFound) => { + return render_auth_error(GITHUB_LINK_ERROR); + } + Err(ConnectionTokenError::Database(error)) => return Err(error.into()), + }, + ConnectionProof::Token { token_id, purpose } => { + let claim = ConnectionClaim { + token_id: token_id.clone(), + user_id: pending.user_id.clone(), + purpose: *purpose, + }; + match consume_and_link( + &state.db, + &claim, + &pending.github_profile, + chrono::Utc::now(), + ) + .await + { + Ok(user) => user, + Err(ConnectionTokenError::GitHubIdentityInUse) => { + return render_auth_error(GITHUB_IDENTITY_IN_USE_ERROR); + } + Err(_) => return render_auth_error(CONNECTION_TOKEN_ERROR), + } + } + }; + session.flush().await?; + session.cycle_id().await?; + login_user(&session, &user).await?; + Ok(Redirect::to("/").into_response()) +} +async fn complete_legacy_connection( + state: &AppState, + pending: &PendingConnection, +) -> Result { let now = crate::connection_tokens::database_timestamp(chrono::Utc::now()); let mut transaction = state.db.begin().await?; let update_result = sqlx::query( "UPDATE users \ - SET github_user_id = ?, \ - github_login = ?, \ - invite_code = NULL, \ - auth_version = auth_version + 1, \ - updated_at = ? \ - WHERE id = ? \ - AND invite_code IS NOT NULL \ - AND github_user_id IS NULL", + SET github_user_id = ?, github_login = ?, invite_code = NULL, \ + auth_version = auth_version + 1, updated_at = ? \ + WHERE id = ? AND invite_code IS NOT NULL AND github_user_id IS NULL", ) .bind(&pending.github_profile.user_id) .bind(&pending.github_profile.login) @@ -372,36 +507,29 @@ async fn github_confirm_submit( let update = match update_result { Ok(update) => update, Err(error) if is_unique_violation(&error) => { - return render_auth_error(GITHUB_IDENTITY_IN_USE_ERROR); + return Err(ConnectionTokenError::GitHubIdentityInUse); } Err(error) => return Err(error.into()), }; if update.rows_affected() != 1 { - return render_auth_error(GITHUB_LINK_ERROR); + return Err(ConnectionTokenError::InvalidToken); } sqlx::query( - "UPDATE auth_connection_tokens \ - SET consumed_at = ? \ - WHERE user_id = ? \ - AND consumed_at IS NULL \ - AND expires_at > ?", + "UPDATE auth_connection_tokens SET consumed_at = ? \ + WHERE user_id = ? AND consumed_at IS NULL AND expires_at > ?", ) .bind(&now) .bind(&pending.user_id) .bind(&now) .execute(&mut *transaction) .await?; - transaction.commit().await?; - let user: User = sqlx::query_as("SELECT * FROM users WHERE id = ?") .bind(&pending.user_id) - .fetch_one(&state.db) + .fetch_one(&mut *transaction) .await?; - session.flush().await?; - session.cycle_id().await?; - login_user(&session, &user).await?; - Ok(Redirect::to("/").into_response()) + transaction.commit().await?; + Ok(user) } async fn live_migration_session( @@ -428,24 +556,7 @@ async fn live_migration_session( Ok(Some(migration)) } -async fn live_pending_connection( - state: &AppState, - session: &Session, -) -> Result, AppError> { - let Some(pending) = get_pending_connection(session).await? else { - return Ok(None); - }; - if pending.expires_at <= chrono::Utc::now().timestamp() - || !pending_matches_live_migration(state, session, &pending).await? - { - take_pending_connection(session).await?; - return Ok(None); - } - - Ok(Some(pending)) -} - -async fn pending_matches_live_migration( +async fn pending_connection_is_live( state: &AppState, session: &Session, pending: &PendingConnection, @@ -455,6 +566,37 @@ async fn pending_matches_live_migration( let migration = live_migration_session(state, session).await?; Ok(migration.is_some_and(|migration| migration.user_id == pending.user_id)) } + ConnectionProof::Token { token_id, purpose } => { + let claim = ConnectionClaim { + token_id: token_id.clone(), + user_id: pending.user_id.clone(), + purpose: *purpose, + }; + Ok(validate_claim(&state.db, &claim, chrono::Utc::now()) + .await + .is_ok()) + } + } +} + +fn connection_error(proof: &ConnectionProof) -> &'static str { + match proof { + ConnectionProof::LegacyInvite => GITHUB_LINK_ERROR, + ConnectionProof::Token { .. } => CONNECTION_TOKEN_ERROR, + } +} + +fn confirmation_action(proof: &ConnectionProof) -> &'static str { + match proof { + ConnectionProof::LegacyInvite => "Connect GitHub", + ConnectionProof::Token { + purpose: ConnectionPurpose::Invite, + .. + } => "Accept invitation", + ConnectionProof::Token { + purpose: ConnectionPurpose::Recovery, + .. + } => "Recover account", } } @@ -513,6 +655,28 @@ fn has_valid_percent_encoding(value: &str) -> bool { true } +fn parse_recovery_token(raw_query: Option<&str>) -> Option { + let raw_query = raw_query?; + if !has_valid_percent_encoding(raw_query) { + return None; + } + + let mut token = None; + let mut seen_keys = HashSet::new(); + for (key, value) in url::form_urlencoded::parse(raw_query.as_bytes()) { + if key.contains('\u{fffd}') + || value.contains('\u{fffd}') + || !seen_keys.insert(key.to_string()) + { + return None; + } + if key == "token" { + token = Some(value.into_owned()); + } + } + token +} + async fn legacy_login_available(state: &AppState) -> Result { Ok(sqlx::query_scalar( "SELECT EXISTS(\ diff --git a/templates/github_confirm.html b/templates/github_confirm.html index ab12061..ff9056d 100644 --- a/templates/github_confirm.html +++ b/templates/github_confirm.html @@ -7,7 +7,7 @@

Confirm GitHub account

Connect Interne to {{ github_login }}?

- +
{% endblock %} diff --git a/tests/connection_tokens.rs b/tests/connection_tokens.rs index 31f045b..2ba4e27 100644 --- a/tests/connection_tokens.rs +++ b/tests/connection_tokens.rs @@ -1,15 +1,472 @@ mod common; +use axum::http::{Response, StatusCode}; use chrono::{Duration, SecondsFormat, TimeZone, Utc}; use interne::connection_tokens::{ ConnectionPurpose, ConnectionTokenError, connection_url, issue_invitation, reset_auth, validate_token, }; +use interne::github::GitHubProfile; use std::process::Command; use std::str::FromStr; use url::Url; -use common::TestApp; +use common::{TestApp, assert_redirect, body_string, cookie_from_response}; + +fn query_parameter(url: &Url, name: &str) -> String { + url.query_pairs() + .find_map(|(key, value)| (key == name).then(|| value.into_owned())) + .unwrap_or_else(|| panic!("URL should contain {name}")) +} + +fn callback_uri(code: &str, state: &str) -> String { + let mut url = Url::parse("https://interne.test/auth/github/callback").unwrap(); + url.query_pairs_mut() + .append_pair("code", code) + .append_pair("state", state); + format!("{}?{}", url.path(), url.query().unwrap()) +} + +async fn begin_connection_oauth( + app: &TestApp, + plaintext_token: &str, +) -> (String, Url, Response) { + let entry = app + .get(&format!("/recover?token={plaintext_token}"), None) + .await; + assert_redirect(&entry, "/auth/github/recover"); + assert_eq!(entry.headers()["referrer-policy"], "no-referrer"); + assert!( + !entry.headers()["location"] + .to_str() + .unwrap() + .contains(plaintext_token) + ); + let cookie = cookie_from_response( + entry + .headers() + .get("set-cookie") + .expect("Recovery entry should establish a claim session"), + ); + + let clean = app.get("/auth/github/recover", Some(&cookie)).await; + assert_eq!(clean.headers()["referrer-policy"], "no-referrer"); + let authorization_url = Url::parse( + clean + .headers() + .get("location") + .expect("Clean recovery path should redirect to GitHub") + .to_str() + .unwrap(), + ) + .unwrap(); + assert_eq!(authorization_url.host_str(), Some("github.test")); + assert!(!authorization_url.as_str().contains(plaintext_token)); + + (cookie, authorization_url, clean) +} + +async fn prepare_token_confirmation( + app: &TestApp, + plaintext_token: &str, + code: &str, + github_user_id: &str, + github_login: &str, +) -> String { + let (cookie, authorization_url, _) = begin_connection_oauth(app, plaintext_token).await; + let state = query_parameter(&authorization_url, "state"); + app.github.profile_for_code( + code, + GitHubProfile { + user_id: github_user_id.to_owned(), + login: github_login.to_owned(), + name: None, + }, + ); + + let callback = app.get(&callback_uri(code, &state), Some(&cookie)).await; + assert_redirect(&callback, "/auth/github/confirm"); + cookie +} + +async fn confirm_token_connection(app: &TestApp, cookie: &str) -> String { + let confirmation = app + .post_form("/auth/github/confirm", "", Some(cookie)) + .await; + assert_redirect(&confirmation, "/"); + cookie_from_response( + confirmation + .headers() + .get("set-cookie") + .expect("Confirmation should rotate to a full session"), + ) +} + +#[tokio::test] +async fn recovery_url_connects_confirmed_github_identity_and_logs_in() { + let app = TestApp::new().await; + let (user_id, previous_github_id) = app.create_user("Axel").await; + let old_cookie = app.login(&previous_github_id).await; + let issued = reset_auth(&app.db, &user_id, Utc::now()).await.unwrap(); + + let cookie = prepare_token_confirmation( + &app, + &issued.plaintext_token, + "recover-code", + "900", + "axelav", + ) + .await; + let confirmation_page = body_string(app.get("/auth/github/confirm", Some(&cookie)).await).await; + assert!(confirmation_page.contains("Recover account")); + let new_cookie = confirm_token_connection(&app, &cookie).await; + + assert_eq!( + app.get("/", Some(&new_cookie)).await.status(), + StatusCode::OK + ); + assert_redirect(&app.get("/", Some(&old_cookie)).await, "/login"); + let linked: (String, String, i64) = + sqlx::query_as("SELECT github_user_id, github_login, auth_version FROM users WHERE id = ?") + .bind(user_id) + .fetch_one(&app.db) + .await + .unwrap(); + assert_eq!(linked, ("900".into(), "axelav".into(), 3)); +} + +#[tokio::test] +async fn invitation_url_connects_the_precreated_user() { + let app = TestApp::new().await; + let issued = issue_invitation(&app.db, "Invited", Utc::now()) + .await + .unwrap(); + let cookie = prepare_token_confirmation( + &app, + &issued.plaintext_token, + "invite-code", + "901", + "invited", + ) + .await; + + let confirmation_page = body_string(app.get("/auth/github/confirm", Some(&cookie)).await).await; + assert!(confirmation_page.contains("Accept invitation")); + let full_cookie = confirm_token_connection(&app, &cookie).await; + + assert_eq!( + app.get("/", Some(&full_cookie)).await.status(), + StatusCode::OK + ); + let linked_user_id: String = + sqlx::query_scalar("SELECT id FROM users WHERE github_user_id = '901'") + .fetch_one(&app.db) + .await + .unwrap(); + assert_eq!(linked_user_id, issued.user_id); +} + +#[tokio::test] +async fn recovery_token_is_not_consumed_before_confirmation() { + let app = TestApp::new().await; + let (user_id, _) = app.create_user("Recovering").await; + let issued = reset_auth(&app.db, &user_id, Utc::now()).await.unwrap(); + + prepare_token_confirmation( + &app, + &issued.plaintext_token, + "pending-code", + "902", + "pending", + ) + .await; + + let consumed_at: Option = + sqlx::query_scalar("SELECT consumed_at FROM auth_connection_tokens WHERE user_id = ?") + .bind(user_id) + .fetch_one(&app.db) + .await + .unwrap(); + assert_eq!(consumed_at, None); +} + +#[tokio::test] +async fn successful_confirmation_consumes_token() { + let app = TestApp::new().await; + let (user_id, _) = app.create_user("Recovering").await; + let issued = reset_auth(&app.db, &user_id, Utc::now()).await.unwrap(); + let now = Utc::now(); + sqlx::query( + "INSERT INTO auth_connection_tokens \ + (id, user_id, token_hash, purpose, created_at, expires_at, consumed_at) \ + VALUES ('other-active-token', ?, 'other-active-hash', 'recovery', ?, ?, NULL)", + ) + .bind(&user_id) + .bind(now.to_rfc3339_opts(SecondsFormat::Nanos, true)) + .bind((now + Duration::hours(1)).to_rfc3339_opts(SecondsFormat::Nanos, true)) + .execute(&app.db) + .await + .unwrap(); + let cookie = prepare_token_confirmation( + &app, + &issued.plaintext_token, + "consume-code", + "903", + "consumed", + ) + .await; + + confirm_token_connection(&app, &cookie).await; + + let active_count: i64 = sqlx::query_scalar( + "SELECT COUNT(*) FROM auth_connection_tokens WHERE user_id = ? AND consumed_at IS NULL", + ) + .bind(user_id) + .fetch_one(&app.db) + .await + .unwrap(); + assert_eq!(active_count, 0); +} + +#[tokio::test] +async fn used_recovery_url_cannot_be_replayed() { + let app = TestApp::new().await; + let (user_id, _) = app.create_user("Recovering").await; + let issued = reset_auth(&app.db, &user_id, Utc::now()).await.unwrap(); + let cookie = + prepare_token_confirmation(&app, &issued.plaintext_token, "once-code", "904", "once").await; + confirm_token_connection(&app, &cookie).await; + + let replay = app + .get(&format!("/recover?token={}", issued.plaintext_token), None) + .await; + let body = body_string(replay).await; + assert!(body.contains("invalid or expired")); + assert!(!body.contains(&issued.plaintext_token)); +} + +#[tokio::test] +async fn superseded_recovery_url_is_rejected() { + let app = TestApp::new().await; + let (user_id, _) = app.create_user("Recovering").await; + let issued = reset_auth(&app.db, &user_id, Utc::now()).await.unwrap(); + let entry = app + .get(&format!("/recover?token={}", issued.plaintext_token), None) + .await; + assert_redirect(&entry, "/auth/github/recover"); + let cookie = cookie_from_response(entry.headers().get("set-cookie").unwrap()); + reset_auth(&app.db, &user_id, Utc::now()).await.unwrap(); + + let response = app.get("/auth/github/recover", Some(&cookie)).await; + let body = body_string(response).await; + assert!(body.contains("invalid or expired")); + let linked: Option = + sqlx::query_scalar("SELECT github_user_id FROM users WHERE id = ?") + .bind(user_id) + .fetch_one(&app.db) + .await + .unwrap(); + assert_eq!(linked, None); +} + +#[tokio::test] +async fn expired_recovery_url_is_rejected() { + let app = TestApp::new().await; + let (user_id, _) = app.create_user("Recovering").await; + let issued = reset_auth(&app.db, &user_id, Utc::now() - Duration::hours(5)) + .await + .unwrap(); + + let response = app + .get(&format!("/recover?token={}", issued.plaintext_token), None) + .await; + assert_eq!(response.headers()["referrer-policy"], "no-referrer"); + let body = body_string(response).await; + assert!(body.contains("invalid or expired")); + assert!(!body.contains(&issued.plaintext_token)); +} + +#[tokio::test] +async fn token_confirmation_rechecks_expiration() { + let app = TestApp::new().await; + let (user_id, _) = app.create_user("Recovering").await; + let issued = reset_auth(&app.db, &user_id, Utc::now()).await.unwrap(); + let claim = validate_token(&app.db, &issued.plaintext_token, Utc::now()) + .await + .unwrap(); + let cookie = + prepare_token_confirmation(&app, &issued.plaintext_token, "late-code", "905", "late").await; + sqlx::query("UPDATE auth_connection_tokens SET expires_at = ? WHERE id = ?") + .bind((Utc::now() - Duration::seconds(1)).to_rfc3339_opts(SecondsFormat::Nanos, true)) + .bind(claim.token_id) + .execute(&app.db) + .await + .unwrap(); + + let confirm = app + .post_form("/auth/github/confirm", "", Some(&cookie)) + .await; + let body = body_string(confirm).await; + assert!(body.contains("invalid or expired")); + let linked: Option = + sqlx::query_scalar("SELECT github_user_id FROM users WHERE id = ?") + .bind(user_id) + .fetch_one(&app.db) + .await + .unwrap(); + assert_eq!(linked, None); +} + +#[tokio::test] +async fn token_confirmation_rechecks_supersession() { + let app = TestApp::new().await; + let (user_id, _) = app.create_user("Recovering").await; + let issued = reset_auth(&app.db, &user_id, Utc::now()).await.unwrap(); + let cookie = prepare_token_confirmation( + &app, + &issued.plaintext_token, + "superseded-code", + "908", + "superseded", + ) + .await; + let replacement = reset_auth(&app.db, &user_id, Utc::now()).await.unwrap(); + + let confirm = app + .post_form("/auth/github/confirm", "", Some(&cookie)) + .await; + let body = body_string(confirm).await; + assert!(body.contains("invalid or expired")); + let linked: Option = + sqlx::query_scalar("SELECT github_user_id FROM users WHERE id = ?") + .bind(&user_id) + .fetch_one(&app.db) + .await + .unwrap(); + assert_eq!(linked, None); + assert!( + validate_token(&app.db, &replacement.plaintext_token, Utc::now()) + .await + .is_ok() + ); +} + +#[tokio::test] +async fn token_confirmation_rejects_duplicate_github_identity() { + let app = TestApp::new().await; + let owner_id = app.create_github_user("Owner", "906", "owner").await; + let issued = issue_invitation(&app.db, "Invited", Utc::now()) + .await + .unwrap(); + let cookie = prepare_token_confirmation( + &app, + &issued.plaintext_token, + "duplicate-code", + "906", + "owner", + ) + .await; + + let response = app + .post_form("/auth/github/confirm", "", Some(&cookie)) + .await; + let body = body_string(response).await; + assert!(body.contains("already connected")); + assert!(!body.contains(&owner_id)); + assert!(!body.contains(&issued.user_id)); + let target_github: Option = + sqlx::query_scalar("SELECT github_user_id FROM users WHERE id = ?") + .bind(&issued.user_id) + .fetch_one(&app.db) + .await + .unwrap(); + assert_eq!(target_github, None); + assert!( + validate_token(&app.db, &issued.plaintext_token, Utc::now()) + .await + .is_ok() + ); +} + +#[tokio::test] +async fn token_query_is_removed_before_redirecting_to_github() { + let app = TestApp::new().await; + let issued = issue_invitation(&app.db, "Invited", Utc::now()) + .await + .unwrap(); + + let (_, authorization_url, clean_response) = + begin_connection_oauth(&app, &issued.plaintext_token).await; + + assert_eq!(clean_response.headers()["referrer-policy"], "no-referrer"); + assert_eq!(authorization_url.path(), "/authorize"); + assert!(!authorization_url.as_str().contains(&issued.plaintext_token)); +} + +#[tokio::test] +async fn recovery_callback_consumes_attempt_before_rejecting_provider_denial() { + let app = TestApp::new().await; + let issued = issue_invitation(&app.db, "Invited", Utc::now()) + .await + .unwrap(); + let (cookie, authorization_url, _) = + begin_connection_oauth(&app, &issued.plaintext_token).await; + let state = query_parameter(&authorization_url, "state"); + + let denied = app + .get( + &format!( + "/auth/github/callback?error=access_denied&error_description=private&state={state}" + ), + Some(&cookie), + ) + .await; + let body = body_string(denied).await; + assert!(body.contains("invalid or expired")); + assert!(!body.contains("access_denied")); + assert!(!body.contains("private")); + + app.github.profile_for_code( + "replay-code", + GitHubProfile { + user_id: "907".into(), + login: "replay".into(), + name: None, + }, + ); + let replay = app + .get(&callback_uri("replay-code", &state), Some(&cookie)) + .await; + let body = body_string(replay).await; + assert!(body.contains("couldn’t complete GitHub sign-in")); +} + +#[tokio::test] +async fn malformed_and_duplicate_recovery_queries_show_the_same_safe_error() { + let app = TestApp::new().await; + let issued = issue_invitation(&app.db, "Invited", Utc::now()) + .await + .unwrap(); + + for uri in [ + "/recover?token=%FF".to_owned(), + format!("/recover?token={0}&token={0}", issued.plaintext_token), + "/recover?unrelated=value".to_owned(), + ] { + let response = app.get(&uri, None).await; + assert_eq!(response.headers()["referrer-policy"], "no-referrer"); + let body = body_string(response).await; + assert!(body.contains("invalid or expired")); + assert!(!body.contains(&issued.plaintext_token)); + assert!(!body.contains("Database")); + } + + assert!( + validate_token(&app.db, &issued.plaintext_token, Utc::now()) + .await + .is_ok() + ); +} #[tokio::test] async fn invitation_is_hashed_single_use_and_expires_in_four_hours() { From 6eab48acf7a62f74b0d4fdf40e0b1c6fd864d9a6 Mon Sep 17 00:00:00 2001 From: Axel Anderson Date: Wed, 12 Aug 2026 12:10:39 -0400 Subject: [PATCH 19/25] fix(auth): log safe recovery failures --- src/routes/auth.rs | 75 ++++++++++++---- tests/common/mod.rs | 47 ++++++++++ tests/connection_tokens.rs | 176 ++++++++++++++++++++++++++++++++++++- tests/github_auth.rs | 21 ++++- 4 files changed, 296 insertions(+), 23 deletions(-) diff --git a/src/routes/auth.rs b/src/routes/auth.rs index ff1faf8..34a46a9 100644 --- a/src/routes/auth.rs +++ b/src/routes/auth.rs @@ -106,7 +106,10 @@ async fn recovery_entry( }; let claim = match validate_token(&state.db, &plaintext_token, chrono::Utc::now()).await { Ok(claim) => claim, - Err(_) => return render_auth_error(CONNECTION_TOKEN_ERROR), + Err(error) => { + log_connection_token_failure("connection token validation failed", &error); + return render_auth_error(CONNECTION_TOKEN_ERROR); + } }; store_connection_claim(&session, &claim).await?; Ok(Redirect::to("/auth/github/recover").into_response()) @@ -119,11 +122,12 @@ async fn github_recovery_start( let Some(claim) = take_connection_claim(&session).await? else { return render_auth_error(CONNECTION_TOKEN_ERROR); }; - if validate_claim(&state.db, &claim, chrono::Utc::now()) - .await - .is_err() - { - return render_auth_error(CONNECTION_TOKEN_ERROR); + match validate_claim(&state.db, &claim, chrono::Utc::now()).await { + Ok(()) => {} + Err(error) => { + log_connection_token_failure("connection claim revalidation failed", &error); + return render_auth_error(CONNECTION_TOKEN_ERROR); + } } let attempt = OAuthAttempt::new( @@ -142,7 +146,10 @@ async fn github_recovery_start( &attempt.pkce_challenge(), ) { Ok(url) => url, - Err(_) => return render_auth_error(CONNECTION_TOKEN_ERROR), + Err(error) => { + log_github_failure("recovery authorization URL creation failed", error); + return render_auth_error(CONNECTION_TOKEN_ERROR); + } }; Ok(Redirect::to(authorization_url.as_str()).into_response()) @@ -226,7 +233,10 @@ async fn github_link_start( &attempt.pkce_challenge(), ) { Ok(url) => url, - Err(_) => return render_auth_error(GITHUB_LINK_ERROR), + Err(error) => { + log_github_failure("legacy link authorization URL creation failed", error); + return render_auth_error(GITHUB_LINK_ERROR); + } }; Ok(Redirect::to(authorization_url.as_str()).into_response()) @@ -245,7 +255,10 @@ async fn github_login_start( &attempt.pkce_challenge(), ) { Ok(url) => url, - Err(_) => return render_auth_error(GITHUB_SIGN_IN_ERROR), + Err(error) => { + log_github_failure("login authorization URL creation failed", error); + return render_auth_error(GITHUB_SIGN_IN_ERROR); + } }; Ok(Redirect::to(authorization_url.as_str()).into_response()) @@ -298,11 +311,12 @@ async fn github_callback( user_id: user_id.clone(), purpose: *purpose, }; - if validate_claim(&state.db, &claim, chrono::Utc::now()) - .await - .is_err() - { - return render_auth_error(callback_error); + match validate_claim(&state.db, &claim, chrono::Utc::now()).await { + Ok(()) => {} + Err(error) => { + log_connection_token_failure("OAuth connection claim revalidation failed", &error); + return render_auth_error(callback_error); + } } } @@ -313,7 +327,10 @@ async fn github_callback( .await { Ok(profile) => profile, - Err(_) => return render_auth_error(callback_error), + Err(error) => { + log_github_failure("OAuth callback exchange failed", error); + return render_auth_error(callback_error); + } }; match attempt.purpose { @@ -475,7 +492,10 @@ async fn github_confirm_submit( Err(ConnectionTokenError::GitHubIdentityInUse) => { return render_auth_error(GITHUB_IDENTITY_IN_USE_ERROR); } - Err(_) => return render_auth_error(CONNECTION_TOKEN_ERROR), + Err(error) => { + log_connection_token_failure("connection token consumption failed", &error); + return render_auth_error(CONNECTION_TOKEN_ERROR); + } } } }; @@ -572,9 +592,16 @@ async fn pending_connection_is_live( user_id: pending.user_id.clone(), purpose: *purpose, }; - Ok(validate_claim(&state.db, &claim, chrono::Utc::now()) - .await - .is_ok()) + match validate_claim(&state.db, &claim, chrono::Utc::now()).await { + Ok(()) => Ok(true), + Err(error) => { + log_connection_token_failure( + "pending connection claim revalidation failed", + &error, + ); + Ok(false) + } + } } } } @@ -600,6 +627,16 @@ fn confirmation_action(proof: &ConnectionProof) -> &'static str { } } +fn log_connection_token_failure(operation: &'static str, error: &ConnectionTokenError) { + if matches!(error, ConnectionTokenError::Database(_)) { + tracing::error!(operation, "connection token database operation failed"); + } +} + +fn log_github_failure(operation: &'static str, error: crate::github::GitHubError) { + tracing::error!(operation, stage = %error, "GitHub authentication operation failed"); +} + fn is_unique_violation(error: &sqlx::Error) -> bool { error .as_database_error() diff --git a/tests/common/mod.rs b/tests/common/mod.rs index 429ab16..e99110a 100644 --- a/tests/common/mod.rs +++ b/tests/common/mod.rs @@ -11,15 +11,62 @@ use interne::github::{GitHubError, GitHubProfile, GitHubProvider}; use sqlx::SqlitePool; use sqlx::sqlite::{SqliteConnectOptions, SqlitePoolOptions}; use std::collections::HashMap; +use std::io::Write; use std::str::FromStr; use std::sync::atomic::{AtomicU64, Ordering}; use std::sync::{Arc, Mutex}; use tower_sessions::{SessionStore, session::Id}; use tower_sessions_sqlx_store::SqliteStore; +use tracing::instrument::WithSubscriber; use url::Url; static NEXT_GITHUB_USER_ID: AtomicU64 = AtomicU64::new(1_000_000); +#[derive(Clone, Default)] +struct LogCapture(Arc>>); + +struct LogWriter(Arc>>); + +impl Write for LogWriter { + fn write(&mut self, bytes: &[u8]) -> std::io::Result { + self.0.lock().unwrap().extend_from_slice(bytes); + Ok(bytes.len()) + } + + fn flush(&mut self) -> std::io::Result<()> { + Ok(()) + } +} + +impl<'a> tracing_subscriber::fmt::MakeWriter<'a> for LogCapture { + type Writer = LogWriter; + + fn make_writer(&'a self) -> Self::Writer { + LogWriter(self.0.clone()) + } +} + +impl LogCapture { + fn contents(&self) -> String { + String::from_utf8(self.0.lock().unwrap().clone()).unwrap() + } +} + +pub async fn capture_error_logs(future: F) -> (T, String) +where + F: Future, +{ + let capture = LogCapture::default(); + let subscriber = tracing_subscriber::fmt() + .without_time() + .with_ansi(false) + .with_max_level(tracing::Level::ERROR) + .with_writer(capture.clone()) + .finish(); + let output = future.with_subscriber(subscriber).await; + (output, capture.contents()) +} + #[derive(Clone, Default)] pub struct FakeGitHubProvider { profiles: Arc>>>, diff --git a/tests/connection_tokens.rs b/tests/connection_tokens.rs index 2ba4e27..e04180d 100644 --- a/tests/connection_tokens.rs +++ b/tests/connection_tokens.rs @@ -3,15 +3,17 @@ mod common; use axum::http::{Response, StatusCode}; use chrono::{Duration, SecondsFormat, TimeZone, Utc}; use interne::connection_tokens::{ - ConnectionPurpose, ConnectionTokenError, connection_url, issue_invitation, reset_auth, - validate_token, + ConnectionPurpose, ConnectionTokenError, connection_url, consume_and_link, issue_invitation, + reset_auth, validate_token, }; use interne::github::GitHubProfile; use std::process::Command; use std::str::FromStr; +use std::sync::Arc; +use std::time::Duration as StdDuration; use url::Url; -use common::{TestApp, assert_redirect, body_string, cookie_from_response}; +use common::{TestApp, assert_redirect, body_string, capture_error_logs, cookie_from_response}; fn query_parameter(url: &Url, name: &str) -> String { url.query_pairs() @@ -468,6 +470,159 @@ async fn malformed_and_duplicate_recovery_queries_show_the_same_safe_error() { ); } +#[tokio::test] +async fn recovery_token_database_failure_logs_only_safe_operation_context() { + let app = TestApp::new().await; + let issued = issue_invitation(&app.db, "Secret", Utc::now()) + .await + .unwrap(); + let hostile_token = issued.plaintext_token; + sqlx::query("DROP TABLE auth_connection_tokens") + .execute(&app.db) + .await + .unwrap(); + + let (response, logs) = + capture_error_logs(app.get(&format!("/recover?token={hostile_token}"), None)).await; + let body = body_string(response).await; + + assert!(body.contains("invalid or expired")); + assert!(logs.contains("connection token validation failed")); + assert!(!logs.contains(&hostile_token)); + assert!(!logs.contains("no such table")); + assert!(!logs.contains("auth_connection_tokens")); +} + +#[tokio::test] +async fn invalid_recovery_token_does_not_log_an_operational_failure() { + let app = TestApp::new().await; + + let (response, logs) = capture_error_logs(app.get("/recover?token=invalid", None)).await; + let body = body_string(response).await; + + assert!(body.contains("invalid or expired")); + assert!( + logs.is_empty(), + "expected invalid tokens should not log: {logs}" + ); +} + +#[tokio::test] +async fn consume_and_link_allows_exactly_one_concurrent_redeemer() { + let database = FileDatabase::new("concurrent-redemption"); + let pool = database.pool(5).await; + let now = Utc::now(); + let issued = issue_invitation(&pool, "Concurrent", now).await.unwrap(); + let claim = validate_token(&pool, &issued.plaintext_token, now) + .await + .unwrap(); + let profile = GitHubProfile { + user_id: "concurrent-github-id".into(), + login: "concurrent-login".into(), + name: None, + }; + let barrier = Arc::new(tokio::sync::Barrier::new(3)); + let first = { + let pool = pool.clone(); + let claim = claim.clone(); + let profile = profile.clone(); + let barrier = barrier.clone(); + tokio::spawn(async move { + barrier.wait().await; + consume_and_link(&pool, &claim, &profile, now).await + }) + }; + let second = { + let pool = pool.clone(); + let claim = claim.clone(); + let profile = profile.clone(); + let barrier = barrier.clone(); + tokio::spawn(async move { + barrier.wait().await; + consume_and_link(&pool, &claim, &profile, now).await + }) + }; + + barrier.wait().await; + let results = [first.await.unwrap(), second.await.unwrap()]; + + assert_eq!(results.iter().filter(|result| result.is_ok()).count(), 1); + assert_eq!( + results + .iter() + .filter(|result| matches!(result, Err(ConnectionTokenError::InvalidToken))) + .count(), + 1 + ); + let user: (Option, Option, i64) = + sqlx::query_as("SELECT github_user_id, github_login, auth_version FROM users WHERE id = ?") + .bind(&issued.user_id) + .fetch_one(&pool) + .await + .unwrap(); + assert_eq!( + user, + ( + Some("concurrent-github-id".into()), + Some("concurrent-login".into()), + 2 + ) + ); + let consumed_count: i64 = sqlx::query_scalar( + "SELECT COUNT(*) FROM auth_connection_tokens WHERE user_id = ? AND consumed_at IS NOT NULL", + ) + .bind(&issued.user_id) + .fetch_one(&pool) + .await + .unwrap(); + assert_eq!(consumed_count, 1); + pool.close().await; +} + +#[tokio::test] +async fn consume_and_link_rolls_back_token_transition_when_user_update_fails() { + let database = FileDatabase::new("redemption-rollback"); + let pool = database.pool(2).await; + let now = Utc::now(); + let issued = issue_invitation(&pool, "Rollback", now).await.unwrap(); + let claim = validate_token(&pool, &issued.plaintext_token, now) + .await + .unwrap(); + sqlx::query( + "CREATE TRIGGER reject_github_link BEFORE UPDATE OF github_user_id ON users \ + BEGIN SELECT RAISE(ABORT, 'hostile-trigger-secret'); END", + ) + .execute(&pool) + .await + .unwrap(); + let profile = GitHubProfile { + user_id: "rollback-github-id".into(), + login: "rollback-login".into(), + name: None, + }; + + let error = consume_and_link(&pool, &claim, &profile, now) + .await + .unwrap_err(); + + assert!(matches!(error, ConnectionTokenError::Database(_))); + let consumed_at: Option = + sqlx::query_scalar("SELECT consumed_at FROM auth_connection_tokens WHERE id = ?") + .bind(&claim.token_id) + .fetch_one(&pool) + .await + .unwrap(); + assert_eq!(consumed_at, None); + let user: (Option, Option, i64) = + sqlx::query_as("SELECT github_user_id, github_login, auth_version FROM users WHERE id = ?") + .bind(&claim.user_id) + .fetch_one(&pool) + .await + .unwrap(); + assert_eq!(user, (None, None, 1)); + pool.close().await; +} + #[tokio::test] async fn invitation_is_hashed_single_use_and_expires_in_four_hours() { let app = TestApp::new().await; @@ -855,6 +1010,21 @@ impl FileDatabase { }; [self.path.clone(), with_suffix("-wal"), with_suffix("-shm")] } + + async fn pool(&self, max_connections: u32) -> sqlx::SqlitePool { + let options = sqlx::sqlite::SqliteConnectOptions::from_str(&self.url()) + .unwrap() + .create_if_missing(true) + .journal_mode(sqlx::sqlite::SqliteJournalMode::Wal) + .busy_timeout(StdDuration::from_secs(5)); + let pool = sqlx::sqlite::SqlitePoolOptions::new() + .max_connections(max_connections) + .connect_with(options) + .await + .unwrap(); + sqlx::migrate!("./migrations").run(&pool).await.unwrap(); + pool + } } impl Drop for FileDatabase { diff --git a/tests/github_auth.rs b/tests/github_auth.rs index 7473f5c..55dbdcb 100644 --- a/tests/github_auth.rs +++ b/tests/github_auth.rs @@ -3,7 +3,7 @@ mod common; use std::collections::HashMap; use axum::http::{Response, StatusCode}; -use common::{TestApp, assert_redirect, body_string, cookie_from_response}; +use common::{TestApp, assert_redirect, body_string, capture_error_logs, cookie_from_response}; use interne::config::SignupMode; use interne::github::{GitHubError, GitHubProfile}; @@ -671,6 +671,25 @@ async fn provider_failure_shows_safe_error() { assert!(!body.contains("ProfileFetch")); } +#[tokio::test] +async fn provider_failure_logs_safe_stage_without_oauth_code() { + let app = TestApp::new().await; + let (cookie, authorization_url) = app.begin_github_login().await; + let state = query_parameters(&authorization_url)["state"].clone(); + let hostile_code = "hostile-secret-oauth-code"; + app.github + .error_for_code(hostile_code, GitHubError::ProfileFetch); + + let (response, logs) = + capture_error_logs(app.get(&callback_uri(hostile_code, &state), Some(&cookie))).await; + let body = callback_body(response).await; + + assert!(body.contains("couldn’t complete GitHub sign-in")); + assert!(logs.contains("GitHub profile fetch failed")); + assert!(logs.contains("OAuth callback exchange failed")); + assert!(!logs.contains(hostile_code)); +} + #[tokio::test] async fn authorization_redirect_has_pkce_and_no_scope() { let app = TestApp::new().await; From 7913452d15ff4782f866bb8b5019e6b6507de34a Mon Sep 17 00:00:00 2001 From: Axel Anderson Date: Wed, 12 Aug 2026 12:15:23 -0400 Subject: [PATCH 20/25] fix(auth): redact legacy connection failures --- src/routes/auth.rs | 8 +++++- tests/github_auth.rs | 68 ++++++++++++++++++++++++++++++++++++++++++-- 2 files changed, 72 insertions(+), 4 deletions(-) diff --git a/src/routes/auth.rs b/src/routes/auth.rs index 34a46a9..f2d982d 100644 --- a/src/routes/auth.rs +++ b/src/routes/auth.rs @@ -472,7 +472,13 @@ async fn github_confirm_submit( Err(ConnectionTokenError::InvalidToken | ConnectionTokenError::UserNotFound) => { return render_auth_error(GITHUB_LINK_ERROR); } - Err(ConnectionTokenError::Database(error)) => return Err(error.into()), + Err(error @ ConnectionTokenError::Database(_)) => { + log_connection_token_failure( + "legacy GitHub connection database operation failed", + &error, + ); + return render_auth_error(GITHUB_LINK_ERROR); + } }, ConnectionProof::Token { token_id, purpose } => { let claim = ConnectionClaim { diff --git a/tests/github_auth.rs b/tests/github_auth.rs index 55dbdcb..333e0e8 100644 --- a/tests/github_auth.rs +++ b/tests/github_auth.rs @@ -127,11 +127,14 @@ async fn link_confirmation_rejects_github_id_owned_by_another_user() { ) .await; - let response = app - .post_form("/auth/github/confirm", "", Some(&cookie)) - .await; + let (response, logs) = + capture_error_logs(app.post_form("/auth/github/confirm", "", Some(&cookie))).await; let body = callback_body(response).await; assert!(body.contains("already connected")); + assert!( + logs.is_empty(), + "duplicate identity is not an operational failure" + ); assert!(!body.contains(&owner_id)); assert!(!body.contains(&legacy_user_id)); @@ -145,6 +148,65 @@ async fn link_confirmation_rejects_github_id_owned_by_another_user() { assert_eq!(legacy_state.1.as_deref(), Some(invite_code.as_str())); } +#[tokio::test] +async fn legacy_confirmation_database_failure_logs_only_safe_operation_context() { + let app = TestApp::new().await; + let (user_id, invite_code) = app.create_legacy_user("Legacy Secret").await; + let oauth_code = "secret-legacy-oauth-code"; + let github_user_id = "secret-legacy-github-id"; + let github_login = "secret-legacy-login"; + let cookie = prepare_legacy_pending_connection( + &app, + &invite_code, + oauth_code, + github_user_id, + github_login, + ) + .await; + let hostile_error = "hostile-legacy-trigger-secret"; + sqlx::query( + "CREATE TRIGGER reject_legacy_github_link BEFORE UPDATE OF github_user_id ON users \ + BEGIN SELECT RAISE(ABORT, 'hostile-legacy-trigger-secret'); END", + ) + .execute(&app.db) + .await + .unwrap(); + + let (response, logs) = + capture_error_logs(app.post_form("/auth/github/confirm", "", Some(&cookie))).await; + let status = response.status(); + let body = body_string(response).await; + + assert!(!logs.contains(hostile_error)); + for secret in [ + oauth_code, + github_user_id, + github_login, + invite_code.as_str(), + user_id.as_str(), + "UPDATE users", + "reject_legacy_github_link", + ] { + assert!(!logs.contains(secret), "secret leaked to logs: {secret}"); + assert!( + !body.contains(secret), + "secret leaked to response: {secret}" + ); + } + assert_eq!(status, StatusCode::OK); + assert!(body.contains("couldn’t connect this GitHub account")); + assert!(logs.contains("legacy GitHub connection database operation failed")); + + let state: (Option, Option, String, i64) = sqlx::query_as( + "SELECT github_user_id, github_login, invite_code, auth_version FROM users WHERE id = ?", + ) + .bind(user_id) + .fetch_one(&app.db) + .await + .unwrap(); + assert_eq!(state, (None, None, invite_code, 1)); +} + #[tokio::test] async fn link_callback_requires_a_live_migration_session() { let app = TestApp::new().await; From 592fe2979b2d9b538b273457b9967fcb7b80b31d Mon Sep 17 00:00:00 2001 From: Axel Anderson Date: Wed, 12 Aug 2026 12:23:58 -0400 Subject: [PATCH 21/25] docs(auth): document GitHub authentication --- .env.example | 6 +++ README.md | 118 ++++++++++++++++++++++++++++++++++----------- docker-compose.yml | 6 +++ static/style.css | 52 +++++++++++++++++++- 4 files changed, 154 insertions(+), 28 deletions(-) diff --git a/.env.example b/.env.example index 55fbc92..45b5645 100644 --- a/.env.example +++ b/.env.example @@ -1,2 +1,8 @@ DATABASE_URL=sqlite:data/interne.db SESSION_SECRET=dev-secret-change-in-prod +GITHUB_CLIENT_ID=replace-with-github-oauth-client-id +GITHUB_CLIENT_SECRET=replace-with-github-oauth-client-secret +PUBLIC_BASE_URL=https://interne.example.com +GITHUB_SIGNUP_MODE=closed +# Local HTTP only. Use true when the site is served over HTTPS. +SECURE_COOKIES=false diff --git a/README.md b/README.md index 5847b34..2759ff6 100644 --- a/README.md +++ b/README.md @@ -4,8 +4,8 @@ Spaced repetition for websites. Track URLs you want to revisit periodically, mar ## Stack -- **Rust + Axum** — web framework -- **SQLite** via sqlx — async database access +- **Rust 1.94 + Axum** — language and web framework +- **SQLite** via sqlx 0.9 — async database access - **Askama** — type-safe Jinja2-style HTML templates - **htmx** — partial page updates without custom JS - **Docker** — multi-stage build for deployment @@ -17,7 +17,7 @@ src/ ├── main.rs # server + CLI entrypoint ├── lib.rs # app builder (shared by server + tests) ├── auth.rs # session auth, AuthUser extractor -├── cli.rs # import and create-user commands +├── cli.rs # import, invitation, and recovery commands ├── db.rs # connection pool + migrations ├── error.rs # AppError type for route handlers ├── models/ @@ -26,7 +26,7 @@ src/ │ ├── user.rs # User │ └── visit.rs # Visit └── routes/ - ├── auth.rs # login/logout + ├── auth.rs # GitHub OAuth, legacy linking, and logout ├── entries.rs # CRUD, visit, availability logic ├── collections.rs # CRUD, join/leave, member management ├── tags.rs # tag cloud + per-tag entry views @@ -41,20 +41,27 @@ build.rs # static asset cache-busting hash ## Development -```bash -# prerequisites: rust toolchain -cargo run -``` +The server requires Rust 1.94 and a GitHub OAuth app. GitHub OAuth apps accept only +one configured callback URL, so create a separate app for local development with: -Server starts at [http://localhost:3000](http://localhost:3000). +- Homepage URL: `http://127.0.0.1:3000` +- Authorization callback URL: `http://127.0.0.1:3000/auth/github/callback` +- Scopes: leave blank -Create a user to log in: +Copy `.env.example` to `.env`, replace both GitHub credential placeholders, and set: -```bash -cargo run -- create-user "Your Name" -# prints an invite code — use it at /login +```dotenv +PUBLIC_BASE_URL=http://127.0.0.1:3000 +GITHUB_SIGNUP_MODE=closed +SECURE_COOKIES=false ``` +`SECURE_COOKIES=false` is a local-only override for HTTP. Do not use it when the +site is served over HTTPS. Start the server with `cargo run`, then open +[http://127.0.0.1:3000](http://127.0.0.1:3000). A bare `cargo run` without +`GITHUB_CLIENT_ID`, `GITHUB_CLIENT_SECRET`, and `PUBLIC_BASE_URL` exits with a +configuration error. + Run the test suite: ```bash @@ -64,31 +71,88 @@ cargo test ## CLI ```bash -interne # start the web server -interne create-user [email] # create a user, prints invite code + ID -interne import # import entries from legacy JSON -interne help # show usage +interne # start the web server +interne invite-user # create a user and invitation URL +interne reset-auth # reset auth and create a recovery URL +interne create-user [email] # deprecated alias for invite-user +interne import # import entries from legacy JSON +interne help # show usage ``` +`invite-user` and `reset-auth` print URLs that expire after four hours, work once, +and must be sent to the intended person manually. Treat either URL as a secret. +`reset-auth` immediately logs out every session for that user and lets whoever +holds the recovery URL attach a different GitHub identity. Keep this command +available for emergency recovery. The deprecated `create-user` alias ignores its +optional email argument and creates the same invitation as `invite-user`. + +The invitation and recovery commands require `PUBLIC_BASE_URL` but do not need the +GitHub client credentials. The web server requires the complete OAuth +configuration. + ## Deployment -```bash -docker compose up -d -``` +Register the production GitHub OAuth app with exactly these values: + +- Homepage URL: `https://interne.honkytonk.in` +- Authorization callback URL: `https://interne.honkytonk.in/auth/github/callback` +- Scopes: leave blank -Uses a multi-stage Docker build. SQLite database is stored in `./data/` via a volume mount. Configure a reverse proxy to route traffic to port 3000. +Set the OAuth variables below in the deployment environment, store +`GITHUB_CLIENT_SECRET` in the deployment's existing secret mechanism, and then run +`docker compose up -d`. Compose passes the variables through substitution; no real +credential belongs in this repository. The multi-stage image stores SQLite in the +mounted `./data/` directory. Configure the reverse proxy for port 3000 and use +secure cookies in production. ## Environment -| Variable | Default | Description | -|------------------|--------------------------|------------------------------------------------| -| `DATABASE_URL` | `sqlite:data/interne.db` | SQLite database path | -| `SECURE_COOKIES` | `true` | Set to `false` for local HTTP dev (no HTTPS) | -| `RUST_LOG` | — | Log level filter (e.g. `info`, `debug`) | +| Variable | Default | Description | +|---|---|---| +| `DATABASE_URL` | `sqlite:data/interne.db` | SQLite database path | +| `GITHUB_CLIENT_ID` | required | GitHub OAuth app client ID | +| `GITHUB_CLIENT_SECRET` | required | GitHub OAuth app client secret; never commit it | +| `PUBLIC_BASE_URL` | required | Public origin used to build callbacks and connection URLs | +| `GITHUB_SIGNUP_MODE` | `closed` | `closed` rejects unknown identities; `public` creates users | +| `SECURE_COOKIES` | `true` | Set to `false` only for local HTTP development | +| `RUST_LOG` | — | Log level filter, such as `info` or `debug` | + +In closed mode, an unknown GitHub user sees exactly: “Access isn’t open yet. Email +webmaster@honkytonk.in for an invite.” Create their account and URL with +`interne invite-user "Name"`. To open registration, set +`GITHUB_SIGNUP_MODE=public` and restart; no schema or code change is needed. + +## Production rollout + +1. Back up the SQLite database. +2. Configure the production OAuth app with the exact homepage and callback above, + leaving scopes blank. +3. Pass `GITHUB_CLIENT_ID`, `GITHUB_CLIENT_SECRET`, + `PUBLIC_BASE_URL=https://interne.honkytonk.in`, and + `GITHUB_SIGNUP_MODE=closed` to the deployed service. The sibling + `honkytonk-infra` repository must be updated separately; do not commit the + client secret there. +4. Deploy the application and migrations in closed mode. +5. Use the existing invite code once to connect the GitHub account `axelav`. +6. Log out, sign in with GitHub, and verify that the same existing entries appear. +7. Retain `interne reset-auth ` for emergencies. Verify that the command + is available without resetting the production account during rollout. + +## Legacy cleanup + +Invite-code login and `create-user` remain temporarily for migration. After every +existing user has connected GitHub, a follow-up change can remove the legacy +invite-code route and conditional form, the Rust model field, the SQLite column, +and the deprecated `create-user` alias. Invitation and recovery continue through +single-use connection URLs. + +## Future Work + +- [ ] Remove the legacy invite-code route, UI, Rust model field, SQLite column, and deprecated `create-user` alias after all existing users have connected GitHub. ## Data Model -- **users** — invite-code auth, no passwords +- **users** — GitHub identity plus a temporary nullable legacy invite code; no passwords - **entries** — URLs with title, description, duration/interval for spaced repetition - **visits** — full history of entry views per user - **collections** — shared groups of entries with invite codes diff --git a/docker-compose.yml b/docker-compose.yml index 4602713..49ab832 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -8,4 +8,10 @@ services: environment: - DATABASE_URL=sqlite:/app/data/interne.db - RUST_LOG=info + - GITHUB_CLIENT_ID=${GITHUB_CLIENT_ID} + - GITHUB_CLIENT_SECRET=${GITHUB_CLIENT_SECRET} + - PUBLIC_BASE_URL=${PUBLIC_BASE_URL:-http://127.0.0.1:3000} + - GITHUB_SIGNUP_MODE=${GITHUB_SIGNUP_MODE:-closed} + # false is only for local HTTP. Set SECURE_COOKIES=true behind HTTPS. + - SECURE_COOKIES=${SECURE_COOKIES:-false} restart: unless-stopped diff --git a/static/style.css b/static/style.css index bc684cb..4302ffa 100644 --- a/static/style.css +++ b/static/style.css @@ -377,28 +377,72 @@ button.link-button:hover { .login-page { max-width: 280px; + width: 100%; margin: 3rem auto; } +.oauth-button, .primary-action { - display: inline-block; + display: block; + width: 100%; padding: 0.5rem 1.25rem; background: var(--black); color: var(--white); + border: var(--border); border-radius: var(--radius); font-size: 0.875rem; + font-weight: 500; + text-align: center; text-decoration: none; transition: background 0.15s ease; } +.oauth-button:hover, .primary-action:hover { background: var(--gray-900); } +.oauth-button:focus-visible, +.primary-action:focus-visible { + outline: 2px solid var(--gray-600); + outline-offset: 2px; +} + +.auth-divider, .login-divider { + display: flex; + align-items: center; + gap: 0.75rem; margin: 1.5rem 0; color: var(--gray-400); font-size: 0.75rem; + text-align: center; +} + +.auth-divider::before, +.auth-divider::after, +.login-divider::before, +.login-divider::after { + content: ""; + height: 1px; + flex: 1; + background: var(--gray-200); +} + +.auth-status, +.login-page > p { + margin-bottom: 1rem; + color: var(--gray-600); + overflow-wrap: anywhere; +} + +.login-page > form { + width: 100%; +} + +.login-page > p + form button[type="submit"] { + width: 100%; + text-align: center; } .login-error { @@ -409,6 +453,12 @@ button.link-button:hover { margin-bottom: 1rem; } +@media (max-width: 320px) { + .login-page { + margin: 2rem auto; + } +} + /* Empty state */ .empty { text-align: center; From a0958683af4cb409dce94cf142b805bf1b0d80cb Mon Sep 17 00:00:00 2001 From: Axel Anderson Date: Wed, 12 Aug 2026 12:29:43 -0400 Subject: [PATCH 22/25] docs(auth): harden deployment configuration --- .env.example | 3 +-- README.md | 13 ++++++++----- docker-compose.yml | 9 ++++----- templates/auth_error.html | 2 +- templates/connect_github.html | 4 ++-- templates/github_confirm.html | 4 ++-- templates/login.html | 6 +++--- 7 files changed, 21 insertions(+), 20 deletions(-) diff --git a/.env.example b/.env.example index 45b5645..1414cf0 100644 --- a/.env.example +++ b/.env.example @@ -4,5 +4,4 @@ GITHUB_CLIENT_ID=replace-with-github-oauth-client-id GITHUB_CLIENT_SECRET=replace-with-github-oauth-client-secret PUBLIC_BASE_URL=https://interne.example.com GITHUB_SIGNUP_MODE=closed -# Local HTTP only. Use true when the site is served over HTTPS. -SECURE_COOKIES=false +SECURE_COOKIES=true diff --git a/README.md b/README.md index 2759ff6..3bce4a9 100644 --- a/README.md +++ b/README.md @@ -48,7 +48,8 @@ one configured callback URL, so create a separate app for local development with - Authorization callback URL: `http://127.0.0.1:3000/auth/github/callback` - Scopes: leave blank -Copy `.env.example` to `.env`, replace both GitHub credential placeholders, and set: +Copy `.env.example` to `.env`, replace both GitHub credential placeholders, and +make these explicit local-only overrides: ```dotenv PUBLIC_BASE_URL=http://127.0.0.1:3000 @@ -57,7 +58,8 @@ SECURE_COOKIES=false ``` `SECURE_COOKIES=false` is a local-only override for HTTP. Do not use it when the -site is served over HTTPS. Start the server with `cargo run`, then open +site is served over HTTPS; `.env.example` and Compose default it to `true`. Start +the server with `cargo run`, then open [http://127.0.0.1:3000](http://127.0.0.1:3000). A bare `cargo run` without `GITHUB_CLIENT_ID`, `GITHUB_CLIENT_SECRET`, and `PUBLIC_BASE_URL` exits with a configuration error. @@ -101,9 +103,10 @@ Register the production GitHub OAuth app with exactly these values: Set the OAuth variables below in the deployment environment, store `GITHUB_CLIENT_SECRET` in the deployment's existing secret mechanism, and then run `docker compose up -d`. Compose passes the variables through substitution; no real -credential belongs in this repository. The multi-stage image stores SQLite in the -mounted `./data/` directory. Configure the reverse proxy for port 3000 and use -secure cookies in production. +credential belongs in this repository. Compose refuses to render without the +client ID, client secret, and public base URL; secure cookies default to `true`. +The multi-stage image stores SQLite in the mounted `./data/` directory. Configure +the reverse proxy for port 3000. Never set `SECURE_COOKIES=false` in production. ## Environment diff --git a/docker-compose.yml b/docker-compose.yml index 49ab832..992f826 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -8,10 +8,9 @@ services: environment: - DATABASE_URL=sqlite:/app/data/interne.db - RUST_LOG=info - - GITHUB_CLIENT_ID=${GITHUB_CLIENT_ID} - - GITHUB_CLIENT_SECRET=${GITHUB_CLIENT_SECRET} - - PUBLIC_BASE_URL=${PUBLIC_BASE_URL:-http://127.0.0.1:3000} + - GITHUB_CLIENT_ID=${GITHUB_CLIENT_ID:?Set GITHUB_CLIENT_ID in the deployment environment} + - GITHUB_CLIENT_SECRET=${GITHUB_CLIENT_SECRET:?Set GITHUB_CLIENT_SECRET in the deployment secret environment} + - PUBLIC_BASE_URL=${PUBLIC_BASE_URL:?Set PUBLIC_BASE_URL to the deployed HTTPS origin} - GITHUB_SIGNUP_MODE=${GITHUB_SIGNUP_MODE:-closed} - # false is only for local HTTP. Set SECURE_COOKIES=true behind HTTPS. - - SECURE_COOKIES=${SECURE_COOKIES:-false} + - SECURE_COOKIES=${SECURE_COOKIES:-true} restart: unless-stopped diff --git a/templates/auth_error.html b/templates/auth_error.html index a3929df..4b13e33 100644 --- a/templates/auth_error.html +++ b/templates/auth_error.html @@ -5,7 +5,7 @@ {% block content %} {% endblock %} diff --git a/templates/connect_github.html b/templates/connect_github.html index 97d0314..0b0c8a9 100644 --- a/templates/connect_github.html +++ b/templates/connect_github.html @@ -5,9 +5,9 @@ {% block content %} {% endblock %} diff --git a/templates/github_confirm.html b/templates/github_confirm.html index ff9056d..f84a5ad 100644 --- a/templates/github_confirm.html +++ b/templates/github_confirm.html @@ -5,9 +5,9 @@ {% block content %} {% endblock %} diff --git a/templates/login.html b/templates/login.html index 34bbd7d..4ebf851 100644 --- a/templates/login.html +++ b/templates/login.html @@ -6,14 +6,14 @@