-
Notifications
You must be signed in to change notification settings - Fork 191
Description
Description
Replace the single-upstream Handler struct with a multi-upstream design that drives a server-side sequential authorization chain: the auth server transparently redirects users through every configured upstream IDP in order, accumulating tokens for each, before issuing a single authorization code to the OAuth client. This is Phase 2 of RFC-0052 and builds directly on the UpstreamTokenStorage interface changes landed in Phase 1 (#4136).
Context
RFC-0052 extends the embedded OAuth authorization server (pkg/authserver/) to support multiple upstream IDPs. Phase 1 (#4136) restructured the storage layer so that UpstreamTokenStorage keys on (sessionID, providerName) and added GetAllUpstreamTokens. Phase 2 wires that storage layer into the handler and server layer: the Handler struct is refactored to hold a map of upstream providers keyed by name, the authorize handler generates a server-side SessionID and kicks off the first leg of the chain, and the callback handler either continues the chain (redirect to next upstream) or issues the final authorization code (all upstreams satisfied).
This task also introduces a documented breaking change: ProviderIdentity records stored with providerID = "oauth2" (previously derived from upstream.Type()) will not match after this change switches to pending.UpstreamProviderName as the providerID. Affected users will receive new User records after upgrade. This must be documented in the PR description.
Dependencies: #4136 (Phase 1: Storage Layer)
Blocks: None (TASK-003 and TASK-004 are independent of this task after Phase 1)
Breaking Change — ProviderIdentity provider ID:
ProviderIdentityrecords stored withproviderID = "oauth2"or"oidc"(fromupstream.Type()) will not match after this change switches to usingpending.UpstreamProviderName(the configured name, e.g.,"github","corporate-idp"). Affected users will receive freshUserrecords. This must be documented in the PR description.
Acceptance Criteria
-
Handlerstruct inpkg/authserver/server/handlers/handler.goreplaces the singleupstream upstream.OAuth2Providerfield withupstreams map[string]upstream.OAuth2Provider(keyed by provider name) andupstreamOrder []string(preserves config order to define chain sequence) -
NewHandlerconstructor signature acceptsupstreams map[string]upstream.OAuth2ProviderandupstreamOrder []stringin place of the singleupstreamIDP upstream.OAuth2Providerparameter -
AuthorizeHandlergeneratesSessionIDserver-side usingrand.Text()(never read from any client-supplied query parameter), stores it inPendingAuthorization.SessionID, setsPendingAuthorization.UpstreamProviderNametoupstreamOrder[0], and redirects to the first upstream -
nextMissingUpstreamhelper method is implemented on*Handlerand returns the name of the first upstream inupstreamOrderwhose tokens are not yet present inGetAllUpstreamTokens(ctx, sessionID), or empty string when all are satisfied; returnsupstreamOrder[0]onGetAllUpstreamTokenserror (fail-safe restart) -
CallbackHandlerusespending.SessionID(not a freshly generated ID) to key allStoreUpstreamTokenscalls for the completed leg -
CallbackHandlerdeletes the usedPendingAuthorizationbefore creating the next leg'sPendingAuthorization(CSRF protection: freshInternalStateper leg) -
CallbackHandlercreates a newPendingAuthorizationwith a freshInternalState, the sameSessionID, andUpstreamProviderNameset to the next upstream name, then redirects to that upstream whennextMissingUpstreamreturns a non-empty name -
CallbackHandlercallswriteAuthorizationResponse(issues the final authorization code) whennextMissingUpstreamreturns an empty string (all upstreams satisfied), threading the accumulatedSessionIDinto the fosite session -
CallbackHandlerusespending.UpstreamProviderName(notstring(h.upstream.Type())) as theproviderIDargument toh.userResolver.ResolveUserandh.userResolver.UpdateLastAuthenticated— this is the breaking change -
CallbackHandlerresolves the correctupstream.OAuth2Providerfromh.upstreams[pending.UpstreamProviderName]rather than using a singleh.upstreamfield -
server_impl.go(newServer) builds the upstream map and ordered slice fromcfg.Upstreamsand passes them tohandlers.NewHandler; the single-upstream path (existingGetUpstream()call) is replaced with iteration overcfg.Upstreams -
handlers_test.goandhelpers_test.goare updated to callNewHandlerwith the new signature; thetestStorageState.upstreamTokensfield and its mock wiring are updated to match Phase 1'sStoreUpstreamTokens(ctx, sessionID, providerName, tokens)signature - New tests cover:
SessionIDis server-generated and present inPendingAuthorizationafterAuthorizeHandlerruns;AuthorizeHandlercorrectly selectsupstreamOrder[0]as the first leg provider - New tests cover:
nextMissingUpstreamreturns the correct first unsatisfied upstream name; returns empty when all are satisfied; returnsupstreamOrder[0]whenGetAllUpstreamTokenserrors - New tests cover:
CallbackHandlerredirects to the second upstream whennextMissingUpstreamreturns a non-empty name (single-leg-done state);CallbackHandlerissues an authorization code when all upstreams are satisfied (all-satisfied state) - New tests cover: fresh
InternalStateis generated per chain leg (the oldPendingAuthorizationis deleted before the new one is stored); the sameSessionIDis threaded through both legs -
server_test.gois updated to pass the newNewHandlersignature (vianewServerfactory path) - A full sequential chain integration test is added in
pkg/authserver/integration_test.go: two mock upstream IDPs, full authorize → leg-1-callback → leg-2-callback → auth code → token exchange flow, verifying both providers' tokens are stored under the correctSessionIDand the issued JWT contains the expectedtsidclaim - PR description documents the
ProviderIdentitybreaking change: records stored withproviderID = "oauth2"or"oidc"will not match post-upgrade; affected users receive freshUserrecords -
task license-checkpasses (SPDX headers on all new/modified.gofiles) -
task lint-fixpasses with no remaining lint errors -
task testpasses (unit tests)
Technical Approach
Recommended Implementation
Start with handler.go: replace the upstream upstream.OAuth2Provider field with upstreams map[string]upstream.OAuth2Provider and upstreamOrder []string, and update NewHandler's signature. This immediately breaks authorize.go, callback.go, and server_impl.go at compile time — use the compiler to drive the remaining changes.
In authorize.go, generate the SessionID (via rand.Text()) and set both PendingAuthorization.SessionID and PendingAuthorization.UpstreamProviderName = h.upstreamOrder[0]. Resolve the first upstream provider from h.upstreams[h.upstreamOrder[0]] for the AuthorizationURL call.
In callback.go, the most significant change is the chain continuation logic: after StoreUpstreamTokens, call nextMissingUpstream(ctx, pending.SessionID). If a non-empty name is returned, delete the current PendingAuthorization, create a new one (fresh InternalState, same SessionID, new UpstreamProviderName), store it, resolve the next upstream from h.upstreams[nextProvider], build the authorization URL, and redirect. If empty, fall through to writeAuthorizationResponse. The providerID passed to ResolveUser changes from string(h.upstream.Type()) to pending.UpstreamProviderName.
In server_impl.go, replace the GetUpstream() → single-provider path with a loop over cfg.Upstreams that builds the map and ordered slice.
Add nextMissingUpstream as a private method on *Handler after the public methods, following the codebase convention of public methods first.
Patterns and Frameworks
rand.Text()forSessionID: Already used in the currentCallbackHandlerfor the single-upstreamsessionID. Phase 2 moves generation toAuthorizeHandlerso the sameSessionIDpersists across all chain legs. Never readSessionIDfrom client-supplied request parameters.- Defensive fresh
InternalStateper leg: Callh.storage.DeletePendingAuthorization(ctx, internalState)for the completed leg before callingh.storage.StorePendingAuthorization(ctx, newSecrets.State, newPending). This is the CSRF protection invariant: no two legs share the sameInternalState. - Fail-safe restart in
nextMissingUpstream: OnGetAllUpstreamTokenserror, returnh.upstreamOrder[0](restart from the beginning). This is conservative but correct: the user repeats all upstream authorizations rather than getting stuck in a broken partial state. - Immutable variable assignment with anonymous functions: Use for any
providerIDornextProviderderivation per the codebase's Go style conventions (seeCLAUDE.md). slogstructured logging: Follow the existingslog.Error/slog.Warn/slog.Debugcalls inauthorize.goandcallback.go. Never log token values.go.uber.org/mock(gomock): Handler tests usemocks.NewMockStorage. After Phase 1 updates theStoreUpstreamTokensmock signature, updatehelpers_test.goto use(ctx, sessionID, providerName, tokens)call pattern.- Table-driven tests with
require.NoError: Follow the patterns inauthorize_test.goandcallback_test.go.
Code Pointers
pkg/authserver/server/handlers/handler.go— Primary struct and constructor to modify: replaceupstream upstream.OAuth2Providerwithupstreams map[string]upstream.OAuth2ProviderandupstreamOrder []string; updateNewHandlersignature. AddnextMissingUpstreamas a private method here or in a new helper file.pkg/authserver/server/handlers/authorize.go— Authorize handler: addSessionIDgeneration (rand.Text()); setPendingAuthorization.SessionIDandPendingAuthorization.UpstreamProviderName; resolve first upstream fromh.upstreams[h.upstreamOrder[0]].pkg/authserver/server/handlers/callback.golines 83–147 — Callback handler: the existingsessionID := rand.Text()on line 113 moves toauthorize.go; instead readsessionIDfrompending.SessionID. ReplaceproviderID := string(h.upstream.Type())(line 98) withproviderID := pending.UpstreamProviderName. AddnextMissingUpstreamcall afterStoreUpstreamTokens; branch on result.pkg/authserver/server_impl.golines 127–138 — Server construction: replacecfg.GetUpstream()→options.upstreamFactory(ctx, upstreamCfg)→handlers.NewHandler(provider, authServerConfig, stor, upstreamIDP)with a loop overcfg.Upstreamsthat builds the map and ordered slice.pkg/authserver/server/handlers/helpers_test.golines 193–205 — UpdateStoreUpstreamTokensmock expectation from(ctx, sessionID, tokens)to(ctx, sessionID, providerName, tokens)after Phase 1 lands. Also addGetAllUpstreamTokensmock expectation for the multi-upstream chain tests.pkg/authserver/integration_test.go— AddTestIntegration_MultiUpstreamSequentialChainfollowing the existingTestIntegration_FullPKCEFlowpattern but with twomockoidc.MockOIDCinstances representing two upstream providers.pkg/authserver/server_test.go—TestNewServer_SuccessandTestNewuse thewithUpstreamFactoryoption; confirm these tests compile correctly afternewServer's internalNewHandlercall is updated.
Component Interfaces
// pkg/authserver/server/handlers/handler.go
// Handler provides HTTP handlers for the OAuth authorization server endpoints.
type Handler struct {
provider fosite.OAuth2Provider
config *server.AuthorizationServerConfig
storage storage.Storage
upstreams map[string]upstream.OAuth2Provider // keyed by provider name
upstreamOrder []string // config order → chain sequence
userResolver *UserResolver
}
// NewHandler creates a new Handler with the given dependencies.
func NewHandler(
provider fosite.OAuth2Provider,
config *server.AuthorizationServerConfig,
stor storage.Storage,
upstreams map[string]upstream.OAuth2Provider,
upstreamOrder []string,
userResolver *UserResolver,
) *Handler
// nextMissingUpstream returns the name of the first upstream in upstreamOrder
// whose tokens are not yet present in storage for the given sessionID.
// Returns "" when all upstreams are satisfied.
// Returns upstreamOrder[0] on storage error (fail-safe: restart chain from beginning).
func (h *Handler) nextMissingUpstream(ctx context.Context, sessionID string) string {
stored, err := h.storage.GetAllUpstreamTokens(ctx, sessionID)
if err != nil {
return h.upstreamOrder[0] // restart from beginning on error
}
for _, name := range h.upstreamOrder {
if _, exists := stored[name]; !exists {
return name
}
}
return "" // all satisfied
}// pkg/authserver/server/handlers/authorize.go — key changes
// AuthorizeHandler — new fields set on PendingAuthorization:
pending := &storage.PendingAuthorization{
// ... existing fields ...
UpstreamProviderName: h.upstreamOrder[0], // first upstream in chain
SessionID: rand.Text(), // server-side generated TSID
}
// Resolve provider for first leg:
firstProvider := h.upstreams[h.upstreamOrder[0]]
upstreamURL, err := firstProvider.AuthorizationURL(secrets.State, secrets.PKCEChallenge, authOpts...)// pkg/authserver/server/handlers/callback.go — key changes
// providerID switches from upstream.Type() to pending.UpstreamProviderName:
providerID := pending.UpstreamProviderName
// sessionID comes from pending (not freshly generated):
sessionID := pending.SessionID
// After StoreUpstreamTokens, determine next upstream:
nextProvider := h.nextMissingUpstream(ctx, sessionID)
if nextProvider != "" {
// Delete current pending (CSRF: fresh InternalState per leg)
_ = h.storage.DeletePendingAuthorization(ctx, internalState)
// Build next leg's PendingAuthorization (same SessionID, fresh InternalState)
nextSecrets := newUpstreamAuthSecrets()
nextPending := &storage.PendingAuthorization{
ClientID: pending.ClientID,
RedirectURI: pending.RedirectURI,
State: pending.State,
PKCEChallenge: pending.PKCEChallenge,
PKCEMethod: pending.PKCEMethod,
Scopes: pending.Scopes,
InternalState: nextSecrets.State,
UpstreamPKCEVerifier: nextSecrets.PKCEVerifier,
UpstreamNonce: nextSecrets.Nonce,
CreatedAt: time.Now(),
UpstreamProviderName: nextProvider,
SessionID: sessionID,
}
if err := h.storage.StorePendingAuthorization(ctx, nextSecrets.State, nextPending); err != nil {
// handle error
}
nextUpstream := h.upstreams[nextProvider]
nextURL, err := nextUpstream.AuthorizationURL(nextSecrets.State, nextSecrets.PKCEChallenge, authOpts...)
// ...
http.Redirect(w, req, nextURL, http.StatusFound)
return
}
// All satisfied — issue authorization code (existing writeAuthorizationResponse call)// pkg/authserver/server_impl.go — replace GetUpstream() single-provider path
// Build upstream map and ordered slice from config
upstreams := make(map[string]upstream.OAuth2Provider, len(cfg.Upstreams))
upstreamOrder := make([]string, 0, len(cfg.Upstreams))
for i := range cfg.Upstreams {
upCfg := &cfg.Upstreams[i]
provider, err := options.upstreamFactory(ctx, upCfg)
if err != nil {
return nil, fmt.Errorf("failed to create upstream provider %q: %w", upCfg.Name, err)
}
upstreams[upCfg.Name] = provider
upstreamOrder = append(upstreamOrder, upCfg.Name)
}
handlerInstance := handlers.NewHandler(provider, authServerConfig, stor, upstreams, upstreamOrder, handlers.NewUserResolver(stor))Testing Strategy
Unit Tests — pkg/authserver/server/handlers/authorize_test.go
-
TestAuthorizeHandler_MultiUpstream_SessionIDGenerated: verify that afterAuthorizeHandlerruns, the storedPendingAuthorizationhas a non-emptySessionID(generated server-side) andUpstreamProviderName == upstreamOrder[0] -
TestAuthorizeHandler_MultiUpstream_RedirectsToFirstUpstream: verify the 302 redirect URL corresponds to the first upstream provider's authorization URL -
TestAuthorizeHandler_NoUpstreams_ReturnsServerError: verify error handling whenupstreamOrderis empty
Unit Tests — pkg/authserver/server/handlers/callback_test.go
-
TestCallbackHandler_FirstLegComplete_RedirectsToSecondUpstream: configure two upstreams, simulate a callback completing the first leg (no tokens yet for the second upstream viaGetAllUpstreamTokens), verifynextMissingUpstreamreturns the second provider name and the handler redirects to its authorization URL -
TestCallbackHandler_AllSatisfied_IssuesAuthCode: configure two upstreams with both already having tokens inGetAllUpstreamTokens, verify the handler callswriteAuthorizationResponse(issues auth code to client, 303 response) -
TestCallbackHandler_FreshInternalStatePerLeg: after completing leg 1, verify the usedPendingAuthorization(keyed byinternalState) is deleted and a new entry with a differentInternalStateis created for leg 2 -
TestCallbackHandler_SessionIDThreadedAcrossLegs: verify theSessionIDin the new leg-2PendingAuthorizationequals theSessionIDfrom the leg-1PendingAuthorization -
TestCallbackHandler_ProviderIDFromPendingName: verifyResolveUserandUpdateLastAuthenticatedare called withpending.UpstreamProviderName(notupstream.Type())
Unit Tests — nextMissingUpstream helper
- Returns first upstream name when no tokens are stored (all missing)
- Returns second upstream name when only first upstream's tokens are stored
- Returns
""when all upstreams' tokens are stored - Returns
upstreamOrder[0]whenGetAllUpstreamTokensreturns an error
Integration Tests — pkg/authserver/integration_test.go
-
TestIntegration_MultiUpstreamSequentialChain: full end-to-end test with twomockoidc.MockOIDCinstances as upstream providers. Flow:GET /oauth/authorize→ redirect to provider-1 →GET /oauth/callback(provider-1 code) → redirect to provider-2 →GET /oauth/callback(provider-2 code) → 303 toredirect_uriwith auth code →POST /oauth/token→ JWT access token. Verify: (1) both providers' tokens are stored under the correctSessionID; (2) the issued JWT contains thetsidclaim equal to theSessionID; (3) the final auth code is issued only after both callbacks complete; (4) the client observes exactly two upstream redirects (one per provider) before receiving the auth code redirect.
Edge Cases
- Single-upstream deployment: verify backward compatibility — when
upstreamOrderhas exactly one entry, the handler behaves identically to the current single-upstream implementation (no second redirect, auth code issued after first callback) -
GetAllUpstreamTokensreturns error duringnextMissingUpstream: verify the chain restarts fromupstreamOrder[0](existing session data is preserved in storage; only the in-memory decision restarts) - Replayed
InternalStatefrom a completed leg: verifyLoadPendingAuthorizationreturnsErrNotFound(the entry was deleted after the leg completed), and the callback handler returns a 400 with an appropriate error
Out of Scope
- Storage layer changes (
UpstreamTokenStorageinterface, backends,PendingAuthorizationstruct): covered in TASK-001 (Implement multi-provider upstream token storage layer (RFC-0052 Phase 1) #4136) and must already be merged - Config and operator validation changes (removing/moving
len > 1guard): covered in TASK-003 Identity.UpstreamTokensenrichment andupstreamswapmiddleware refactor: covered in TASK-004UpstreamTokenRefreshermulti-provider support: Phase 1 updated the storage call; full multi-provider refresh chain is deferred to a later RFC- Distributed locking for concurrent token refresh races
- Dynamic upstream IDP registration at runtime
References
- RFC:
docs/proposals/THV-0052-multi-upstream-idp-authserver.md - Parent epic: Auth Server: multi-upstream provider support #3924
- Phase 1 (dependency): Implement multi-provider upstream token storage layer (RFC-0052 Phase 1) #4136
- Architecture doc for auth server storage:
docs/arch/11-auth-server-storage.md - Related Stacklok epic: https://github.com/stacklok/stacklok-epics/issues/251