T5: FIT OAuth client + docs/fit-auth.md #5

Merged
dzenda merged 101 commits from t5-fit-oauth into t4-crypto-token-store 2026-11-14 17:50:53 +01:00
Owner

T5: FIT OAuth client

Stacked on #4 (t4-crypto-token-store) → #3 → #2 → #1. Merge those first.

What

  • src/lib/fit-auth.ts:
    • buildAuthorizeUrl (SPEC scopes, state, S256 code_challenge) and exchangeCode.
    • refreshTokens / fitRefresher, which plugs into the T4 token store and maps invalid_grant to RefreshRejectedError.
    • checkToken (POSTs the token so it stays out of URLs).
    • createPkce / randomToken / timingSafeEqual, and fitAuthConfigFromEnv (single callback PUBLIC_URL/auth/callback).
  • Scope policy: cvut:grades:course-restricted is never requested. Scopes are tokenized before the denylist check, so whitespace smuggling is blocked.
  • No leakage: the live server's error_description echoes the submitted token or code ("Invalid refresh token: <token>"). FitAuthError keeps only the OAuth error code and HTTP status. Tests assert that secrets never appear in errors.
  • docs/fit-auth.md: endpoints, observed responses, error mapping, PKCE notes, and open questions for the first real login.
  • Added zod (also needed by T8).

Probed the live server (no user login, credentials never printed)

  • The server is Spring Security OAuth2 with no OIDC discovery. Endpoints: /oauth/authorize, /oauth/token, public /oauth/check_token, /oauth/userinfo.
  • Our client credentials are valid: client_credentials returns "Unauthorized grant type", meaning the client authenticated. A wrong secret returns 401 Bad credentials.
  • HTTP Basic client auth only. client_secret_post is rejected with 401.
  • Ran the real client functions against the live server: checkToken(bogus) → null, bogus refresh → RefreshRejectedError, wrong secret → FitAuthError unauthorized (401), and authorize → 302 to the login page.

Verified

  • bun run lint && bun run typecheck && bun run test: 79 tests, 24 of them for this client. They include the RFC 7636 Appendix B PKCE vector, RFC 6749 Basic encoding, request shapes, error mapping and no-leak assertions.
  • No server routes yet. The /auth/callback route comes in T6.

Astra review (gpt-6-astra)

Fixed (all four):

  1. (P2) A whitespace-joined scope entry could smuggle cvut:grades:course-restricted → scopes are tokenized before the denylist check.
  2. (P2) encodeURIComponent left !'()~ unencoded in Basic credentials → URLSearchParams form encoding.
  3. (P2) Missing or unsupported token_type was accepted → bearer is required (RFC 6749 §7.1).
  4. (P2) {} from check_token looked like a valid introspection → client_id is required.

Fixes 3 and 4 make the client stricter than strictly needed for the probed error paths. They're listed in docs/fit-auth.md to confirm on the first real login.

Needs you (soon)

T6 (Better Auth login) comes next. Once it lands I'll ask for one browser login at http://localhost:3000. That answers the open questions in docs/fit-auth.md: refresh tokens, lifetimes, rotation and PKCE.

🤖 Generated with Claude Code

