[client] Force interactive login when extending the auth session

A session extend must be answered from the account the peer is registered
under. With a silent PKCE flow (DisablePromptLogin or max_age=0) the IdP
answers from whatever session it already holds, which need not be the
peer's account when several are signed in; the token then fails the
user match in ExtendAuthSession with no way to pick another account.

Mark the PKCE flow request as a session extend so the management server
can force prompt=login for it, overriding the configured silent flow.
This commit is contained in:
Zoltán Papp
2026-08-15 10:33:50 +02:00
parent 2cfe14d7ec
commit 0738734b6e
16 changed files with 854 additions and 694 deletions
@@ -0,0 +1,75 @@
package grpc
import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/netbirdio/netbird/shared/management/client/common"
"github.com/netbirdio/netbird/shared/management/proto"
)
func TestApplySessionExtendFlowPolicy(t *testing.T) {
tests := []struct {
name string
flow *proto.PKCEAuthorizationFlow
sessionExtend bool
disablePromptLogin bool
loginFlag uint32
}{
{
name: "extend forces prompt=login over a silent flow",
flow: &proto.PKCEAuthorizationFlow{
ProviderConfig: &proto.ProviderConfig{
DisablePromptLogin: true,
LoginFlag: uint32(common.LoginFlagMaxAge0),
},
},
sessionExtend: true,
disablePromptLogin: false,
loginFlag: uint32(common.LoginFlagPromptLogin),
},
{
name: "extend replaces max_age=0 so login_hint is honoured",
flow: &proto.PKCEAuthorizationFlow{
ProviderConfig: &proto.ProviderConfig{
DisablePromptLogin: false,
LoginFlag: uint32(common.LoginFlagMaxAge0),
},
},
sessionExtend: true,
disablePromptLogin: false,
loginFlag: uint32(common.LoginFlagPromptLogin),
},
{
name: "login keeps the configured flow untouched",
flow: &proto.PKCEAuthorizationFlow{
ProviderConfig: &proto.ProviderConfig{
DisablePromptLogin: true,
LoginFlag: uint32(common.LoginFlagMaxAge0),
},
},
sessionExtend: false,
disablePromptLogin: true,
loginFlag: uint32(common.LoginFlagMaxAge0),
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
applySessionExtendFlowPolicy(tc.flow, tc.sessionExtend)
cfg := tc.flow.GetProviderConfig()
assert.Equal(t, tc.disablePromptLogin, cfg.GetDisablePromptLogin())
assert.Equal(t, tc.loginFlag, cfg.GetLoginFlag())
})
}
}
// A provider config is not guaranteed to be present on the response; clearing
// the flag must not panic when the validator returned an empty flow.
func TestApplySessionExtendFlowPolicyWithoutProviderConfig(t *testing.T) {
assert.NotPanics(t, func() {
applySessionExtendFlowPolicy(&proto.PKCEAuthorizationFlow{}, true)
applySessionExtendFlowPolicy(nil, true)
})
}
+29 -1
View File
@@ -1180,7 +1180,8 @@ func (s *Server) GetPKCEAuthorizationFlow(ctx context.Context, req *proto.Encryp
return nil, status.Errorf(codes.Internal, "failed to get server key")
}
err = encryption.DecryptMessage(peerKey, key, req.Body, &proto.PKCEAuthorizationFlowRequest{})
flowReq := &proto.PKCEAuthorizationFlowRequest{}
err = encryption.DecryptMessage(peerKey, key, req.Body, flowReq)
if err != nil {
errMSG := fmt.Sprintf("error while decrypting peer's message with Wireguard public key %s.", req.WgPubKey)
log.WithContext(ctx).Warn(errMSG)
@@ -1224,6 +1225,7 @@ func (s *Server) GetPKCEAuthorizationFlow(ctx context.Context, req *proto.Encryp
}
flowInfoResp := s.integratedPeerValidator.ValidateFlowResponse(ctx, peerKey.String(), initInfoFlow)
applySessionExtendFlowPolicy(flowInfoResp, flowReq.GetSessionExtend())
encryptedResp, err := encryption.EncryptMessage(peerKey, key, flowInfoResp)
if err != nil {
@@ -1236,6 +1238,32 @@ func (s *Server) GetPKCEAuthorizationFlow(ctx context.Context, req *proto.Encryp
}, nil
}
// applySessionExtendFlowPolicy forces a prompt=login flow for a session extend.
//
// An extend renews the session of one specific peer, so its token has to come
// from the account that peer is registered under. A flow that does not prompt
// leaves the choice to the IdP, which answers a silent authorization from any
// session it already holds — not necessarily this peer's account when several
// are signed in, and login_hint is a suggestion the IdP may ignore. The token
// then fails the jwt.UserID == peer.UserID check in ExtendAuthSession, and the
// user is given no opportunity to pick a different account.
//
// LoginFlagPromptLogin rather than max_age=0: both re-authenticate, but with
// prompt=login the IdP honours login_hint and offers the peer's own account,
// whereas max_age=0 leaves the user to find it among every account signed in.
//
// Called after ValidateFlowResponse so that a per-peer override cannot reinstate
// the silent flow for an extend.
func applySessionExtendFlowPolicy(flow *proto.PKCEAuthorizationFlow, sessionExtend bool) {
if !sessionExtend {
return
}
if cfg := flow.GetProviderConfig(); cfg != nil {
cfg.DisablePromptLogin = false
cfg.LoginFlag = uint32(common.LoginFlagPromptLogin)
}
}
// SyncMeta endpoint is used to synchronize peer's system metadata and notifies the connected,
// peer's under the same account of any updates.
func (s *Server) SyncMeta(ctx context.Context, req *proto.EncryptedMessage) (*proto.Empty, error) {