handlers

package
v1.4.0 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Aug 8, 2026 License: Apache-2.0 Imports: 28 Imported by: 0

Documentation

Index

Constants

View Source
const (
	// DefaultClientTTL is the default lifetime of a sealed
	// client_id. Set to match refreshTokenTTL so a client holding
	// a still-valid refresh token can always exchange it: a shorter
	// clientTTL would silently kill long-running MCP clients
	// (which treat DCR as one-shot at startup) the moment their
	// access token first expired and they tried to rotate. The
	// operator can override via CLIENT_REGISTRATION_TTL when their
	// deployment needs a different lifetime envelope.
	DefaultClientTTL = 7 * 24 * time.Hour
)

Variables

This section is empty.

Functions

func Authorize

func Authorize(tm *token.Manager, logger *zap.Logger, baseURL string, oauth2Cfg *oauth2.Config, authzCfg AuthorizeConfig) http.HandlerFunc

Authorize handles GET /authorize (OAuth 2.1 PKCE authorization request). Session state is encrypted into the IdP state parameter for stateless operation.

Error-delivery follows RFC 6749 §4.1.2.1: errors that occur BEFORE client_id + redirect_uri are validated render on the AS itself (the redirect target is not yet trusted, so we cannot bounce to it) — JSON, or the negotiated HTML page for a browser, see error_page.go. Once both are validated, every subsequent failure redirects 302 to the registered redirect_uri with `error=…&state=…&iss=…` so the client never sees a body it can't correlate. The function flow reflects this split: client/redirect validation is deliberately front-loaded above the response_type / resource / PKCE / state checks.

func BrowserFacing added in v1.4.0

func BrowserFacing(next http.Handler) http.Handler

BrowserFacing marks every request routed through it as browser-terminated. Wire it OUTSIDE the rate limiter so a throttled user gets the page too.

func Callback

func Callback(tm *token.Manager, logger *zap.Logger, audience string, oauth2Cfg *oauth2.Config, verifier *oidc.IDTokenVerifier, cbCfg CallbackConfig) http.HandlerFunc

Callback handles GET /callback (IdP redirect after user authentication). audience binds the issued authorization code to a specific proxy deployment.

func CallbackWithVerifyFunc

func CallbackWithVerifyFunc(tm *token.Manager, logger *zap.Logger, audience string, oauth2Cfg *oauth2.Config, verifyFunc verifyIDTokenFunc, cbCfg CallbackConfig) http.HandlerFunc

CallbackWithVerifyFunc allows injecting a custom ID token verification function (for testing).

func ComputePKCEChallenge

func ComputePKCEChallenge(verifier string) string

ComputePKCEChallenge computes the S256 challenge from a verifier.

func Consent(tm *token.Manager, logger *zap.Logger, baseURL string, oauth2Cfg *oauth2.Config, cfg ConsentConfig) http.HandlerFunc

Consent handles POST /consent (consent-page approval submit).

Replays /authorize Phase 3 on approval: opens the sealedConsent, mints the upstream OIDC nonce and PKCE verifier, seals a sealedSession, and answers with the navigation interstitial targeting the IdP (see renderNavInterstitial for why not a 302). The original sealedClient is NOT reopened here — the consent blob carries only the inner client_id UUID, not the sealed registration handle, so a re-validation would have nothing to re-validate against. The audience + TTL + AAD-purpose triple binding on the consent blob is the integrity check.

On deny: answers with the interstitial targeting the user's registered redirect_uri carrying `error=access_denied` per RFC 6749 §4.1.2.1.

CSRF: the sealedConsent itself is the CSRF token (audience- and purpose-bound, 5-min TTL). A POST without a valid consent_token is rejected.

Replay defense: when ConsentConfig.ReplayStore is wired, the consent token's JTI is claimed single-use before either branch runs. Each GET /authorize render mints a fresh JTI so the back-button case still works (a re-render gets a new claim slot); a stolen consent_token can be POSTed at most once. Empty JTI (token sealed by an older binary still in flight during rollout) falls through to the prior stateless behavior.

