T6: Better Auth login via FIT OAuth #6

Merged
dzenda merged 97 commits from t6-better-auth into t5-fit-oauth 2026-11-14 17:50:46 +01:00
Owner

T6: Better Auth login via FIT OAuth

Stacked on #5 (t5-fit-oauth) → #4 → #3 → #2 → #1. Merge those first.

What

  • src/server/auth.ts (createAuth):

    • Better Auth 1.7.23 with the Drizzle adapter on D1 (usePlural), plus the generic-OAuth plugin for FIT. FIT is a first-class social provider: POST /api/auth/sign-in/social {provider:"fit"}.
    • Custom getToken / getUserInfo reuse the T5 client, so we get Basic auth, strict validation and leak-proof errors.
    • Identity comes from check_token, which gives user_name and requires client_id to match our app, so a token issued to another app is rejected. The display name and e-mail come from Usermap (best effort, falling back to username@fit.cvut.cz).
  • Single redirect URI: /auth/callback forwards to Better Auth's /api/auth/callback/fit. T14 will route MCP logins by state.

  • Upstream tokens never touch Better Auth's accounts table:

    1. getToken stashes the token set in a request-scoped AsyncLocalStorage (runAuthRequest).
    2. The account create/update hooks null out accessToken, refreshToken and idToken.
    3. session.create.before (same request, now with a user id) encrypts the tokens into connections through the T4 token store, and sets users.username server-side.

    Without a request scope, tokens are dropped, never stored in plaintext (tested).

  • Hardening:

    • Client-supplied scopes on sign-in are rejected, because Better Auth would otherwise merge them into the authorize URL.
    • Grants carrying cvut:grades:course-restricted are rejected.
    • username is input: false. /update-user, /change-email, /delete-user and /link-social are disabled.
    • Account linking is off. Errors redirect to /login?error=….
  • Optional ALLOWED_USERS (SPEC §3, for self-hosting): checked before any rows are created and again at session creation. Existing sessions of removed users are revoked on get-session.

  • tanstackStartCookies() is appended in the Worker so session renewal from server-side getSession reaches the browser.

  • UI: a /login page (en/cs) and the home page showing "Signed in as …" with sign-out. The root beforeLoad loads { locale, viewer } in one server call (getRootData). Viewer never contains tokens.

Verified

  • bun run lint && bun run typecheck && bun run test: 92 tests, 13 of them end-to-end login tests against a fake FIT server and Usermap on SQLite. They cover:
    • PKCE challenge/verifier match, SPEC scopes and the single redirect URI.
    • The session is created, accounts has null tokens, and connections holds only ciphertext that decrypts to the issued token.
    • A re-login updates the tokens and keeps the scope.
    • Fallback when Usermap fails, a token from the wrong client, and a bad code or state.
    • The allowlist, both at login and for existing sessions.
    • No plaintext without a request scope, sign-out, and disabled profile edits.
    • Scope smuggling and forbidden granted scopes.
  • bun run dev + curl:
    • / and /login (en/cs) render. Sign-in returns the FIT authorize URL with the SPEC scopes, S256 and redirect_uri=http://localhost:3000/auth/callback, plus an HttpOnly state cookie.
    • A bogus state redirects to /login?error=state_mismatch. An external callbackURL gets 403, and scopes in the body gets 400.
  • Browser: clicking "Sign in with FIT ČVUT" lands on the real auth.fit.cvut.cz/login.html. The only step left is a human typing credentials.

Astra review (gpt-6-astra), 2 rounds

Fixed:

  1. (P2) Client scopes were merged into the authorize URL, which allowed requesting course-restricted → rejected in a before hook, plus a check_token scope check.
  2. (P2) Session-renewal cookies from server-side getSession were dropped → tanstackStartCookies() plugin.
  3. (P2) ALLOWED_USERS didn't affect existing sessions → after hook on /get-session revokes them.

Round 2: "No remaining concrete defects", verified against the installed Better Auth source.

Noted, not changed: Better Auth skips the Origin check for cookie-less sign-in/social requests. That's acceptable: the flow is bound to the browser by the HttpOnly better-auth.state cookie and the server-side verification record, so a cross-site initiated flow can't be completed by an attacker.

Needs you

One browser login at http://localhost:3000 (with bun run dev running). It verifies the real flow and answers the open questions in docs/fit-auth.md: whether we get a refresh token, the token lifetime, rotation, and PKCE. It also unblocks T7 (fixtures).

🤖 Generated with Claude Code

