Skip to content

Commit 609c0ad

Browse files
tgrunnagleclaude
andauthored
Filter upstream auth chain via callback hook (#5725)
* Filter upstream auth chain via callback hook The embedded OAuth authorization server walks every configured upstream on every authorization, prompting for and storing tokens that a given authorization may not need. This widens the stored-token surface and forces needless upstream prompts. Add an optional UpstreamFilter hook (WithUpstreamFilter), consulted once in the callback handler after the first upstream resolves, that narrows the remaining chain to a subset of the configured upstreams: - The first upstream is always required and is never passed to nor removable by the filter. - The kept set is computed once and carried in PendingAuthorization (ChainUpstreams) so the filter is not re-run per leg. - nextMissingUpstream and the cross-leg identity check now operate on the effective chain rather than the raw config. - A filter error fails the authorization with a server error; it never silently falls back to walking every upstream. With no filter configured, behaviour is unchanged: the handler walks all configured upstreams as before. SingleLeg flows are unaffected and the upstream-token storage key contracts are unchanged. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * Harden upstream chain resolution per review Addresses #5725 review comments: - MEDIUM callback.go (3525733910): distinguish a true first leg (ResolvedUserID == "") from a subsequent leg; reject a subsequent-leg pending that lacks a computed chain instead of recomputing the filter against the wrong leg's request context. - MEDIUM callback.go (3525733913): validate a chain loaded from storage (non-empty, leads with the first configured upstream, names only configured upstreams) so a tampered PendingAuthorization row cannot shrink the chain and disable the cross-leg identity check. - MEDIUM callback.go (3525733916): restore discrete expected/got/provider slog fields on the identity-mismatch check after the verifyChainIdentity extraction, so log pipelines can still alert on it. - MEDIUM redis_test.go (3525733919): add a backward-compatibility test that loads a legacy pending JSON lacking chain_upstreams and asserts an empty ChainUpstreams. Also expands the UpstreamFilter doc comment (first-leg-context guarantee across rolling upgrades, WithPlatformUser in ctx) and updates existing chain tests to carry ChainUpstreams on subsequent legs. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * Expose first-upstream identity to chain filter A useful upstream filter needs to key its narrowing decision on who authenticated — the subject and the authorization-relevant claims from the first upstream — not just on request context. Widen the hook to receive that identity explicitly. - FilterUpstreams now takes the canonical platform user ID and the resolved auth.PrincipalInfo (claim-mapped Subject, Name, Email, and the Claims map used by authz policies) alongside the configured upstreams. The principal is passed as an explicit parameter, not via context: the callback deliberately avoids placing a bearer-less Identity in ctx. - Capture the upstream claims that previously had nowhere to live: add Claims to upstream.Identity, populated from the validated ID token (OIDC) and the userinfo response (OAuth2). identityFromToken and synthetic OAuth2 resolve no structured claim set and leave it nil. - The callback builds the principal from the first leg's resolved identity and threads it through continueChainOrComplete -> resolveChain -> computeChain to the filter. Tests: an OIDC provider integration test (real provider + mock IdP) asserting ID-token claims are captured into the resolved Identity, and a callback integration test asserting the first upstream's subject and claims reach FilterUpstreams. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * Add callback/chain integration tests for auth server The embedded auth server integration suite stopped at the first-leg redirect and never exercised the /oauth/callback or multi-upstream chain traversal. Add coverage that drives the callback end to end against the real embedded server and mock upstream IDPs: - Single upstream: authorize -> callback -> the chain completes at the sole upstream and issues the authorization code to the client. - Multi-upstream: the first callback continues the chain to the second upstream, and the second completes it and issues the code. Adds a WithUpstreams helper option, an OAuth2 upstream builder, and a Callback client method to support driving the flow. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * Tighten chain validation and clarify filter docs Addresses #5725 review comments: - MEDIUM handler.go (3528199173): validateChain now enforces an in-order, duplicate-free subsequence of the configured upstreams (config-cursor walk), so a tampered pending row cannot shrink/reorder the chain to skip legs (e.g. ["provider-1"] or ["provider-1","provider-1"]) and slip past the single-element identity-check no-op. Adds a TestValidateChain table covering wrong-first / unconfigured / out-of-order / duplicate. - MEDIUM handler.go (body): NewHandler rejects duplicate upstream names — the name invariant is now load-bearing (chain keyed by name; tokens and upstreamByName key by name). Adds a constructor test. - LOW handler.go (3528199199): document the Claims contract — values are untyped (assert defensively, never panic), nil is possible on a transient OIDC extraction failure (treat as fail-closed), and aud is the upstream's client_id, not this AS. - LOW callback.go (3528199204): reword verifyChainIdentity to match scope — it reconciles only the first leg; intermediate legs are intentionally not identity-checked. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * Drop redundant platformUserID param from UpstreamFilter The canonical user is already carried as principal.PlatformUserID, so the separate platformUserID argument to FilterUpstreams was redundant. Remove it and have consumers read principal.PlatformUserID; update the doc, the stub filter, and the tests accordingly. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
1 parent d27d85a commit 609c0ad

17 files changed

Lines changed: 1173 additions & 42 deletions

File tree

pkg/authserver/server/handlers/authorize_test.go

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -185,6 +185,24 @@ func TestNewHandler_ErrorsOnNilConfig(t *testing.T) {
185185
"NewHandler should error when AuthorizationServerConfig.Config is nil")
186186
}
187187

