[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()
+26
View File
@@ -783,6 +783,12 @@ func TestWasCredentialSubmitted(t *testing.T) {
query: url.Values{"session_token": {"abc123"}},
expected: true,
},
{
name: "OIDC code in query",
method: auth.MethodOIDC,
query: url.Values{"session_code": {"abc123"}},
expected: true,
},
{
name: "OIDC token not in query",
method: auth.MethodOIDC,
@@ -1571,3 +1577,23 @@ func TestProtect_TunnelPeerFastPath_TakesPathWithInboundMarker(t *testing.T) {
assert.Equal(t, http.StatusOK, rec.Code,
"a successful tunnel-peer validation must forward to the next handler")
}
func TestStripSessionTokenParam(t *testing.T) {
cases := []struct {
name string
in string
want string
}{
{"strips session_token", "https://ex.com/p?a=1&session_token=tok", "/p?a=1"},
{"strips session_code", "https://ex.com/p?a=1&session_code=code", "/p?a=1"},
{"strips both", "https://ex.com/p?session_token=tok&session_code=code&a=1", "/p?a=1"},
{"no-op when absent", "https://ex.com/p?a=1", "/p?a=1"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
u, err := url.Parse(tc.in)
require.NoError(t, err)
assert.Equal(t, tc.want, stripSessionTokenParam(u))
})
}
}
+8 -3
View File
@@ -40,9 +40,14 @@ func (OIDC) Type() auth.Method {
// Authenticate checks for an OIDC session token or obtains the OIDC redirect URL.
func (o OIDC) Authenticate(r *http.Request) (string, string, error) {
// Check for the session_token query param (from OIDC redirects).
// The management server passes the token in the URL because it cannot set
// cookies for the proxy's domain (cookies are domain-scoped per RFC 6265).
// Check for the session credential returned by the OIDC callback. The management
// server passes it in the URL because it cannot set a cookie for the proxy's
// domain (cookies are domain-scoped per RFC 6265). The current flow uses a
// single-use session_code to keep the durable token out of the URL.
// session_token remains supported for backward compatibility.
if code := r.URL.Query().Get("session_code"); code != "" {
return code, "", nil
}
if token := r.URL.Query().Get("session_token"); token != "" {
return token, "", nil
}
+4 -3
View File
@@ -721,12 +721,13 @@ func stripSessionCookie(r *httputil.ProxyRequest) {
}
}
// stripSessionTokenQuery removes the OIDC session_token query parameter from
// the outgoing URL to prevent credential leakage to backends.
// stripSessionTokenQuery removes the OIDC session hand-off query parameters
// from the outgoing URL to prevent credential leakage to backends.
func stripSessionTokenQuery(r *httputil.ProxyRequest) {
q := r.Out.URL.Query()
if q.Has("session_token") {
if q.Has("session_token") || q.Has("session_code") {
q.Del("session_token")
q.Del("session_code")
r.Out.URL.RawQuery = q.Encode()
}
}
+8 -2
View File
@@ -131,7 +131,7 @@ func setupIntegrationTest(t *testing.T) *integrationTestSetup {
HMACKey: []byte("test-hmac-key"),
}
proxyManager := &testProxyManager{}
proxyManager := &testProxyManager{supportsSessionCode: true}
proxyService := nbgrpc.NewProxyServiceServer(
&testAccessLogManager{},
@@ -202,7 +202,9 @@ func (m *testAccessLogManager) GetAllAccessLogs(_ context.Context, _, _ string,
}
// testProxyManager is a mock implementation of proxy.Manager for testing.
type testProxyManager struct{}
type testProxyManager struct {
supportsSessionCode bool
}
func (m *testProxyManager) Connect(_ context.Context, proxyID, sessionID, _, _, _ string, _ *string, _ *nbproxy.Capabilities) (*nbproxy.Proxy, error) {
return &nbproxy.Proxy{ID: proxyID, SessionID: sessionID, Status: nbproxy.StatusConnected}, nil
@@ -244,6 +246,10 @@ func (m *testProxyManager) ClusterSupportsPrivate(_ context.Context, _ string) *
return nil
}
func (m *testProxyManager) ClusterSupportsSessionCode(_ context.Context, _ string) bool {
return m.supportsSessionCode
}
func (m *testProxyManager) CleanupStale(_ context.Context, _ time.Duration) error {
return nil
}
+1
View File
@@ -181,6 +181,7 @@ func ServeAccessDeniedPage(w http.ResponseWriter, r *http.Request, code int, tit
func stripAuthParams(u *url.URL) string {
q := u.Query()
q.Del("session_token")
q.Del("session_code")
q.Del("error")
q.Del("error_description")
clean := *u