## T5: FIT OAuth client **Stacked on #4 (`t4-crypto-token-store`) → #3 → #2 → #1.** Merge those first. ### What - `src/lib/fit-auth.ts`: - `buildAuthorizeUrl` (SPEC scopes, `state`, S256 `code_challenge`) and `exchangeCode`. - `refreshTokens` / `fitRefresher`, which plugs into the T4 token store and maps `invalid_grant` to `RefreshRejectedError`. - `checkToken` (POSTs the token so it stays out of URLs). - `createPkce` / `randomToken` / `timingSafeEqual`, and `fitAuthConfigFromEnv` (single callback `PUBLIC_URL/auth/callback`). - **Scope policy:** `cvut:grades:course-restricted` is never requested. Scopes are tokenized before the denylist check, so whitespace smuggling is blocked. - **No leakage:** the live server's `error_description` echoes the submitted token or code (`"Invalid refresh token: <token>"`). `FitAuthError` keeps only the OAuth error code and HTTP status. Tests assert that secrets never appear in errors. - `docs/fit-auth.md`: endpoints, observed responses, error mapping, PKCE notes, and open questions for the first real login. - Added `zod` (also needed by T8). ### Probed the live server (no user login, credentials never printed) - The server is Spring Security OAuth2 with no OIDC discovery. Endpoints: `/oauth/authorize`, `/oauth/token`, public `/oauth/check_token`, `/oauth/userinfo`. - Our client credentials are **valid**: `client_credentials` returns "Unauthorized grant type", meaning the client authenticated. A wrong secret returns `401 Bad credentials`. - **HTTP Basic client auth only.** `client_secret_post` is rejected with 401. - Ran the real client functions against the live server: `checkToken(bogus)` → `null`, bogus refresh → `RefreshRejectedError`, wrong secret → `FitAuthError unauthorized (401)`, and authorize → 302 to the login page. ### Verified - `bun run lint && bun run typecheck && bun run test`: 79 tests, 24 of them for this client. They include the RFC 7636 Appendix B PKCE vector, RFC 6749 Basic encoding, request shapes, error mapping and no-leak assertions. - No server routes yet. The `/auth/callback` route comes in T6. ### Astra review (gpt-6-astra) **Fixed (all four):** 1. *(P2)* A whitespace-joined scope entry could smuggle `cvut:grades:course-restricted` → scopes are tokenized before the denylist check. 2. *(P2)* `encodeURIComponent` left `!'()~` unencoded in Basic credentials → `URLSearchParams` form encoding. 3. *(P2)* Missing or unsupported `token_type` was accepted → `bearer` is required (RFC 6749 §7.1). 4. *(P2)* `{}` from `check_token` looked like a valid introspection → `client_id` is required. Fixes 3 and 4 make the client stricter than strictly needed for the probed error paths. They're listed in `docs/fit-auth.md` to confirm on the first real login. ### Needs you (soon) T6 (Better Auth login) comes next. Once it lands I'll ask for **one browser login at `http://localhost:3000`**. That answers the open questions in `docs/fit-auth.md`: refresh tokens, lifetimes, rotation and PKCE. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Applies to Usermap calls, the FIT token/check_token requests and the login profile lookup.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- fixtures:record: reads the encrypted token from local D1, GET-only, never refreshes,
  scrubs PII into git-ignored test/fixtures/live/, prints shapes only
- Marast: FIT token goes in Api-Key (Bearer is 401); only_open is 401 → filter locally
- Usermap: fields may be null (preferredEmail) → nullish schemas
- Grades: precise error for missing OAuth app resource; docs on scopes vs. resources

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Also fixes a production bug found while smoke-testing: createFnContext stored the global
fetch on the context object, so ctx.fetch(...) threw "Illegal invocation" in workerd. That
broke every registry call in the Worker (including all MCP tool calls); Node tests could
not see it. Regression test emulates workerd's this-check.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- ALLOWED_USERS enforced for MCP too (shared policy); excluded users' grants revoked on use
- telemetry: streaming 512-byte cap before parsing
- POST /oauth/register rate-limited per IP and instance-wide (Workers rate-limit bindings)
- FIT API bodies capped at 2 MB (streaming); Grades tree walk iterative and bounded
- readBounded moved to src/lib/bounded.ts

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Logos: FIT service marks vendored in public/services (see README there); assistant marks
from @lobehub/icons (MIT), simple-icons (CC0) and devicon (MIT).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- service_settings table (migration 0002); MCP calls to a switched-off service fail with
  service_disabled, aggregates skip it with a warning, fit.status lists it
- setServiceEnabledFn: same-origin + session only

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- fit.sirius.courseEvents: every parallel of a course (room, teachers, capacity/occupied),
  shared paging helper with fit.sirius.events; respects the Sirius MCP switch
- getCourseWeek server fn for the calendar overlay; upcoming deadlines (60 days) beside the week

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- layoutLanes/pragueMinutes in src/lib/calendar.ts (tested)
- shared EventDetails popover; agenda list stays on mobile
- agentSetupPrompt in src/mcp/setup.ts (tested)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- Referrer-Policy: same-origin (no-referrer made browsers send Origin: null on our form POSTs,
  so every management form was rejected with 403); shared isSameOriginRequest accepts
  Origin: null only with Sec-Fetch-Site: same-origin
- No COOP on OAuth/consent/login routes (popup-based web MCP clients keep window.opener)
- fit.sirius.icalUrl is websiteOnly: excluded from the MCP registry (URL embeds a credential)
- docs: telemetry wording (operator-enabled, consent advice), CSRF/COOP/credential notes

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Reviewed-on: #25
Reviewed-on: #24
Reviewed-on: #23
Reviewed-on: #22
Reviewed-on: #21
Reviewed-on: #20
Reviewed-on: #19
Reviewed-on: #18
Reviewed-on: #17
Reviewed-on: #16
Reviewed-on: #15
Reviewed-on: #14
Reviewed-on: #13
Reviewed-on: #12
Reviewed-on: #11
Reviewed-on: #10
Reviewed-on: #9
Reviewed-on: #8
Reviewed-on: #7
Reviewed-on: #6
dzenda merged commit 723c750310 into t4-crypto-token-store 2026-11-14 17:50:53 +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!5
No description provided.