188+
// TestNewHandler_ErrorsOnDuplicateUpstreamNames pins that the constructor rejects
189+
// duplicate upstream names. Names must be unique: upstreamByName returns the first
190+
// match, tokens are keyed by name, and the authorization chain is keyed by name, so
191+
// a duplicate would silently shadow a provider.
192+
func TestNewHandler_ErrorsOnDuplicateUpstreamNames(t *testing.T) {
193+
t.Parallel()
194+
195+
_, oauth2Config, stor, _ := baseTestSetup(t)
196+
upstreams := []NamedUpstream{
197+
{Name: "dup", Provider: &mockIDPProvider{}},
198+
{Name: "dup", Provider: &mockIDPProvider{}},
199+
}
200+
201+
_, err := NewHandler(nil, oauth2Config, stor, upstreams)
202+
require.Error(t, err, "NewHandler should reject duplicate upstream names")
203+
assert.ErrorContains(t, err, "duplicate upstream name")
204+
}
205+
188206
func TestAuthorizeHandler_RedirectsToUpstream(t *testing.T) {
189207
t.Parallel()
190208
handler, storState, mockUpstream := handlerTestSetup(t)

pkg/authserver/server/handlers/callback.go

Lines changed: 90 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -180,7 +180,20 @@ func (h *Handler) CallbackHandler(w http.ResponseWriter, req *http.Request) {
180180
return
181181
}
182182

183-
h.continueChainOrComplete(ctx, w, req, ar, pending, sessionID, subject, userName, userEmail)
183+
// Build the credential-free principal for the optional upstream filter, keyed on
184+
// the identity the first leg just established. providerSubject is the claim-mapped
185+
// upstream subject; subject is the canonical ToolHive user ID. On subsequent legs
186+
// the filter is not consulted, so this only drives filtering for upstreams[0].
187+
// result.Claims carries the ID-token/userinfo claims (nil for synthetic upstreams).
188+
principal := auth.PrincipalInfo{
189+
Subject: providerSubject,
190+
PlatformUserID: subject,
191+
Name: userName,
192+
Email: userEmail,
193+
Claims: result.Claims,
194+
}
195+
196+
h.continueChainOrComplete(ctx, w, req, ar, pending, sessionID, principal)
184197
}
185198

