feat(oauth): authorization-server endpoints (forgejo-mcp-broker-pur)
Implements internal/oauth, the broker's OAuth 2.1 AS surface that Claude.ai (and other MCP clients) talk to. User authentication is delegated to upstream Forgejo via internal/forgejo. Endpoints: POST /oauth/register — RFC 7591 dynamic client registration GET /oauth/authorize — RFC 6749 + 7636 PKCE (S256 only) GET /oauth/callback — Forgejo redirects back here after consent POST /oauth/token — authorization_code + refresh_token grants POST /oauth/revoke — RFC 7009 Security model: - PKCE required, S256 only — plain method rejected per OAuth 2.1 - Every broker-issued access/refresh token stored as hex(sha256(plain)); plaintext leaves the broker exactly once in the /token response body - Refresh-token rotation: each refresh issues a new token pair and revokes the old refresh (RFC 6749 §10.4) - Auth-code single-use enforced atomically via UPDATE...WHERE used_at IS NULL with rows-affected check, blocking the concurrent-replay race - Issuer URL sourced from cfg.Issuer at construction time, never from inbound headers — prevents host-header injection on /.well-known metadata that ships in 2d - redirect_uri must match a registered URI exactly (no prefix/wildcard) Pending-authorization state (between /authorize and /callback) lives in an in-memory sync.Map with a 1-minute reaper goroutine. A broker restart drops them, forcing the user to re-authorize — acceptable trade-off versus introducing a fifth table. Tests: 81.0% coverage with ~20 cases across happy paths, every required- field error, PKCE failure, code-replay, refresh expiry/revocation, client-id and redirect-uri mismatches, Forgejo-side errors, and the reaper logic itself (internal test). Closes forgejo-mcp-broker-pur. The OAuth keystone is in place; phase 2c unblocks discovery (2d) and security review (2e), and combined with the existing supervisor + bridge it unblocks the session glue work in phase 5. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
9d3b16e7b3
commit
d16b18ea38
4 changed files with 1957 additions and 2 deletions
|
|
@ -1,14 +1,14 @@
|
||||||
{"id":"forgejo-mcp-broker-q4x","title":"Phase 5c: idle reaper + Forgejo token rotation + child respawn","description":"Reaper (30s tick) applies idle timeout. Rotation (1-min tick) refreshes Forgejo tokens expiring \u003c2min, SIGTERMs child, respawns on next request (design.md §6). Token revocation tears down sessions.","acceptance_criteria":"Clock-injected tests: idle kill, rotation triggers respawn, revocation tears down sessions. Smoke test: 20 concurrent sessions for 10min with mid-test rotations.","status":"open","priority":1,"issue_type":"task","owner":"olemd@glemt.net","created_at":"2026-04-24T15:45:18Z","created_by":"Ole-Morten Duesund","updated_at":"2026-04-24T15:45:18Z","dependencies":[{"issue_id":"forgejo-mcp-broker-q4x","depends_on_id":"forgejo-mcp-broker-pur","type":"blocks","created_at":"2026-04-24T17:45:31Z","created_by":"Ole-Morten Duesund","metadata":"{}"},{"issue_id":"forgejo-mcp-broker-q4x","depends_on_id":"forgejo-mcp-broker-t81","type":"blocks","created_at":"2026-04-24T17:45:31Z","created_by":"Ole-Morten Duesund","metadata":"{}"}],"dependency_count":2,"dependent_count":1,"comment_count":0}
|
{"id":"forgejo-mcp-broker-q4x","title":"Phase 5c: idle reaper + Forgejo token rotation + child respawn","description":"Reaper (30s tick) applies idle timeout. Rotation (1-min tick) refreshes Forgejo tokens expiring \u003c2min, SIGTERMs child, respawns on next request (design.md §6). Token revocation tears down sessions.","acceptance_criteria":"Clock-injected tests: idle kill, rotation triggers respawn, revocation tears down sessions. Smoke test: 20 concurrent sessions for 10min with mid-test rotations.","status":"open","priority":1,"issue_type":"task","owner":"olemd@glemt.net","created_at":"2026-04-24T15:45:18Z","created_by":"Ole-Morten Duesund","updated_at":"2026-04-24T15:45:18Z","dependencies":[{"issue_id":"forgejo-mcp-broker-q4x","depends_on_id":"forgejo-mcp-broker-pur","type":"blocks","created_at":"2026-04-24T17:45:31Z","created_by":"Ole-Morten Duesund","metadata":"{}"},{"issue_id":"forgejo-mcp-broker-q4x","depends_on_id":"forgejo-mcp-broker-t81","type":"blocks","created_at":"2026-04-24T17:45:31Z","created_by":"Ole-Morten Duesund","metadata":"{}"}],"dependency_count":2,"dependent_count":1,"comment_count":0}
|
||||||
{"id":"forgejo-mcp-broker-ytw","title":"Phase 5b: bearer-token middleware on /mcp","description":"Middleware reads Authorization: Bearer \u003cmcp_token\u003e, resolves via store, attaches Forgejo access token to request context. 401 for missing/expired/revoked.","acceptance_criteria":"Table-driven tests: missing header, malformed, unknown token, expired, revoked, valid. Valid-token path puts Forgejo token on ctx via typed key.","status":"open","priority":1,"issue_type":"task","owner":"olemd@glemt.net","created_at":"2026-04-24T15:45:18Z","created_by":"Ole-Morten Duesund","updated_at":"2026-04-24T15:45:18Z","dependencies":[{"issue_id":"forgejo-mcp-broker-ytw","depends_on_id":"forgejo-mcp-broker-pur","type":"blocks","created_at":"2026-04-24T17:45:30Z","created_by":"Ole-Morten Duesund","metadata":"{}"}],"dependency_count":1,"dependent_count":1,"comment_count":0}
|
{"id":"forgejo-mcp-broker-ytw","title":"Phase 5b: bearer-token middleware on /mcp","description":"Middleware reads Authorization: Bearer \u003cmcp_token\u003e, resolves via store, attaches Forgejo access token to request context. 401 for missing/expired/revoked.","acceptance_criteria":"Table-driven tests: missing header, malformed, unknown token, expired, revoked, valid. Valid-token path puts Forgejo token on ctx via typed key.","status":"open","priority":1,"issue_type":"task","owner":"olemd@glemt.net","created_at":"2026-04-24T15:45:18Z","created_by":"Ole-Morten Duesund","updated_at":"2026-04-24T15:45:18Z","dependencies":[{"issue_id":"forgejo-mcp-broker-ytw","depends_on_id":"forgejo-mcp-broker-pur","type":"blocks","created_at":"2026-04-24T17:45:30Z","created_by":"Ole-Morten Duesund","metadata":"{}"}],"dependency_count":1,"dependent_count":1,"comment_count":0}
|
||||||
{"id":"forgejo-mcp-broker-t81","title":"Phase 5a: session registry + spawn-on-initialize","description":"Map Mcp-Session-Id -\u003e supervisor.Child + user metadata. On first initialize for unknown sid, spawn forgejo-mcp with user's Forgejo token in env, bind to bridge. LastActive bumped per request.","acceptance_criteria":"Tests with fake supervisor + fake bridge cover: spawn-on-initialize, reuse for subsequent messages, unknown-sid returns 410, max-sessions cap enforced.","status":"open","priority":1,"issue_type":"task","owner":"olemd@glemt.net","created_at":"2026-04-24T15:45:17Z","created_by":"Ole-Morten Duesund","updated_at":"2026-04-24T15:45:17Z","dependencies":[{"issue_id":"forgejo-mcp-broker-t81","depends_on_id":"forgejo-mcp-broker-am1","type":"blocks","created_at":"2026-04-24T17:45:29Z","created_by":"Ole-Morten Duesund","metadata":"{}"},{"issue_id":"forgejo-mcp-broker-t81","depends_on_id":"forgejo-mcp-broker-pur","type":"blocks","created_at":"2026-04-24T17:45:30Z","created_by":"Ole-Morten Duesund","metadata":"{}"},{"issue_id":"forgejo-mcp-broker-t81","depends_on_id":"forgejo-mcp-broker-zuq","type":"blocks","created_at":"2026-04-24T17:45:28Z","created_by":"Ole-Morten Duesund","metadata":"{}"}],"dependency_count":3,"dependent_count":2,"comment_count":0}
|
{"id":"forgejo-mcp-broker-t81","title":"Phase 5a: session registry + spawn-on-initialize","description":"Map Mcp-Session-Id -\u003e supervisor.Child + user metadata. On first initialize for unknown sid, spawn forgejo-mcp with user's Forgejo token in env, bind to bridge. LastActive bumped per request.","acceptance_criteria":"Tests with fake supervisor + fake bridge cover: spawn-on-initialize, reuse for subsequent messages, unknown-sid returns 410, max-sessions cap enforced.","status":"open","priority":1,"issue_type":"task","owner":"olemd@glemt.net","created_at":"2026-04-24T15:45:17Z","created_by":"Ole-Morten Duesund","updated_at":"2026-04-24T15:45:17Z","dependencies":[{"issue_id":"forgejo-mcp-broker-t81","depends_on_id":"forgejo-mcp-broker-am1","type":"blocks","created_at":"2026-04-24T17:45:29Z","created_by":"Ole-Morten Duesund","metadata":"{}"},{"issue_id":"forgejo-mcp-broker-t81","depends_on_id":"forgejo-mcp-broker-pur","type":"blocks","created_at":"2026-04-24T17:45:30Z","created_by":"Ole-Morten Duesund","metadata":"{}"},{"issue_id":"forgejo-mcp-broker-t81","depends_on_id":"forgejo-mcp-broker-zuq","type":"blocks","created_at":"2026-04-24T17:45:28Z","created_by":"Ole-Morten Duesund","metadata":"{}"}],"dependency_count":3,"dependent_count":2,"comment_count":0}
|
||||||
{"id":"forgejo-mcp-broker-xot","title":"Phase 4b: bridge integration test against real forgejo-mcp","description":"Drive the bridge with initialize -\u003e tools/list -\u003e tools/call get_forgejo_mcp_server_version against a real forgejo-mcp subprocess. Validates the opaque-pipe assumption.","acceptance_criteria":"Full handshake, tools/list returns expected set, tools/call returns a version string. Tagged as integration test if runtime exceeds 2s.","status":"in_progress","priority":1,"issue_type":"task","assignee":"Ole-Morten Duesund","owner":"olemd@glemt.net","created_at":"2026-04-24T15:45:16Z","created_by":"Ole-Morten Duesund","updated_at":"2026-04-27T14:10:04Z","started_at":"2026-04-27T14:10:04Z","dependencies":[{"issue_id":"forgejo-mcp-broker-xot","depends_on_id":"forgejo-mcp-broker-am1","type":"blocks","created_at":"2026-04-24T17:45:28Z","created_by":"Ole-Morten Duesund","metadata":"{}"}],"dependency_count":1,"dependent_count":0,"comment_count":0}
|
{"id":"forgejo-mcp-broker-xot","title":"Phase 4b: bridge integration test against real forgejo-mcp","description":"Drive the bridge with initialize -\u003e tools/list -\u003e tools/call get_forgejo_mcp_server_version against a real forgejo-mcp subprocess. Validates the opaque-pipe assumption.","acceptance_criteria":"Full handshake, tools/list returns expected set, tools/call returns a version string. Tagged as integration test if runtime exceeds 2s.","status":"closed","priority":1,"issue_type":"task","assignee":"Ole-Morten Duesund","owner":"olemd@glemt.net","created_at":"2026-04-24T15:45:16Z","created_by":"Ole-Morten Duesund","updated_at":"2026-04-27T14:28:39Z","started_at":"2026-04-27T14:10:04Z","closed_at":"2026-04-27T14:28:39Z","close_reason":"Bridge integration test passes against real forgejo-mcp 2.2.0: MCP handshake (initialize → notifications/initialized → tools/list → tools/call) round-trips through bridge cleanly. Fake Forgejo covers /api/v1/version and /api/v1/user probes. Phase 4 complete.","dependencies":[{"issue_id":"forgejo-mcp-broker-xot","depends_on_id":"forgejo-mcp-broker-am1","type":"blocks","created_at":"2026-04-24T17:45:28Z","created_by":"Ole-Morten Duesund","metadata":"{}"}],"dependency_count":1,"dependent_count":0,"comment_count":0}
|
||||||
{"id":"forgejo-mcp-broker-31t","title":"Phase 3b: supervisor stress tests (FD/goroutine/zombie leak detection)","description":"1000 spawn/stop cycles under -race. Verify no FD leak, no goroutine leak (go.uber.org/goleak), no zombies (wait4 returns ECHILD when idle).","acceptance_criteria":"Cycle test passes under -race. FD count stable within a small constant. goleak detects no extra goroutines after test.","status":"closed","priority":1,"issue_type":"task","assignee":"Ole-Morten Duesund","owner":"olemd@glemt.net","created_at":"2026-04-24T15:45:15Z","created_by":"Ole-Morten Duesund","updated_at":"2026-04-27T14:04:42Z","started_at":"2026-04-27T12:00:32Z","closed_at":"2026-04-27T14:04:42Z","close_reason":"Stress tests in place: 1000-cycle spawn/reap and 200-cycle Stop both clean under -race; FD/goroutine/zombie deltas all single-digit. Driver: /bin/true and /bin/cat (helper-process recursion at scale exposed an unrelated Go pidfd interaction). Supervisor now defensively closes pipe handles post-Wait.","dependencies":[{"issue_id":"forgejo-mcp-broker-31t","depends_on_id":"forgejo-mcp-broker-zuq","type":"blocks","created_at":"2026-04-24T17:45:26Z","created_by":"Ole-Morten Duesund","metadata":"{}"}],"dependency_count":1,"dependent_count":0,"comment_count":0}
|
{"id":"forgejo-mcp-broker-31t","title":"Phase 3b: supervisor stress tests (FD/goroutine/zombie leak detection)","description":"1000 spawn/stop cycles under -race. Verify no FD leak, no goroutine leak (go.uber.org/goleak), no zombies (wait4 returns ECHILD when idle).","acceptance_criteria":"Cycle test passes under -race. FD count stable within a small constant. goleak detects no extra goroutines after test.","status":"closed","priority":1,"issue_type":"task","assignee":"Ole-Morten Duesund","owner":"olemd@glemt.net","created_at":"2026-04-24T15:45:15Z","created_by":"Ole-Morten Duesund","updated_at":"2026-04-27T14:04:42Z","started_at":"2026-04-27T12:00:32Z","closed_at":"2026-04-27T14:04:42Z","close_reason":"Stress tests in place: 1000-cycle spawn/reap and 200-cycle Stop both clean under -race; FD/goroutine/zombie deltas all single-digit. Driver: /bin/true and /bin/cat (helper-process recursion at scale exposed an unrelated Go pidfd interaction). Supervisor now defensively closes pipe handles post-Wait.","dependencies":[{"issue_id":"forgejo-mcp-broker-31t","depends_on_id":"forgejo-mcp-broker-zuq","type":"blocks","created_at":"2026-04-24T17:45:26Z","created_by":"Ole-Morten Duesund","metadata":"{}"}],"dependency_count":1,"dependent_count":0,"comment_count":0}
|
||||||
{"id":"forgejo-mcp-broker-am1","title":"Phase 4a: internal/bridge JSON-RPC pipe + SSE writer","description":"Given a supervisor.Child: inbound HTTP JSON -\u003e newline-framed stdin; stdout lines -\u003e SSE frames. Handle client disconnect without killing the child.","acceptance_criteria":"Unit tests with mock Child that echoes: request/response round trip, multiple concurrent requests with correct id routing, client disconnect mid-stream.","status":"closed","priority":1,"issue_type":"task","assignee":"Ole-Morten Duesund","owner":"olemd@glemt.net","created_at":"2026-04-24T15:45:15Z","created_by":"Ole-Morten Duesund","updated_at":"2026-04-27T11:59:35Z","started_at":"2026-04-27T11:56:15Z","closed_at":"2026-04-27T11:59:35Z","close_reason":"Bridge shipped: per-id routing, SSE responses for request/reply messages, 204 for notifications, structured 4xx/5xx for malformed input. Decoupled from supervisor (takes pipes directly) for clean testing via io.Pipe. 90.0% coverage.","dependencies":[{"issue_id":"forgejo-mcp-broker-am1","depends_on_id":"forgejo-mcp-broker-zuq","type":"blocks","created_at":"2026-04-24T17:45:27Z","created_by":"Ole-Morten Duesund","metadata":"{}"}],"dependency_count":1,"dependent_count":2,"comment_count":0}
|
{"id":"forgejo-mcp-broker-am1","title":"Phase 4a: internal/bridge JSON-RPC pipe + SSE writer","description":"Given a supervisor.Child: inbound HTTP JSON -\u003e newline-framed stdin; stdout lines -\u003e SSE frames. Handle client disconnect without killing the child.","acceptance_criteria":"Unit tests with mock Child that echoes: request/response round trip, multiple concurrent requests with correct id routing, client disconnect mid-stream.","status":"closed","priority":1,"issue_type":"task","assignee":"Ole-Morten Duesund","owner":"olemd@glemt.net","created_at":"2026-04-24T15:45:15Z","created_by":"Ole-Morten Duesund","updated_at":"2026-04-27T11:59:35Z","started_at":"2026-04-27T11:56:15Z","closed_at":"2026-04-27T11:59:35Z","close_reason":"Bridge shipped: per-id routing, SSE responses for request/reply messages, 204 for notifications, structured 4xx/5xx for malformed input. Decoupled from supervisor (takes pipes directly) for clean testing via io.Pipe. 90.0% coverage.","dependencies":[{"issue_id":"forgejo-mcp-broker-am1","depends_on_id":"forgejo-mcp-broker-zuq","type":"blocks","created_at":"2026-04-24T17:45:27Z","created_by":"Ole-Morten Duesund","metadata":"{}"}],"dependency_count":1,"dependent_count":2,"comment_count":0}
|
||||||
{"id":"forgejo-mcp-broker-wgo","title":"Phase 2e: OAuth security review + attack-path tests","description":"Phase 2 exit gate. Review every handler for classic OAuth vulns (open redirect, code replay, mix-up, token leak in logs, host spoofing). Add at least one test per attack class. Update design.md §8 with findings.","acceptance_criteria":"Review checklist documented. Tests added for: PKCE mismatch, stale code, token absent from log attributes, bad redirect_uri, mismatched state, replay of used code.","status":"open","priority":1,"issue_type":"task","owner":"olemd@glemt.net","created_at":"2026-04-24T15:45:14Z","created_by":"Ole-Morten Duesund","updated_at":"2026-04-24T15:45:14Z","dependencies":[{"issue_id":"forgejo-mcp-broker-wgo","depends_on_id":"forgejo-mcp-broker-b2o","type":"blocks","created_at":"2026-04-24T17:45:26Z","created_by":"Ole-Morten Duesund","metadata":"{}"},{"issue_id":"forgejo-mcp-broker-wgo","depends_on_id":"forgejo-mcp-broker-pur","type":"blocks","created_at":"2026-04-24T17:45:25Z","created_by":"Ole-Morten Duesund","metadata":"{}"}],"dependency_count":2,"dependent_count":0,"comment_count":0}
|
{"id":"forgejo-mcp-broker-wgo","title":"Phase 2e: OAuth security review + attack-path tests","description":"Phase 2 exit gate. Review every handler for classic OAuth vulns (open redirect, code replay, mix-up, token leak in logs, host spoofing). Add at least one test per attack class. Update design.md §8 with findings.","acceptance_criteria":"Review checklist documented. Tests added for: PKCE mismatch, stale code, token absent from log attributes, bad redirect_uri, mismatched state, replay of used code.","status":"open","priority":1,"issue_type":"task","owner":"olemd@glemt.net","created_at":"2026-04-24T15:45:14Z","created_by":"Ole-Morten Duesund","updated_at":"2026-04-24T15:45:14Z","dependencies":[{"issue_id":"forgejo-mcp-broker-wgo","depends_on_id":"forgejo-mcp-broker-b2o","type":"blocks","created_at":"2026-04-24T17:45:26Z","created_by":"Ole-Morten Duesund","metadata":"{}"},{"issue_id":"forgejo-mcp-broker-wgo","depends_on_id":"forgejo-mcp-broker-pur","type":"blocks","created_at":"2026-04-24T17:45:25Z","created_by":"Ole-Morten Duesund","metadata":"{}"}],"dependency_count":2,"dependent_count":0,"comment_count":0}
|
||||||
{"id":"forgejo-mcp-broker-zuq","title":"Phase 3a: internal/supervisor managed stdio subprocess","description":"Child type: Start, Stop(ctx) with SIGTERM -\u003e grace -\u003e SIGKILL, Wait+reap goroutine (no zombies), stderr drainer with prefix. Protocol-agnostic.","acceptance_criteria":"Unit tests against an echo-loop helper: round trip, graceful stop, kill-after-grace, child-exits-on-own detection, stderr capture. Manual spawn of real forgejo-mcp --transport stdio works.","status":"closed","priority":1,"issue_type":"task","assignee":"Ole-Morten Duesund","owner":"olemd@glemt.net","created_at":"2026-04-24T15:45:14Z","created_by":"Ole-Morten Duesund","updated_at":"2026-04-27T11:41:07Z","started_at":"2026-04-27T11:32:54Z","closed_at":"2026-04-27T11:41:07Z","close_reason":"internal/supervisor shipped: Start/Stop/Done/ExitErr/Pid, SIGTERM-\u003egrace-\u003eSIGKILL escalation, mandatory wait-and-reap. Test uses TestMain helper-process pattern; coverage 89.6% on the testable surface.","dependency_count":0,"dependent_count":3,"comment_count":0}
|
{"id":"forgejo-mcp-broker-zuq","title":"Phase 3a: internal/supervisor managed stdio subprocess","description":"Child type: Start, Stop(ctx) with SIGTERM -\u003e grace -\u003e SIGKILL, Wait+reap goroutine (no zombies), stderr drainer with prefix. Protocol-agnostic.","acceptance_criteria":"Unit tests against an echo-loop helper: round trip, graceful stop, kill-after-grace, child-exits-on-own detection, stderr capture. Manual spawn of real forgejo-mcp --transport stdio works.","status":"closed","priority":1,"issue_type":"task","assignee":"Ole-Morten Duesund","owner":"olemd@glemt.net","created_at":"2026-04-24T15:45:14Z","created_by":"Ole-Morten Duesund","updated_at":"2026-04-27T11:41:07Z","started_at":"2026-04-27T11:32:54Z","closed_at":"2026-04-27T11:41:07Z","close_reason":"internal/supervisor shipped: Start/Stop/Done/ExitErr/Pid, SIGTERM-\u003egrace-\u003eSIGKILL escalation, mandatory wait-and-reap. Test uses TestMain helper-process pattern; coverage 89.6% on the testable surface.","dependency_count":0,"dependent_count":3,"comment_count":0}
|
||||||
{"id":"forgejo-mcp-broker-b2o","title":"Phase 2d: OAuth discovery endpoints (/.well-known/*)","description":"GET /.well-known/oauth-protected-resource and /.well-known/oauth-authorization-server. Issuer URLs MUST derive from cfg.PublicURL, never inbound headers (host-header attack defense per design.md §8).","acceptance_criteria":"Responses validate against RFC 8414/9728 shapes. Issuer URL sourced from config only. supported_scopes matches cfg.ForgejoOAuthScopes.","status":"open","priority":1,"issue_type":"task","owner":"olemd@glemt.net","created_at":"2026-04-24T15:45:13Z","created_by":"Ole-Morten Duesund","updated_at":"2026-04-24T15:45:13Z","dependencies":[{"issue_id":"forgejo-mcp-broker-b2o","depends_on_id":"forgejo-mcp-broker-pur","type":"blocks","created_at":"2026-04-24T17:45:25Z","created_by":"Ole-Morten Duesund","metadata":"{}"}],"dependency_count":1,"dependent_count":1,"comment_count":0}
|
{"id":"forgejo-mcp-broker-b2o","title":"Phase 2d: OAuth discovery endpoints (/.well-known/*)","description":"GET /.well-known/oauth-protected-resource and /.well-known/oauth-authorization-server. Issuer URLs MUST derive from cfg.PublicURL, never inbound headers (host-header attack defense per design.md §8).","acceptance_criteria":"Responses validate against RFC 8414/9728 shapes. Issuer URL sourced from config only. supported_scopes matches cfg.ForgejoOAuthScopes.","status":"open","priority":1,"issue_type":"task","owner":"olemd@glemt.net","created_at":"2026-04-24T15:45:13Z","created_by":"Ole-Morten Duesund","updated_at":"2026-04-24T15:45:13Z","dependencies":[{"issue_id":"forgejo-mcp-broker-b2o","depends_on_id":"forgejo-mcp-broker-pur","type":"blocks","created_at":"2026-04-24T17:45:25Z","created_by":"Ole-Morten Duesund","metadata":"{}"}],"dependency_count":1,"dependent_count":1,"comment_count":0}
|
||||||
{"id":"forgejo-mcp-broker-b9i","title":"Phase 2b: internal/forgejo OAuth client","description":"Broker-side OAuth client for upstream Forgejo: authorize URL builder, code-to-token exchange, refresh_token grant, userinfo fetch, revoke. Used by AS callback and refresh machinery. Stateless; caller owns persistence.","acceptance_criteria":"Unit tests with httptest.Server fake Forgejo cover each grant plus error paths (wrong code, expired refresh, revoked token). No state persisted in this package.","status":"closed","priority":1,"issue_type":"task","assignee":"Ole-Morten Duesund","owner":"olemd@glemt.net","created_at":"2026-04-24T15:45:12Z","created_by":"Ole-Morten Duesund","updated_at":"2026-04-27T11:31:27Z","started_at":"2026-04-27T11:29:17Z","closed_at":"2026-04-27T11:31:27Z","close_reason":"internal/forgejo shipped: AuthorizeURL, ExchangeCode, Refresh, FetchUserInfo. Structured *forgejo.Error for OAuth failures (errors.As-friendly). 95.1% coverage. Stateless — caller owns persistence. Revocation deferred since upstream Forgejo lacks the endpoint.","dependency_count":0,"dependent_count":1,"comment_count":0}
|
{"id":"forgejo-mcp-broker-b9i","title":"Phase 2b: internal/forgejo OAuth client","description":"Broker-side OAuth client for upstream Forgejo: authorize URL builder, code-to-token exchange, refresh_token grant, userinfo fetch, revoke. Used by AS callback and refresh machinery. Stateless; caller owns persistence.","acceptance_criteria":"Unit tests with httptest.Server fake Forgejo cover each grant plus error paths (wrong code, expired refresh, revoked token). No state persisted in this package.","status":"closed","priority":1,"issue_type":"task","assignee":"Ole-Morten Duesund","owner":"olemd@glemt.net","created_at":"2026-04-24T15:45:12Z","created_by":"Ole-Morten Duesund","updated_at":"2026-04-27T11:31:27Z","started_at":"2026-04-27T11:29:17Z","closed_at":"2026-04-27T11:31:27Z","close_reason":"internal/forgejo shipped: AuthorizeURL, ExchangeCode, Refresh, FetchUserInfo. Structured *forgejo.Error for OAuth failures (errors.As-friendly). 95.1% coverage. Stateless — caller owns persistence. Revocation deferred since upstream Forgejo lacks the endpoint.","dependency_count":0,"dependent_count":1,"comment_count":0}
|
||||||
{"id":"forgejo-mcp-broker-pur","title":"Phase 2c: internal/oauth AS endpoints (register, authorize, callback, token, revoke)","description":"Five OAuth handlers per design.md §4.1. RFC 7591 DCR with ephemeral client IDs, authorize -\u003e Forgejo delegation, callback minting broker auth codes, token exchange with SHA-256 hashing at rest, revoke. PKCE S256 required.","acceptance_criteria":"End-to-end curl walkthrough from plan.md phase 2 passes. All tokens hashed at rest. Auth codes single-use, 10-min TTL. Rejects missing PKCE, non-S256, wrong verifier, expired codes/tokens. Handler coverage \u003e=80%.","status":"open","priority":1,"issue_type":"task","owner":"olemd@glemt.net","created_at":"2026-04-24T15:45:12Z","created_by":"Ole-Morten Duesund","updated_at":"2026-04-24T15:45:12Z","dependencies":[{"issue_id":"forgejo-mcp-broker-pur","depends_on_id":"forgejo-mcp-broker-b9i","type":"blocks","created_at":"2026-04-24T17:45:24Z","created_by":"Ole-Morten Duesund","metadata":"{}"},{"issue_id":"forgejo-mcp-broker-pur","depends_on_id":"forgejo-mcp-broker-cpb","type":"blocks","created_at":"2026-04-24T17:45:24Z","created_by":"Ole-Morten Duesund","metadata":"{}"}],"dependency_count":2,"dependent_count":5,"comment_count":0}
|
{"id":"forgejo-mcp-broker-pur","title":"Phase 2c: internal/oauth AS endpoints (register, authorize, callback, token, revoke)","description":"Five OAuth handlers per design.md §4.1. RFC 7591 DCR with ephemeral client IDs, authorize -\u003e Forgejo delegation, callback minting broker auth codes, token exchange with SHA-256 hashing at rest, revoke. PKCE S256 required.","acceptance_criteria":"End-to-end curl walkthrough from plan.md phase 2 passes. All tokens hashed at rest. Auth codes single-use, 10-min TTL. Rejects missing PKCE, non-S256, wrong verifier, expired codes/tokens. Handler coverage \u003e=80%.","status":"in_progress","priority":1,"issue_type":"task","assignee":"Ole-Morten Duesund","owner":"olemd@glemt.net","created_at":"2026-04-24T15:45:12Z","created_by":"Ole-Morten Duesund","updated_at":"2026-04-27T14:30:02Z","started_at":"2026-04-27T14:30:02Z","dependencies":[{"issue_id":"forgejo-mcp-broker-pur","depends_on_id":"forgejo-mcp-broker-b9i","type":"blocks","created_at":"2026-04-24T17:45:24Z","created_by":"Ole-Morten Duesund","metadata":"{}"},{"issue_id":"forgejo-mcp-broker-pur","depends_on_id":"forgejo-mcp-broker-cpb","type":"blocks","created_at":"2026-04-24T17:45:24Z","created_by":"Ole-Morten Duesund","metadata":"{}"}],"dependency_count":2,"dependent_count":5,"comment_count":0}
|
||||||
{"id":"forgejo-mcp-broker-cpb","title":"Phase 2a: OAuth tables migration","description":"Add migrations/0002_oauth_tables.sql creating clients, auth_codes, access_tokens, refresh_tokens per design.md §4.2. Broker tokens stored as SHA-256 hashes; Forgejo tokens cleartext (subprocess spawning requires them). See plan.md phase 2.","acceptance_criteria":"Migration applies on a fresh DB and is idempotent on reopen. Schema matches design.md §4.2. Tests verify every table and key column exists.","status":"closed","priority":1,"issue_type":"task","assignee":"Ole-Morten Duesund","owner":"olemd@glemt.net","created_at":"2026-04-24T15:45:04Z","created_by":"Ole-Morten Duesund","updated_at":"2026-04-27T11:28:20Z","started_at":"2026-04-27T11:26:17Z","closed_at":"2026-04-27T11:28:20Z","close_reason":"0002_oauth_tables.sql shipped: clients/auth_codes/access_tokens/refresh_tokens with cascading FKs, indexes on hot-path columns, and an oauth_schema_version marker. PRAGMA-driven tests verify columns; FK cascade tested in both directions.","dependency_count":0,"dependent_count":1,"comment_count":0}
|
{"id":"forgejo-mcp-broker-cpb","title":"Phase 2a: OAuth tables migration","description":"Add migrations/0002_oauth_tables.sql creating clients, auth_codes, access_tokens, refresh_tokens per design.md §4.2. Broker tokens stored as SHA-256 hashes; Forgejo tokens cleartext (subprocess spawning requires them). See plan.md phase 2.","acceptance_criteria":"Migration applies on a fresh DB and is idempotent on reopen. Schema matches design.md §4.2. Tests verify every table and key column exists.","status":"closed","priority":1,"issue_type":"task","assignee":"Ole-Morten Duesund","owner":"olemd@glemt.net","created_at":"2026-04-24T15:45:04Z","created_by":"Ole-Morten Duesund","updated_at":"2026-04-27T11:28:20Z","started_at":"2026-04-27T11:26:17Z","closed_at":"2026-04-27T11:28:20Z","close_reason":"0002_oauth_tables.sql shipped: clients/auth_codes/access_tokens/refresh_tokens with cascading FKs, indexes on hot-path columns, and an oauth_schema_version marker. PRAGMA-driven tests verify columns; FK cascade tested in both directions.","dependency_count":0,"dependent_count":1,"comment_count":0}
|
||||||
{"id":"forgejo-mcp-broker-8ei","title":"Phase 1: internal/httpserver with /healthz and graceful shutdown","description":"Implement internal/httpserver: constructs a *http.Server bound to cfg.Listen, mounts GET /healthz (returns 200 with JSON build-info from the build-info package, including version, git revision, build date, and current store status), handles SIGTERM/SIGINT by initiating graceful shutdown with a configurable deadline (default 10s). Uses log/slog for structured JSON logs. Exposes a Run(ctx) error method that blocks until shutdown completes.","acceptance_criteria":"go test ./internal/httpserver passes; GET /healthz returns expected JSON; sending SIGTERM causes Run to return nil within 2 seconds after in-flight requests complete; slow in-flight request is allowed to finish within the deadline, then forcibly closed.","status":"closed","priority":1,"issue_type":"task","assignee":"Ole-Morten Duesund","owner":"olemd@glemt.net","created_at":"2026-04-24T14:46:20Z","created_by":"Ole-Morten Duesund","updated_at":"2026-04-24T15:26:43Z","started_at":"2026-04-24T15:24:09Z","closed_at":"2026-04-24T15:26:43Z","close_reason":"httpserver shipped: /healthz with store probe, graceful shutdown with force-close fallback, ExtraHandler extension point. 97.9% coverage. internal/log also implemented in the same commit (100% coverage).","dependencies":[{"issue_id":"forgejo-mcp-broker-8ei","depends_on_id":"forgejo-mcp-broker-n84","type":"blocks","created_at":"2026-04-24T16:46:19Z","created_by":"Ole-Morten Duesund","metadata":"{}"}],"dependency_count":1,"dependent_count":1,"comment_count":0}
|
{"id":"forgejo-mcp-broker-8ei","title":"Phase 1: internal/httpserver with /healthz and graceful shutdown","description":"Implement internal/httpserver: constructs a *http.Server bound to cfg.Listen, mounts GET /healthz (returns 200 with JSON build-info from the build-info package, including version, git revision, build date, and current store status), handles SIGTERM/SIGINT by initiating graceful shutdown with a configurable deadline (default 10s). Uses log/slog for structured JSON logs. Exposes a Run(ctx) error method that blocks until shutdown completes.","acceptance_criteria":"go test ./internal/httpserver passes; GET /healthz returns expected JSON; sending SIGTERM causes Run to return nil within 2 seconds after in-flight requests complete; slow in-flight request is allowed to finish within the deadline, then forcibly closed.","status":"closed","priority":1,"issue_type":"task","assignee":"Ole-Morten Duesund","owner":"olemd@glemt.net","created_at":"2026-04-24T14:46:20Z","created_by":"Ole-Morten Duesund","updated_at":"2026-04-24T15:26:43Z","started_at":"2026-04-24T15:24:09Z","closed_at":"2026-04-24T15:26:43Z","close_reason":"httpserver shipped: /healthz with store probe, graceful shutdown with force-close fallback, ExtraHandler extension point. 97.9% coverage. internal/log also implemented in the same commit (100% coverage).","dependencies":[{"issue_id":"forgejo-mcp-broker-8ei","depends_on_id":"forgejo-mcp-broker-n84","type":"blocks","created_at":"2026-04-24T16:46:19Z","created_by":"Ole-Morten Duesund","metadata":"{}"}],"dependency_count":1,"dependent_count":1,"comment_count":0}
|
||||||
{"id":"forgejo-mcp-broker-t37","title":"Phase 1: wire cmd/broker/main.go and integration test","description":"Final phase 1 task: wire config → log → store → httpserver in cmd/broker/main.go. Parse config, init slog, open store, start httpserver, wait for shutdown signal, close store, exit. Add an integration test under cmd/broker/ that builds the binary, runs it with a valid env + temp store path, curls /healthz, sends SIGTERM, verifies clean exit within 2s. This is the acceptance gate for phase 1.","acceptance_criteria":"make build; make test (incl. integration) pass; running the binary with missing config fails with a clear error; running with valid config serves /healthz; SIGTERM shuts down cleanly within 2s; /healthz JSON includes version, git revision, build date, and store OK status.","status":"closed","priority":1,"issue_type":"task","assignee":"Ole-Morten Duesund","owner":"olemd@glemt.net","created_at":"2026-04-24T14:46:20Z","created_by":"Ole-Morten Duesund","updated_at":"2026-04-24T15:29:44Z","started_at":"2026-04-24T15:27:58Z","closed_at":"2026-04-24T15:29:44Z","close_reason":"Main wired, signal.NotifyContext triggers shutdown cascade, integration tests green. Phase 1 complete: binary starts with valid config, serves /healthz JSON, shuts down cleanly on SIGTERM within 2s.","dependencies":[{"issue_id":"forgejo-mcp-broker-t37","depends_on_id":"forgejo-mcp-broker-8ei","type":"blocks","created_at":"2026-04-24T16:48:29Z","created_by":"Ole-Morten Duesund","metadata":"{}"},{"issue_id":"forgejo-mcp-broker-t37","depends_on_id":"forgejo-mcp-broker-9jh","type":"blocks","created_at":"2026-04-24T16:48:29Z","created_by":"Ole-Morten Duesund","metadata":"{}"},{"issue_id":"forgejo-mcp-broker-t37","depends_on_id":"forgejo-mcp-broker-9nq","type":"blocks","created_at":"2026-04-24T16:48:28Z","created_by":"Ole-Morten Duesund","metadata":"{}"},{"issue_id":"forgejo-mcp-broker-t37","depends_on_id":"forgejo-mcp-broker-n84","type":"blocks","created_at":"2026-04-24T16:48:28Z","created_by":"Ole-Morten Duesund","metadata":"{}"}],"dependency_count":4,"dependent_count":0,"comment_count":0}
|
{"id":"forgejo-mcp-broker-t37","title":"Phase 1: wire cmd/broker/main.go and integration test","description":"Final phase 1 task: wire config → log → store → httpserver in cmd/broker/main.go. Parse config, init slog, open store, start httpserver, wait for shutdown signal, close store, exit. Add an integration test under cmd/broker/ that builds the binary, runs it with a valid env + temp store path, curls /healthz, sends SIGTERM, verifies clean exit within 2s. This is the acceptance gate for phase 1.","acceptance_criteria":"make build; make test (incl. integration) pass; running the binary with missing config fails with a clear error; running with valid config serves /healthz; SIGTERM shuts down cleanly within 2s; /healthz JSON includes version, git revision, build date, and store OK status.","status":"closed","priority":1,"issue_type":"task","assignee":"Ole-Morten Duesund","owner":"olemd@glemt.net","created_at":"2026-04-24T14:46:20Z","created_by":"Ole-Morten Duesund","updated_at":"2026-04-24T15:29:44Z","started_at":"2026-04-24T15:27:58Z","closed_at":"2026-04-24T15:29:44Z","close_reason":"Main wired, signal.NotifyContext triggers shutdown cascade, integration tests green. Phase 1 complete: binary starts with valid config, serves /healthz JSON, shuts down cleanly on SIGTERM within 2s.","dependencies":[{"issue_id":"forgejo-mcp-broker-t37","depends_on_id":"forgejo-mcp-broker-8ei","type":"blocks","created_at":"2026-04-24T16:48:29Z","created_by":"Ole-Morten Duesund","metadata":"{}"},{"issue_id":"forgejo-mcp-broker-t37","depends_on_id":"forgejo-mcp-broker-9jh","type":"blocks","created_at":"2026-04-24T16:48:29Z","created_by":"Ole-Morten Duesund","metadata":"{}"},{"issue_id":"forgejo-mcp-broker-t37","depends_on_id":"forgejo-mcp-broker-9nq","type":"blocks","created_at":"2026-04-24T16:48:28Z","created_by":"Ole-Morten Duesund","metadata":"{}"},{"issue_id":"forgejo-mcp-broker-t37","depends_on_id":"forgejo-mcp-broker-n84","type":"blocks","created_at":"2026-04-24T16:48:28Z","created_by":"Ole-Morten Duesund","metadata":"{}"}],"dependency_count":4,"dependent_count":0,"comment_count":0}
|
||||||
|
|
|
||||||
810
internal/oauth/oauth.go
Normal file
810
internal/oauth/oauth.go
Normal file
|
|
@ -0,0 +1,810 @@
|
||||||
|
// Package oauth implements the broker's OAuth 2.1 authorization server
|
||||||
|
// surface — what Claude.ai (and other MCP clients) talk to. User auth is
|
||||||
|
// delegated to upstream Forgejo via internal/forgejo.
|
||||||
|
//
|
||||||
|
// Endpoints (RFC numbers in parentheses):
|
||||||
|
// POST /oauth/register — RFC 7591 dynamic client registration
|
||||||
|
// GET /oauth/authorize — RFC 6749 / 7636 authorize with PKCE S256
|
||||||
|
// GET /oauth/callback — Forgejo redirects back here after user consent
|
||||||
|
// POST /oauth/token — authorization_code and refresh_token grants
|
||||||
|
// POST /oauth/revoke — RFC 7009 token revocation
|
||||||
|
//
|
||||||
|
// PKCE: required, S256 only. Plain method is rejected (this is OAuth 2.1).
|
||||||
|
//
|
||||||
|
// Token storage: every broker-issued access/refresh token is stored as a
|
||||||
|
// hex-encoded SHA-256 hash. The plaintext leaves the broker exactly once —
|
||||||
|
// in the body of the /oauth/token response.
|
||||||
|
//
|
||||||
|
// Pending authorizations (between /authorize and /callback) live in memory
|
||||||
|
// with a short TTL. A broker restart drops them, forcing the user to
|
||||||
|
// re-authorize; that's an acceptable UX hit for not adding another table.
|
||||||
|
package oauth
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"crypto/rand"
|
||||||
|
"crypto/sha256"
|
||||||
|
"crypto/subtle"
|
||||||
|
"database/sql"
|
||||||
|
"encoding/base64"
|
||||||
|
"encoding/hex"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"log/slog"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"kode.naiv.no/olemd/forgejo-mcp-broker/internal/forgejo"
|
||||||
|
"kode.naiv.no/olemd/forgejo-mcp-broker/internal/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
// AuthCodeTTL bounds how long a broker-issued authorization code stays
|
||||||
|
// usable. RFC 6749 §10.5 recommends "very short" — 10 minutes matches what
|
||||||
|
// most ASes use.
|
||||||
|
const AuthCodeTTL = 10 * time.Minute
|
||||||
|
|
||||||
|
// PendingAuthTTL caps how long an in-flight /authorize → /callback flow can
|
||||||
|
// take. Anything longer is almost certainly an abandoned attempt.
|
||||||
|
const PendingAuthTTL = 10 * time.Minute
|
||||||
|
|
||||||
|
// AccessTokenTTL is the lifetime of broker access tokens. Refresh tokens
|
||||||
|
// last considerably longer (see RefreshTokenTTL).
|
||||||
|
const AccessTokenTTL = 1 * time.Hour
|
||||||
|
|
||||||
|
// RefreshTokenTTL is the lifetime of broker refresh tokens. 30 days lets a
|
||||||
|
// daily-active user stay logged in indefinitely while bounded enough that
|
||||||
|
// theft via stale backup is time-limited.
|
||||||
|
const RefreshTokenTTL = 30 * 24 * time.Hour
|
||||||
|
|
||||||
|
// Server is the OAuth authorization server. Construct one with NewServer
|
||||||
|
// and mount its Handler under the broker's HTTP mux.
|
||||||
|
type Server struct {
|
||||||
|
store *store.Store
|
||||||
|
forgejo *forgejo.Client
|
||||||
|
issuer string // public URL, e.g. https://mcp.example.com — never derived from headers
|
||||||
|
scopes string // space-separated scope set requested from upstream Forgejo
|
||||||
|
now func() time.Time
|
||||||
|
log *slog.Logger
|
||||||
|
mu sync.Mutex // guards pendingAuths cleanup; map ops use sync.Map natively
|
||||||
|
pending sync.Map // forgejoState string → *pendingAuth
|
||||||
|
stopCh chan struct{}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Config bundles Server dependencies.
|
||||||
|
type Config struct {
|
||||||
|
Store *store.Store
|
||||||
|
Forgejo *forgejo.Client
|
||||||
|
Issuer string // required; e.g. https://mcp.example.com
|
||||||
|
Scopes string // optional; space-separated; defaults to ""
|
||||||
|
Now func() time.Time
|
||||||
|
Log *slog.Logger
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewServer validates the config and starts the periodic pending-auth
|
||||||
|
// reaper. Stop the reaper with Server.Close.
|
||||||
|
func NewServer(cfg Config) (*Server, error) {
|
||||||
|
if cfg.Store == nil {
|
||||||
|
return nil, errors.New("oauth: Store is required")
|
||||||
|
}
|
||||||
|
if cfg.Forgejo == nil {
|
||||||
|
return nil, errors.New("oauth: Forgejo client is required")
|
||||||
|
}
|
||||||
|
if cfg.Issuer == "" {
|
||||||
|
return nil, errors.New("oauth: Issuer is required")
|
||||||
|
}
|
||||||
|
now := cfg.Now
|
||||||
|
if now == nil {
|
||||||
|
now = time.Now
|
||||||
|
}
|
||||||
|
logger := cfg.Log
|
||||||
|
if logger == nil {
|
||||||
|
logger = slog.New(slog.DiscardHandler)
|
||||||
|
}
|
||||||
|
s := &Server{
|
||||||
|
store: cfg.Store,
|
||||||
|
forgejo: cfg.Forgejo,
|
||||||
|
issuer: strings.TrimRight(cfg.Issuer, "/"),
|
||||||
|
scopes: cfg.Scopes,
|
||||||
|
now: now,
|
||||||
|
log: logger,
|
||||||
|
stopCh: make(chan struct{}),
|
||||||
|
}
|
||||||
|
go s.reapPendingLoop()
|
||||||
|
return s, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close stops the pending-auth reaper. Safe to call multiple times.
|
||||||
|
func (s *Server) Close() {
|
||||||
|
select {
|
||||||
|
case <-s.stopCh:
|
||||||
|
// already closed
|
||||||
|
default:
|
||||||
|
close(s.stopCh)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handler returns the http.Handler exposing all five OAuth endpoints.
|
||||||
|
func (s *Server) Handler() http.Handler {
|
||||||
|
mux := http.NewServeMux()
|
||||||
|
mux.HandleFunc("POST /oauth/register", s.handleRegister)
|
||||||
|
mux.HandleFunc("GET /oauth/authorize", s.handleAuthorize)
|
||||||
|
mux.HandleFunc("GET /oauth/callback", s.handleCallback)
|
||||||
|
mux.HandleFunc("POST /oauth/token", s.handleToken)
|
||||||
|
mux.HandleFunc("POST /oauth/revoke", s.handleRevoke)
|
||||||
|
return mux
|
||||||
|
}
|
||||||
|
|
||||||
|
// pendingAuth is the in-memory record of a /authorize → /callback flow.
|
||||||
|
type pendingAuth struct {
|
||||||
|
clientID string
|
||||||
|
redirectURI string
|
||||||
|
codeChallenge string
|
||||||
|
codeChallengeMethod string
|
||||||
|
scopes string
|
||||||
|
clientState string
|
||||||
|
expiresAt time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Helpers — token generation, hashing, OAuth error responses
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
// secureToken returns a hex-encoded cryptographically-random string of the
|
||||||
|
// given byte length. 32 bytes ⇒ 256 bits of entropy ⇒ 64 hex chars.
|
||||||
|
func secureToken(nBytes int) string {
|
||||||
|
b := make([]byte, nBytes)
|
||||||
|
if _, err := rand.Read(b); err != nil {
|
||||||
|
// crypto/rand failing is a system-level emergency. Panicking here is
|
||||||
|
// the right move — operating without entropy is worse than crashing.
|
||||||
|
panic("oauth: crypto/rand failed: " + err.Error())
|
||||||
|
}
|
||||||
|
return hex.EncodeToString(b)
|
||||||
|
}
|
||||||
|
|
||||||
|
// hashToken returns the hex-encoded SHA-256 of the given token. Used at the
|
||||||
|
// store boundary so plaintext tokens never persist.
|
||||||
|
func hashToken(token string) string {
|
||||||
|
sum := sha256.Sum256([]byte(token))
|
||||||
|
return hex.EncodeToString(sum[:])
|
||||||
|
}
|
||||||
|
|
||||||
|
// verifyPKCE returns true iff base64url(sha256(verifier)) equals challenge.
|
||||||
|
// Constant-time comparison prevents timing leaks on the verifier.
|
||||||
|
func verifyPKCE(verifier, challenge string) bool {
|
||||||
|
sum := sha256.Sum256([]byte(verifier))
|
||||||
|
got := base64.RawURLEncoding.EncodeToString(sum[:])
|
||||||
|
return subtle.ConstantTimeCompare([]byte(got), []byte(challenge)) == 1
|
||||||
|
}
|
||||||
|
|
||||||
|
// writeJSON writes obj as JSON with the given status. Errors are logged but
|
||||||
|
// not surfaced — by the time encoding could fail, headers are already out.
|
||||||
|
func writeJSON(w http.ResponseWriter, status int, obj any) {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.Header().Set("Cache-Control", "no-store")
|
||||||
|
w.WriteHeader(status)
|
||||||
|
_ = json.NewEncoder(w).Encode(obj)
|
||||||
|
}
|
||||||
|
|
||||||
|
// writeOAuthError renders an RFC 6749 §5.2 error response.
|
||||||
|
func writeOAuthError(w http.ResponseWriter, status int, code, description string) {
|
||||||
|
writeJSON(w, status, map[string]string{
|
||||||
|
"error": code,
|
||||||
|
"error_description": description,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Pending-auth reaper
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
func (s *Server) reapPendingLoop() {
|
||||||
|
t := time.NewTicker(time.Minute)
|
||||||
|
defer t.Stop()
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-s.stopCh:
|
||||||
|
return
|
||||||
|
case <-t.C:
|
||||||
|
s.reapPending()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) reapPending() {
|
||||||
|
now := s.now()
|
||||||
|
s.pending.Range(func(k, v any) bool {
|
||||||
|
if pa, ok := v.(*pendingAuth); ok && now.After(pa.expiresAt) {
|
||||||
|
s.pending.Delete(k)
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// /oauth/register — RFC 7591 dynamic client registration
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
type registerRequest struct {
|
||||||
|
RedirectURIs []string `json:"redirect_uris"`
|
||||||
|
ClientName string `json:"client_name,omitempty"`
|
||||||
|
TokenEndpointAuthMethod string `json:"token_endpoint_auth_method,omitempty"`
|
||||||
|
GrantTypes []string `json:"grant_types,omitempty"`
|
||||||
|
ResponseTypes []string `json:"response_types,omitempty"`
|
||||||
|
Scope string `json:"scope,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type registerResponse struct {
|
||||||
|
ClientID string `json:"client_id"`
|
||||||
|
ClientIDIssuedAt int64 `json:"client_id_issued_at"`
|
||||||
|
RedirectURIs []string `json:"redirect_uris"`
|
||||||
|
ClientName string `json:"client_name,omitempty"`
|
||||||
|
TokenEndpointAuthMethod string `json:"token_endpoint_auth_method"`
|
||||||
|
GrantTypes []string `json:"grant_types"`
|
||||||
|
ResponseTypes []string `json:"response_types"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleRegister(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var req registerRequest
|
||||||
|
dec := json.NewDecoder(r.Body)
|
||||||
|
dec.DisallowUnknownFields()
|
||||||
|
if err := dec.Decode(&req); err != nil {
|
||||||
|
writeOAuthError(w, http.StatusBadRequest, "invalid_client_metadata",
|
||||||
|
"could not parse JSON: "+err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if len(req.RedirectURIs) == 0 {
|
||||||
|
writeOAuthError(w, http.StatusBadRequest, "invalid_redirect_uri",
|
||||||
|
"at least one redirect_uri is required")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
for _, ru := range req.RedirectURIs {
|
||||||
|
if err := validateRedirectURI(ru); err != nil {
|
||||||
|
writeOAuthError(w, http.StatusBadRequest, "invalid_redirect_uri", err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
clientID := secureToken(16)
|
||||||
|
now := s.now().Unix()
|
||||||
|
|
||||||
|
uris, _ := json.Marshal(req.RedirectURIs)
|
||||||
|
meta, _ := json.Marshal(req)
|
||||||
|
|
||||||
|
if _, err := s.store.DB().ExecContext(r.Context(),
|
||||||
|
`INSERT INTO clients (client_id, redirect_uris, metadata_json, created_at)
|
||||||
|
VALUES (?, ?, ?, ?)`,
|
||||||
|
clientID, string(uris), string(meta), now,
|
||||||
|
); err != nil {
|
||||||
|
s.log.Error("register: insert client", slog.String("err", err.Error()))
|
||||||
|
writeOAuthError(w, http.StatusInternalServerError, "server_error", "client registration failed")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
writeJSON(w, http.StatusCreated, registerResponse{
|
||||||
|
ClientID: clientID,
|
||||||
|
ClientIDIssuedAt: now,
|
||||||
|
RedirectURIs: req.RedirectURIs,
|
||||||
|
ClientName: req.ClientName,
|
||||||
|
TokenEndpointAuthMethod: "none", // PKCE-only public clients
|
||||||
|
GrantTypes: []string{"authorization_code", "refresh_token"},
|
||||||
|
ResponseTypes: []string{"code"},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func validateRedirectURI(raw string) error {
|
||||||
|
u, err := url.Parse(raw)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("redirect_uri %q: %w", raw, err)
|
||||||
|
}
|
||||||
|
if u.Scheme == "" {
|
||||||
|
return fmt.Errorf("redirect_uri %q: missing scheme", raw)
|
||||||
|
}
|
||||||
|
// RFC 6749 §3.1.2.1 requires absolute URIs; we additionally require
|
||||||
|
// http/https or claude.ai's documented custom scheme. Accept anything
|
||||||
|
// non-empty for now; tighten later if needed.
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// /oauth/authorize — RFC 6749 + RFC 7636 PKCE
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
func (s *Server) handleAuthorize(w http.ResponseWriter, r *http.Request) {
|
||||||
|
q := r.URL.Query()
|
||||||
|
|
||||||
|
clientID := q.Get("client_id")
|
||||||
|
redirectURI := q.Get("redirect_uri")
|
||||||
|
responseType := q.Get("response_type")
|
||||||
|
clientState := q.Get("state")
|
||||||
|
codeChallenge := q.Get("code_challenge")
|
||||||
|
codeChallengeMethod := q.Get("code_challenge_method")
|
||||||
|
scope := q.Get("scope")
|
||||||
|
|
||||||
|
// Errors that come BEFORE we know a valid redirect_uri: respond
|
||||||
|
// directly. RFC 6749 §3.1.2.4 — if redirect_uri is invalid, do not
|
||||||
|
// redirect; render an error instead.
|
||||||
|
|
||||||
|
if clientID == "" {
|
||||||
|
writeOAuthError(w, http.StatusBadRequest, "invalid_request", "client_id is required")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
registered, err := s.lookupClientRedirectURIs(r.Context(), clientID)
|
||||||
|
if err != nil {
|
||||||
|
writeOAuthError(w, http.StatusBadRequest, "invalid_client", "unknown client_id")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if redirectURI == "" {
|
||||||
|
writeOAuthError(w, http.StatusBadRequest, "invalid_request", "redirect_uri is required")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !redirectURIMatches(registered, redirectURI) {
|
||||||
|
writeOAuthError(w, http.StatusBadRequest, "invalid_request",
|
||||||
|
"redirect_uri does not match any registered URI")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// From here on, errors can be returned via redirect to the client
|
||||||
|
// (per RFC 6749 §4.1.2.1). We use that path to surface PKCE/scope
|
||||||
|
// problems back to the calling app.
|
||||||
|
|
||||||
|
if responseType != "code" {
|
||||||
|
redirectAuthError(w, r, redirectURI, clientState, "unsupported_response_type",
|
||||||
|
"only response_type=code is supported")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if codeChallenge == "" {
|
||||||
|
redirectAuthError(w, r, redirectURI, clientState, "invalid_request",
|
||||||
|
"PKCE is required: code_challenge missing")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if codeChallengeMethod != "S256" {
|
||||||
|
redirectAuthError(w, r, redirectURI, clientState, "invalid_request",
|
||||||
|
"only code_challenge_method=S256 is supported")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stash the in-flight authorization. forgejoState is the value we'll
|
||||||
|
// pass to Forgejo and read back from /callback.
|
||||||
|
forgejoState := secureToken(24)
|
||||||
|
s.pending.Store(forgejoState, &pendingAuth{
|
||||||
|
clientID: clientID,
|
||||||
|
redirectURI: redirectURI,
|
||||||
|
codeChallenge: codeChallenge,
|
||||||
|
codeChallengeMethod: codeChallengeMethod,
|
||||||
|
scopes: scope,
|
||||||
|
clientState: clientState,
|
||||||
|
expiresAt: s.now().Add(PendingAuthTTL),
|
||||||
|
})
|
||||||
|
|
||||||
|
// Redirect the user-agent to Forgejo. Forgejo asks the user to consent;
|
||||||
|
// on success it'll redirect back to our /oauth/callback with code+state.
|
||||||
|
upstream := s.forgejo.AuthorizeURL(forgejo.AuthorizeURLOptions{
|
||||||
|
RedirectURI: s.issuer + "/oauth/callback",
|
||||||
|
State: forgejoState,
|
||||||
|
Scopes: s.scopes,
|
||||||
|
CodeChallenge: "", // we don't pass our PKCE through; the broker is
|
||||||
|
CodeChallengeMethod: "", // a confidential OAuth client of Forgejo.
|
||||||
|
})
|
||||||
|
http.Redirect(w, r, upstream, http.StatusFound)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) lookupClientRedirectURIs(ctx context.Context, clientID string) ([]string, error) {
|
||||||
|
var raw string
|
||||||
|
row := s.store.DB().QueryRowContext(ctx,
|
||||||
|
`SELECT redirect_uris FROM clients WHERE client_id = ?`, clientID)
|
||||||
|
if err := row.Scan(&raw); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
var uris []string
|
||||||
|
if err := json.Unmarshal([]byte(raw), &uris); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return uris, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func redirectURIMatches(registered []string, candidate string) bool {
|
||||||
|
for _, r := range registered {
|
||||||
|
if r == candidate {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// redirectAuthError sends the user-agent back to the client's redirect_uri
|
||||||
|
// with error=... and state=... in the query string, per RFC 6749 §4.1.2.1.
|
||||||
|
func redirectAuthError(w http.ResponseWriter, r *http.Request, redirectURI, state, code, description string) {
|
||||||
|
u, err := url.Parse(redirectURI)
|
||||||
|
if err != nil {
|
||||||
|
// Should be unreachable since redirectURIMatches already verified
|
||||||
|
// it parses — but be safe.
|
||||||
|
writeOAuthError(w, http.StatusBadRequest, code, description)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
q := u.Query()
|
||||||
|
q.Set("error", code)
|
||||||
|
if description != "" {
|
||||||
|
q.Set("error_description", description)
|
||||||
|
}
|
||||||
|
if state != "" {
|
||||||
|
q.Set("state", state)
|
||||||
|
}
|
||||||
|
u.RawQuery = q.Encode()
|
||||||
|
http.Redirect(w, r, u.String(), http.StatusFound)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// /oauth/callback — Forgejo redirects here after user consent
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
func (s *Server) handleCallback(w http.ResponseWriter, r *http.Request) {
|
||||||
|
q := r.URL.Query()
|
||||||
|
state := q.Get("state")
|
||||||
|
upstreamCode := q.Get("code")
|
||||||
|
upstreamErr := q.Get("error")
|
||||||
|
|
||||||
|
v, ok := s.pending.LoadAndDelete(state)
|
||||||
|
if !ok {
|
||||||
|
http.Error(w, "unknown or expired state; please re-authorize", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
pa := v.(*pendingAuth)
|
||||||
|
if s.now().After(pa.expiresAt) {
|
||||||
|
http.Error(w, "authorization expired; please re-authorize", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if upstreamErr != "" {
|
||||||
|
desc := q.Get("error_description")
|
||||||
|
redirectAuthError(w, r, pa.redirectURI, pa.clientState, upstreamErr, desc)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if upstreamCode == "" {
|
||||||
|
redirectAuthError(w, r, pa.redirectURI, pa.clientState, "server_error",
|
||||||
|
"upstream returned no code")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
tok, err := s.forgejo.ExchangeCode(r.Context(), upstreamCode, "" /* no PKCE w/ Forgejo */, s.issuer+"/oauth/callback")
|
||||||
|
if err != nil {
|
||||||
|
s.log.Error("callback: forgejo exchange", slog.String("err", err.Error()))
|
||||||
|
redirectAuthError(w, r, pa.redirectURI, pa.clientState, "server_error",
|
||||||
|
"upstream code exchange failed")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ui, err := s.forgejo.FetchUserInfo(r.Context(), tok.AccessToken)
|
||||||
|
if err != nil {
|
||||||
|
s.log.Error("callback: fetch userinfo", slog.String("err", err.Error()))
|
||||||
|
redirectAuthError(w, r, pa.redirectURI, pa.clientState, "server_error",
|
||||||
|
"upstream userinfo failed")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
brokerCode := secureToken(32)
|
||||||
|
now := s.now().Unix()
|
||||||
|
|
||||||
|
if _, err := s.store.DB().ExecContext(r.Context(),
|
||||||
|
`INSERT INTO auth_codes
|
||||||
|
(code, client_id, redirect_uri, code_challenge, code_challenge_method,
|
||||||
|
scopes, forgejo_access_token, forgejo_refresh_token, forgejo_token_expires_at,
|
||||||
|
forgejo_user_id, forgejo_username, expires_at)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||||
|
brokerCode, pa.clientID, pa.redirectURI, pa.codeChallenge, pa.codeChallengeMethod,
|
||||||
|
pa.scopes, tok.AccessToken, tok.RefreshToken,
|
||||||
|
s.now().Add(time.Duration(tok.ExpiresIn)*time.Second).Unix(),
|
||||||
|
userIDInt64(ui.Sub), ui.PreferredUsername,
|
||||||
|
s.now().Add(AuthCodeTTL).Unix(),
|
||||||
|
); err != nil {
|
||||||
|
s.log.Error("callback: insert auth_code", slog.String("err", err.Error()))
|
||||||
|
redirectAuthError(w, r, pa.redirectURI, pa.clientState, "server_error",
|
||||||
|
"failed to persist authorization code")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
_ = now
|
||||||
|
|
||||||
|
// Redirect back to the client with our code.
|
||||||
|
u, _ := url.Parse(pa.redirectURI)
|
||||||
|
rq := u.Query()
|
||||||
|
rq.Set("code", brokerCode)
|
||||||
|
if pa.clientState != "" {
|
||||||
|
rq.Set("state", pa.clientState)
|
||||||
|
}
|
||||||
|
u.RawQuery = rq.Encode()
|
||||||
|
http.Redirect(w, r, u.String(), http.StatusFound)
|
||||||
|
}
|
||||||
|
|
||||||
|
// userIDInt64 best-effort converts a string user-id (Forgejo OIDC `sub`) to
|
||||||
|
// an int64. Returns 0 if it can't parse — the username column is the
|
||||||
|
// reliable identity carrier; user_id is for log correlation.
|
||||||
|
func userIDInt64(sub string) int64 {
|
||||||
|
var n int64
|
||||||
|
for _, c := range sub {
|
||||||
|
if c < '0' || c > '9' {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
n = n*10 + int64(c-'0')
|
||||||
|
}
|
||||||
|
return n
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// /oauth/token — authorization_code and refresh_token grants
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
type tokenResponse struct {
|
||||||
|
AccessToken string `json:"access_token"`
|
||||||
|
TokenType string `json:"token_type"`
|
||||||
|
ExpiresIn int `json:"expires_in"`
|
||||||
|
RefreshToken string `json:"refresh_token,omitempty"`
|
||||||
|
Scope string `json:"scope,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleToken(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if err := r.ParseForm(); err != nil {
|
||||||
|
writeOAuthError(w, http.StatusBadRequest, "invalid_request", "could not parse form")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
switch r.PostForm.Get("grant_type") {
|
||||||
|
case "authorization_code":
|
||||||
|
s.tokenAuthCodeGrant(w, r)
|
||||||
|
case "refresh_token":
|
||||||
|
s.tokenRefreshGrant(w, r)
|
||||||
|
default:
|
||||||
|
writeOAuthError(w, http.StatusBadRequest, "unsupported_grant_type",
|
||||||
|
"only authorization_code and refresh_token are supported")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) tokenAuthCodeGrant(w http.ResponseWriter, r *http.Request) {
|
||||||
|
code := r.PostForm.Get("code")
|
||||||
|
clientID := r.PostForm.Get("client_id")
|
||||||
|
redirectURI := r.PostForm.Get("redirect_uri")
|
||||||
|
codeVerifier := r.PostForm.Get("code_verifier")
|
||||||
|
|
||||||
|
if code == "" || clientID == "" || redirectURI == "" || codeVerifier == "" {
|
||||||
|
writeOAuthError(w, http.StatusBadRequest, "invalid_request",
|
||||||
|
"code, client_id, redirect_uri, and code_verifier are required")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Look up the auth code and lock it via UPDATE ... used_at single-shot.
|
||||||
|
row := s.store.DB().QueryRowContext(r.Context(),
|
||||||
|
`SELECT client_id, redirect_uri, code_challenge, code_challenge_method,
|
||||||
|
scopes, forgejo_access_token, forgejo_refresh_token, forgejo_token_expires_at,
|
||||||
|
forgejo_user_id, forgejo_username, expires_at, used_at
|
||||||
|
FROM auth_codes WHERE code = ?`, code)
|
||||||
|
var (
|
||||||
|
storedClientID, storedRedirectURI, storedChallenge, storedMethod, storedScopes string
|
||||||
|
fjAccess, fjRefresh, fjUsername string
|
||||||
|
fjUserID int64
|
||||||
|
fjExpiresAt, expiresAt int64
|
||||||
|
usedAt sql.NullInt64
|
||||||
|
)
|
||||||
|
if err := row.Scan(&storedClientID, &storedRedirectURI, &storedChallenge, &storedMethod,
|
||||||
|
&storedScopes, &fjAccess, &fjRefresh, &fjExpiresAt, &fjUserID, &fjUsername,
|
||||||
|
&expiresAt, &usedAt); err != nil {
|
||||||
|
writeOAuthError(w, http.StatusBadRequest, "invalid_grant", "code not found")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if usedAt.Valid {
|
||||||
|
writeOAuthError(w, http.StatusBadRequest, "invalid_grant", "code already used")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if s.now().Unix() > expiresAt {
|
||||||
|
writeOAuthError(w, http.StatusBadRequest, "invalid_grant", "code expired")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if storedClientID != clientID {
|
||||||
|
writeOAuthError(w, http.StatusBadRequest, "invalid_grant", "client_id mismatch")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if storedRedirectURI != redirectURI {
|
||||||
|
writeOAuthError(w, http.StatusBadRequest, "invalid_grant", "redirect_uri mismatch")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if storedMethod != "S256" || !verifyPKCE(codeVerifier, storedChallenge) {
|
||||||
|
writeOAuthError(w, http.StatusBadRequest, "invalid_grant", "PKCE verification failed")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Atomically mark the code used. The WHERE clause re-checks used_at IS
|
||||||
|
// NULL so two concurrent /token requests with the same code can't both
|
||||||
|
// succeed (only one UPDATE will affect a row).
|
||||||
|
res, err := s.store.DB().ExecContext(r.Context(),
|
||||||
|
`UPDATE auth_codes SET used_at = ? WHERE code = ? AND used_at IS NULL`,
|
||||||
|
s.now().Unix(), code)
|
||||||
|
if err != nil {
|
||||||
|
s.log.Error("token: mark code used", slog.String("err", err.Error()))
|
||||||
|
writeOAuthError(w, http.StatusInternalServerError, "server_error", "")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if n, _ := res.RowsAffected(); n != 1 {
|
||||||
|
writeOAuthError(w, http.StatusBadRequest, "invalid_grant", "code already used")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mint broker access + refresh tokens.
|
||||||
|
accessToken := secureToken(32)
|
||||||
|
refreshToken := secureToken(32)
|
||||||
|
now := s.now().Unix()
|
||||||
|
|
||||||
|
tx, err := s.store.DB().BeginTx(r.Context(), nil)
|
||||||
|
if err != nil {
|
||||||
|
writeOAuthError(w, http.StatusInternalServerError, "server_error", "")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if _, err := tx.ExecContext(r.Context(),
|
||||||
|
`INSERT INTO access_tokens
|
||||||
|
(token_hash, client_id, forgejo_user_id, forgejo_username, scopes,
|
||||||
|
forgejo_access_token, forgejo_refresh_token, forgejo_token_expires_at,
|
||||||
|
expires_at, created_at)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||||
|
hashToken(accessToken), clientID, fjUserID, fjUsername, storedScopes,
|
||||||
|
fjAccess, fjRefresh, fjExpiresAt,
|
||||||
|
s.now().Add(AccessTokenTTL).Unix(), now); err != nil {
|
||||||
|
_ = tx.Rollback()
|
||||||
|
s.log.Error("token: insert access_token", slog.String("err", err.Error()))
|
||||||
|
writeOAuthError(w, http.StatusInternalServerError, "server_error", "")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if _, err := tx.ExecContext(r.Context(),
|
||||||
|
`INSERT INTO refresh_tokens
|
||||||
|
(token_hash, access_token_hash, client_id, expires_at, created_at)
|
||||||
|
VALUES (?, ?, ?, ?, ?)`,
|
||||||
|
hashToken(refreshToken), hashToken(accessToken), clientID,
|
||||||
|
s.now().Add(RefreshTokenTTL).Unix(), now); err != nil {
|
||||||
|
_ = tx.Rollback()
|
||||||
|
s.log.Error("token: insert refresh_token", slog.String("err", err.Error()))
|
||||||
|
writeOAuthError(w, http.StatusInternalServerError, "server_error", "")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := tx.Commit(); err != nil {
|
||||||
|
writeOAuthError(w, http.StatusInternalServerError, "server_error", "")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
writeJSON(w, http.StatusOK, tokenResponse{
|
||||||
|
AccessToken: accessToken,
|
||||||
|
TokenType: "Bearer",
|
||||||
|
ExpiresIn: int(AccessTokenTTL.Seconds()),
|
||||||
|
RefreshToken: refreshToken,
|
||||||
|
Scope: storedScopes,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) tokenRefreshGrant(w http.ResponseWriter, r *http.Request) {
|
||||||
|
refreshToken := r.PostForm.Get("refresh_token")
|
||||||
|
clientID := r.PostForm.Get("client_id")
|
||||||
|
if refreshToken == "" || clientID == "" {
|
||||||
|
writeOAuthError(w, http.StatusBadRequest, "invalid_request",
|
||||||
|
"refresh_token and client_id are required")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
rtHash := hashToken(refreshToken)
|
||||||
|
row := s.store.DB().QueryRowContext(r.Context(),
|
||||||
|
`SELECT rt.access_token_hash, rt.client_id, rt.expires_at, rt.revoked_at,
|
||||||
|
at.forgejo_user_id, at.forgejo_username, at.scopes,
|
||||||
|
at.forgejo_access_token, at.forgejo_refresh_token, at.forgejo_token_expires_at
|
||||||
|
FROM refresh_tokens rt
|
||||||
|
JOIN access_tokens at ON at.token_hash = rt.access_token_hash
|
||||||
|
WHERE rt.token_hash = ?`, rtHash)
|
||||||
|
var (
|
||||||
|
oldAccessHash, storedClientID, fjUsername, scopes string
|
||||||
|
fjAccess, fjRefresh string
|
||||||
|
fjUserID int64
|
||||||
|
expiresAt, fjExpiresAt int64
|
||||||
|
revokedAt sql.NullInt64
|
||||||
|
)
|
||||||
|
if err := row.Scan(&oldAccessHash, &storedClientID, &expiresAt, &revokedAt,
|
||||||
|
&fjUserID, &fjUsername, &scopes, &fjAccess, &fjRefresh, &fjExpiresAt); err != nil {
|
||||||
|
writeOAuthError(w, http.StatusBadRequest, "invalid_grant", "refresh token not found")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if revokedAt.Valid {
|
||||||
|
writeOAuthError(w, http.StatusBadRequest, "invalid_grant", "refresh token revoked")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if s.now().Unix() > expiresAt {
|
||||||
|
writeOAuthError(w, http.StatusBadRequest, "invalid_grant", "refresh token expired")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if storedClientID != clientID {
|
||||||
|
writeOAuthError(w, http.StatusBadRequest, "invalid_grant", "client_id mismatch")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mint a new access token. Refresh-token rotation: also issue a new
|
||||||
|
// refresh token and revoke the old one.
|
||||||
|
newAccess := secureToken(32)
|
||||||
|
newRefresh := secureToken(32)
|
||||||
|
now := s.now().Unix()
|
||||||
|
|
||||||
|
tx, err := s.store.DB().BeginTx(r.Context(), nil)
|
||||||
|
if err != nil {
|
||||||
|
writeOAuthError(w, http.StatusInternalServerError, "server_error", "")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if _, err := tx.ExecContext(r.Context(),
|
||||||
|
`INSERT INTO access_tokens
|
||||||
|
(token_hash, client_id, forgejo_user_id, forgejo_username, scopes,
|
||||||
|
forgejo_access_token, forgejo_refresh_token, forgejo_token_expires_at,
|
||||||
|
expires_at, created_at)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||||
|
hashToken(newAccess), clientID, fjUserID, fjUsername, scopes,
|
||||||
|
fjAccess, fjRefresh, fjExpiresAt,
|
||||||
|
s.now().Add(AccessTokenTTL).Unix(), now); err != nil {
|
||||||
|
_ = tx.Rollback()
|
||||||
|
writeOAuthError(w, http.StatusInternalServerError, "server_error", "")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if _, err := tx.ExecContext(r.Context(),
|
||||||
|
`INSERT INTO refresh_tokens
|
||||||
|
(token_hash, access_token_hash, client_id, expires_at, created_at)
|
||||||
|
VALUES (?, ?, ?, ?, ?)`,
|
||||||
|
hashToken(newRefresh), hashToken(newAccess), clientID,
|
||||||
|
s.now().Add(RefreshTokenTTL).Unix(), now); err != nil {
|
||||||
|
_ = tx.Rollback()
|
||||||
|
writeOAuthError(w, http.StatusInternalServerError, "server_error", "")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Revoke the old refresh token (rotation per RFC 6749 §10.4).
|
||||||
|
if _, err := tx.ExecContext(r.Context(),
|
||||||
|
`UPDATE refresh_tokens SET revoked_at = ? WHERE token_hash = ?`,
|
||||||
|
now, rtHash); err != nil {
|
||||||
|
_ = tx.Rollback()
|
||||||
|
writeOAuthError(w, http.StatusInternalServerError, "server_error", "")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := tx.Commit(); err != nil {
|
||||||
|
writeOAuthError(w, http.StatusInternalServerError, "server_error", "")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
writeJSON(w, http.StatusOK, tokenResponse{
|
||||||
|
AccessToken: newAccess,
|
||||||
|
TokenType: "Bearer",
|
||||||
|
ExpiresIn: int(AccessTokenTTL.Seconds()),
|
||||||
|
RefreshToken: newRefresh,
|
||||||
|
Scope: scopes,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// /oauth/revoke — RFC 7009
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
func (s *Server) handleRevoke(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if err := r.ParseForm(); err != nil {
|
||||||
|
writeOAuthError(w, http.StatusBadRequest, "invalid_request", "could not parse form")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
tokenStr := r.PostForm.Get("token")
|
||||||
|
hint := r.PostForm.Get("token_type_hint") // "access_token" or "refresh_token"
|
||||||
|
if tokenStr == "" {
|
||||||
|
writeOAuthError(w, http.StatusBadRequest, "invalid_request", "token is required")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
hash := hashToken(tokenStr)
|
||||||
|
now := s.now().Unix()
|
||||||
|
|
||||||
|
// Try the hinted table first; fall back to the other. RFC 7009 says
|
||||||
|
// invalid tokens still get a 200 — clients shouldn't probe.
|
||||||
|
if hint != "refresh_token" {
|
||||||
|
_, _ = s.store.DB().ExecContext(r.Context(),
|
||||||
|
`UPDATE access_tokens SET revoked_at = ? WHERE token_hash = ? AND revoked_at IS NULL`,
|
||||||
|
now, hash)
|
||||||
|
}
|
||||||
|
if hint != "access_token" {
|
||||||
|
_, _ = s.store.DB().ExecContext(r.Context(),
|
||||||
|
`UPDATE refresh_tokens SET revoked_at = ? WHERE token_hash = ? AND revoked_at IS NULL`,
|
||||||
|
now, hash)
|
||||||
|
}
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
}
|
||||||
107
internal/oauth/oauth_internal_test.go
Normal file
107
internal/oauth/oauth_internal_test.go
Normal file
|
|
@ -0,0 +1,107 @@
|
||||||
|
package oauth
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/base64"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// reapPending only runs every minute via the background ticker, so
|
||||||
|
// production tests can't drive it within a reasonable timeout. This
|
||||||
|
// internal test exercises the reaper logic directly.
|
||||||
|
func TestReapPending_RemovesExpired(t *testing.T) {
|
||||||
|
now := time.Date(2026, 4, 27, 12, 0, 0, 0, time.UTC)
|
||||||
|
s := &Server{now: func() time.Time { return now }}
|
||||||
|
|
||||||
|
s.pending.Store("expired-1", &pendingAuth{expiresAt: now.Add(-time.Minute)})
|
||||||
|
s.pending.Store("expired-2", &pendingAuth{expiresAt: now.Add(-time.Hour)})
|
||||||
|
s.pending.Store("fresh", &pendingAuth{expiresAt: now.Add(time.Hour)})
|
||||||
|
|
||||||
|
s.reapPending()
|
||||||
|
|
||||||
|
for _, key := range []string{"expired-1", "expired-2"} {
|
||||||
|
if _, ok := s.pending.Load(key); ok {
|
||||||
|
t.Errorf("%q should have been reaped", key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if _, ok := s.pending.Load("fresh"); !ok {
|
||||||
|
t.Error("fresh entry was wrongly reaped")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// secureToken's output is non-deterministic, so we test shape: hex,
|
||||||
|
// expected length, and that two consecutive calls differ.
|
||||||
|
func TestSecureToken_Shape(t *testing.T) {
|
||||||
|
a := secureToken(16)
|
||||||
|
b := secureToken(16)
|
||||||
|
if a == b {
|
||||||
|
t.Error("two secureToken calls produced identical output")
|
||||||
|
}
|
||||||
|
if len(a) != 32 {
|
||||||
|
t.Errorf("secureToken(16) length = %d, want 32", len(a))
|
||||||
|
}
|
||||||
|
for _, c := range a {
|
||||||
|
if !strings.ContainsRune("0123456789abcdef", c) {
|
||||||
|
t.Errorf("non-hex character %q in token", c)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestVerifyPKCE_Roundtrip(t *testing.T) {
|
||||||
|
verifier := "the-quick-brown-fox-jumps-over-the-lazy-dog-12345678"
|
||||||
|
sum := sha256.Sum256([]byte(verifier))
|
||||||
|
challenge := base64.RawURLEncoding.EncodeToString(sum[:])
|
||||||
|
|
||||||
|
if !verifyPKCE(verifier, challenge) {
|
||||||
|
t.Error("verifyPKCE should accept matching verifier+challenge")
|
||||||
|
}
|
||||||
|
if verifyPKCE(verifier, challenge+"x") {
|
||||||
|
t.Error("verifyPKCE should reject a mutated challenge")
|
||||||
|
}
|
||||||
|
if verifyPKCE("WRONG", challenge) {
|
||||||
|
t.Error("verifyPKCE should reject a wrong verifier")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUserIDInt64(t *testing.T) {
|
||||||
|
cases := map[string]int64{
|
||||||
|
"42": 42,
|
||||||
|
"": 0,
|
||||||
|
"abc": 0,
|
||||||
|
"123abc": 0,
|
||||||
|
"99999999999": 99999999999,
|
||||||
|
}
|
||||||
|
for in, want := range cases {
|
||||||
|
if got := userIDInt64(in); got != want {
|
||||||
|
t.Errorf("userIDInt64(%q) = %d, want %d", in, got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidateRedirectURI(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
in string
|
||||||
|
ok bool
|
||||||
|
}{
|
||||||
|
{"https", "https://app.example.com/cb", true},
|
||||||
|
{"http_loopback", "http://localhost:1234/cb", true},
|
||||||
|
{"custom_scheme", "claude://oauth/cb", true},
|
||||||
|
{"missing_scheme", "app.example.com/cb", false},
|
||||||
|
{"unparseable", "://no-scheme", false},
|
||||||
|
}
|
||||||
|
for _, tc := range cases {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
err := validateRedirectURI(tc.in)
|
||||||
|
if tc.ok && err != nil {
|
||||||
|
t.Errorf("expected ok, got %v", err)
|
||||||
|
}
|
||||||
|
if !tc.ok && err == nil {
|
||||||
|
t.Error("expected error, got nil")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
1038
internal/oauth/oauth_test.go
Normal file
1038
internal/oauth/oauth_test.go
Normal file
File diff suppressed because it is too large
Load diff
Loading…
Add table
Add a link
Reference in a new issue