A detected replay re-renders the consent page with a fresh JTI instead of returning a dead-end 400. This loses nothing: the protected action is the approval *decision* (the replayed blob never auto-approves — a new explicit click is required), and /authorize is unauthenticated, so anyone holding the client's authorize URL can obtain a fresh consent page anyway. It fixes the back-button / double-submit UX where the user's second Approve used to land on a JSON error.

func Discovery

func Discovery(baseURL string) http.HandlerFunc

Discovery returns the OAuth 2.0 Authorization Server Metadata.

func MethodNotAllowed added in v1.4.0

func MethodNotAllowed(w http.ResponseWriter, r *http.Request)

MethodNotAllowed answers a wrong-method request so a browser gets a readable page instead of the router's empty body. Exported because the router installs it (chi resolves MethodNotAllowed per router, not per route) — which is also why it does not go through the negotiating sink: at router level no per-route limiter has run, so this shares the 429's unbounded-path rules rather than the sink's.

func NotFound added in v1.4.0

func NotFound(w http.ResponseWriter, r *http.Request)

NotFound answers an unrouted browser-facing path — a mistyped or trailing-slash bookmark on /authorize, /consent or /callback, which never reaches MethodNotAllowed. Same unbounded-path rules as the 405: the router resolves it before any per-route limiter.

func RateLimitExceeded added in v1.4.0

func RateLimitExceeded(w http.ResponseWriter, r *http.Request)

RateLimitExceeded writes the throttle response for the per-endpoint limiters. Exported because the limiters are built in main, and going through the shared sink is what keeps a throttled human on the page instead of a JSON dead end.

func Register

func Register(tm *token.Manager, logger *zap.Logger, audience string, clientTTL time.Duration) http.HandlerFunc

Register handles POST /register (RFC 7591 Dynamic Client Registration). Client record is encrypted into the client_id itself for stateless operation. audience binds the client to a specific proxy deployment. clientTTL is the sealed client_id's lifetime; pass DefaultClientTTL to keep the standard 7-day envelope (aligns with refreshTokenTTL so a client holding a still-valid refresh can always exchange it).

func ResourceMetadata

func ResourceMetadata(resourceURI, baseURL, resourceName string) http.HandlerFunc

ResourceMetadata returns the OAuth 2.0 Protected Resource Metadata (RFC 9728) for a specific resource URI. MCP clients use this to discover which authorization server protects the resource.

resourceURI is what the handler advertises under the "resource" field — it must match exactly what clients send back in RFC 8707 resource indicators. Callers are responsible for picking the form (root "/"-suffixed for Claude.ai compat, path-scoped per-resource variant for RFC 9728 §3.1, etc.). baseURL stays as the issuer identifier in "authorization_servers". resourceName, when non-empty, is advertised under the optional "resource_name" field (RFC 9728 §2 — human-readable display name).

func Token

func Token(tm *token.Manager, logger *zap.Logger, audience string, revokeBefore time.Time, replayStore replay.Store, cfg TokenConfig, resourceURIs ...string) http.HandlerFunc

audience binds issued tokens to a specific proxy deployment; revokeBefore is the bulk-revocation cutoff applied to refresh tokens (the access-token path is enforced separately by middleware/auth.go). replayStore, when non-nil, enforces single-use authorization codes AND refresh token rotation with reuse detection across replicas; when nil, the handler retains stateless behavior (codes/refresh tokens unique, audience-bound and expiry-checked but not single-use).

func VerifyPKCE

func VerifyPKCE(verifier, challenge string) bool

VerifyPKCE checks that SHA256(verifier) base64url-encoded matches the challenge. Uses constant-time comparison to prevent timing side-channel attacks.

Types

type AuthorizeConfig

