forgejo-mcp/openspec/changes/stateless-http-auth/tasks.md
Christoph Görn df15877777
docs: 📝 retrofit openspec for stateless-http-auth (#137, PR #138)
Captures the per-request token design landed in byteflavour's PR #138,
plus the three open review blockers (silent fallback, bare-token branch,
weak test) as unchecked tasks tied to the spec.
2026-05-24 08:36:22 +02:00

4.8 KiB

1. Context-aware client factory

  • 1.1 Add forgejo.WithToken(ctx, token) context.Context and an unexported tokenKey type to pkg/forgejo/forgejo.go. Read back via tokenFromContext(ctx) string.
  • 1.2 Refactor Client() to Client(ctx context.Context) *forgejo.Client. When tokenFromContext(ctx) returns a non-empty string, build a fresh client via forgejo.NewClient(flag.URL, forgejo.SetToken(token)). Otherwise return the singleton initialized via sync.Once from flag.Token (existing behavior).
  • 1.3 Blocker fix — when ctx-token is supplied and forgejo.NewClient(...) returns an error, MUST NOT fall through to the singleton. Either change Client(ctx) signature to (*forgejo.Client, error) and propagate, or return nil and log loudly. Silent demotion to the global token is a privilege-escalation vector. See design D3.

2. Transport-layer extraction

  • 2.1 In operation/operation.go, register WithHTTPContextFunc on server.NewStreamableHTTPServer and WithSSEContextFunc on server.NewSSEServer. Each func reads r.Header.Get("Authorization") and calls forgejo.WithToken(ctx, token) when a recognized scheme is present.
  • 2.2 Accept token <X> prefix (Forgejo native scheme).
  • 2.3 Accept Bearer <X> prefix (OAuth2 transport).
  • 2.4 Blocker fix — match scheme case-insensitively (RFC 7235). bearer xyz MUST resolve to the same token as Bearer xyz. Today the case-sensitive strings.HasPrefix("Bearer ") rejects lowercase, which then falls through to the bare-token branch (1.5), which silently downgrades to the global identity.
  • 2.5 Blocker fix — drop the no-scheme bare-token branch (operation/operation.go:152-154 and :181-183). Not in the spec. Accepting a bare token without a scheme expands the contract silently. If kept, document in proposal and README.
  • 2.6 Stdio path is left without a context func; stdio handlers see no ctx-token, Client(ctx) returns the singleton.

3. Handler refactor

  • 3.1 Change every handler under operation/**/*.go to pass its ctx to forgejo.Client(ctx). Mechanical; 84 callsites.
  • 3.2 Update pkg/forgejo/rawhttp.go (setCommonHeaders) to read the token from ctx first, falling back to flag.Token. Attachment upload/download paths must honor per-request identity.
  • 3.3 Adjust all attachment handlers in operation/attachment/ to pass ctx into the raw-http path.

4. Tests

  • 4.1 pkg/forgejo/forgejo_test.go exists, exercises WithToken → Client(ctx) → distinct client returned.
  • 4.2 Blocker fix — replace the pointer-inequality assertion (c1 != c2) with an httptest.Server based test. Two parallel goroutines, each calling Client(ctx) with a distinct token, each making an outbound SDK call. The test server records inbound Authorization headers and asserts each goroutine's request arrives carrying its own token (not the other's, not the global). This is the only test that proves the no-leakage requirement.
  • 4.3 Add a test for the D3 failure path: stub forgejo.NewClient to return an error, supply a ctx-token, assert Client(ctx) returns error / nil (after 1.3 lands) rather than silently returning the singleton.
  • 4.4 Add a test for case-insensitive scheme matching (after 2.4 lands): bearer xyz, BEARER xyz, TOKEN xyz all resolve to the same context token.
  • 4.5 Existing tests under test/race/ continue to pass with the singleton path exercised in stdio mode.

5. Documentation

  • 5.1 Add a "Multi-tenant HTTP mode" section to README with a runnable showboat demo: start the server with no --token, two curl calls with distinct Authorization headers hitting the same Mcp-Session-Id show distinct identities via get_my_user_info, a third call with a bogus token returns the 401. Showboat ask already posted on PR #138.
  • 5.2 Cross-link this openspec change from the README section so future readers can find the rationale.

6. Verification

  • 6.1 make build passes against PR #138's branch.
  • 6.2 go test ./... passes against PR #138's branch.
  • 6.3 openspec validate stateless-http-auth --strict passes.
  • 6.4 Manual smoke: start server with --transport http --url https://codeberg.org (no --token); send two requests with distinct tokens; each get_my_user_info returns the correct distinct identity.
  • 6.5 Manual smoke: send a request with Authorization: bearer xyz (lowercase scheme) and confirm it resolves to the per-request identity, not the global fallback. (Validates 2.4.)
  • 6.6 Manual smoke: send a request with a deliberately broken Forgejo URL so the SDK's version probe fails; confirm the request returns an error rather than silently authenticating as the global token. (Validates 1.3.)
  • 6.7 After PR #138 merges with blocker fixes, archive this change under openspec/changes/archive/<date>-stateless-http-auth/.