mirror of
https://github.com/PerpetualSoftware/pad.git
synced 2026-09-11 13:28:57 +00:00
3f6bcc0ff2
* 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.
630 lines
28 KiB
Go
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,
|
|
},
|
|
})
|
|
}
|