Files
pad/internal/server/middleware_mcp_auth.go
T
xarmian 3f6bcc0ff2 feat(oauth): per-connection state tables + dual-read introspection gate (TASK-1520) (#581)
* feat(oauth): per-connection state tables + dual-read introspection gate (TASK-1520)

Phase A foundation for PLAN-1519 / IDEA-1517's per-OAuth-connection state
overhaul. Promotes the consent-time workspace allow-list out of
session.Extra (per-token, re-minted on every refresh-token rotation) into
dedicated tables keyed by request_id (the grant chain identifier preserved
across rotations).

Schema (SQLite migration 059 + Postgres migration 038):
- oauth_connections: one row per grant chain with name + three scope flags
  (may_create_workspaces, all_current_workspaces, include_future_workspaces).
- oauth_connection_workspaces: mutable allow-list join table; PK on
  (request_id, workspace_id); FK ON DELETE CASCADE; added_by audit column.

Store (internal/store/oauth_connections.go): Create/Get/Rename/SetScopeFlags/
Add+Remove+IsAllowed/Delete CRUD. GetOAuthConnectionAccess is the hot-path
projection — one PK lookup + one indexed join when the wildcard flag is off,
nothing else when it's on.

Dual-read gate (internal/server/middleware_mcp_auth.go): OR-merges the
legacy session.Extra allow-list with the new-table projection. A workspace
is allowed iff either source allows it; either source's wildcard makes the
gate unrestricted. New tables stay empty until Phase C writes the consent
screen, so the dual-read is a no-op until then — and existing OAuth grants
keep working unchanged through the Extra path. I/O errors on the new path
fall back to the Extra path so a transient outage of the new tables can't
regress existing connections.

Tests:
- 10 store tests cover CRUD, FK cascade, wildcard short-circuit, sorted
  slug projection, idempotent add/remove, ErrOAuthConnectionNotFound on
  missing rows.
- 11-case table-driven test on mergeAllowedWorkspaces directly verifies
  PLAN-1519's acceptance criterion: "token with allow-list in session.Extra
  still passes; token with empty session.Extra but row in
  oauth_connection_workspaces also passes." Plus wildcard precedence, union
  dedup, and fail-closed-on-empty-scope.
- BenchmarkMergeAllowedWorkspaces measures policy-function overhead on the
  hot path (the store-side lookup is the other half of the dual-read cost).

Parent: PLAN-1519.

* fix(oauth): fail-closed on connection lookup error per Codex review (round 1)

PR #581 Codex review round 1 caught two issues:

1. middleware_mcp_auth.go: GetOAuthConnectionAccess errors fell through
   to "no connection" + nil allow-list = unrestricted. Post-Phase-C
   (when the new tables are authoritative and session.Extra is empty),
   a DB read error on a scoped token would silently grant access to
   every workspace the user belongs to. Now fails closed with a 401
   matching the IntrospectToken storage-error policy, increments
   MCPAuthzDenialsTotal{connection_lookup_error} for ops visibility.

2. oauth_connections.go: CreateOAuthConnection's docstring claimed
   "scope flags default ON if not supplied," but the method writes
   the three Go bools verbatim — and Go zero-values for bool are
   false, not true. The schema-level DEFAULT TRUE is unreachable
   through this path. Docstring updated to clarify that defaults
   live at the form-rendering layer; the store is a faithful
   pass-through.

Parent: PLAN-1519.

* test(oauth): add store-side bench for GetOAuthConnectionAccess per Codex review (round 2)

PR #581 Codex review round 2 flagged the docstring references to
bench_oauth_connections_test.go pointing at a file that didn't exist
— only the in-memory mergeAllowedWorkspaces bench was wired. Add the
store-side bench so the documented file is real and PLAN-1519 Phase
A's "Hot-path benchmark: dual-read overhead measured and documented"
acceptance bullet is satisfied end-to-end.

Three shapes covered: Wildcard (PK lookup, join short-circuited),
Explicit (PK + indexed scan + small workspaces join), and NoRow (the
dominant Phase-A path until Phase C wires the write path). Local
numbers (Ryzen 3 5300U, SQLite WAL): 12µs/12µs/37µs respectively —
all comfortably sub-millisecond.

Parent: PLAN-1519.
2026-05-18 00:03:20 -04:00

630 lines
28 KiB
Go

package server
import (
"context"
"encoding/json"
"errors"
"log/slog"
"net/http"
"sort"
"strings"
"time"
"github.com/ory/fosite"
"github.com/PerpetualSoftware/pad/internal/oauth"
"github.com/PerpetualSoftware/pad/internal/store"
)
// MCPBearerAuth is the auth gate for the /mcp Streamable HTTP endpoint.
//
// Behaviour, in order:
//
// 1. No Authorization header, or one that doesn't start with "Bearer " →
// 401 with WWW-Authenticate per RFC 9728. The header points the
// client at our protected-resource discovery doc so it can begin
// the OAuth flow.
// 2. Bearer token present but not a recognized format / not in the
// api_tokens table / expired → 401 with the same WWW-Authenticate
// header (so a stale token doesn't drop the client into a "token
// unrecognized" dead end — they re-discover and recover).
// 3. Valid token → user attached to context via WithCurrentUser; next
// handler runs.
//
// Two token paths (sub-PR E, TASK-1027):
//
// - PAT (`pad_<60-hex-chars>`) → store.ValidateToken. The original
// TASK-950 path; PATs predate the OAuth server and continue to
// work as a developer / CLI escape hatch.
// - OAuth opaque (anything else) → s.oauthServer.IntrospectToken.
// fosite-issued from the /oauth/token flow that sub-PRs A-D
// wired. RFC 8707 audience binding is enforced here so a token
// issued for a different resource can't replay against /mcp.
//
// The branch is chosen on token shape, not on header content: the
// PAT prefix-and-length check is cheap and unambiguous (no real
// OAuth opaque token starts with `pad_` because fosite uses base64
// of the HMAC, never that prefix). PATs that don't validate fall
// through to the same 401 envelope the OAuth path uses — clients
// re-running discovery will see the same WWW-Authenticate pointer
// either way.
//
// Why introspect server-side rather than via the public /oauth/introspect
// HTTP endpoint: pad-cloud is the resource server AND the auth
// server, so a roundtrip would be pointless overhead. Calling
// fosite's IntrospectToken directly skips HTTP parsing, client-auth
// negotiation, and JSON encoding/decoding — and doesn't require
// minting a separate "introspection bearer" for the resource server.
// The public HTTP endpoint exists for spec-compliant external
// clients but isn't on the hot path here.
//
// Why a dedicated middleware (not the existing TokenAuth):
//
// TokenAuth on /api/v1/* writes a JSON error envelope on 401 (so the
// SPA / CLI clients can render a friendly message) and never sets
// WWW-Authenticate. MCP clients expect the spec-shape: 401 with the
// WWW-Authenticate "resource_metadata" parameter pointing them at
// our discovery doc (RFC 9728 §5.3, MCP authorization spec
// 2025-11-25). Wrapping TokenAuth would mean rewriting its 401
// responses post-hoc — a layered hack. A standalone middleware is
// shorter and clearer.
//
// CSRF / rate limiting:
//
// /mcp uses Bearer auth (Authorization header), not session cookies,
// so CSRF doesn't apply (CSRF defends cookie-bearing requests). Rate
// limiting is wired separately via TASK-959; this middleware is auth
// only, intentionally.
func (s *Server) MCPBearerAuth(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Capture the entry timestamp so denied-but-resolved requests
// (e.g. valid bearer rate-limited at the per-token bucket) can
// still emit an audit row with real latency. The audit row
// emit path lives in middleware_mcp_audit.go's
// emitMCPAuditDenied — see that helper for the rationale on
// why we audit those branches directly here rather than from
// the wrapping middleware (Codex review on PR #389 round 1).
mcpAuthStart := time.Now().UTC()
token, ok := extractBearer(r.Header.Get("Authorization"))
if !ok {
s.writeMCPUnauthorized(w, r, "missing_token", "Bearer token required.")
return
}
// PAT path: prefix `pad_` + 68 chars total (4 prefix + 64 hex
// secret). Cheap shape gate before the DB lookup. Anything
// else falls into the OAuth introspection branch.
if strings.HasPrefix(token, "pad_") && len(token) == 68 {
s.handleMCPPATAuth(w, r, token, next, mcpAuthStart)
return
}
// OAuth introspection path. Requires sub-PR B's NewServer
// + sub-PRs A/C/D for the storage and flow endpoints to be
// wired. Cloud-mode-without-OAuth deployments (rare:
// PAD_MCP_PUBLIC_URL set but no encryption-key wiring) get a
// clean reject rather than a confusing fall-through.
if s.oauthServer == nil {
s.writeMCPUnauthorized(w, r, "invalid_token", "Token format not recognized.")
return
}
s.handleMCPOAuthAuth(w, r, token, next, mcpAuthStart)
})
}
// handleMCPPATAuth runs the original TASK-950 PAT validation path.
// Extracted from MCPBearerAuth so the OAuth branch reads cleanly
// without a deeply-nested if/else; behaviour identical to the
// pre-sub-PR-E single-path version.
func (s *Server) handleMCPPATAuth(w http.ResponseWriter, r *http.Request, token string, next http.Handler, mcpAuthStart time.Time) {
apiToken, err := s.store.ValidateToken(token)
if err != nil {
// A DB error during token validation is a server-side
// problem, not the client's fault. 500 (not 401) so MCP
// clients don't churn through reconnect loops on a backend
// outage. No WWW-Authenticate header — re-running
// discovery wouldn't help.
writeError(w, http.StatusInternalServerError, "internal_error", "Token validation failed.")
return
}
if apiToken == nil {
s.writeMCPUnauthorized(w, r, "invalid_token", "Token is invalid or expired.")
return
}
// Resolve the user. Workspace-scope binding (apiToken.WorkspaceID)
// is intentionally NOT pinned here for user-owned PATs — the
// downstream RequireWorkspaceAccess middleware (when handlers
// run in-process via HTTPHandlerDispatcher) checks
// GetWorkspaceMember instead, matching the v0.2 design where
// a PAT grants access to whichever workspace its owning user
// belongs to. TASK-953 introduces a per-token allowed_workspaces[]
// allow-list which IS enforced here once it lands; for now,
// membership is the gate.
if apiToken.UserID == "" {
// Legacy workspace-scoped tokens (no user_id) predate the
// user-token refactor. They still authenticate the existing
// API surface — see handlers_events.go:52 — but the MCP
// transport requires a user identity (every audit log entry,
// every "who created this item", etc.). Reject cleanly.
s.writeMCPUnauthorized(w, r, "invalid_token", "MCP requires a user-owned token. Legacy workspace-scoped tokens are not supported here.")
return
}
user, err := s.store.GetUser(apiToken.UserID)
if err != nil || user == nil {
s.writeMCPUnauthorized(w, r, "invalid_token", "Token references an unknown user.")
return
}
// Per-token rate limit (TASK-959). Runs AFTER ALL PAT validation
// gates pass: ValidateToken, the legacy-workspace-scoped guard,
// and the GetUser lookup. Codex review #378 round 3 caught the
// gap where rate-limiting between ValidateToken and these later
// gates would create limiter buckets for active-but-not-
// authorized tokens (legacy workspace-scoped tokens with no
// UserID, tokens whose user row was deleted between issuance
// and use). Symmetric to the OAuth path's positioning — both
// paths run the rate limit at the very end of their happy path.
if !s.checkMCPRateLimit(w, r, token) {
// Resolved user + token but rate-limited: emit an audit row
// directly. The wrapping MCPAuditLog never sees this branch
// because we return before next.ServeHTTP. Codex review on
// PR #389 round 1 — see emitMCPAuditDenied for the design
// rationale.
s.emitMCPAuditDenied(r, user, "pat", apiToken.ID, "rate_limited", mcpAuthStart)
return
}
ctx := WithCurrentUser(r.Context(), user)
// Mirror TokenAuth's ctxIsAPIToken signal so downstream
// handlers that distinguish session vs token auth see the same
// shape they would on /api/v1/*. Cheap, future-proof.
ctx = context.WithValue(ctx, ctxIsAPIToken, true)
// Stash the token's scopes so the in-process MCP dispatcher
// (internal/mcp/dispatch_http.go) can enforce per-tool scope
// checks. Without this, a PAT with `["read"]` scope can drive
// write MCP tools because the synthesized in-process request
// looks pre-authenticated to the handler tree, bypassing
// TokenAuth's chain-level scopeAllows check. Codex review
// #369 round 1.
ctx = WithTokenScopes(ctx, apiToken.Scopes)
// Stash the token identity so the audit middleware (TASK-960)
// can record which PAT drove the call. Audit logging is
// independent of every other gate above and below — even calls
// that the inner handler later rejects produce a row.
ctx = WithMCPTokenIdentity(ctx, "pat", apiToken.ID)
// Surface near-expiry warning headers the same way TokenAuth
// does (middleware_auth.go:125). MCP clients can read them,
// but more importantly: the existing handlers_auth.go logic
// expects the warning to fire consistently regardless of
// transport.
setTokenExpiryWarning(w, apiToken)
next.ServeHTTP(w, r.WithContext(ctx))
}
// handleMCPOAuthAuth runs introspection against the OAuth server
// for any non-PAT bearer. Validates:
//
// - Token is recognized (storage lookup) and active.
// - Token kind is access_token (refresh tokens can't authorize a
// resource call — RFC 6749 §1.5 calls them "credentials used to
// obtain access tokens," not bearers themselves).
// - Granted audience contains the canonical MCP URL (RFC 8707
// anti-replay). A token issued for a different resource MUST
// NOT pass even if otherwise valid.
// - Subject is set + resolves to a real user row.
//
// On success, attaches user + scopes to context exactly like the
// PAT path. The dispatcher's per-tool scope check
// (TokenScopeAllows from middleware_auth.go) recognizes the OAuth
// scope vocabulary (pad:read / pad:write / pad:admin) alongside
// the legacy PAT vocabulary, so MCP tools see one uniform policy
// regardless of which transport issued the bearer.
func (s *Server) handleMCPOAuthAuth(w http.ResponseWriter, r *http.Request, token string, next http.Handler, mcpAuthStart time.Time) {
ar, tokenUse, err := s.oauthServer.IntrospectToken(r.Context(), token)
if err != nil {
// fosite returns ErrInactiveToken / ErrNotFound for unknown
// or revoked tokens; ErrInvalidTokenFormat for "not even an
// HMAC value." All of those collapse to the same 401 here —
// we don't need to distinguish for the client (re-running
// discovery + re-auth handles every recovery path).
// Storage errors (DB outage, etc.) ALSO collapse here rather
// than to 500: from a security standpoint we can't validate
// the token, so the client must NOT proceed; from a client
// UX standpoint a transient 401 + retry is no worse than a
// 500 + retry, and the spec-shape WWW-Authenticate keeps
// discovery agents on rails.
if !errors.Is(err, fosite.ErrInactiveToken) && !errors.Is(err, fosite.ErrNotFound) {
// Log non-validation errors so ops can spot DB / config
// problems via existing alerting.
slog.Warn("oauth introspect failed", "error", err)
}
s.writeMCPUnauthorized(w, r, "invalid_token", "Token is invalid or expired.")
return
}
if ar == nil {
// Defensive: fosite's contract says either err or ar is set,
// but a mocked dispatcher could violate that. 401 on the
// safe side.
s.writeMCPUnauthorized(w, r, "invalid_token", "Token is invalid or expired.")
return
}
// Refresh tokens are NOT valid bearers for resource calls. RFC
// 6749 §1.5: "refresh tokens are credentials used to obtain
// access tokens." A client misconfigured to send the refresh
// token in the Authorization header would otherwise look
// authenticated — reject explicitly.
if tokenUse != fosite.AccessToken {
s.writeMCPUnauthorized(w, r, "invalid_token", "Refresh tokens cannot authorize MCP requests; use the access token.")
return
}
// RFC 8707 audience binding (anti-replay). The canonical
// audience is what /authorize/decide grants on every token
// (audience.go's audienceMatchingStrategy enforces it on the
// grant side); this check is the corresponding resource-server
// gate. Without it, a token issued for resource=https://other.example
// would silently authorize on /mcp because we have no other
// boundary that distinguishes resources. Belt-and-suspenders:
// the grant-side check should already prevent foreign audiences,
// but the resource server validating its own incoming tokens is
// the spec's primary defense.
canonical := s.oauthServer.AllowedAudience()
if canonical == "" {
// Misconfigured server — no canonical audience to check
// against. Fail-closed: refuse the token rather than
// fall through to "any audience is fine."
slog.Error("oauth server has no canonical audience; refusing all OAuth tokens at /mcp")
s.writeMCPUnauthorized(w, r, "invalid_token", "OAuth server is misconfigured.")
return
}
if !audienceContains(ar.GetGrantedAudience(), canonical) {
// TASK-961: audience-mismatch is a primary spec-compliance
// signal — count it explicitly so dashboards distinguish
// "client misconfigured the resource indicator" from generic
// 401s. Fires BEFORE the response so a slow metrics path
// can't delay the reject. The MCPAuditLog wrapper never sees
// this branch because we return before next.ServeHTTP, so
// the metric is the only real-time signal for these denials.
if s.metrics != nil {
s.metrics.MCPAuthzDenialsTotal.WithLabelValues("audience_mismatch").Inc()
}
s.writeMCPUnauthorized(w, r, "invalid_token", "Token audience does not match this resource.")
return
}
// Subject is the pad user ID, set in /authorize/decide via
// oauth.NewSession(user.ID). An empty subject would mean the
// grant flow shipped without a user identity, which sub-PR C's
// renderConsentStub guards against (only logged-in users reach
// /authorize/decide). Defense in depth: reject anyway.
session := ar.GetSession()
if session == nil {
s.writeMCPUnauthorized(w, r, "invalid_token", "Token has no session.")
return
}
userID := session.GetSubject()
if userID == "" {
s.writeMCPUnauthorized(w, r, "invalid_token", "Token has no subject.")
return
}
user, err := s.store.GetUser(userID)
if err != nil || user == nil {
// User was deleted between grant and use, OR the storage
// layer is failing. Either way, the bearer can't represent
// a valid identity — reject.
s.writeMCPUnauthorized(w, r, "invalid_token", "Token references an unknown user.")
return
}
// Per-token rate limit (TASK-959). Runs AFTER ALL OAuth
// validation gates pass: introspection, refresh-vs-access
// check, RFC 8707 audience match, subject presence, user
// lookup. Codex review #378 round 2 caught the gap where
// rate-limiting between IntrospectToken and these later gates
// would create limiter buckets for active-but-not-authorized
// tokens (refresh tokens used as bearers, wrong-audience
// tokens, deleted users). Moving the check to the very end
// of the OAuth happy path ensures the limiter map only
// contains tokens that would otherwise reach next.ServeHTTP.
if !s.checkMCPRateLimit(w, r, token) {
// Resolved user + OAuth connection but rate-limited: emit
// an audit row directly. Wrapping MCPAuditLog never sees
// this branch because we return before next.ServeHTTP.
// Codex review on PR #389 round 1.
s.emitMCPAuditDenied(r, user, "oauth", ar.GetID(), "rate_limited", mcpAuthStart)
return
}
ctx := WithCurrentUser(r.Context(), user)
ctx = context.WithValue(ctx, ctxIsAPIToken, true)
// Translate fosite's space-separated scope string into the
// JSON-array form the existing scope-policy check expects.
// tokenScopeAllows recognizes pad:read / pad:write / pad:admin
// alongside the legacy PAT scopes, so the same per-method
// policy applies to OAuth-issued tokens.
ctx = WithTokenScopes(ctx, oauthScopesToJSON(ar.GetGrantedScopes()))
// Stash the OAuth connection identity (request_id chain) for
// the audit middleware (TASK-960). request_id is preserved
// across refresh-token rotations, so it's the stable identifier
// for a single user-authorized connection — exactly what the
// connected-apps page (TASK-954) keys revoke + last-used on.
ctx = WithMCPTokenIdentity(ctx, "oauth", ar.GetID())
// Stash the workspace allow-list onto the request context so
// RequireWorkspaceAccess can gate workspace access per-token
// (TASK-953). Two sources of truth during PLAN-1519's transition:
//
// 1. Legacy session.Extra path (TASK-952). Per-token, re-minted
// on every refresh rotation. Used everywhere pre-Phase-C.
// 2. New oauth_connections + oauth_connection_workspaces tables
// (TASK-1520). Per-grant-chain, keyed by request_id, survives
// rotation natively. Write path lands in Phase C; until then
// the tables are empty and the dual-read is a no-op.
//
// Allowed-when policy (the "OR" gate from IDEA-1517 §2):
//
// - If EITHER source says "unrestricted" (nil from Extra, or
// all_current_workspaces=1 from oauth_connections, or ["*"]
// wildcard from Extra) → stash nothing; downstream membership
// check is the only gate.
// - Otherwise → stash the UNION of both source slug lists.
// A workspace is allowed iff it appears in either list.
//
// The OR-on-allow semantic is what makes the migration safe: a
// token that was issued pre-Phase-C with an explicit Extra
// allow-list still passes (Extra path covers it); a token issued
// post-Phase-C with rows ONLY in the new tables also passes (new
// path covers it). No request loses access during the transition.
//
// Hot-path cost: one PK lookup against oauth_connections per
// request, plus one indexed scan + small join when the
// all_current_workspaces flag is false. Both queries hit indexed
// columns; total overhead is sub-millisecond per call (see
// PLAN-1519's Risks section + the bench in
// internal/store/bench_oauth_connections_test.go).
var extraAllowed []string
if oauthSession, ok := session.(*oauth.Session); ok {
extraAllowed = oauthSession.AllowedWorkspaces()
}
access, accessErr := s.store.GetOAuthConnectionAccess(ar.GetID())
if accessErr != nil {
// I/O error reading the connection — fail CLOSED. We cannot
// know whether this grant is scoped or unrestricted without a
// successful read; falling through to the legacy-Extra-only
// path would silently widen a post-Phase-C connection (where
// the new tables are authoritative and session.Extra is empty
// by design) into "no allow-list," granting access to every
// workspace the user is a member of. Codex review #581 round
// 1 caught the regression.
//
// Mirrors the IntrospectToken storage-error policy a few
// branches up: storage failures collapse to a 401 rather than
// a 500 because (a) we cannot validate the grant, so the
// client MUST not proceed, and (b) a spec-shaped 401 + retry
// is the same recovery path the client would take for a
// transient outage anyway. The slog.Warn lets ops spot the
// underlying DB issue via existing alerting.
slog.Warn("oauth_connections access lookup failed; failing closed",
"request_id", ar.GetID(), "error", accessErr)
if s.metrics != nil {
s.metrics.MCPAuthzDenialsTotal.WithLabelValues("connection_lookup_error").Inc()
}
s.writeMCPUnauthorized(w, r, "invalid_token", "Token validation failed; please retry.")
return
}
if allowed := mergeAllowedWorkspaces(extraAllowed, access); allowed != nil {
ctx = WithTokenAllowedWorkspaces(ctx, allowed)
}
next.ServeHTTP(w, r.WithContext(ctx))
}
// mergeAllowedWorkspaces computes the effective workspace allow-list
// for an OAuth-authenticated MCP request, OR-merging the two sources
// described in handleMCPOAuthAuth's stashing block.
//
// Returns nil iff EITHER source declares the request unrestricted:
//
// - extra == nil — no allow-list in session.Extra (pre-TASK-952
// token or one that omitted the key).
// - extra contains "*" — wildcard from the legacy consent flow.
// - access.HasConnection && access.AllCurrentWorkspaces — wildcard
// from the new-table consent flow.
//
// Otherwise returns the lexicographically-sorted, deduplicated union
// of (extra slugs that aren't "*") and access.WorkspaceSlugs. An empty
// non-nil slice is fail-closed (consent flow rejected; defense in
// depth — same posture as the existing AllowedWorkspaces semantic).
//
// Pulled out as a free function so the policy can be unit-tested
// without spinning up the OAuth server. The integration with fosite
// + introspection lives in handleMCPOAuthAuth above.
func mergeAllowedWorkspaces(extra []string, access store.OAuthConnectionAccess) []string {
// Case 1: connection says unrestricted → no gate regardless of
// what Extra holds. The new-tables path "winning" here matches
// the IDEA-1517 §2 design where all_current_workspaces=1 IS the
// wildcard semantic; Phase-C backfill maps the old ["*"] / nil
// shapes onto the flag.
if access.HasConnection && access.AllCurrentWorkspaces {
return nil
}
// Case 2: Extra has the wildcard sentinel → no gate. Even if the
// new-tables side has a (presumably stale) explicit list, the
// user-granted wildcard wins per "allow if either source allows."
for _, s := range extra {
if s == "*" {
return nil
}
}
// Case 3: Extra unset AND no connection row → no gate. Pre-TASK-952
// + pre-Phase-C tokens take this path (legacy "no allow-list at
// all" behaviour).
if extra == nil && !access.HasConnection {
return nil
}
// Case 4: explicit allow-list on at least one side. Union them.
seen := make(map[string]struct{}, len(extra)+len(access.WorkspaceSlugs))
union := make([]string, 0, len(extra)+len(access.WorkspaceSlugs))
add := func(s string) {
if s == "" || s == "*" {
return
}
if _, dup := seen[s]; dup {
return
}
seen[s] = struct{}{}
union = append(union, s)
}
for _, s := range extra {
add(s)
}
for _, s := range access.WorkspaceSlugs {
add(s)
}
sort.Strings(union)
// Non-nil empty slice is the fail-closed "consent existed but
// scoped to nothing" signal — keep it as []string{} rather than
// converting to nil, so downstream sees "explicit empty list"
// instead of "no gate."
return union
}
// audienceContains reports whether haystack contains needle, treating
// scheme-equivalent forms as equal via oauth.NormalizeAudience.
//
// Why normalize: real OAuth clients reconstruct the resource indicator
// from the URL the user pasted, and URL parsing canonicalizes empty
// path → "/" — so a client given `https://mcp.example` may emit
// `https://mcp.example/` as the audience. RFC 3986 §6.2.3 declares
// these equivalent for HTTP scheme; pad's AS-side audienceMatchingStrategy
// applies the same trim. Mirroring the rule here keeps the AS and RS
// in lockstep — without it, tokens the AS minted (with the requested
// audience stored verbatim) would fail validation at /mcp.
func audienceContains(haystack []string, needle string) bool {
needleNorm := oauth.NormalizeAudience(needle)
for _, a := range haystack {
if oauth.NormalizeAudience(a) == needleNorm {
return true
}
}
return false
}
// oauthScopesToJSON serializes fosite's granted-scope slice to the
// JSON array form tokenScopeAllows expects.
//
// Crucial fail-closed: nil / empty granted scopes map to JSON `null`
// (which tokenScopeAllows denies via the "scopes == nil" branch),
// NOT to `[]` (which the same function would accept as the legacy
// "unrestricted" PAT form). Codex review #375 round 1 caught the
// bug: OAuth's `scope` parameter is optional per RFC 6749 §3.3, so
// a client can run the auth-code flow without requesting any scopes
// — fosite hands back a token with no granted scopes, and without
// this guard MCPBearerAuth would treat that token as unrestricted
// (because `[]` is the legacy PAT shape). Mapping empty OAuth
// scopes to `null` instead routes them through the deny path.
//
// Note: in the production setup, sub-PR C's handleOAuthRegister
// defaults a registered client's scope set to `pad:read pad:write`
// when DCR omits it, and audienceMatchingStrategy refuses any
// authorize-side request with an unrecognized audience. So the
// "empty granted scopes" path is hard to hit through the public
// endpoints — but defense-in-depth at the resource server is the
// right policy regardless.
//
// json.Marshal can't fail on a []string, so we ignore the error
// and the result string is always valid JSON.
func oauthScopesToJSON(scopes []string) string {
if len(scopes) == 0 {
// Fail closed: route empty OAuth scopes through
// tokenScopeAllows's deny path, NOT the legacy `[]`
// unrestricted-PAT path.
return "null"
}
b, _ := json.Marshal(scopes)
return string(b)
}
// extractBearer parses an Authorization header value. Returns the
// token and true on success. Anything that isn't "Bearer <token>"
// (case-insensitive scheme, single space, non-empty token) returns
// "", false. Permissive on the scheme casing because RFC 6750 §2.1
// says it's case-insensitive; strict on the single-space separator
// to match the actual wire format mainstream clients send.
func extractBearer(h string) (string, bool) {
if h == "" {
return "", false
}
const prefix = "Bearer "
if len(h) <= len(prefix) {
return "", false
}
if !strings.EqualFold(h[:len(prefix)], prefix) {
return "", false
}
tok := strings.TrimSpace(h[len(prefix):])
if tok == "" {
return "", false
}
return tok, true
}
// writeMCPUnauthorized emits the spec-shaped 401 every MCP client
// expects: WWW-Authenticate with realm + resource_metadata, plus a
// small JSON body so curl / log scrapers see the reason without
// needing to parse the header.
//
// resource_metadata points at the same URL handleOAuthProtectedResource
// serves — Claude Desktop, Cursor, etc. follow it to begin the OAuth
// discovery flow described in the MCP authorization spec.
//
// URL resolution for the resource_metadata parameter:
//
// 1. s.mcpPublicURL (set by SetMCPTransport from PAD_MCP_PUBLIC_URL).
// The canonical case — production deployments always set this.
// 2. Fallback: derive from the request's Host header with "https://"
// prefix. Matches handleOAuthProtectedResource's fallback so the
// two URLs stay in sync for local dev without env vars set.
//
// Codex review #369 round 1 caught a regression where the fallback
// path dropped the WWW-Authenticate header entirely — that broke MCP
// client discovery on cloud-mode-without-PAD_MCP_PUBLIC_URL deploys
// because fresh clients rely on the header to find the metadata doc.
func (s *Server) writeMCPUnauthorized(w http.ResponseWriter, r *http.Request, code, msg string) {
resourceBase := strings.TrimRight(s.mcpPublicURL, "/")
if resourceBase == "" && r != nil && r.Host != "" {
// Same fallback as handleOAuthProtectedResource — assume HTTPS
// because RFC 9728 §3 + MCP authorization spec both require
// HTTPS in production, and the test harness doesn't probe the
// scheme. Operators on dev hosts running plain HTTP will see
// "https://localhost:7777/..." in the header; the test rig
// already pins the canonical case via SetMCPTransport.
resourceBase = "https://" + r.Host
}
if resourceBase == "" {
// No request and no configured URL — extremely rare (would
// only fire if writeMCPUnauthorized is called from a path
// that synthesizes a 401 without a request, which today none
// do). Fall back to the plain JSON 401 so the response is
// still well-formed; agents will get a generic "unauthorized"
// rather than a discovery-pointing one.
writeError(w, http.StatusUnauthorized, code, msg)
return
}
resourceMeta := resourceBase + "/.well-known/oauth-protected-resource"
w.Header().Set("WWW-Authenticate", `Bearer realm="pad", resource_metadata="`+resourceMeta+`"`)
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusUnauthorized)
_ = json.NewEncoder(w).Encode(map[string]any{
"error": map[string]string{
"code": code,
"message": msg,
},
})
}