Files
pad/internal/oauth/server.go
T
xarmian 924d82dae4 feat(oauth): MCPBearerAuth OAuth integration + public-info (TASK-1027) — closes TASK-951 (#375)
* feat(oauth): MCPBearerAuth OAuth integration + public-info endpoint (TASK-1027, sub-PR E of TASK-951)

Closes the OAuth server build-out by connecting sub-PRs A-D to the MCP
transport from TASK-950 and shipping the consent-screen support endpoint.

## MCPBearerAuth OAuth path

middleware_mcp_auth.go now branches on token shape:

  - pad_<60-hex>  → existing PAT validation (TASK-950 path)
  - anything else → fosite.IntrospectToken via the new
    internal/oauth.Server.IntrospectToken wrapper (server-side, no
    HTTP roundtrip — pad-cloud is both auth server and resource
    server, so the public /oauth/introspect endpoint is for external
    clients only).

OAuth path validation gates:

  - Token must be active (fosite returns ErrInactiveToken / ErrNotFound
    on revoked / unknown / expired tokens).
  - tokenUse must be access_token; refresh tokens explicitly rejected
    (RFC 6749 §1.5 — refresh tokens aren't bearers for resource calls).
  - Granted audience MUST contain the canonical MCP URL (RFC 8707
    anti-replay; resource-server-side check defends against compromised
    or shared auth servers).
  - Subject must resolve to a real user row.

Successful path stashes user + scopes via WithCurrentUser /
WithTokenScopes. Scopes are translated from fosite's space-separated
form to JSON-array form via oauthScopesToJSON.

## tokenScopeAllows pad:* extension

Extended to recognize the OAuth scope vocabulary alongside PAT scopes:
  - pad:read  ↔ read   (GET/HEAD/OPTIONS only)
  - pad:write ↔ write  (all methods)
  - pad:admin ↔ *      (all methods)

So MCP tool authorization stays uniform regardless of which transport
issued the bearer.

## /api/v1/oauth/clients/{id}/public-info

New read-only endpoint for the consent screen (TASK-952) and the
OAuth-intent banner (TASK-1001, already shipped). Returns four
non-sensitive fields: client_id, client_name, logo_uri, redirect_uris.

  - Auth-required (any logged-in user).
  - Cloud-mode-gated (404s outside cloud).
  - 404 for unknown clients.
  - Whitelisted leak surface — explicit fields, no embedded
    models.OAuthClient, so a future field addition (e.g. a confidential-
    client secret) doesn't accidentally appear here.

## Tests

- TestMCP_OAuthAccessToken_Authenticates — happy path: full flow
  yields a token that authenticates against /mcp.
- TestMCP_OAuthAccessToken_AudienceMismatch_Rejected — RFC 8707
  resource-server check; mints a token, swaps the OAuth server
  for one with a different canonical, confirms 401.
- TestMCP_OAuthRefreshToken_RejectedAtMCP — refresh tokens MUST
  NOT authenticate.
- TestMCP_RevokedOAuthToken_Rejected — revocation takes effect at
  the resource server.
- TestMCP_PATPath_StillWorks — regression for sub-PR D's coexistence
  with the OAuth path.
- TestMCP_OAuthScopeReadOnly_StashesPadReadScope — scope round-trip.
- TestOAuthClientPublicInfo_HappyPath / UnknownClient_404 /
  Unauthenticated_401 / NotMountedOutsideCloudMode — full coverage
  of the new endpoint.
- TestE2E_ClaudeDesktopFlow — simulates the full sequence
  (discovery → DCR → authorize → token → /mcp call) Claude Desktop
  walks on first connect.
- TestTokenScopeAllows extended with pad:* coverage.

## TASK-951 status

Closes TASK-951 when this lands (5/5 sub-PRs done):
- A: schema + storage layer (#370 / 2a00775)
- B: fosite-backed authorization-server constructor (#371 / f6eeee4)
- C: DCR + authorize + token endpoints + populated discovery (#372 / 48776a3)
- D: revoke + introspect endpoints (#373 / 4250fb1)
- E: MCPBearerAuth + public-info (this PR)

* fix(oauth): fail-closed on empty OAuth scopes per Codex review (round 1)

Codex caught a high-severity bug in oauthScopesToJSON: the helper
mapped empty granted scopes to `[]`, which tokenScopeAllows interprets
as the legacy "unrestricted" PAT shape (allow all methods). Combined
with OAuth's RFC 6749 §3.3 rule that the `scope` parameter is
OPTIONAL, this meant a client could:

  1. Run the auth-code flow without requesting scopes.
  2. Get back a token with empty granted_scopes.
  3. Drive write MCP tools because MCPBearerAuth stashed `[]` and
     tokenScopeAllows fell through to the legacy unrestricted path.

Fix: map empty OAuth scopes to JSON `null` instead. tokenScopeAllows
denies on the "scopes == nil" branch (existing TASK-667 behavior),
so the entire write surface is denied for empty-scope OAuth tokens.

In production this path is hard to hit — sub-PR C's DCR handler
defaults registered clients to `pad:read pad:write` when omitted,
and audienceMatchingStrategy enforces canonical-audience matching at
grant time. Defense-in-depth at the resource server is the right
policy regardless.

Test: TestOAuthScopesToJSON_FailClosedOnEmpty asserts both halves of
the contract — the helper produces "null" for empty input, and
tokenScopeAllows denies every method when fed that value.
2026-05-02 13:33:53 -04:00

305 lines
12 KiB
Go

package oauth
import (
"context"
"errors"
"fmt"
"time"
"github.com/ory/fosite"
"github.com/ory/fosite/compose"
"github.com/ory/fosite/handler/oauth2"
"github.com/PerpetualSoftware/pad/internal/store"
)
// Config configures NewServer. Every field is required; pass via
// the wiring in cmd/pad/main.go (sub-PR C extends that wiring with
// the HTTP handlers).
type Config struct {
// Store is the persistence layer (sub-PR A's *store.Store).
// NewServer wraps it in *Storage to satisfy fosite's storage
// interfaces.
Store *store.Store
// HMACSecret is the 32-byte secret fosite uses to sign opaque
// access + refresh + auth-code values. In production this is
// derived from cfg.EncryptionKey (already required in cloud
// mode, see cmd/pad/main.go's encryption-key bootstrap).
//
// Rotation: fosite supports rotating secrets via
// Config.RotatedGlobalSecrets. We don't expose that yet —
// rotation arrives with the operator runbook for TASK-953 /
// TASK-954.
HMACSecret []byte
// AllowedAudience is the canonical resource indicator that
// every issued token is bound to (RFC 8707). In production:
// cfg.MCPPublicURL + "/mcp" (e.g. "https://mcp.getpad.dev/mcp").
// /authorize and /token reject requests with mismatched
// `resource` per the audienceMatchingStrategy in audience.go.
AllowedAudience string
// Optional lifespan overrides — sensible defaults below if zero.
// Operators who need shorter access tokens (e.g. compliance
// regimes) override via env vars in sub-PR C's wiring.
AccessTokenLifespan time.Duration
RefreshTokenLifespan time.Duration
AuthorizeCodeLifespan time.Duration
}
// Server is pad's OAuth 2.1 authorization server. It composes
// fosite handlers over the storage adapter and exposes the
// fosite.OAuth2Provider that sub-PR C's HTTP handlers consume.
type Server struct {
provider fosite.OAuth2Provider
cfg Config
storage *Storage
}
// NewServer constructs an OAuth 2.1 authorization server backed by
// fosite v0.49.0.
//
// Compliance posture (PLAN-943 TASK-951):
//
// - PKCE required (S256 only). Config.EnforcePKCE = true,
// EnablePKCEPlainChallengeMethod stays false (the default), so
// `plain` is rejected. fosite's PKCE handler enforces this on
// /authorize + /token.
// - Refresh tokens rotate single-use. compose.OAuth2RefreshTokenGrantFactory
// wires fosite's standard rotation flow which calls
// Storage.RotateRefreshToken (revokes the entire grant family
// per the round-2 fix in sub-PR A) before issuing the new pair.
// - Audience-bound tokens (RFC 8707). Custom AudienceMatchingStrategy
// from audience.go rejects any audience that isn't the canonical
// MCP resource URL.
// - Opaque HMAC tokens (not JWT). compose.NewOAuth2HMACStrategy
// produces opaque values that can only be validated by us +
// introspected per RFC 7662 — easier to revoke than JWT.
// - HTTPS-only enforcement at the HTTP boundary (sub-PR C's job).
//
// Factories included:
// - OAuth2AuthorizeExplicitFactory — auth-code grant
// - OAuth2RefreshTokenGrantFactory — refresh-token rotation
// - OAuth2TokenIntrospectionFactory — RFC 7662 introspection
// - OAuth2TokenRevocationFactory — RFC 7009 revocation
// - OAuth2PKCEFactory — PKCE (S256 enforced)
//
// Excluded by design:
// - OAuth2ClientCredentialsGrantFactory (server-to-server, not
// applicable for the public-clients-only model)
// - OAuth2AuthorizeImplicitFactory (deprecated in OAuth 2.1)
// - OAuth2ResourceOwnerPasswordCredentialsFactory (deprecated in
// OAuth 2.1)
// - OpenID factories (we're not an OIDC IdP — yet)
// - PushedAuthorizeHandlerFactory (PAR — not needed for v1)
func NewServer(cfg Config) (*Server, error) {
if cfg.Store == nil {
return nil, errors.New("oauth: NewServer: Store is required")
}
if len(cfg.HMACSecret) < 32 {
return nil, errors.New("oauth: NewServer: HMACSecret must be at least 32 bytes (256 bits)")
}
if cfg.AllowedAudience == "" {
return nil, errors.New("oauth: NewServer: AllowedAudience is required (RFC 8707)")
}
// Sensible lifespans. Tunable via Config because TASK-959 may
// want shorter access tokens once observability lands and we can
// see realistic refresh frequency. Defaults are conservative —
// short-lived enough to bound replay damage, long enough to
// avoid excess refresh churn.
access := cfg.AccessTokenLifespan
if access == 0 {
access = time.Hour
}
refresh := cfg.RefreshTokenLifespan
if refresh == 0 {
refresh = 30 * 24 * time.Hour
}
authCode := cfg.AuthorizeCodeLifespan
if authCode == 0 {
authCode = 15 * time.Minute
}
fcfg := &fosite.Config{
// Spec compliance.
EnforcePKCE: true,
EnablePKCEPlainChallengeMethod: false, // S256 only; plain rejected
// fosite is content with the default ScopeStrategy
// (HierarchicScopeStrategy). PLAN-943 ships simple
// pad:read / pad:write / pad:admin scopes — the hierarchic
// strategy treats them as opaque, which is what we want.
// TASK-953's allow-list scopes plug in via an
// AccessTokenIssuer / RequestValidator hook; sub-PR E adds
// the required custom strategy when we layer those in.
// Token shapes.
AccessTokenLifespan: access,
RefreshTokenLifespan: refresh,
AuthorizeCodeLifespan: authCode,
// HMAC signing material. fosite uses the secret to derive
// the signature half of opaque tokens (the value the user
// sees is the public half + a "." + signature).
GlobalSecret: cfg.HMACSecret,
// Refresh-token issuance gate. fosite defaults
// RefreshTokenScopes to ["offline", "offline_access"], which
// means refresh tokens only get minted when one of those
// scopes is granted. PLAN-943's scope vocabulary is
// pad:read / pad:write / pad:admin — no offline scope —
// so the default would silently block refresh issuance for
// every Pad grant, defeating the whole rotation + family-
// revocation flow this PR adds. Codex review #371 round 3
// caught the gap.
//
// Empty slice tells fosite "issue refresh tokens on every
// authorize-code grant whose client allows the
// refresh_token grant type, no scope predicate." Matches
// fosite's own tests (flow_authorize_code_token_test.go:129).
// If we ever introduce per-grant offline opt-in, switch to
// the named scope here.
RefreshTokenScopes: []string{},
// Custom audience strategy (RFC 8707). Rejects any audience
// that isn't the canonical MCP resource URL. See audience.go.
AudienceMatchingStrategy: audienceMatchingStrategy(cfg.AllowedAudience),
// Strategies fosite needs to introspect:
// (no extra config — defaults handle these)
}
// Storage carries the canonical audience so hydrated clients
// pass audienceMatchingStrategy's haystack-side check. Single-
// resource AS for v1 — every client implicitly allowed for the
// configured audience. See storage.go modelClientToFosite.
storage := NewStorage(cfg.Store, cfg.AllowedAudience)
strategy := compose.NewOAuth2HMACStrategy(fcfg)
provider := compose.Compose(
fcfg,
storage,
strategy,
// Auth-code grant (the only authorize-side flow we run).
compose.OAuth2AuthorizeExplicitFactory,
// Refresh-token rotation. The factory's flow_refresh.go
// calls Storage.RotateRefreshToken before issuing the new
// pair, which under our adapter revokes the entire grant
// family — matching fosite's reference MemoryStore behaviour.
compose.OAuth2RefreshTokenGrantFactory,
// RFC 7662 introspection (sub-PR D wires the endpoint).
compose.OAuth2TokenIntrospectionFactory,
// RFC 7009 revocation (sub-PR D wires the endpoint).
compose.OAuth2TokenRevocationFactory,
// PKCE (S256 enforced).
compose.OAuth2PKCEFactory,
)
// Sanity check that compose actually built a usable provider.
// Defensive — fosite's compose returns a non-nil provider in all
// public paths, but the type-assertions inside compose.Compose
// silently drop unrecognized factory return types and we don't
// want to mask that.
if provider == nil {
return nil, fmt.Errorf("oauth: compose.Compose returned nil provider")
}
return &Server{
provider: provider,
cfg: cfg,
storage: storage,
}, nil
}
// Provider returns the fosite.OAuth2Provider that sub-PR C's HTTP
// handlers will call (NewAuthorizeRequest, NewAccessRequest,
// WriteAuthorizeResponse, etc.). Exposed as a method rather than a
// public field so the field can stay unexported and a future change
// (e.g. adding a tracing wrapper) only mutates Server.provider once.
func (s *Server) Provider() fosite.OAuth2Provider {
return s.provider
}
// Storage returns the storage adapter. Exposed for sub-PR C's
// /oauth/register handler (which calls Storage.store directly to
// CreateOAuthClient) and for tests that want to seed rows under
// pad-internal types rather than constructing fosite.Requester.
func (s *Server) Storage() *Storage {
return s.storage
}
// AllowedAudience returns the canonical resource URL the server is
// configured to accept. Sub-PR C's /authorize handler reads this to
// reject mismatched `resource=` query params at request entry,
// before fosite's downstream validation runs.
func (s *Server) AllowedAudience() string {
return s.cfg.AllowedAudience
}
// IntrospectToken validates an opaque OAuth access token without
// going through the public RFC 7662 HTTP endpoint. Used by sub-PR E's
// MCPBearerAuth middleware to gate /mcp on OAuth-issued tokens —
// server-side, no roundtrip, no client-auth dance.
//
// Returns:
//
// - ar: the AccessRequester with session hydrated. The session's
// Subject is the pad user ID (set in /authorize/decide via
// oauth.NewSession(user.ID)); GetGrantedScopes / GetGrantedAudience
// return the values fosite persisted at grant time.
// - tokenUse: fosite.AccessToken or fosite.RefreshToken. Resource-
// server callers MUST reject anything other than AccessToken;
// refresh tokens are not valid bearers for protected-resource
// calls.
// - err: fosite.ErrInactiveToken / fosite.ErrNotFound when the
// token is invalid; a wrapped storage error otherwise.
//
// Why a wrapper rather than calling fosite directly: fosite.Fosite's
// IntrospectToken isn't on the OAuth2Provider interface — it's on
// the concrete *Fosite type. compose.Compose returns OAuth2Provider
// (interface) but the underlying value is always *Fosite (compose.go:38
// hardcodes the constructor). Type-asserting at every call site would
// scatter the dependency on this implementation detail; centralizing
// it here keeps the boundary clean and gives us a single place to
// fail loudly if a future fosite version-bump changes the return
// type.
//
// Hint: fosite.AccessToken — if the token is actually a refresh
// token, fosite tries the access-token strategy first, fails, then
// falls through to the refresh-token strategy. The returned tokenUse
// reports the actual kind, which the caller is expected to validate.
func (s *Server) IntrospectToken(ctx context.Context, token string) (fosite.AccessRequester, fosite.TokenUse, error) {
f, ok := s.provider.(*fosite.Fosite)
if !ok {
// Defensive: compose.Compose has hardcoded *Fosite as the
// concrete return type since fosite v0.1.0. If a future
// version changes that we want to fail loudly, not silently
// fall through to a refused-everything state.
return nil, "", fmt.Errorf("oauth: provider type-assertion to *fosite.Fosite failed; got %T (compose.Compose internals changed?)", s.provider)
}
// Empty session pointer — fosite hydrates from storage. The
// session type must match what's stored (oauth.Session, written
// via oauth.NewSession in /authorize/decide), or the JSON
// unmarshal in oauthRequestToFositeRequest would silently drop
// pad-specific fields.
session := NewSession("")
tokenUse, ar, err := f.IntrospectToken(ctx, token, fosite.AccessToken, session)
return ar, tokenUse, err
}
// Compile-time guard: NewStorage produces a value that satisfies the
// minimal fosite.Storage interface (which is just ClientManager).
// Ensures storage.go's interface coverage doesn't drift.
var _ fosite.Storage = (*Storage)(nil)
// Compile-time guard: also assert the per-handler interfaces
// individually so a future rename / removal in fosite surfaces here
// at build time rather than at runtime.
var (
_ oauth2.AuthorizeCodeStorage = (*Storage)(nil)
_ oauth2.AccessTokenStorage = (*Storage)(nil)
_ oauth2.RefreshTokenStorage = (*Storage)(nil)
_ oauth2.TokenRevocationStorage = (*Storage)(nil)
)