type AuthorizeConfig struct {
	PKCERequired bool // false = allow clients that omit code_challenge (Cursor, MCP Inspector)
	ResourceURIs []string
	// CanonicalResource is the RFC 8707 resource indicator every
	// issued access + refresh token will be bound to. For the
	// single-mount proxy this is {baseURL}{mountPath}. Sealed into
	// the session at /authorize so the binding is locked BEFORE
	// the upstream IdP round trip — a later code-substitution
	// cannot retarget the issued token to a different mount on a
	// future multi-mount proxy (RFC 8707 §2.2). Empty disables the
	// resource-binding plumbing (legacy / non-MCP callers).
	CanonicalResource string
	// CompatAllowStateless keeps the legacy behavior of synthesizing a
	// server-side state when the client omits it. Default false — strict
	// mode refuses (400 invalid_request) so a client-side CSRF bug cannot
	// hide behind the proxy. Either way the denial is counted under
	// mcp_auth_access_denied_total{reason="state_missing"} for visibility.
	CompatAllowStateless bool
	// RenderConsentPage gates the proxy-side consent screen. When
	// true, /authorize stops after parameter validation, seals the
	// validated request as a sealedConsent, and renders an HTML
	// page that requires an explicit user click before the upstream
	// IdP redirect happens. Closes the silent-issuance phishing
	// path where a malicious DCR client + an active upstream IdP
	// session = a token issued without the user ever seeing the
	// proxy.
	//
	// Production wiring (main.go) defaults this to true via the
	// RENDER_CONSENT_PAGE env var. The struct zero-value is false
	// so tests / callers wiring AuthorizeConfig directly default
	// to the silent-redirect path and opt in explicitly.
	RenderConsentPage bool
	// ResourceName mirrors config.ResourceName so the consent page
	// can show "{ClientName} wants to access {ResourceName}" rather
	// than the raw mount URI when the operator has set a friendly
	// name via MCP_RESOURCE_NAME. Falls back to CanonicalResource
	// when empty.
	ResourceName string
}

AuthorizeConfig holds optional relaxation flags for /authorize.

type CallbackConfig

type CallbackConfig struct {
	AllowedGroups []string // empty = allow all authenticated users
	GroupsClaim   string   // flat claim name in id_token (default "groups")
	// ReplayStore, when non-nil, enforces single-use semantics on the
	// sealed `state` parameter via its embedded SessionID: a captured
	// /callback URL cannot be replayed to fan out to the upstream IdP
	// (audit-noise + outbound-fan-out defense — the IdP authorization
	// code is itself single-use, so a successful replay would just
	// burn the IdP's `invalid_grant` quota anyway). nil = stateless
	// fallback (configured opt-out).
	ReplayStore replay.Store
	// IdPExchangeLimiter, when non-nil, throttles the proxy → IdP
	// token-endpoint exchange (the second leg of /callback). Defense
	// in depth: if a flood of /callback hits slips past the per-IP
	// rate limiter (e.g. distributed across IPs, behind a permissive
	// XFF trust matrix), the limiter caps the rate at which the
	// proxy can fan out to the IdP. Denied requests get a 503 +
	// `idp_exchange_throttled` log + metric; the user can retry once
	// the bucket refills. nil = no outbound throttling.
	IdPExchangeLimiter *rate.Limiter
}

CallbackConfig holds optional dependencies for the callback handler.

type ConsentConfig added in v1.0.0

type ConsentConfig struct {
	// ReplayStore, when non-nil, enforces single-use semantics on the
	// consent token's JTI: a captured consent_token can be POSTed at
	// most once. nil = stateless fallback (configured opt-out — the
	// token is still audience- and TTL-bound).
	ReplayStore replay.Store
	// ResourceName mirrors the AuthorizeConfig field of the same
	// name. Needed because a detected replay re-renders the consent
	// page (fresh JTI) instead of returning a dead-end 400.
	ResourceName string
}

ConsentConfig holds optional dependencies for the consent approval handler. Mirrors the shape of CallbackConfig.

type OAuthError

type OAuthError struct {
	Error            string `json:"error"`
	ErrorDescription string `json:"error_description,omitempty"`
	ErrorCode        string `json:"error_code,omitempty"`
}

OAuthError represents an RFC 6749 error response.

type TokenConfig added in v1.0.0

type TokenConfig struct {
	// RefreshRaceGrace, when > 0, treats a refresh-claim collision
	// inside this window as a benign concurrent submit (parallel
	// tab refresh, slow-network double-submit) and returns 429
	// `refresh_concurrent_submit` without revoking the family.
	// Outside the window the prior strict "every collision revokes"
	// behavior applies. Set to 0 to disable.
	RefreshRaceGrace time.Duration
}

Token handles POST /token (authorization_code and refresh_token grants). TokenConfig holds optional dependencies and tunables for the /token handler. Replaces the old positional signature so future knobs (refresh-rate-grace, etc.) don't keep breaking the call site.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL