mirror of
https://github.com/netbirdio/netbird.git
synced 2026-09-29 18:19:07 +02:00
[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:
@@ -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()
|
||||
|
||||
@@ -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))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user