Skip to content

Filter upstream auth chain via callback hook#5725

Draft
tgrunnagle wants to merge 1 commit into
mainfrom
childish-waste
Draft

Filter upstream auth chain via callback hook#5725
tgrunnagle wants to merge 1 commit into
mainfrom
childish-waste

Conversation

@tgrunnagle

Copy link
Copy Markdown
Contributor

Summary

The embedded OAuth authorization server walks every configured upstream provider
on every authorization. There is no way for a consumer to say that only a subset
of the configured upstreams is relevant for a given authorization, so the flow
prompts for upstreams the authorization does not need and stores upstream tokens
that are never used — widening the stored-token surface for no benefit.

This PR adds an optional filter hook, consulted once in the callback handler after
the first upstream resolves, that narrows the remaining chain to a subset of the
configured upstreams. The change is fully backward compatible: with no filter
configured, the handler walks all configured upstreams exactly as before.

  • Why: let consumers scope an authorization to only the upstreams it needs,
    avoiding needless prompts and reducing the stored-token surface.
  • What: a new UpstreamFilter interface and WithUpstreamFilter construction
    option; the effective chain is computed once and carried in
    PendingAuthorization so the filter is not re-run per leg; the missing-leg walk
    and the cross-leg identity check now operate on the effective chain rather than
    the raw config.

Closes #5724

Type of change

  • Bug fix
  • New feature
  • Refactoring (no behavior change)
  • Dependency update
  • Documentation
  • Other (describe):

Test plan

  • Unit tests (task test)
  • E2E tests (task test-e2e)
  • Linting (task lint-fix)
  • Manual testing (describe below)

New unit tests cover the callback flow and the chain computation:

  • Filter drops the second leg → authorization completes at the first upstream and
    issues the code without prompting for the dropped leg.
  • Filter keeps the second leg → the effective chain is carried forward in the
    pending authorization.
  • Filter error → the authorization fails cleanly with a server error (no fallback
    to a full walk).
  • Second leg reuses the carried chain and does not re-run the filter.
  • computeChain table cases: no-filter full walk in order, single upstream skips
    the filter, subset kept with the first upstream always leading, empty keep set
    leaves only the first upstream, filter return order ignored in favor of
    configured order, and unknown / first-upstream names in the keep set ignored.
  • nextMissingUpstream respects the chain subset rather than the raw config.

Changes

