PR #185 merged the last blocker fix and smoke tests 6.4-6.6 are resolved (6.4/6.5 verified E2E against codeberg.org, 6.6 covered by unit test TestClient_EphemeralConstructionError). The change is now complete. - sync delta -> openspec/specs/stateless-http-auth/spec.md (new normative capability, 7 requirements) - archive change to openspec/changes/archive/2026-06-02-stateless-http-auth/ - close bead forgejo-mcp-8if
8.9 KiB
stateless-http-auth
Purpose
Enable per-request authentication for the HTTP and SSE transports by extracting a token from the Authorization header and constructing a request-scoped Forgejo client, while preserving the pre-existing global-token (singleton) behavior for the stdio transport. This allows a single server process to serve multiple distinct Forgejo identities concurrently without leaking credentials across requests.
Requirements
Requirement: HTTP transport extracts per-request token from Authorization header
When the server is started with --transport http, every incoming MCP request SHALL be inspected for an Authorization header. The header SHALL be parsed for one of two schemes, case-insensitively:
token <X>— Forgejo's native token scheme.Bearer <X>— OAuth2-style bearer transport.
When a recognized scheme is present, the parsed token value SHALL be injected into the request context.Context via forgejo.WithToken(ctx, token). The MCP handler invoked for that request receives the augmented context.
When the Authorization header is absent, empty, or carries an unrecognized scheme, the request context.Context SHALL NOT carry a token. Downstream code falls back to the global singleton client (see "Token-aware client factory" below).
The transport MUST NOT accept tokens without a scheme prefix; a header value that does not match one of the two named schemes SHALL be treated as if no header were present.
Scenario: Request with token scheme injects the token
- WHEN an HTTP request arrives with header
Authorization: token abc123 - THEN the system SHALL inject
abc123into the request context viaforgejo.WithToken - AND the MCP handler SHALL see that token via
forgejo.Client(ctx)
Scenario: Request with Bearer scheme injects the token
- WHEN an HTTP request arrives with header
Authorization: Bearer abc123 - THEN the system SHALL inject
abc123into the request context
Scenario: Scheme matching is case-insensitive
- WHEN an HTTP request arrives with header
Authorization: bearer abc123(lowercase) - OR with header
Authorization: TOKEN abc123(uppercase) - THEN the system SHALL inject
abc123into the request context the same as it would for the canonical-case form
Scenario: Absent header falls through to global client
- WHEN an HTTP request arrives with no
Authorizationheader - THEN the request context SHALL NOT carry a token
- AND
forgejo.Client(ctx)SHALL return the process-wide singleton initialized from the--tokenflag
Scenario: Bare token (no scheme) is rejected
- WHEN an HTTP request arrives with header
Authorization: abc123(no scheme prefix) - THEN the system SHALL treat the request as if no
Authorizationheader were present - AND the system SHALL fall through to the global singleton client
Requirement: SSE transport extracts per-request token from Authorization header
When the server is started with --transport sse, the SSE server SHALL apply the same Authorization-header extraction rules as the HTTP transport (above), via the SDK's WithSSEContextFunc. All scheme-matching scenarios apply identically.
Scenario: SSE request with Bearer scheme injects the token
- WHEN an SSE request arrives with header
Authorization: Bearer abc123 - THEN the system SHALL inject
abc123into the request context the same as the HTTP transport would
Requirement: Token-aware client factory selects ephemeral or singleton client
The function forgejo.Client(ctx context.Context) *forgejo.Client SHALL be the single entry point used by every tool handler to obtain a Forgejo SDK client.
When the supplied ctx carries a non-empty token (set via forgejo.WithToken), Client SHALL construct a fresh, request-scoped SDK client via forgejo.NewClient(flag.URL, forgejo.SetToken(token)) and return it. This client SHALL NOT be cached, shared, or reused across requests.
When the supplied ctx carries no token, Client SHALL return the process-wide singleton client. The singleton SHALL be initialized lazily on first call via sync.Once, using flag.Token as the credential. This preserves the pre-existing stdio behavior verbatim.
Scenario: Context with token returns ephemeral client
- WHEN
forgejo.Client(ctx)is called with a context that carriestoken=abc123 - THEN the system SHALL return a freshly constructed
*forgejo.Clientconfigured withabc123 - AND the returned client SHALL NOT be the package singleton
Scenario: Context without token returns singleton
- WHEN
forgejo.Client(ctx)is called with a context that carries no token - THEN the system SHALL return the package singleton constructed from
flag.Token - AND consecutive calls SHALL return the same pointer
Requirement: Failed ephemeral construction MUST NOT fall through to the singleton
When forgejo.Client(ctx) is called with a context that carries a token, but the SDK call forgejo.NewClient(...) returns an error (e.g. the SDK's server-version probe fails on a network error, slow response, or HTTP 5xx from Forgejo), the function MUST NOT return the package singleton. Returning the singleton would silently authenticate the request as the startup --token identity instead of the requested per-request identity — a silent identity downgrade with privilege-escalation implications.
Instead, the function SHALL surface the failure to the caller. The implementation MAY accomplish this by changing the function signature to return (*forgejo.Client, error), or by returning nil and logging an error at Error level. The chosen mechanism MUST cause the calling tool handler to fail the MCP request rather than silently process it under the wrong identity.
Scenario: Ephemeral construction failure surfaces an error
- WHEN
forgejo.Client(ctx)is called withctxcarrying tokenabc123 - AND the underlying
forgejo.NewClient(...)call returns an error - THEN the system SHALL NOT return the package singleton
- AND the system SHALL surface the error such that the calling tool handler fails the request
Scenario: Ephemeral construction failure does NOT log the token
- WHEN the failure scenario above occurs
- THEN any logged error message or wrapped error chain SHALL NOT contain the value of
abc123
Requirement: Per-request clients are isolated across concurrent requests
Two concurrent requests carrying distinct tokens T_A and T_B SHALL produce two distinct, independent SDK clients. Outbound HTTP requests made by request A's handler SHALL carry Authorization: token T_A; outbound requests made by request B's handler SHALL carry Authorization: token T_B. Under no circumstances SHALL request A's client carry T_B, or vice versa, or the global --token value when a per-request token was supplied.
Scenario: Two concurrent requests produce two distinct identities at Forgejo
- GIVEN two goroutines, each obtaining a client via
forgejo.Client(ctx)with distinct context tokensT_AandT_B - WHEN each goroutine makes an outbound SDK call concurrently
- THEN the request originating from goroutine A SHALL arrive at Forgejo carrying
Authorization: token T_A - AND the request originating from goroutine B SHALL arrive carrying
Authorization: token T_B
Requirement: Raw HTTP path respects per-request token
The pkg/forgejo/rawhttp.go helper used for attachment upload and download SHALL read the request token from ctx (via tokenFromContext) and prefer it over the global flag.Token value when setting the Authorization header on outbound raw HTTP calls. When no token is present in ctx, the helper SHALL fall back to flag.Token.
Attachment tool handlers in operation/attachment/ SHALL pass their request ctx into this helper so per-request identity is honored for binary operations.
Scenario: Attachment upload uses per-request token
- WHEN an HTTP MCP request with
Authorization: token abc123invokescreate_issue_attachment - THEN the outbound HTTP POST to Forgejo's attachment endpoint SHALL carry
Authorization: token abc123in its headers, not the global--tokenvalue
Requirement: Stdio transport preserves pre-existing global-token behavior
When the server is started with --transport stdio, no Authorization extraction SHALL be performed. Tool handlers under stdio receive a context that carries no token; forgejo.Client(ctx) returns the package singleton initialized from --token. All pre-existing stdio behavior, including tests, SHALL remain valid.
Scenario: Stdio handler receives global-token client
- WHEN the server is run with
--transport stdio --token xyz789 - AND a tool handler invokes
forgejo.Client(ctx) - THEN the system SHALL return the package singleton client constructed from
xyz789 - AND consecutive calls within and across handlers SHALL return the same client pointer