mirror of
https://github.com/PerpetualSoftware/pad.git
synced 2026-09-11 21:39:01 +00:00
924d82dae4
* 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.
305 lines
12 KiB
Go
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)
|
|
)
|