[management,proxy] Use single-use codes for OIDC session handoff (#7635)

* Generalize PKCE verifier store into SingleUseStore

* Generalize PKCE verifier store into SingleUseStore

* Extend single-use store to generate one-time retrieval codes

* Hand off proxy OIDC session via one-time code instead of URL token

* Use the single-use store in integration tests

* Read active proxy versions by cluster

* Detect proxy clusters that support session codes

* Bind OIDC session handoff mode to signed state

* Deprecate legacy OIDC session token handoff

* Remove unrelated session code test stub

* fix tests

* fix merge

* Fix session code compatibility detection

* Isolate proxy session codes in shared cache

* bump min session version
This commit is contained in:
Bethuel Mmbaga
2026-09-29 18:29:55 +03:00
committed by GitHub
parent 7ff709f565
commit 30dd076b36
22 changed files with 676 additions and 281 deletions
+32 -17
View File
@@ -46,7 +46,7 @@ type Scheme interface {
// an authenticated user. An empty token indicates an unauthenticated
// request; optionally, promptData may be returned for the login UI.
// An error indicates an infrastructure failure (e.g. gRPC unavailable).
Authenticate(*http.Request) (token string, promptData string, err error)
Authenticate(*http.Request) (token, promptData string, err error)
}
// DomainConfig holds the authentication and restriction settings for a protected domain.
@@ -77,6 +77,8 @@ type validationResult struct {
// Groups for tokens minted before names were embedded; the consumer
// falls back to ids for missing positions.
GroupNames []string
// MintedToken is the session token issued when a one-time code is redeemed.
MintedToken string
}
// Middleware applies per-domain authentication and IP restriction checks.
@@ -581,7 +583,8 @@ func (mw *Middleware) authenticateWithSchemes(w http.ResponseWriter, r *http.Req
// handleAuthenticatedToken validates the token, handles denied access, and on
// success sets a session cookie and redirects to the original URL.
func (mw *Middleware) handleAuthenticatedToken(w http.ResponseWriter, r *http.Request, host, token string, config DomainConfig, scheme Scheme) {
result, err := mw.validateSessionToken(r.Context(), host, token, config.SessionPublicKey, scheme.Type())
isCode := scheme.Type() == auth.MethodOIDC && r.URL.Query().Get("session_code") != ""
result, err := mw.validateSessionToken(r.Context(), host, token, isCode, config.SessionPublicKey, scheme.Type())
if err != nil {
if cd := proxy.CapturedDataFromContext(r.Context()); cd != nil {
cd.SetOrigin(proxy.OriginAuth)
@@ -612,7 +615,13 @@ func (mw *Middleware) handleAuthenticatedToken(w http.ResponseWriter, r *http.Re
return
}
setSessionCookie(w, token, config.SessionExpiration)
// When a code was redeemed, the cookie must hold the durable token the
// server returned, not the single-use code.
cookieValue := token
if result.MintedToken != "" {
cookieValue = result.MintedToken
}
setSessionCookie(w, cookieValue, config.SessionExpiration)
// Redirect instead of forwarding the auth POST to the backend.
// The browser will follow with a GET carrying the new session cookie.
@@ -652,7 +661,7 @@ func wasCredentialSubmitted(r *http.Request, method auth.Method) bool {
case auth.MethodPassword:
return credentialFormValue(r, passwordFormId) != ""
case auth.MethodOIDC:
return r.URL.Query().Get("session_token") != ""
return r.URL.Query().Get("session_token") != "" || r.URL.Query().Get("session_code") != ""
}
return false
}
@@ -706,12 +715,15 @@ func (mw *Middleware) RemoveDomain(domain string) {
// validateSessionToken validates a session token. OIDC tokens with a configured
// validator go through gRPC for group access checks; other methods validate locally.
func (mw *Middleware) validateSessionToken(ctx context.Context, host, token string, publicKey ed25519.PublicKey, method auth.Method) (*validationResult, error) {
func (mw *Middleware) validateSessionToken(ctx context.Context, host, token string, isCode bool, publicKey ed25519.PublicKey, method auth.Method) (*validationResult, error) {
if method == auth.MethodOIDC && mw.sessionValidator != nil {
resp, err := mw.sessionValidator.ValidateSession(ctx, &proto.ValidateSessionRequest{
Domain: host,
SessionToken: token,
})
req := &proto.ValidateSessionRequest{Domain: host}
if isCode {
req.SessionCode = token
} else {
req.SessionToken = token //nolint:staticcheck
}
resp, err := mw.sessionValidator.ValidateSession(ctx, req)
if err != nil {
return nil, fmt.Errorf("%w: %w", errValidationUnavailable, err)
}
@@ -729,11 +741,12 @@ func (mw *Middleware) validateSessionToken(ctx context.Context, host, token stri
}, nil
}
return &validationResult{
UserID: resp.UserId,
UserEmail: resp.GetUserEmail(),
Valid: true,
Groups: resp.GetPeerGroupIds(),
GroupNames: resp.GetPeerGroupNames(),
UserID: resp.UserId,
UserEmail: resp.GetUserEmail(),
Valid: true,
Groups: resp.GetPeerGroupIds(),
GroupNames: resp.GetPeerGroupNames(),
MintedToken: resp.GetSessionToken(),
}, nil
}
@@ -788,14 +801,16 @@ func sessionGroupsAllowed(allowed map[string]struct{}, method auth.Method, group
}
}
// stripSessionTokenParam returns the request URI with the session_token query
// parameter removed so it doesn't linger in the browser's address bar or history.
// stripSessionTokenParam returns the request URI with the session hand-off
// query parameters removed so they don't linger in the browser's address bar
// or history.
func stripSessionTokenParam(u *url.URL) string {
q := u.Query()
if !q.Has("session_token") {
if !q.Has("session_token") && !q.Has("session_code") {
return u.RequestURI()
}
q.Del("session_token")
q.Del("session_code")
clean := *u
clean.RawQuery = q.Encode()
return clean.RequestURI()