diff --git a/.beads/issues.jsonl b/.beads/issues.jsonl index d7be074..586877c 100644 --- a/.beads/issues.jsonl +++ b/.beads/issues.jsonl @@ -1,12 +1,12 @@ {"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":"in_progress","priority":1,"issue_type":"task","assignee":"Ole-Morten Duesund","owner":"olemd@glemt.net","created_at":"2026-04-24T15:45:18Z","created_by":"Ole-Morten Duesund","updated_at":"2026-04-27T15:08:52Z","started_at":"2026-04-27T15:08:52Z","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-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-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-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":"in_progress","priority":1,"issue_type":"task","assignee":"Ole-Morten Duesund","owner":"olemd@glemt.net","created_at":"2026-04-24T15:45:13Z","created_by":"Ole-Morten Duesund","updated_at":"2026-04-27T15:06:24Z","started_at":"2026-04-27T15:06:24Z","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":"closed","priority":1,"issue_type":"task","assignee":"Ole-Morten Duesund","owner":"olemd@glemt.net","created_at":"2026-04-24T15:45:13Z","created_by":"Ole-Morten Duesund","updated_at":"2026-04-27T15:08:19Z","started_at":"2026-04-27T15:06:24Z","closed_at":"2026-04-27T15:08:19Z","close_reason":"Discovery endpoints shipped: /.well-known/oauth-authorization-server (RFC 8414) and /.well-known/oauth-protected-resource (RFC 9728). All URLs derived from cfg.Issuer; explicit Host-header-spoofing test verifies attacker-supplied hosts don't leak into metadata.","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-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":"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-27T15:04:43Z","started_at":"2026-04-27T14:30:02Z","closed_at":"2026-04-27T15:04:43Z","close_reason":"OAuth AS shipped: register, authorize, callback, token (auth_code + refresh grants), revoke. PKCE S256 enforced. Tokens hashed at rest. Refresh rotation with old-token revocation. Auth-code single-use via atomic UPDATE rows-affected. 81% coverage. ~1700 LOC. Unblocks 2d, 2e, and is a key dep for 5a.","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} diff --git a/internal/oauth/auth.go b/internal/oauth/auth.go new file mode 100644 index 0000000..3c5040e --- /dev/null +++ b/internal/oauth/auth.go @@ -0,0 +1,165 @@ +package oauth + +import ( + "context" + "database/sql" + "errors" + "fmt" + "net/http" + "strings" + "time" + + "kode.naiv.no/olemd/forgejo-mcp-broker/internal/store" +) + +// Session is the per-request OAuth context attached by the bearer +// middleware. Downstream handlers (the MCP endpoint, in phase 5a) read +// the upstream Forgejo token from here to spawn forgejo-mcp subprocesses +// scoped to the right user. +type Session struct { + ClientID string + ForgejoUserID int64 + ForgejoUsername string + Scopes string + BrokerTokenHash string // SHA-256 hex of the broker token; for log correlation + ForgejoToken string // plaintext upstream token — keep in memory, never log + ForgejoRefresh string + ForgejoTokenExp time.Time +} + +// Authenticator resolves Bearer tokens against the access_tokens table. +// Use Authenticator.RequireBearer to wrap the protected handler. +type Authenticator struct { + Store *store.Store + Now func() time.Time // optional; defaults to time.Now +} + +type sessionCtxKey struct{} + +// SessionFromContext returns the Session attached by RequireBearer, if any. +func SessionFromContext(ctx context.Context) (*Session, bool) { + s, ok := ctx.Value(sessionCtxKey{}).(*Session) + return s, ok +} + +// RequireBearer is HTTP middleware that: +// 1. Demands an `Authorization: Bearer ` header. +// 2. Looks the token up by SHA-256 hash in access_tokens. +// 3. Rejects expired or revoked tokens. +// 4. Attaches the resolved Session to the request context for downstream +// handlers to read via SessionFromContext. +// +// Failures emit a 401 with an RFC 6750 §3 WWW-Authenticate header carrying +// the appropriate error code (invalid_token / invalid_request). +func (a *Authenticator) RequireBearer(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + now := a.Now + if now == nil { + now = time.Now + } + + raw := r.Header.Get("Authorization") + if raw == "" { + respondAuthError(w, "invalid_request", "missing Authorization header") + return + } + token, ok := strings.CutPrefix(raw, "Bearer ") + if !ok || token == "" { + respondAuthError(w, "invalid_request", "Authorization header must use Bearer scheme") + return + } + + sess, err := a.lookupSession(r.Context(), hashToken(token), now()) + if err != nil { + switch { + case errors.Is(err, errTokenNotFound): + respondAuthError(w, "invalid_token", "unknown token") + case errors.Is(err, errTokenExpired): + respondAuthError(w, "invalid_token", "token expired") + case errors.Is(err, errTokenRevoked): + respondAuthError(w, "invalid_token", "token revoked") + default: + // Unexpected DB or scan error: don't leak internals to + // the caller. Logging would land in middleware-of-the- + // future once we wire a logger here. + respondAuthError(w, "invalid_token", "auth lookup failed") + } + return + } + + ctx := context.WithValue(r.Context(), sessionCtxKey{}, sess) + next.ServeHTTP(w, r.WithContext(ctx)) + }) +} + +// Sentinel errors so RequireBearer can render distinct WWW-Authenticate +// reasons for the operator while always returning 401 to the client. +var ( + errTokenNotFound = errors.New("token not found") + errTokenExpired = errors.New("token expired") + errTokenRevoked = errors.New("token revoked") +) + +func (a *Authenticator) lookupSession(ctx context.Context, tokenHash string, now time.Time) (*Session, error) { + var ( + clientID, fjUsername, scopes, fjAccess, fjRefresh string + fjUserID int64 + expiresAt, fjExpiresAt int64 + revokedAt sql.NullInt64 + ) + row := a.Store.DB().QueryRowContext(ctx, + `SELECT client_id, forgejo_user_id, forgejo_username, scopes, + forgejo_access_token, forgejo_refresh_token, forgejo_token_expires_at, + expires_at, revoked_at + FROM access_tokens WHERE token_hash = ?`, tokenHash) + err := row.Scan(&clientID, &fjUserID, &fjUsername, &scopes, + &fjAccess, &fjRefresh, &fjExpiresAt, &expiresAt, &revokedAt) + if errors.Is(err, sql.ErrNoRows) { + return nil, errTokenNotFound + } + if err != nil { + return nil, err + } + if revokedAt.Valid { + return nil, errTokenRevoked + } + if now.Unix() > expiresAt { + return nil, errTokenExpired + } + + return &Session{ + ClientID: clientID, + ForgejoUserID: fjUserID, + ForgejoUsername: fjUsername, + Scopes: scopes, + BrokerTokenHash: tokenHash, + ForgejoToken: fjAccess, + ForgejoRefresh: fjRefresh, + ForgejoTokenExp: time.Unix(fjExpiresAt, 0).UTC(), + }, nil +} + +// respondAuthError writes a 401 with a WWW-Authenticate header per RFC 6750 +// §3. The body stays empty — error info goes in the header so it's discoverable +// to compliant clients without leaking detail in a body that browsers might +// render. +func respondAuthError(w http.ResponseWriter, errorCode, description string) { + w.Header().Set("WWW-Authenticate", + fmt.Sprintf(`Bearer error="%s", error_description="%s"`, + escapeHeader(errorCode), escapeHeader(description))) + w.WriteHeader(http.StatusUnauthorized) +} + +// escapeHeader strips characters that would break a quoted-string in an +// HTTP header value. Conservative: only allow safe ASCII. The error codes +// we emit are well-known constants, so this is a defense-in-depth check +// against a future bug accidentally interpolating user input. +func escapeHeader(s string) string { + var b strings.Builder + for _, c := range s { + if c >= 0x20 && c < 0x7f && c != '"' && c != '\\' { + b.WriteRune(c) + } + } + return b.String() +} diff --git a/internal/oauth/auth_test.go b/internal/oauth/auth_test.go new file mode 100644 index 0000000..06bdc20 --- /dev/null +++ b/internal/oauth/auth_test.go @@ -0,0 +1,208 @@ +package oauth_test + +import ( + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "kode.naiv.no/olemd/forgejo-mcp-broker/internal/oauth" +) + +// authFixture wraps the OAuth fixture and exposes a fresh Authenticator +// pointed at the same store and clock. +type authFixture struct { + *fixture + auth *oauth.Authenticator +} + +func newAuthFixture(t *testing.T) *authFixture { + t.Helper() + fx := newFixture(t) + return &authFixture{ + fixture: fx, + auth: &oauth.Authenticator{Store: fx.store, Now: fx.now}, + } +} + +// echoHandler reads the Session from context and writes a recognisable +// payload, so tests can confirm the right Session reached the handler. +var echoHandler = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + sess, ok := oauth.SessionFromContext(r.Context()) + if !ok { + http.Error(w, "no session", http.StatusInternalServerError) + return + } + _, _ = io.WriteString(w, "ok username="+sess.ForgejoUsername+ + " client="+sess.ClientID+ + " forgejo_token="+sess.ForgejoToken) +}) + +func TestRequireBearer_ValidTokenPasses(t *testing.T) { + fx := newAuthFixture(t) + cid := fx.registerClient("https://app.example.com/cb") + tok := runFullFlow(t, fx.fixture, "https://app.example.com/cb", cid, "verifier-auth-1") + + srv := httptest.NewServer(fx.auth.RequireBearer(echoHandler)) + t.Cleanup(srv.Close) + + req, _ := http.NewRequest(http.MethodGet, srv.URL, nil) + req.Header.Set("Authorization", "Bearer "+tok.AccessToken) + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatalf("do: %v", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(resp.Body) + t.Fatalf("status = %d, want 200; body: %s", resp.StatusCode, body) + } + body, _ := io.ReadAll(resp.Body) + if !strings.Contains(string(body), "username=alice") { + t.Errorf("session not surfaced correctly: %s", body) + } + if !strings.Contains(string(body), "forgejo_token=fj-access") { + t.Errorf("forgejo token not in session: %s", body) + } + if !strings.Contains(string(body), "client="+cid) { + t.Errorf("client_id not in session: %s", body) + } +} + +func TestRequireBearer_NoHeader_401(t *testing.T) { + fx := newAuthFixture(t) + srv := httptest.NewServer(fx.auth.RequireBearer(echoHandler)) + t.Cleanup(srv.Close) + + resp, err := http.Get(srv.URL) + if err != nil { + t.Fatalf("get: %v", err) + } + resp.Body.Close() + if resp.StatusCode != http.StatusUnauthorized { + t.Errorf("status = %d, want 401", resp.StatusCode) + } + if got := resp.Header.Get("WWW-Authenticate"); !strings.Contains(got, "invalid_request") { + t.Errorf("WWW-Authenticate = %q, want invalid_request", got) + } +} + +func TestRequireBearer_WrongScheme_401(t *testing.T) { + fx := newAuthFixture(t) + srv := httptest.NewServer(fx.auth.RequireBearer(echoHandler)) + t.Cleanup(srv.Close) + + req, _ := http.NewRequest(http.MethodGet, srv.URL, nil) + req.Header.Set("Authorization", "Basic abc==") + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatalf("do: %v", err) + } + resp.Body.Close() + if resp.StatusCode != http.StatusUnauthorized { + t.Errorf("status = %d, want 401", resp.StatusCode) + } +} + +func TestRequireBearer_EmptyToken_401(t *testing.T) { + fx := newAuthFixture(t) + srv := httptest.NewServer(fx.auth.RequireBearer(echoHandler)) + t.Cleanup(srv.Close) + + req, _ := http.NewRequest(http.MethodGet, srv.URL, nil) + req.Header.Set("Authorization", "Bearer ") + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatalf("do: %v", err) + } + resp.Body.Close() + if resp.StatusCode != http.StatusUnauthorized { + t.Errorf("status = %d, want 401", resp.StatusCode) + } +} + +func TestRequireBearer_UnknownToken_401(t *testing.T) { + fx := newAuthFixture(t) + srv := httptest.NewServer(fx.auth.RequireBearer(echoHandler)) + t.Cleanup(srv.Close) + + req, _ := http.NewRequest(http.MethodGet, srv.URL, nil) + req.Header.Set("Authorization", "Bearer made-up-not-in-store") + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatalf("do: %v", err) + } + resp.Body.Close() + if resp.StatusCode != http.StatusUnauthorized { + t.Errorf("status = %d, want 401", resp.StatusCode) + } + if got := resp.Header.Get("WWW-Authenticate"); !strings.Contains(got, "invalid_token") { + t.Errorf("WWW-Authenticate = %q, want invalid_token", got) + } +} + +func TestRequireBearer_ExpiredToken_401(t *testing.T) { + fx := newAuthFixture(t) + cid := fx.registerClient("https://app.example.com/cb") + tok := runFullFlow(t, fx.fixture, "https://app.example.com/cb", cid, "verifier-auth-exp") + + // Push the clock past the access-token lifetime. + fx.advance(oauth.AccessTokenTTL + time.Minute) + + srv := httptest.NewServer(fx.auth.RequireBearer(echoHandler)) + t.Cleanup(srv.Close) + + req, _ := http.NewRequest(http.MethodGet, srv.URL, nil) + req.Header.Set("Authorization", "Bearer "+tok.AccessToken) + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatalf("do: %v", err) + } + resp.Body.Close() + if resp.StatusCode != http.StatusUnauthorized { + t.Errorf("status = %d, want 401", resp.StatusCode) + } + if !strings.Contains(resp.Header.Get("WWW-Authenticate"), "expired") { + t.Errorf("WWW-Authenticate missing expired reason: %q", resp.Header.Get("WWW-Authenticate")) + } +} + +func TestRequireBearer_RevokedToken_401(t *testing.T) { + fx := newAuthFixture(t) + cid := fx.registerClient("https://app.example.com/cb") + tok := runFullFlow(t, fx.fixture, "https://app.example.com/cb", cid, "verifier-auth-rev") + + // Revoke through the public /oauth/revoke endpoint. + form := strings.NewReader("token=" + tok.AccessToken + "&token_type_hint=access_token") + revResp, err := http.Post(fx.httpServer.URL+"/oauth/revoke", "application/x-www-form-urlencoded", form) + if err != nil { + t.Fatalf("revoke: %v", err) + } + revResp.Body.Close() + + srv := httptest.NewServer(fx.auth.RequireBearer(echoHandler)) + t.Cleanup(srv.Close) + + req, _ := http.NewRequest(http.MethodGet, srv.URL, nil) + req.Header.Set("Authorization", "Bearer "+tok.AccessToken) + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatalf("do: %v", err) + } + resp.Body.Close() + if resp.StatusCode != http.StatusUnauthorized { + t.Errorf("status = %d, want 401", resp.StatusCode) + } + if !strings.Contains(resp.Header.Get("WWW-Authenticate"), "revoked") { + t.Errorf("WWW-Authenticate missing revoked reason: %q", resp.Header.Get("WWW-Authenticate")) + } +} + +func TestSessionFromContext_NotPresent(t *testing.T) { + if _, ok := oauth.SessionFromContext(t.Context()); ok { + t.Error("SessionFromContext should return false on a context with no session attached") + } +}