## T6: Better Auth login via FIT OAuth **Stacked on #5 (`t5-fit-oauth`) → #4 → #3 → #2 → #1.** Merge those first. ### What - `src/server/auth.ts` (`createAuth`): - Better Auth 1.7.23 with the Drizzle adapter on D1 (`usePlural`), plus the generic-OAuth plugin for FIT. FIT is a first-class social provider: `POST /api/auth/sign-in/social {provider:"fit"}`. - Custom `getToken` / `getUserInfo` reuse the T5 client, so we get Basic auth, strict validation and leak-proof errors. - Identity comes from `check_token`, which gives `user_name` and **requires `client_id` to match our app**, so a token issued to another app is rejected. The display name and e-mail come from Usermap (best effort, falling back to `username@fit.cvut.cz`). - **Single redirect URI:** `/auth/callback` forwards to Better Auth's `/api/auth/callback/fit`. T14 will route MCP logins by `state`. - **Upstream tokens never touch Better Auth's `accounts` table:** 1. `getToken` stashes the token set in a request-scoped `AsyncLocalStorage` (`runAuthRequest`). 2. The account create/update hooks null out `accessToken`, `refreshToken` and `idToken`. 3. `session.create.before` (same request, now with a user id) encrypts the tokens into `connections` through the T4 token store, and sets `users.username` server-side. Without a request scope, tokens are **dropped**, never stored in plaintext (tested). - **Hardening:** - Client-supplied `scopes` on sign-in are rejected, because Better Auth would otherwise merge them into the authorize URL. - Grants carrying `cvut:grades:course-restricted` are rejected. - `username` is `input: false`. `/update-user`, `/change-email`, `/delete-user` and `/link-social` are disabled. - Account linking is off. Errors redirect to `/login?error=…`. - **Optional `ALLOWED_USERS`** (SPEC §3, for self-hosting): checked before any rows are created and again at session creation. Existing sessions of removed users are revoked on `get-session`. - `tanstackStartCookies()` is appended in the Worker so session renewal from server-side `getSession` reaches the browser. - **UI:** a `/login` page (en/cs) and the home page showing "Signed in as …" with sign-out. The root `beforeLoad` loads `{ locale, viewer }` in one server call (`getRootData`). `Viewer` never contains tokens. ### Verified - `bun run lint && bun run typecheck && bun run test`: 92 tests, 13 of them end-to-end login tests against a fake FIT server and Usermap on SQLite. They cover: - PKCE challenge/verifier match, SPEC scopes and the single redirect URI. - The session is created, `accounts` has **null tokens**, and `connections` holds only ciphertext that decrypts to the issued token. - A re-login updates the tokens and keeps the scope. - Fallback when Usermap fails, a token from the wrong client, and a bad code or state. - The allowlist, both at login and for existing sessions. - No plaintext without a request scope, sign-out, and disabled profile edits. - Scope smuggling and forbidden granted scopes. - `bun run dev` + curl: - `/` and `/login` (en/cs) render. Sign-in returns the FIT authorize URL with the SPEC scopes, S256 and `redirect_uri=http://localhost:3000/auth/callback`, plus an HttpOnly state cookie. - A bogus state redirects to `/login?error=state_mismatch`. An external `callbackURL` gets 403, and `scopes` in the body gets 400. - Browser: clicking "Sign in with FIT ČVUT" lands on the real `auth.fit.cvut.cz/login.html`. The only step left is a human typing credentials. ### Astra review (gpt-6-astra), 2 rounds **Fixed:** 1. *(P2)* Client `scopes` were merged into the authorize URL, which allowed requesting `course-restricted` → rejected in a before hook, plus a `check_token` scope check. 2. *(P2)* Session-renewal cookies from server-side `getSession` were dropped → `tanstackStartCookies()` plugin. 3. *(P2)* `ALLOWED_USERS` didn't affect existing sessions → after hook on `/get-session` revokes them. Round 2: "No remaining concrete defects", verified against the installed Better Auth source. **Noted, not changed:** Better Auth skips the Origin check for cookie-less `sign-in/social` requests. That's acceptable: the flow is bound to the browser by the HttpOnly `better-auth.state` cookie and the server-side verification record, so a cross-site initiated flow can't be completed by an attacker. ### Needs you **One browser login at http://localhost:3000** (with `bun run dev` running). It verifies the real flow and answers the open questions in `docs/fit-auth.md`: whether we get a refresh token, the token lifetime, rotation, and PKCE. It also unblocks T7 (fixtures). 🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
dzenda merged commit 547389f0b8 into t5-fit-oauth 2026-11-14 17:50:46 +01:00
Sign in to join this conversation.
No reviewers
No labels
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
dzenda/os!6
No description provided.