Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -238,8 +238,9 @@ rollout notes, and K8s deployment shape.
expected interaction, not a policy rejection. Pair with
`mcp_auth_tokens_issued_total{grant_type="authorization_code"}`
to derive abandoned-after-approve as a funnel signal
- `mcp_auth_replay_detected_total{kind}` — `code` or `refresh` replays
caught by the Redis-backed store
- `mcp_auth_replay_detected_total{kind}` — `code`, `refresh`,
`consent`, or `callback_state` replays caught by the Redis-backed
store
- `mcp_auth_rate_limited_total{endpoint}` — httprate 429s by endpoint
- `mcp_auth_clients_registered_total` — RFC 7591 registrations
- `mcp_auth_groups_claim_shape_mismatch_total` — id_token `groups`
Expand Down
6 changes: 6 additions & 0 deletions handlers/authorize.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import (

"github.com/babs/mcp-auth-proxy/metrics"
"github.com/babs/mcp-auth-proxy/token"
"github.com/google/uuid"
"go.uber.org/zap"
"golang.org/x/oauth2"
)
Expand Down Expand Up @@ -226,6 +227,10 @@ func Authorize(tm *token.Manager, logger *zap.Logger, baseURL string, oauth2Cfg
// redirect) replays from POST /consent on approval.
if authzCfg.RenderConsentPage {
renderConsent(w, r, tm, logger, baseURL, authzCfg.ResourceName, sealedConsent{
// Per-render JTI: a fresh id every GET /authorize so
// back-button = re-consent (each render gets its own
// single-use claim slot) rather than dead-state errors.
JTI: uuid.New().String(),
ClientID: client.ID,
ClientName: client.ClientName,
RedirectURI: redirectURI,
Expand Down Expand Up @@ -280,6 +285,7 @@ func Authorize(tm *token.Manager, logger *zap.Logger, baseURL string, oauth2Cfg
PKCEVerifier: upstreamVerifier,
SvrVerifier: svrVerifier, // empty unless H6 server-side PKCE kicked in
SvrChallenge: svrChallenge, // mirrors sessionChallenge in that case
SessionID: uuid.New().String(),
Typ: token.PurposeSession,
Audience: baseURL,
Resource: authzCfg.CanonicalResource,
Expand Down
50 changes: 49 additions & 1 deletion handlers/callback.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,14 @@ package handlers
import (
"context"
"encoding/json"
"errors"
"net/http"
"net/url"
"strings"
"time"

"github.com/babs/mcp-auth-proxy/metrics"
"github.com/babs/mcp-auth-proxy/replay"
"github.com/babs/mcp-auth-proxy/token"
"github.com/coreos/go-oidc/v3/oidc"
"github.com/google/uuid"
Expand Down Expand Up @@ -72,10 +74,18 @@ func sanitizeErrorDescription(s string) string {
return string(b)
}

// CallbackConfig holds the group filtering parameters for the callback handler.
// CallbackConfig holds optional dependencies for the callback handler.
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
}

// Callback handles GET /callback (IdP redirect after user authentication).
Expand Down Expand Up @@ -118,6 +128,17 @@ func callbackHandler(tm *token.Manager, logger *zap.Logger, audience string, oau
internalState := q.Get("state")

if idpError != "" {
// Deliberately NO ClaimOnce on this branch even when the
// session opens cleanly. The error redirect produces no
// IdP fan-out (no token-endpoint call) and no token
// issuance — just an idempotent 302 to the registered
// redirect_uri carrying `error=…`. Claiming here would
// burn the state on the first transient IdP error and
// force the user through full re-auth on the next legit
// retry. Trade-off accepted: a stolen state can replay
// the same error redirect, but the attacker gains
// nothing not already producible by sending the user a
// crafted /authorize URL.
safeError := normalizeIdPError(idpError)
// `error_description` from a /callback hit is fully
// attacker-controlled — anyone can craft a /callback URL
Expand Down Expand Up @@ -175,6 +196,33 @@ func callbackHandler(tm *token.Manager, logger *zap.Logger, audience string, oau
return
}

// Single-use claim on the sealed state's SessionID. Applies
// BEFORE the upstream IdP exchange so a replayed /callback
// URL never fans out to the IdP. Same nil-store /
// ErrAlreadyClaimed / fail-closed-on-error policy as the
// /token code claim. Empty SessionID is the in-flight-rollout
// fallback (older binary sealed the session before this
// field existed).
if cbCfg.ReplayStore != nil && session.SessionID != "" {
remaining := max(time.Until(session.ExpiresAt), time.Second)
key := replay.NamespacedKey("callback_state", session.SessionID)
if err := cbCfg.ReplayStore.ClaimOnce(r.Context(), key, remaining); err != nil {
if errors.Is(err, replay.ErrAlreadyClaimed) {
metrics.ReplayDetected.WithLabelValues("callback_state").Inc()
logger.Warn("callback_state_replay",
zap.String("session_id", session.SessionID),
zap.String("client_id", session.ClientID),
)
writeOAuthError(w, http.StatusBadRequest, "invalid_request", "callback state already used", "callback_state_replay")
return
}
logger.Error("replay_store_error", zap.String("op", "claim_callback_state"), zap.Error(err))
metrics.AccessDenied.WithLabelValues("replay_store_unavailable").Inc()
writeOAuthError(w, http.StatusServiceUnavailable, "server_error", "replay store unavailable", "replay_store_unavailable")
return
}
}

// Explicit timeout for upstream OIDC token exchange
exchangeCtx, cancel := context.WithTimeout(r.Context(), oidcExchangeTTL)
defer cancel()
Expand Down
67 changes: 52 additions & 15 deletions handlers/consent.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,9 @@ import (
"time"

"github.com/babs/mcp-auth-proxy/metrics"
"github.com/babs/mcp-auth-proxy/replay"
"github.com/babs/mcp-auth-proxy/token"
"github.com/google/uuid"
"go.uber.org/zap"
"golang.org/x/oauth2"
)
Expand Down Expand Up @@ -183,10 +185,14 @@ func redirectHost(redirectURI string) string {
}

// ConsentConfig holds optional dependencies for the consent
// approval handler. Mirrors the shape of CallbackConfig — no
// runtime tunables today, kept as a struct so future fields don't
// re-break the constructor signature.
type ConsentConfig struct{}
// approval handler. Mirrors the shape of CallbackConfig.
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
}

// Consent handles POST /consent (consent-page approval submit).
//
Expand All @@ -206,17 +212,14 @@ type ConsentConfig struct{}
// purpose-bound, 5-min TTL). A POST without a valid consent_token
// is rejected.
//
// Replay note: the consent token is NOT integrated with the replay
// store. A given token can be redeemed multiple times within its
// 5-min window. Each Approve mints a fresh sealedSession (different
// nonce / verifier), and the IdP login still has to complete; each
// Deny just emits another error=access_denied to the registered
// redirect_uri. Neither path produces an effect the original user
// couldn't reproduce by clicking again. Wiring single-use through
// the replay store would be belt-and-braces against an attacker
// who exfiltrated the consent token (a stronger compromise than
// this defense addresses) — accepted as a deliberate trade-off.
func Consent(tm *token.Manager, logger *zap.Logger, baseURL string, oauth2Cfg *oauth2.Config, _ ConsentConfig) http.HandlerFunc {
// 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.
func Consent(tm *token.Manager, logger *zap.Logger, baseURL string, oauth2Cfg *oauth2.Config, cfg ConsentConfig) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
r.Body = http.MaxBytesReader(w, r.Body, maxBodySize)

Expand Down Expand Up @@ -278,6 +281,39 @@ func Consent(tm *token.Manager, logger *zap.Logger, baseURL string, oauth2Cfg *o
return
}

// Single-use claim on the consent JTI — applies BEFORE the
// approve/deny branch so a captured token cannot be replayed
// for either decision. Mirrors the /token authorization-code
// claim policy: nil store = stateless fallback (configured
// opt-out); ErrAlreadyClaimed = 400 + replay metric; other
// backend errors = fail-closed 503 so we never proceed
// against an uncertain replay-state result. Empty JTI is the
// in-flight-rollout fallback (older binary sealed the token
// before this field existed).
if cfg.ReplayStore != nil && consent.JTI != "" {
remaining := max(time.Until(consent.ExpiresAt), time.Second)
key := replay.NamespacedKey("consent", consent.JTI)
if err := cfg.ReplayStore.ClaimOnce(r.Context(), key, remaining); err != nil {
if errors.Is(err, replay.ErrAlreadyClaimed) {
metrics.ReplayDetected.WithLabelValues("consent").Inc()
logger.Warn("consent_token_replay",
zap.String("jti", consent.JTI),
zap.String("client_id", consent.ClientID),
)
writeOAuthError(w, http.StatusBadRequest, "invalid_request", "consent token already used", "consent_replay")
return
}
// Reuse the same access_denied{replay_store_unavailable}
// counter as /token rather than a per-site counter — a
// Redis outage hits every claim site at once and a single
// alerting rule on this counter covers all of them.
logger.Error("replay_store_error", zap.String("op", "claim_consent"), zap.Error(err))
metrics.AccessDenied.WithLabelValues("replay_store_unavailable").Inc()
writeOAuthError(w, http.StatusServiceUnavailable, "server_error", "replay store unavailable", "replay_store_unavailable")
return
}
}

if action == "deny" {
// Counted on a dedicated funnel counter rather than
// AccessDenied: clicking Deny is a normal expected user
Expand Down Expand Up @@ -335,6 +371,7 @@ func Consent(tm *token.Manager, logger *zap.Logger, baseURL string, oauth2Cfg *o
PKCEVerifier: upstreamVerifier,
SvrVerifier: svrVerifier,
SvrChallenge: svrChallenge,
SessionID: uuid.New().String(),
Typ: token.PurposeSession,
Audience: baseURL,
Resource: consent.Resource,
Expand Down
17 changes: 15 additions & 2 deletions handlers/helpers.go
Original file line number Diff line number Diff line change
Expand Up @@ -60,8 +60,14 @@ type sealedSession struct {
PKCEVerifier string `json:"pv"`
SvrVerifier string `json:"sv,omitempty"`
SvrChallenge string `json:"sch,omitempty"`
Typ string `json:"typ"`
Audience string `json:"aud"`
// SessionID is a per-session unique id used as the replay-store
// claim key at /callback so a captured `state` cannot be replayed
// against the IdP token endpoint (audit + outbound-fan-out
// defense). Empty on sessions sealed before this field existed —
// /callback then falls through to the prior stateless behavior.
SessionID string `json:"sid,omitempty"`
Typ string `json:"typ"`
Audience string `json:"aud"`
// Resource is the canonical RFC 8707 resource indicator the
// downstream tokens will be bound to. Captured at /authorize so
// the binding is fixed BEFORE the upstream IdP round trip — a
Expand Down Expand Up @@ -125,6 +131,13 @@ type sealedCode struct {
// usefulness). AAD purpose binding keeps it from being opened as
// any other sealed type.
type sealedConsent struct {
// JTI is a per-render unique id used as the single-use claim key
// at POST /consent. Each GET /authorize render mints a fresh JTI
// (back-button-safe: a re-render gets a new claim slot, the prior
// one is dead once redeemed). Empty on tokens sealed before this
// field existed — /consent then falls through to the prior
// stateless behavior for that token.
JTI string `json:"jti,omitempty"`
ClientID string `json:"cid"`
ClientName string `json:"cn,omitempty"`
RedirectURI string `json:"ru"`
Expand Down
Loading