File Change
pkg/authserver/server/handlers/handler.go Add UpstreamFilter interface, WithUpstreamFilter option, and computeChain; nextMissingUpstream now walks the effective chain
pkg/authserver/server/handlers/callback.go Compute the effective chain once on the first leg, carry it forward, and extract verifyChainIdentity to gate on the effective chain
pkg/authserver/storage/types.go Add ChainUpstreams field to PendingAuthorization
pkg/authserver/storage/memory.go Persist/restore ChainUpstreams in the in-memory store
pkg/authserver/storage/redis.go Persist/restore ChainUpstreams in the Redis store
pkg/authserver/server/handlers/*_test.go, pkg/authserver/storage/*_test.go Unit tests for the filter hook, chain computation, and storage round-trip

Does this introduce a user-facing change?

No end-user-facing change to the thv CLI. For consumers embedding the auth
server, this adds a new optional construction option (WithUpstreamFilter). It is
opt-in and behavior is unchanged when unset.

Special notes for reviewers

  • First upstream is never filtered. An authorization always begins at
    upstreams[0]; it is not passed to the filter and cannot be removed by it.
  • Filter can only narrow, never reorder or extend. computeChain iterates the
    configured order and ignores any returned name that is not a non-first configured
    upstream, so a filter cannot reorder the chain or inject unknown providers.
  • No silent fallback on error. A filter error fails the authorization with a
    server error rather than reverting to walking every upstream.
  • Backward/forward compatibility. A legacy PendingAuthorization predating the
    new ChainUpstreams field (empty on load) is safely recomputed in the callback,
    which yields all configured upstreams in order for the no-filter case.
  • Non-goals preserved. SingleLeg flows are unaffected and the
    upstream-token storage key contracts are unchanged.

Generated with Claude Code

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>
@github-actions github-actions Bot added the size/M Medium PR: 300-599 lines changed label Jul 5, 2026
@codecov

codecov Bot commented Jul 5, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 96.66667% with 2 lines in your changes missing coverage. Please review.
✅ Project coverage is 70.70%. Comparing base (fb69e00) to head (424b657).

Files with missing lines Patch % Lines
pkg/authserver/server/handlers/callback.go 92.59% 1 Missing and 1 partial ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main    #5725      +/-   ##
==========================================
+ Coverage   70.65%   70.70%   +0.05%     
==========================================
  Files         682      682              
  Lines       68854    68890      +36     
==========================================
+ Hits        48651    48712      +61     
+ Misses      16657    16611      -46     
- Partials     3546     3567      +21     

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@tgrunnagle tgrunnagle left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Multi-Agent Consensus Review

Agents consulted: security, architecture-go, storage-persistence, test-coverage, general-quality

Consensus Summary

# Finding Consensus Severity Action
1 Legacy-pending recompute can use the wrong leg's request context for the filter 9/10 MEDIUM Fix
2 Stored ChainUpstreams is trusted without validation against configured upstreams 7/10 MEDIUM Fix
3 Identity-mismatch log loses structured fields after the verifyChainIdentity extraction 7/10 MEDIUM Fix
4 Missing backward-compatibility test for legacy Redis pending records lacking chain_upstreams 7/10 MEDIUM Fix

Overall

This adds an optional UpstreamFilter hook, injected via WithUpstreamFilter, that narrows a multi-upstream OAuth authorization chain to a subset after the first upstream resolves. The implementation matches the linked issue's proposed shape closely, computes the effective chain once and carries it forward in PendingAuthorization.ChainUpstreams rather than re-invoking the filter per leg, and fails closed (server error, no fallback to a full walk) on filter error. computeChain's narrowing logic is small, pure, and has thorough table-driven coverage of every scenario called out in the linked issue. For existing no-filter deployments, behavior is unchanged end to end.

The findings below are all MEDIUM and none block merging. The most notable one (#1) is a narrow edge case in the "empty ChainUpstreams" recompute branch: it can't distinguish a genuine first leg from a non-first leg whose pending predates this field (a realistic rolling-deploy scenario for the Redis backend), so a context-sensitive filter could see the wrong leg's request context during that window. The other three are smaller and self-contained: a defense-in-depth gap where a chain loaded from storage isn't cross-checked against the configured upstream list, a logging regression where the identity-mismatch check lost its structured slog fields during extraction into verifyChainIdentity, and a test-coverage gap for the legacy-Redis-record backward-compatibility case (the repo already has a precedent for exactly this kind of test at redis_test.go:1109).

Documentation

UpstreamFilter's doc comment (pkg/authserver/server/handlers/handler.go) states it receives "the request context of the first leg's callback" — worth adding a note there about the rolling-upgrade edge case in finding #1, and mentioning that the context it receives already carries auth.WithPlatformUser, since that's the most concrete signal a real filter implementation would key off of.


Generated with Claude Code

// subsequent leg reuses the chain carried in the pending authorization so the
// filter is not re-run per leg. A legacy pending predating ChainUpstreams also
// lands here and is safely recomputed.
chain := pending.ChainUpstreams

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[MEDIUM] Legacy-pending recompute can use the wrong leg's request context for the filter (Consensus: 9/10)

len(chain) == 0 is meant to catch a true first leg or a legacy pending predating ChainUpstreams, but it also fires for a genuine non-first leg whose pending was written before this field existed (a realistic rolling-deploy window for the Redis backend). In that case computeChain(ctx) — and the filter — runs with the later leg's context instead of the first leg's, contradicting UpstreamFilter's "consulted once, first-leg context" doc contract.

Consider disambiguating with pending.ResolvedUserID == "" (true first leg) in addition to len(chain) == 0, and treating a non-first leg missing its chain as a hard error requiring re-authentication rather than silently recomputing with a mismatched context.

Raised by: security, architecture-go, storage-persistence, general-quality

chain = computed
}

nextProvider, err := h.nextMissingUpstream(ctx, sessionID, chain)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[MEDIUM] Stored ChainUpstreams is trusted without validation against configured upstreams (Consensus: 7/10)

chain is used as-is here once non-empty — nothing asserts chain[0] == h.upstreams[0].Name or that every other entry resolves via h.upstreamByName. Combined with verifyChainIdentity's len(chain) <= 1 no-op gate, a party able to write a single PendingAuthorization row could shrink an in-flight chain to one element, skipping legs and disabling the cross-leg identity check. This requires storage write access to the specific row, which tempers real-world severity, but the handler already validates the analogous pending.UpstreamProviderName field elsewhere in this file — worth applying the same discipline here.

Raised by: security

return
}
if err := h.verifyChainIdentity(ctx, sessionID, chain, subject); err != nil {
slog.Error("chain identity verification failed", "error", err)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[MEDIUM] Identity-mismatch log loses structured fields after the verifyChainIdentity extraction (Consensus: 7/10)

The pre-PR inline check logged expected, got, and provider as separate structured slog keys. Here, only "error", err is logged, with those three values now folded into one formatted string inside verifyChainIdentity. A log pipeline that filtered/alerted on those keys for this defense-in-depth check loses that capability.

Consider having verifyChainIdentity return a typed error (or having the caller re-derive the fields) so this call site can re-emit "expected", subject, "got", firstTokens.UserID, "provider", firstProvider alongside the error.

Raised by: general-quality

Scopes: []string{"openid", "profile"}, InternalState: state,
UpstreamPKCEVerifier: "verifier", UpstreamNonce: "nonce",
SingleLeg: true, CreatedAt: time.Now(),
SingleLeg: true, ChainUpstreams: []string{"provider-1", "provider-2"},

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[MEDIUM] Missing backward-compatibility test for legacy Redis records lacking chain_upstreams (Consensus: 7/10)

makePending() always sets a non-empty ChainUpstreams, so no test exercises loading a pre-upgrade JSON blob that lacks the chain_upstreams key — the exact scenario the PR's backward-compatibility claim rests on. The repo already has a precedent for this pattern at redis_test.go:1109 ("legacy JSON without session_expires_at decodes with zero SessionExpiresAt") that could be mirrored here: seed a raw JSON string missing chain_upstreams via s.client.Set, then assert LoadPendingAuthorization returns a nil/empty ChainUpstreams.

Raised by: storage-persistence

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

size/M Medium PR: 300-599 lines changed

Projects

None yet

Development

Successfully merging this pull request may close these issues.

authserver: filter the upstream authorization chain via a callback-handler hook

1 participant