186199
// maybeCarryForwardRefreshToken preserves a prior refresh token when the upstream IdP
@@ -409,10 +422,15 @@ func (h *Handler) continueChainOrComplete(
409422
ar fosite.AuthorizeRequester,
410423
pending *storage.PendingAuthorization,
411424
sessionID string,
412-
subject string,
413-
name string,
414-
email string,
425+
principal auth.PrincipalInfo,
415426
) {
427+
// subject is the canonical ToolHive user ID used for chain state, token keying,
428+
// and the cross-leg identity check. Note this is principal.PlatformUserID, NOT
429+
// principal.Subject (which is the upstream provider's subject).
430+
subject := principal.PlatformUserID
431+
name := principal.Name
432+
email := principal.Email
433+
416434
// SingleLeg authorizations intentionally bypass chain continuation: the caller
417435
// scoped this flow to one specific upstream (e.g. a UI-initiated "connect one
418436
// backend" request), so other configured-but-tokenless upstreams must not
@@ -433,7 +451,20 @@ func (h *Handler) continueChainOrComplete(
433451
return
434452
}
435453

436-
nextProvider, err := h.nextMissingUpstream(ctx, sessionID)
454+
// Resolve the effective chain of upstreams to walk. The first leg computes it
455+
// (consulting the optional filter with this leg's request context); every
456+
// subsequent leg reuses the validated chain the first leg carried forward, so
457+
// the filter is not re-run per leg. A subsequent leg whose pending predates the
458+
// chain is rejected rather than recomputed against a later leg's context.
459+
chain, err := h.resolveChain(ctx, pending, principal)
460+
if err != nil {
461+
slog.Error("failed to resolve upstream chain", "error", err)
462+
_ = h.storage.DeleteUpstreamTokens(ctx, sessionID)
463+
h.provider.WriteAuthorizeError(ctx, w, ar, fosite.ErrServerError.WithHint("failed to determine authorization chain"))
464+
return
465+
}
466+
467+
nextProvider, err := h.nextMissingUpstream(ctx, sessionID, chain)
437468
if err != nil {
438469
slog.Error("failed to determine next upstream", "error", err)
439470
_ = h.storage.DeleteUpstreamTokens(ctx, sessionID)
@@ -442,28 +473,12 @@ func (h *Handler) continueChainOrComplete(
442473
}
443474

444475
if nextProvider == "" {
445-
// Defense-in-depth: verify identity consistency across chain legs.
446-
// The subject was resolved from the first leg's upstream and carried through
447-
// PendingAuthorization. Cross-check it against the stored upstream tokens.
448-
if len(h.upstreams) > 1 {
449-
allTokens, err := h.storage.GetAllUpstreamTokens(ctx, sessionID)
450-
if err != nil {
451-
slog.Error("failed to verify identity consistency", "error", err)
452-
_ = h.storage.DeleteUpstreamTokens(ctx, sessionID)
453-
h.provider.WriteAuthorizeError(ctx, w, ar, fosite.ErrServerError.WithHint("failed to verify identity consistency"))
454-
return
455-
}
456-
firstProvider := h.upstreams[0].Name
457-
if firstTokens, ok := allTokens[firstProvider]; ok && firstTokens.UserID != subject {
458-
slog.Error("identity mismatch between chain state and stored tokens",
459-
"expected", subject,
460-
"got", firstTokens.UserID,
461-
"provider", firstProvider,
462-
)
463-
_ = h.storage.DeleteUpstreamTokens(ctx, sessionID)
464-
h.provider.WriteAuthorizeError(ctx, w, ar, fosite.ErrServerError.WithHint("identity verification failed"))
465-
return
466-
}
476+
if err := h.verifyChainIdentity(ctx, sessionID, chain, subject); err != nil {
477+
// verifyChainIdentity already logged the specific cause (with structured
478+
// fields for a mismatch); here we just clean up and fail closed.
479+
_ = h.storage.DeleteUpstreamTokens(ctx, sessionID)
480+
h.provider.WriteAuthorizeError(ctx, w, ar, fosite.ErrServerError.WithHint("identity verification failed"))
481+
return
467482
}
468483

469484
// All upstreams satisfied — issue authorization code
@@ -495,6 +510,9 @@ func (h *Handler) continueChainOrComplete(
495510
// Chain state
496511
UpstreamProviderName: nextProvider,
497512
SessionID: sessionID,
513+
// Carry the effective chain forward so the filter is computed once, on the
514+
// first leg, and reused for every subsequent leg.
515+
ChainUpstreams: chain,
498516
// Carry resolved identity from first leg
499517
ResolvedUserID: subject,
500518
ResolvedUserName: name,
@@ -533,3 +551,48 @@ func (h *Handler) continueChainOrComplete(
533551

534552
http.Redirect(w, req, nextURL, http.StatusFound)
535553
}
554+
555+
// verifyChainIdentity is a defense-in-depth check run once every leg of the
556+
// effective chain is satisfied. Despite the "chain" framing, it reconciles only
557+
// the first leg: it confirms the identity provider's stored token (chain[0]) still
558+
// belongs to the subject carried through the flow. Intermediate/later legs are
559+
// deliberately NOT identity-checked — those are connect-this-backend flows whose
560+
// upstream identity can legitimately differ from the first leg's user.
561+
//
562+
// subject MUST be the canonical ToolHive user ID resolved from the first leg's
563+
// upstream via that provider's configured subject-claim mapping (the OIDC
564+
// SubjectClaim, or the "sub" claim by default) and carried forward unchanged
565+
// through PendingAuthorization.ResolvedUserID. The caller resolves it exactly once,
566+
// on the first leg, from the claim-mapped upstream subject. This cross-checks it
567+
// against firstTokens.UserID — the same canonical ID persisted when the first leg
568+
// stored its tokens — so a first leg whose stored user disagrees with the carried
569+
// subject is rejected.
570+
//
571+
// It gates on the effective chain rather than the raw config: chain[0] is always
572+
// the first (required) upstream, so a chain the filter narrowed to just that
573+
// upstream has no first-leg cross-check to run and the check is a no-op. Returns a
574+
// non-nil error when the storage lookup fails or an identity mismatch is detected;
575+
// the caller maps either to a server error.
576+
func (h *Handler) verifyChainIdentity(ctx context.Context, sessionID string, chain []string, subject string) error {
577+
if len(chain) <= 1 {
578+
return nil
579+
}
580+
allTokens, err := h.storage.GetAllUpstreamTokens(ctx, sessionID)
581+
if err != nil {
582+
slog.Error("failed to load upstream tokens for chain identity check", "error", err)
583+
return fmt.Errorf("failed to load upstream tokens for identity check: %w", err)
584+
}
585+
firstProvider := chain[0]
586+
if firstTokens, ok := allTokens[firstProvider]; ok && firstTokens.UserID != subject {
587+
// Emit the mismatch as discrete slog fields — not folded into the returned
588+
// error string — so log pipelines can filter/alert on this defense-in-depth
589+
// check, matching the logging from before this check was extracted here.
590+
slog.Error("identity mismatch between chain state and stored tokens",
591+
"expected", subject,
592+
"got", firstTokens.UserID,
593+
"provider", firstProvider,
594+
)
595+
return fmt.Errorf("identity mismatch on provider %q", firstProvider)
596+
}
597+
return nil
598+
}

0 commit comments

Comments
 (0)