Documentation
¶
Index ¶
- Constants
- func Authorize(tm *token.Manager, logger *zap.Logger, baseURL string, ...) http.HandlerFunc
- func BrowserFacing(next http.Handler) http.Handler
- func Callback(tm *token.Manager, logger *zap.Logger, audience string, ...) http.HandlerFunc
- func CallbackWithVerifyFunc(tm *token.Manager, logger *zap.Logger, audience string, ...) http.HandlerFunc
- func ComputePKCEChallenge(verifier string) string
- func Consent(tm *token.Manager, logger *zap.Logger, baseURL string, ...) http.HandlerFunc
- func Discovery(baseURL string) http.HandlerFunc
- func MethodNotAllowed(w http.ResponseWriter, r *http.Request)
- func NotFound(w http.ResponseWriter, r *http.Request)
- func RateLimitExceeded(w http.ResponseWriter, r *http.Request)
- func Register(tm *token.Manager, logger *zap.Logger, audience string, ...) http.HandlerFunc
- func ResourceMetadata(resourceURI, baseURL, resourceName string) http.HandlerFunc
- func Token(tm *token.Manager, logger *zap.Logger, audience string, revokeBefore time.Time, ...) http.HandlerFunc
- func VerifyPKCE(verifier, challenge string) bool
- type AuthorizeConfig
- type CallbackConfig
- type ConsentConfig
- type OAuthError
- type TokenConfig
Constants ¶
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
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 ¶
ComputePKCEChallenge computes the S256 challenge from a verifier.
func Consent ¶ added in v1.0.0
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 ¶
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.