feat(oauth): F3 — Forgejo OAuth provider #505
No reviewers
Labels
No labels
area:agents
area:dashboard
area:database
area:design
area:design-review
area:flows
area:infra
area:meta
area:security
area:sessions
area:webhook
area:workdir
security
type:bug
type:chore
type:meta
type:user-story
No milestone
No project
No assignees
2 participants
Notifications
Due date
No due date set.
Dependencies
No dependencies set.
Reference
charles/claude-hooks!505
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "boss/482"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Adds the Forgejo operator-login flow:
/oauth/forgejo/initissues a CSRF state and redirects to the configured Forgejo authorize URL;/oauth/forgejo/callbackexchanges the code, fetches the profile, persists the token row, setsactive_forge_type='forgejo', creates a session, and issues theclaude-hooks-sessioncookie.Test plan
bun x turbo run typecheckclean across all 4 workspaces.bun x turbo run test— 2302 server tests + the rest, all green.forgejo-oauth.test.tscover state issuance + replay protection, open-redirect rejection, exchange / profile failure paths (asserting upstream bodies don't leak), happy-path DB writes + cookie shape, env-override precedence, refresh-token rotation, andwithOperatorOAuthretry-on-401.bun x @biomejs/biome check .exits 0.just oauth-register-forgejoprints the registration steps without error.Closes #482
CI green. AC coverage is thorough — CSRF replay protection, open-redirect rejection, no body leakage, DB writes, session creation, env-override precedence, refresh rotation, and
withOperatorOAuthretry semantics are all verified. Two findings:behavior (security) —
forgejo-oauth.ts:443: theclaude-hooks-sessioncookie is set without theSecureattribute. In any HTTPS deployment (publicBaseUrlstarts withhttps://) the browser will transmit the session cookie over plain HTTP too, exposing operator credentials to network observers. The base URL is already resolved at this point — fix:test-gap —
forgejo-oauth.test.ts: the expired-state branch (entry.expiresAt <= Date.now()athandleForgejoOAuthCallback:372) is never exercised. Every invalid-state test sends a token that was never issued; none exercise a token that was issued but has since expired. Add a case that writes directly into the state store (via_resetStateStoreForTest+ a helper that pre-seeds an expired entry, or by mockingDate.now) to cover that branch.589c1bbea1b5ce275022b5ce27502249a69b113f49a69b113fbccc870455bccc870455151127577bCI green. The delta commit (
fix(ci): drop duplicate oauth-register-forgejo recipe) is a Justfile-only change — neither finding from round 1 was addressed.behavior (security) —
forgejo-oauth.ts(cookie set inhandleForgejoOAuthCallback): theSet-Cookieheader is stillHttpOnly; SameSite=Lax; Max-Age=…; Path=/with noSecureattribute. On any HTTPS deployment the browser will send the session cookie over plain HTTP. ThepublicBaseUrlconfig value is already in scope at that point — fix:The test assertion in the happy-path case should also add
expect(cookie).toContain("Secure")when the base URL ishttps://.test-gap —
forgejo-oauth.test.ts: the"unknown state"test uses a state that was never issued, so!entryis true andentry.expiresAt <= Date.now()is never evaluated. Add a case that seeds an already-expired entry directly into the store (via_resetStateStoreForTest+ manualstateStore.set, or by temporarily mockingDate.now) so the expiry check is exercised. Note: becausepruneStateStore()runs before theget(), seeding with a pastexpiresAtand then calling the handler will have the entry pruned inpruneStateStore()— mockDate.nowto a past time during seeding, restore before invoking the handler, to actually hit theexpiresAt <= Date.now()branch.e5ce69ec49cc04153fe2CI green. Both round-2 findings addressed:
Secureattribute is now conditionally set on the session cookie (baseUrl.startsWith("https://")guard, line 457) and the happy-path test assertsexpect(cookie).toContain("Secure"); the expired-state branch is covered by the newDate.nowmock in the "secondary expiry check" describe block (line 510).