mirror of
https://github.com/netbirdio/netbird.git
synced 2026-08-18 13:41:30 +02:00
Compare commits
5 Commits
agent-netw
...
fix/pkce-f
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
907e66b9d7 | ||
|
|
bfa5d0e1f3 | ||
|
|
70ef1d2f25 | ||
|
|
9e9e33ae68 | ||
|
|
0738734b6e |
@@ -199,7 +199,15 @@ type loginHintSetter interface {
|
||||
}
|
||||
|
||||
func (a *Auth) foregroundGetTokenInfo(authClient *auth.Auth, urlOpener URLOpener, isAndroidTV bool) (*auth.TokenInfo, error) {
|
||||
oAuthFlow, err := authClient.GetOAuthFlow(a.ctx, isAndroidTV)
|
||||
return a.foregroundGetTokenInfoFlow(authClient, urlOpener, isAndroidTV, false)
|
||||
}
|
||||
|
||||
// foregroundGetTokenInfoFlow runs the interactive flow. sessionExtend tells the
|
||||
// server the token will renew this peer's session rather than log a peer in, so
|
||||
// it can rule out a silent authorization the IdP could answer from an unrelated
|
||||
// account. See PKCEAuthorizationFlowRequest.
|
||||
func (a *Auth) foregroundGetTokenInfoFlow(authClient *auth.Auth, urlOpener URLOpener, isAndroidTV bool, sessionExtend bool) (*auth.TokenInfo, error) {
|
||||
oAuthFlow, err := authClient.GetOAuthFlow(a.ctx, isAndroidTV, sessionExtend)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get OAuth flow: %v", err)
|
||||
}
|
||||
@@ -207,14 +215,49 @@ func (a *Auth) foregroundGetTokenInfo(authClient *auth.Auth, urlOpener URLOpener
|
||||
// An empty hint is deliberate, not a fallback: a fresh profile leaves the
|
||||
// choice to the IdP. Switching accounts is done by switching or removing
|
||||
// profiles, not by logging out — logout keeps the email.
|
||||
hint := ""
|
||||
if a.cfgPath != "" {
|
||||
if hint := readProfileEmail(a.cfgPath); hint != "" {
|
||||
if setter, ok := oAuthFlow.(loginHintSetter); ok {
|
||||
setter.SetLoginHint(hint)
|
||||
}
|
||||
hint = readProfileEmail(a.cfgPath)
|
||||
}
|
||||
if hint != "" {
|
||||
if setter, ok := oAuthFlow.(loginHintSetter); ok {
|
||||
setter.SetLoginHint(hint)
|
||||
}
|
||||
}
|
||||
|
||||
tokenInfo, err := a.runInteractiveFlow(oAuthFlow, urlOpener)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if tokenInfo.MatchesAccount(hint) {
|
||||
return tokenInfo, nil
|
||||
}
|
||||
|
||||
// The IdP answered from a session belonging to another account. Retrying is
|
||||
// what makes this recoverable: on a peer already registered the server would
|
||||
// reject the token, and on a fresh one it would silently register the peer
|
||||
// under the wrong account and bind the profile to it.
|
||||
log.Infof("login returned an account other than the one this profile is bound to, retrying with an account prompt")
|
||||
retryFlow := auth.RetryFlowForAccount(oAuthFlow)
|
||||
if retryFlow == nil {
|
||||
return tokenInfo, nil
|
||||
}
|
||||
|
||||
retryToken, err := a.runInteractiveFlow(retryFlow, urlOpener)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !retryToken.MatchesAccount(hint) {
|
||||
log.Warnf("login still returned a different account after the prompt, continuing with it")
|
||||
}
|
||||
|
||||
return retryToken, nil
|
||||
}
|
||||
|
||||
// runInteractiveFlow requests the authorization info, hands the URL to the
|
||||
// user and blocks until the token comes back.
|
||||
func (a *Auth) runInteractiveFlow(oAuthFlow auth.OAuthFlow, urlOpener URLOpener) (*auth.TokenInfo, error) {
|
||||
flowInfo, err := oAuthFlow.RequestAuthInfo(context.TODO())
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("getting a request OAuth flow info failed: %v", err)
|
||||
|
||||
@@ -293,11 +293,13 @@ func (c *Client) extendAuthSession(ctx context.Context, urlOpener URLOpener, isA
|
||||
}
|
||||
defer authClient.Close()
|
||||
|
||||
// Passing the config path makes the flow pick up the login_hint: an extend
|
||||
// renews the session of the account already signed in, so it must not stop to
|
||||
// offer a choice.
|
||||
// Passing the config path makes the flow pick up the login_hint. That alone
|
||||
// cannot keep the IdP on this profile's account though — a hint is only a
|
||||
// suggestion, and a silent authorization is answered from whatever session the
|
||||
// IdP already has, which need not be this peer's when several accounts are
|
||||
// signed in. Marking the flow as an extend lets the server rule that out.
|
||||
a := NewAuthWithConfig(ctx, cfg, cfgPath)
|
||||
tokenInfo, err := a.foregroundGetTokenInfo(authClient, urlOpener, isAndroidTV)
|
||||
tokenInfo, err := a.foregroundGetTokenInfoFlow(authClient, urlOpener, isAndroidTV, true)
|
||||
if err != nil {
|
||||
return fmt.Errorf("interactive sso login failed: %v", err)
|
||||
}
|
||||
|
||||
@@ -408,11 +408,44 @@ func foregroundGetTokenInfo(ctx context.Context, cmd *cobra.Command, config *pro
|
||||
hint = profileState.Email
|
||||
}
|
||||
|
||||
oAuthFlow, err := auth.NewOAuthFlow(ctx, config, util.HasGraphicalSession(), false, hint)
|
||||
oAuthFlow, err := auth.NewOAuthFlow(ctx, config, util.HasGraphicalSession(), false, hint, false)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
tokenInfo, err := runInteractiveFlow(cmd, oAuthFlow)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if tokenInfo.MatchesAccount(hint) {
|
||||
return tokenInfo, nil
|
||||
}
|
||||
|
||||
// The IdP answered from a session belonging to another account. Retrying is
|
||||
// what makes this recoverable: on a peer already registered the server would
|
||||
// reject the token, and on a fresh one it would silently register the peer
|
||||
// under the wrong account and bind the profile to it.
|
||||
cmd.Println("The login returned a different account than this profile uses. Asking to sign in again.")
|
||||
retryFlow := auth.RetryFlowForAccount(oAuthFlow)
|
||||
if retryFlow == nil {
|
||||
return tokenInfo, nil
|
||||
}
|
||||
|
||||
retryToken, err := runInteractiveFlow(cmd, retryFlow)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !retryToken.MatchesAccount(hint) {
|
||||
log.Warnf("login still returned a different account after the prompt, continuing with it")
|
||||
}
|
||||
|
||||
return retryToken, nil
|
||||
}
|
||||
|
||||
// runInteractiveFlow requests the authorization info, shows the URL to the user
|
||||
// and blocks until the token comes back.
|
||||
func runInteractiveFlow(cmd *cobra.Command, oAuthFlow auth.OAuthFlow) (*auth.TokenInfo, error) {
|
||||
flowInfo, err := oAuthFlow.RequestAuthInfo(context.TODO())
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("getting a request OAuth flow info failed: %v", err)
|
||||
|
||||
104
client/internal/auth/account_match_test.go
Normal file
104
client/internal/auth/account_match_test.go
Normal file
@@ -0,0 +1,104 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestTokenInfoMatchesAccount(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
token TokenInfo
|
||||
hint string
|
||||
match bool
|
||||
}{
|
||||
{
|
||||
name: "same account",
|
||||
token: TokenInfo{Email: "user@example.com"},
|
||||
hint: "user@example.com",
|
||||
match: true,
|
||||
},
|
||||
{
|
||||
name: "different account",
|
||||
token: TokenInfo{Email: "other@example.com"},
|
||||
hint: "user@example.com",
|
||||
match: false,
|
||||
},
|
||||
{
|
||||
name: "case differences are the same account",
|
||||
token: TokenInfo{Email: "User@Example.com"},
|
||||
hint: "user@example.com",
|
||||
match: true,
|
||||
},
|
||||
{
|
||||
name: "no hint leaves the choice to the IdP",
|
||||
token: TokenInfo{Email: "other@example.com"},
|
||||
hint: "",
|
||||
match: true,
|
||||
},
|
||||
{
|
||||
name: "token without an email is not judged",
|
||||
token: TokenInfo{Email: ""},
|
||||
hint: "user@example.com",
|
||||
match: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
assert.Equal(t, tc.match, tc.token.MatchesAccount(tc.hint))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseEmailFromIDToken(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
claims map[string]interface{}
|
||||
wantValue string
|
||||
wantErr bool
|
||||
}{
|
||||
{
|
||||
name: "email claim",
|
||||
claims: map[string]interface{}{"email": "user@example.com", "name": "Some One"},
|
||||
wantValue: "user@example.com",
|
||||
},
|
||||
{
|
||||
name: "name fallback",
|
||||
claims: map[string]interface{}{"name": "Some One"},
|
||||
wantValue: "Some One",
|
||||
},
|
||||
{
|
||||
name: "neither claim present",
|
||||
claims: map[string]interface{}{"sub": "abc"},
|
||||
wantErr: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
value, err := parseEmailFromIDToken(idTokenWithClaims(t, tc.claims))
|
||||
if tc.wantErr {
|
||||
require.Error(t, err)
|
||||
return
|
||||
}
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, tc.wantValue, value)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestRetryFlowForAccountUnsupportedFlow(t *testing.T) {
|
||||
assert.Nil(t, RetryFlowForAccount(&DeviceAuthorizationFlow{}))
|
||||
}
|
||||
|
||||
func idTokenWithClaims(t *testing.T, claims map[string]interface{}) string {
|
||||
t.Helper()
|
||||
payload, err := json.Marshal(claims)
|
||||
require.NoError(t, err)
|
||||
return "header." + base64.RawURLEncoding.EncodeToString(payload) + ".signature"
|
||||
}
|
||||
@@ -103,7 +103,7 @@ func (a *Auth) IsSSOSupported(ctx context.Context) (bool, error) {
|
||||
|
||||
err := a.withRetry(ctx, func(client *mgm.GrpcClient) error {
|
||||
// Try PKCE flow first
|
||||
_, err := a.getPKCEFlow(client)
|
||||
_, err := a.getPKCEFlow(client, false)
|
||||
if err == nil {
|
||||
supportsSSO = true
|
||||
return nil
|
||||
@@ -138,7 +138,11 @@ func (a *Auth) IsSSOSupported(ctx context.Context) (bool, error) {
|
||||
|
||||
// GetOAuthFlow returns an OAuth flow (PKCE or Device) using the existing management connection
|
||||
// This avoids creating a new connection to the management server
|
||||
func (a *Auth) GetOAuthFlow(ctx context.Context, forceDeviceAuth bool) (OAuthFlow, error) {
|
||||
//
|
||||
// sessionExtend marks the flow as renewing an existing peer's session rather than
|
||||
// logging one in; the server needs it to rule out a silent authorization that the
|
||||
// IdP could answer from another account. See PKCEAuthorizationFlowRequest.
|
||||
func (a *Auth) GetOAuthFlow(ctx context.Context, forceDeviceAuth bool, sessionExtend bool) (OAuthFlow, error) {
|
||||
var flow OAuthFlow
|
||||
var err error
|
||||
|
||||
@@ -149,7 +153,7 @@ func (a *Auth) GetOAuthFlow(ctx context.Context, forceDeviceAuth bool) (OAuthFlo
|
||||
}
|
||||
|
||||
// Try PKCE flow first
|
||||
flow, err = a.getPKCEFlow(client)
|
||||
flow, err = a.getPKCEFlow(client, sessionExtend)
|
||||
if err != nil {
|
||||
// If PKCE not supported, try Device flow
|
||||
if s, ok := status.FromError(err); ok && (s.Code() == codes.NotFound || s.Code() == codes.Unimplemented) {
|
||||
@@ -229,8 +233,8 @@ func (a *Auth) Login(ctx context.Context, setupKey string, jwtToken string) (err
|
||||
}
|
||||
|
||||
// getPKCEFlow retrieves PKCE authorization flow configuration and creates a flow instance
|
||||
func (a *Auth) getPKCEFlow(client *mgm.GrpcClient) (*PKCEAuthorizationFlow, error) {
|
||||
protoFlow, err := client.GetPKCEAuthorizationFlow()
|
||||
func (a *Auth) getPKCEFlow(client *mgm.GrpcClient, sessionExtend bool) (*PKCEAuthorizationFlow, error) {
|
||||
protoFlow, err := client.GetPKCEAuthorizationFlow(sessionExtend)
|
||||
if err != nil {
|
||||
if s, ok := status.FromError(err); ok && s.Code() == codes.NotFound {
|
||||
log.Warnf("server couldn't find pkce flow, contact admin: %v", err)
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"runtime"
|
||||
"strings"
|
||||
|
||||
log "github.com/sirupsen/logrus"
|
||||
"google.golang.org/grpc/codes"
|
||||
@@ -25,6 +26,14 @@ type HTTPClient interface {
|
||||
Do(req *http.Request) (*http.Response, error)
|
||||
}
|
||||
|
||||
// accountPromptForcer is implemented by the PKCE flow only. The device code
|
||||
// flow has no equivalent: RFC 8628 defines no prompt parameter, and the user
|
||||
// confirms the code on a page that shows which account signs in, so a silent
|
||||
// wrong-account answer is not the failure mode there.
|
||||
type accountPromptForcer interface {
|
||||
ForceAccountPrompt()
|
||||
}
|
||||
|
||||
// AuthFlowInfo holds information for the OAuth 2.0 authorization flow
|
||||
type AuthFlowInfo struct { //nolint:revive
|
||||
DeviceCode string `json:"device_code"`
|
||||
@@ -51,6 +60,22 @@ type TokenInfo struct {
|
||||
Email string `json:"-"`
|
||||
}
|
||||
|
||||
// MatchesAccount reports whether the token belongs to the account a profile is
|
||||
// bound to. A hint the IdP could not have acted on — no hint stored, or a token
|
||||
// that carried no email — is reported as a match: the check exists to catch a
|
||||
// login answered from the wrong account, not to block one it cannot judge.
|
||||
//
|
||||
// The comparison is case-insensitive. Local-parts are case-sensitive per RFC
|
||||
// 5321, but no IdP in practice issues two accounts differing only in case, and
|
||||
// an IdP that echoes a differently-cased address would otherwise fail every
|
||||
// login.
|
||||
func (t TokenInfo) MatchesAccount(hint string) bool {
|
||||
if hint == "" || t.Email == "" {
|
||||
return true
|
||||
}
|
||||
return strings.EqualFold(t.Email, hint)
|
||||
}
|
||||
|
||||
// GetTokenToUse returns either the access or id token based on UseIDToken field
|
||||
func (t TokenInfo) GetTokenToUse() string {
|
||||
if t.UseIDToken {
|
||||
@@ -70,12 +95,15 @@ func shouldUseDeviceFlow(force bool, isUnixDesktopClient bool) bool {
|
||||
//
|
||||
// On Linux distros without desktop environment support, it only tries to initialize the Device Code Flow
|
||||
// forceDeviceCodeFlow can be used to skip PKCE and go directly to Device Code Flow (e.g., for Android TV)
|
||||
func NewOAuthFlow(ctx context.Context, config *profilemanager.Config, isUnixDesktopClient bool, forceDeviceCodeFlow bool, hint string) (OAuthFlow, error) {
|
||||
//
|
||||
// sessionExtend marks the flow as renewing an existing peer's session rather than
|
||||
// logging one in; see PKCEAuthorizationFlowRequest for what the server makes of it.
|
||||
func NewOAuthFlow(ctx context.Context, config *profilemanager.Config, isUnixDesktopClient bool, forceDeviceCodeFlow bool, hint string, sessionExtend bool) (OAuthFlow, error) {
|
||||
if shouldUseDeviceFlow(forceDeviceCodeFlow, isUnixDesktopClient) {
|
||||
return authenticateWithDeviceCodeFlow(ctx, config, hint)
|
||||
}
|
||||
|
||||
pkceFlow, err := authenticateWithPKCEFlow(ctx, config, hint)
|
||||
pkceFlow, err := authenticateWithPKCEFlow(ctx, config, hint, sessionExtend)
|
||||
if err != nil {
|
||||
log.Debugf("failed to initialize pkce authentication with error: %v\n", err)
|
||||
log.Debug("falling back to device code flow")
|
||||
@@ -85,14 +113,14 @@ func NewOAuthFlow(ctx context.Context, config *profilemanager.Config, isUnixDesk
|
||||
}
|
||||
|
||||
// authenticateWithPKCEFlow initializes the Proof Key for Code Exchange flow auth flow
|
||||
func authenticateWithPKCEFlow(ctx context.Context, config *profilemanager.Config, hint string) (OAuthFlow, error) {
|
||||
func authenticateWithPKCEFlow(ctx context.Context, config *profilemanager.Config, hint string, sessionExtend bool) (OAuthFlow, error) {
|
||||
authClient, err := NewAuth(ctx, config.PrivateKey, config.ManagementURL, config)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create auth client: %v", err)
|
||||
}
|
||||
defer authClient.Close()
|
||||
|
||||
pkceFlowInfo, err := authClient.getPKCEFlow(authClient.client)
|
||||
pkceFlowInfo, err := authClient.getPKCEFlow(authClient.client, sessionExtend)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("getting pkce authorization flow info failed with error: %v", err)
|
||||
}
|
||||
@@ -133,3 +161,21 @@ func authenticateWithDeviceCodeFlow(ctx context.Context, config *profilemanager.
|
||||
|
||||
return deviceFlowInfo, nil
|
||||
}
|
||||
|
||||
// RetryFlowForAccount returns a flow that asks the IdP to re-authenticate, for
|
||||
// a login answered with an account other than the one hinted. Returns nil when
|
||||
// the flow cannot ask — the caller then proceeds with the token it has.
|
||||
//
|
||||
// Proceeding rather than failing is deliberate. The hint is an email that may
|
||||
// simply have changed since it was stored, and refusing the login would lock a
|
||||
// user out of their own profile over a rename. The retry gives the account a
|
||||
// chance to be corrected; the server still rejects a token that does not own
|
||||
// the peer.
|
||||
func RetryFlowForAccount(flow OAuthFlow) OAuthFlow {
|
||||
forcer, ok := flow.(accountPromptForcer)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
forcer.ForceAccountPrompt()
|
||||
return flow
|
||||
}
|
||||
|
||||
@@ -87,10 +87,11 @@ func validatePKCEConfig(config *PKCEAuthProviderConfig) error {
|
||||
// PKCEAuthorizationFlow implements the OAuthFlow interface for
|
||||
// the Authorization Code Flow with PKCE.
|
||||
type PKCEAuthorizationFlow struct {
|
||||
providerConfig PKCEAuthProviderConfig
|
||||
state string
|
||||
codeVerifier string
|
||||
oAuthConfig *oauth2.Config
|
||||
providerConfig PKCEAuthProviderConfig
|
||||
state string
|
||||
codeVerifier string
|
||||
oAuthConfig *oauth2.Config
|
||||
forceAccountPrompt bool
|
||||
}
|
||||
|
||||
// NewPKCEAuthorizationFlow returns new PKCE authorization code flow.
|
||||
@@ -154,10 +155,12 @@ func (p *PKCEAuthorizationFlow) RequestAuthInfo(ctx context.Context) (AuthFlowIn
|
||||
oauth2.SetAuthURLParam("audience", p.providerConfig.Audience),
|
||||
}
|
||||
if !p.providerConfig.DisablePromptLogin {
|
||||
switch p.providerConfig.LoginFlag {
|
||||
case common.LoginFlagPromptLogin:
|
||||
switch {
|
||||
case p.forceAccountPrompt:
|
||||
params = append(params, oauth2.SetAuthURLParam("prompt", "login"))
|
||||
case common.LoginFlagMaxAge0:
|
||||
case p.providerConfig.LoginFlag == common.LoginFlagPromptLogin:
|
||||
params = append(params, oauth2.SetAuthURLParam("prompt", "login"))
|
||||
case p.providerConfig.LoginFlag == common.LoginFlagMaxAge0:
|
||||
params = append(params, oauth2.SetAuthURLParam("max_age", "0"))
|
||||
}
|
||||
}
|
||||
@@ -178,6 +181,17 @@ func (p *PKCEAuthorizationFlow) SetLoginHint(hint string) {
|
||||
p.providerConfig.LoginHint = hint
|
||||
}
|
||||
|
||||
// ForceAccountPrompt makes the next authorization request ask the IdP to
|
||||
// re-authenticate instead of answering from the session it already holds. Used
|
||||
// to retry a login that came back for an account other than the one hinted.
|
||||
//
|
||||
// DisablePromptLogin still wins: it is set for IdPs that break on prompt=login,
|
||||
// where retrying with it would replace a wrong-account login with one that
|
||||
// cannot complete at all.
|
||||
func (p *PKCEAuthorizationFlow) ForceAccountPrompt() {
|
||||
p.forceAccountPrompt = true
|
||||
}
|
||||
|
||||
// WaitToken waits for the OAuth token in the PKCE Authorization Flow.
|
||||
// It starts an HTTP server to receive the OAuth token callback and waits for the token or an error.
|
||||
// Once the token is received, it is converted to TokenInfo and validated before returning.
|
||||
@@ -321,10 +335,10 @@ func (p *PKCEAuthorizationFlow) parseOAuthToken(token *oauth2.Token) (TokenInfo,
|
||||
}
|
||||
|
||||
// parseEmailFromIDToken extracts the email (or name) claim from an ID token
|
||||
// without verifying its signature. The value is best-effort and used only as a
|
||||
// UX convenience (login hint prefill and display); it never drives an
|
||||
// authorization decision. The authoritative identity is established server-side
|
||||
// from the signature-verified token.
|
||||
// without verifying its signature. The value is best-effort: it prefills the
|
||||
// login hint, is displayed, and is compared against the account a profile is
|
||||
// bound to (see MatchesAccount). It never grants anything — the authoritative
|
||||
// identity is established server-side from the signature-verified token.
|
||||
func parseEmailFromIDToken(token string) (string, error) {
|
||||
parts := strings.Split(token, ".")
|
||||
if len(parts) < 2 {
|
||||
@@ -340,19 +354,14 @@ func parseEmailFromIDToken(token string) (string, error) {
|
||||
return "", fmt.Errorf("json unmarshal error: %w", err)
|
||||
}
|
||||
|
||||
var email string
|
||||
if emailValue, ok := claims["email"].(string); ok {
|
||||
email = emailValue
|
||||
} else {
|
||||
val, ok := claims["name"].(string)
|
||||
if ok {
|
||||
email = val
|
||||
} else {
|
||||
return "", fmt.Errorf("email or name field not found in token payload")
|
||||
}
|
||||
if email, ok := claims["email"].(string); ok {
|
||||
return email, nil
|
||||
}
|
||||
if name, ok := claims["name"].(string); ok {
|
||||
return name, nil
|
||||
}
|
||||
|
||||
return email, nil
|
||||
return "", fmt.Errorf("email or name field not found in token payload")
|
||||
}
|
||||
|
||||
func createCodeChallenge(codeVerifier string) string {
|
||||
|
||||
@@ -429,7 +429,7 @@ func (c *Client) LoginForMobile() string {
|
||||
return fmt.Sprintf("failed to load config: %v", err)
|
||||
}
|
||||
|
||||
oAuthFlow, err := auth.NewOAuthFlow(ctx, cfg, false, false, "")
|
||||
oAuthFlow, err := auth.NewOAuthFlow(ctx, cfg, false, false, "", false)
|
||||
if err != nil {
|
||||
return err.Error()
|
||||
}
|
||||
|
||||
@@ -323,7 +323,7 @@ func (a *Auth) login(urlOpener URLOpener, forceDeviceAuth bool, deviceName strin
|
||||
const authInfoRequestTimeout = 30 * time.Second
|
||||
|
||||
func (a *Auth) foregroundGetTokenInfo(authClient *auth.Auth, urlOpener URLOpener, forceDeviceAuth bool) (*auth.TokenInfo, error) {
|
||||
oAuthFlow, err := authClient.GetOAuthFlow(a.ctx, forceDeviceAuth)
|
||||
oAuthFlow, err := authClient.GetOAuthFlow(a.ctx, forceDeviceAuth, false)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get OAuth flow: %v", err)
|
||||
}
|
||||
|
||||
125
client/server/login_account_test.go
Normal file
125
client/server/login_account_test.go
Normal file
@@ -0,0 +1,125 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/netbirdio/netbird/client/internal"
|
||||
"github.com/netbirdio/netbird/client/internal/auth"
|
||||
"github.com/netbirdio/netbird/client/proto"
|
||||
)
|
||||
|
||||
type stubOAuthFlow struct {
|
||||
token auth.TokenInfo
|
||||
}
|
||||
|
||||
func (f *stubOAuthFlow) RequestAuthInfo(context.Context) (auth.AuthFlowInfo, error) {
|
||||
return auth.AuthFlowInfo{}, nil
|
||||
}
|
||||
|
||||
func (f *stubOAuthFlow) WaitToken(context.Context, auth.AuthFlowInfo) (auth.TokenInfo, error) {
|
||||
return f.token, nil
|
||||
}
|
||||
|
||||
func (f *stubOAuthFlow) GetClientID(context.Context) string {
|
||||
return "stub-client"
|
||||
}
|
||||
|
||||
func TestWaitSSOLogin_WrongAccountArmsPromptAndFails(t *testing.T) {
|
||||
s := newSSOTestServer(t, "user@example.com", false, "other@example.com")
|
||||
attempts := 0
|
||||
s.loginAttemptFn = func(context.Context, string, string) (internal.StatusType, error) {
|
||||
attempts++
|
||||
return "", nil
|
||||
}
|
||||
|
||||
resp, err := s.WaitSSOLogin(context.Background(), &proto.WaitSSOLoginRequest{UserCode: "code"})
|
||||
require.Error(t, err)
|
||||
require.Nil(t, resp)
|
||||
require.Equal(t, 0, attempts, "the wrong account's token reached the management login")
|
||||
require.True(t, s.forceAccountPrompt, "the next login was not armed to ask for the account")
|
||||
require.Nil(t, s.oauthAuthFlow.flow, "the mismatched flow stayed cached for reuse")
|
||||
|
||||
status, stateErr := internal.CtxGetState(s.rootCtx).Status()
|
||||
require.NoError(t, stateErr)
|
||||
require.Equal(t, internal.StatusNeedsLogin, status, "the mismatch must stay retryable")
|
||||
}
|
||||
|
||||
func TestWaitSSOLogin_WrongAccountAfterPromptProceeds(t *testing.T) {
|
||||
s := newSSOTestServer(t, "user@example.com", true, "other@example.com")
|
||||
attempts := 0
|
||||
s.loginAttemptFn = func(context.Context, string, string) (internal.StatusType, error) {
|
||||
attempts++
|
||||
return "", nil
|
||||
}
|
||||
|
||||
resp, err := s.WaitSSOLogin(context.Background(), &proto.WaitSSOLoginRequest{UserCode: "code"})
|
||||
require.NoError(t, err, "a prompted round must not error again on a mismatch")
|
||||
require.NotNil(t, resp)
|
||||
require.Equal(t, "other@example.com", resp.Email)
|
||||
require.Equal(t, 1, attempts)
|
||||
require.False(t, s.forceAccountPrompt)
|
||||
}
|
||||
|
||||
func TestWaitSSOLogin_MatchingAccountProceeds(t *testing.T) {
|
||||
s := newSSOTestServer(t, "user@example.com", false, "User@Example.com")
|
||||
attempts := 0
|
||||
s.loginAttemptFn = func(context.Context, string, string) (internal.StatusType, error) {
|
||||
attempts++
|
||||
return "", nil
|
||||
}
|
||||
|
||||
resp, err := s.WaitSSOLogin(context.Background(), &proto.WaitSSOLoginRequest{UserCode: "code"})
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, resp)
|
||||
require.Equal(t, 1, attempts)
|
||||
require.False(t, s.forceAccountPrompt)
|
||||
}
|
||||
|
||||
func TestWaitSSOLogin_NoHintIsNotJudged(t *testing.T) {
|
||||
s := newSSOTestServer(t, "", false, "whoever@example.com")
|
||||
attempts := 0
|
||||
s.loginAttemptFn = func(context.Context, string, string) (internal.StatusType, error) {
|
||||
attempts++
|
||||
return "", nil
|
||||
}
|
||||
|
||||
_, err := s.WaitSSOLogin(context.Background(), &proto.WaitSSOLoginRequest{UserCode: "code"})
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, 1, attempts)
|
||||
require.False(t, s.forceAccountPrompt)
|
||||
}
|
||||
|
||||
func TestSwitchProfile_DropsAccountPromptAndPendingFlow(t *testing.T) {
|
||||
s, ctx, _, _, _ := setupServerWithProfile(t)
|
||||
s.forceAccountPrompt = true
|
||||
cancelled := false
|
||||
s.oauthAuthFlow = oauthAuthFlow{
|
||||
flow: &stubOAuthFlow{},
|
||||
hint: "user@example.com",
|
||||
waitCancel: func() { cancelled = true },
|
||||
}
|
||||
|
||||
_, err := s.SwitchProfile(ctx, nil)
|
||||
require.NoError(t, err)
|
||||
require.False(t, s.forceAccountPrompt, "the prompt flag leaked across a profile switch")
|
||||
require.Nil(t, s.oauthAuthFlow.flow, "the previous profile's flow leaked across a profile switch")
|
||||
require.Empty(t, s.oauthAuthFlow.hint)
|
||||
require.True(t, cancelled, "the pending wait was not cancelled")
|
||||
}
|
||||
|
||||
func newSSOTestServer(t *testing.T, hint string, accountPrompted bool, tokenEmail string) *Server {
|
||||
t.Helper()
|
||||
s := New(internal.CtxInitState(context.Background()), "console", "", false, false, false, false)
|
||||
s.oauthAuthFlow = oauthAuthFlow{
|
||||
flow: &stubOAuthFlow{token: auth.TokenInfo{Email: tokenEmail}},
|
||||
info: auth.AuthFlowInfo{UserCode: "code"},
|
||||
expiresAt: time.Now().Add(time.Minute),
|
||||
hint: hint,
|
||||
accountPrompted: accountPrompted,
|
||||
}
|
||||
return s
|
||||
}
|
||||
@@ -61,6 +61,10 @@ const (
|
||||
|
||||
var ErrServiceNotUp = errors.New("service is not up")
|
||||
|
||||
type statusSetter interface {
|
||||
Set(update internal.StatusType)
|
||||
}
|
||||
|
||||
// Server for service control.
|
||||
type Server struct {
|
||||
rootCtx context.Context
|
||||
@@ -78,6 +82,12 @@ type Server struct {
|
||||
uiLogPath string
|
||||
|
||||
oauthAuthFlow oauthAuthFlow
|
||||
// forceAccountPrompt makes the next startSSOLogin build its flow with a
|
||||
// forced account prompt. Armed when a login came back for an account other
|
||||
// than the hinted one: that flow's browser is gone, so the correction has to
|
||||
// ride on the user's next connect. Guarded by mutex; deliberately not
|
||||
// persisted — a lost flag only costs one more mismatch round.
|
||||
forceAccountPrompt bool
|
||||
// extendAuthSessionFlow holds the pending PKCE flow created by
|
||||
// RequestExtendAuthSession until WaitExtendAuthSession resolves it.
|
||||
// Kept separate from oauthAuthFlow (which is reserved for the SSH
|
||||
@@ -149,6 +159,14 @@ type oauthAuthFlow struct {
|
||||
flow auth.OAuthFlow
|
||||
info auth.AuthFlowInfo
|
||||
waitCancel context.CancelFunc
|
||||
// hint is the account the flow was asked to sign in (login_hint). The token
|
||||
// that comes back is compared against it; empty means nothing to compare.
|
||||
hint string
|
||||
// accountPrompted records that this flow already asked the IdP to re-decide
|
||||
// the account (or could not ask — the device flow has no way to). A token
|
||||
// for the wrong account is then let through with a warning instead of
|
||||
// erroring again, so the flow cannot loop.
|
||||
accountPrompted bool
|
||||
}
|
||||
|
||||
// New server instance constructor.
|
||||
@@ -675,54 +693,7 @@ func (s *Server) Login(callerCtx context.Context, msg *proto.LoginRequest) (*pro
|
||||
}
|
||||
|
||||
if msg.SetupKey == "" {
|
||||
hint := ""
|
||||
if msg.Hint != nil {
|
||||
hint = *msg.Hint
|
||||
}
|
||||
oAuthFlow, err := auth.NewOAuthFlow(ctx, config, msg.IsUnixDesktopClient, false, hint)
|
||||
if err != nil {
|
||||
state.Set(internal.StatusLoginFailed)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if s.oauthAuthFlow.flow != nil && s.oauthAuthFlow.flow.GetClientID(ctx) == oAuthFlow.GetClientID(ctx) {
|
||||
if s.oauthAuthFlow.expiresAt.After(time.Now().Add(90 * time.Second)) {
|
||||
log.Debugf("using previous oauth flow info")
|
||||
state.Set(internal.StatusNeedsLogin)
|
||||
return &proto.LoginResponse{
|
||||
NeedsSSOLogin: true,
|
||||
VerificationURI: s.oauthAuthFlow.info.VerificationURI,
|
||||
VerificationURIComplete: s.oauthAuthFlow.info.VerificationURIComplete,
|
||||
UserCode: s.oauthAuthFlow.info.UserCode,
|
||||
}, nil
|
||||
} else {
|
||||
log.Warnf("canceling previous waiting execution")
|
||||
if s.oauthAuthFlow.waitCancel != nil {
|
||||
s.oauthAuthFlow.waitCancel()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
authInfo, err := oAuthFlow.RequestAuthInfo(ctx)
|
||||
if err != nil {
|
||||
log.Errorf("getting a request OAuth flow failed: %v", err)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
s.mutex.Lock()
|
||||
s.oauthAuthFlow.flow = oAuthFlow
|
||||
s.oauthAuthFlow.info = authInfo
|
||||
s.oauthAuthFlow.expiresAt = time.Now().Add(time.Duration(authInfo.ExpiresIn) * time.Second)
|
||||
s.mutex.Unlock()
|
||||
|
||||
state.Set(internal.StatusNeedsLogin)
|
||||
|
||||
return &proto.LoginResponse{
|
||||
NeedsSSOLogin: true,
|
||||
VerificationURI: authInfo.VerificationURI,
|
||||
VerificationURIComplete: authInfo.VerificationURIComplete,
|
||||
UserCode: authInfo.UserCode,
|
||||
}, nil
|
||||
return s.startSSOLogin(ctx, msg, config, state)
|
||||
}
|
||||
|
||||
// Setup-key path: we are about to dial Management with the key, so the
|
||||
@@ -738,6 +709,95 @@ func (s *Server) Login(callerCtx context.Context, msg *proto.LoginRequest) (*pro
|
||||
return &proto.LoginResponse{}, nil
|
||||
}
|
||||
|
||||
// startSSOLogin opens the interactive leg of a login: it reuses the in-flight
|
||||
// OAuth flow when one is still valid for the same client, and otherwise
|
||||
// requests fresh auth info and parks the daemon on StatusNeedsLogin.
|
||||
func (s *Server) startSSOLogin(ctx context.Context, msg *proto.LoginRequest, config *profilemanager.Config, state statusSetter) (*proto.LoginResponse, error) {
|
||||
hint := ""
|
||||
if msg.Hint != nil {
|
||||
hint = *msg.Hint
|
||||
}
|
||||
oAuthFlow, err := auth.NewOAuthFlow(ctx, config, msg.IsUnixDesktopClient, false, hint, false)
|
||||
if err != nil {
|
||||
state.Set(internal.StatusLoginFailed)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
s.mutex.Lock()
|
||||
promptForAccount := s.forceAccountPrompt
|
||||
s.forceAccountPrompt = false
|
||||
s.mutex.Unlock()
|
||||
if promptForAccount && auth.RetryFlowForAccount(oAuthFlow) == nil {
|
||||
// The device flow cannot ask; run it as-is. accountPrompted still goes
|
||||
// true below so a second mismatch is let through instead of looping.
|
||||
log.Warnf("the previous login returned a different account, but this flow cannot ask the IdP to choose one")
|
||||
}
|
||||
|
||||
if resp := s.reuseOAuthFlow(ctx, oAuthFlow, state); resp != nil {
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
authInfo, err := oAuthFlow.RequestAuthInfo(ctx)
|
||||
if err != nil {
|
||||
log.Errorf("getting a request OAuth flow failed: %v", err)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
s.mutex.Lock()
|
||||
s.oauthAuthFlow.flow = oAuthFlow
|
||||
s.oauthAuthFlow.info = authInfo
|
||||
s.oauthAuthFlow.expiresAt = time.Now().Add(time.Duration(authInfo.ExpiresIn) * time.Second)
|
||||
s.oauthAuthFlow.hint = hint
|
||||
s.oauthAuthFlow.accountPrompted = promptForAccount
|
||||
s.mutex.Unlock()
|
||||
|
||||
state.Set(internal.StatusNeedsLogin)
|
||||
|
||||
return &proto.LoginResponse{
|
||||
NeedsSSOLogin: true,
|
||||
VerificationURI: authInfo.VerificationURI,
|
||||
VerificationURIComplete: authInfo.VerificationURIComplete,
|
||||
UserCode: authInfo.UserCode,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// reuseOAuthFlow returns the cached auth info when the previous flow targets
|
||||
// the same client and still has enough life left, and otherwise cancels the
|
||||
// stale wait and returns nil so the caller requests a fresh flow.
|
||||
//
|
||||
// The whole decision runs off one snapshot taken under s.mutex: a concurrent
|
||||
// WaitSSOLogin replaces waitCancel and expires the flow, so reading the fields
|
||||
// one at a time could cancel a wait that no longer belongs to the flow just
|
||||
// judged stale, or answer with auth info from a flow that was already replaced.
|
||||
// The cancel itself is called after unlocking — it runs arbitrary teardown, and
|
||||
// WaitSSOLogin takes s.mutex on the way out.
|
||||
func (s *Server) reuseOAuthFlow(ctx context.Context, oAuthFlow auth.OAuthFlow, state statusSetter) *proto.LoginResponse {
|
||||
s.mutex.Lock()
|
||||
current := s.oauthAuthFlow
|
||||
s.mutex.Unlock()
|
||||
|
||||
if current.flow == nil || current.flow.GetClientID(ctx) != oAuthFlow.GetClientID(ctx) {
|
||||
return nil
|
||||
}
|
||||
|
||||
if !current.expiresAt.After(time.Now().Add(90 * time.Second)) {
|
||||
log.Warnf("canceling previous waiting execution")
|
||||
if current.waitCancel != nil {
|
||||
current.waitCancel()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
log.Debugf("using previous oauth flow info")
|
||||
state.Set(internal.StatusNeedsLogin)
|
||||
return &proto.LoginResponse{
|
||||
NeedsSSOLogin: true,
|
||||
VerificationURI: current.info.VerificationURI,
|
||||
VerificationURIComplete: current.info.VerificationURIComplete,
|
||||
UserCode: current.info.UserCode,
|
||||
}
|
||||
}
|
||||
|
||||
// WaitSSOLogin validates the supplied userCode against the in-flight OAuth
|
||||
// device/PKCE flow and blocks until the user finishes the browser leg.
|
||||
//
|
||||
@@ -808,9 +868,10 @@ func (s *Server) WaitSSOLogin(callerCtx context.Context, msg *proto.WaitSSOLogin
|
||||
}
|
||||
|
||||
s.actCancel = cancel
|
||||
flow := s.oauthAuthFlow.flow
|
||||
s.mutex.Unlock()
|
||||
|
||||
if s.oauthAuthFlow.flow == nil {
|
||||
if flow == nil {
|
||||
return nil, gstatus.Errorf(codes.Internal, "oauth flow is not initialized")
|
||||
}
|
||||
|
||||
@@ -837,18 +898,23 @@ func (s *Server) WaitSSOLogin(callerCtx context.Context, msg *proto.WaitSSOLogin
|
||||
return nil, gstatus.Errorf(codes.InvalidArgument, "sso user code is invalid")
|
||||
}
|
||||
|
||||
if s.oauthAuthFlow.waitCancel != nil {
|
||||
s.oauthAuthFlow.waitCancel()
|
||||
}
|
||||
|
||||
waitCTX, cancel := context.WithCancel(ctx)
|
||||
defer cancel()
|
||||
|
||||
// Swap in this wait's cancel and take over the one it displaces in a single
|
||||
// critical section, so two WaitSSOLogin calls racing here cannot both read
|
||||
// the same predecessor and leave one wait uncancelled. Cancelling happens
|
||||
// after the unlock: the displaced wait takes s.mutex as it unwinds.
|
||||
s.mutex.Lock()
|
||||
staleCancel := s.oauthAuthFlow.waitCancel
|
||||
s.oauthAuthFlow.waitCancel = cancel
|
||||
s.mutex.Unlock()
|
||||
|
||||
tokenInfo, err := s.oauthAuthFlow.flow.WaitToken(waitCTX, flowInfo)
|
||||
if staleCancel != nil {
|
||||
staleCancel()
|
||||
}
|
||||
|
||||
tokenInfo, err := flow.WaitToken(waitCTX, flowInfo)
|
||||
if err != nil {
|
||||
s.mutex.Lock()
|
||||
s.oauthAuthFlow.expiresAt = time.Now()
|
||||
@@ -883,8 +949,33 @@ func (s *Server) WaitSSOLogin(callerCtx context.Context, msg *proto.WaitSSOLogin
|
||||
|
||||
s.mutex.Lock()
|
||||
s.oauthAuthFlow.expiresAt = time.Now()
|
||||
hint := s.oauthAuthFlow.hint
|
||||
accountPrompted := s.oauthAuthFlow.accountPrompted
|
||||
s.mutex.Unlock()
|
||||
|
||||
if !tokenInfo.MatchesAccount(hint) {
|
||||
if !accountPrompted {
|
||||
// The IdP answered from a session belonging to another account. The
|
||||
// browser for this flow is gone, so a new URL cannot be handed out
|
||||
// here — arm the prompt for the user's next connect and fail this
|
||||
// round. Never log in with the token: on a registered peer the
|
||||
// server would reject it, and on a fresh one it would silently
|
||||
// register the peer under the wrong account.
|
||||
log.Warnf("login returned an account other than the one this profile is bound to; the next connect will ask the IdP to choose")
|
||||
s.mutex.Lock()
|
||||
s.oauthAuthFlow = oauthAuthFlow{}
|
||||
s.forceAccountPrompt = true
|
||||
s.mutex.Unlock()
|
||||
state.Set(internal.StatusNeedsLogin)
|
||||
return nil, gstatus.Errorf(codes.FailedPrecondition, "the login used a different account than this profile; connect again to choose the account")
|
||||
}
|
||||
// Already asked once; the account may legitimately differ (a changed
|
||||
// email address). Refusing again would lock the user out of the profile,
|
||||
// and the management server still rejects a token that does not own the
|
||||
// peer.
|
||||
log.Warnf("login still returned a different account after the prompt, continuing with it")
|
||||
}
|
||||
|
||||
if loginStatus, err := s.attemptLogin(ctx, "", tokenInfo.GetTokenToUse()); err != nil {
|
||||
state.Set(loginStatus)
|
||||
return nil, err
|
||||
@@ -1185,6 +1276,16 @@ func (s *Server) SwitchProfile(callerCtx context.Context, msg *proto.SwitchProfi
|
||||
|
||||
s.config = config
|
||||
|
||||
// A pending login flow and the account-prompt flag describe the previous
|
||||
// profile's login; carried across a switch they would judge the new
|
||||
// profile's token against the old profile's account. CancelFunc is
|
||||
// non-blocking, so calling it under the mutex is safe.
|
||||
if cancel := s.oauthAuthFlow.waitCancel; cancel != nil {
|
||||
cancel()
|
||||
}
|
||||
s.oauthAuthFlow = oauthAuthFlow{}
|
||||
s.forceAccountPrompt = false
|
||||
|
||||
if msg != nil && msg.ProfileName != nil {
|
||||
s.publishProfileListChanged(*msg.ProfileName)
|
||||
}
|
||||
@@ -1724,7 +1825,7 @@ func (s *Server) RequestJWTAuth(
|
||||
}
|
||||
|
||||
// the daemon has no graphical session of its own, only the caller can answer this
|
||||
oAuthFlow, err := auth.NewOAuthFlow(ctx, config, msg.GetHasGraphicalSession(), false, hint)
|
||||
oAuthFlow, err := auth.NewOAuthFlow(ctx, config, msg.GetHasGraphicalSession(), false, hint, false)
|
||||
if err != nil {
|
||||
return nil, gstatus.Errorf(codes.Internal, "failed to create OAuth flow: %v", err)
|
||||
}
|
||||
@@ -1828,7 +1929,7 @@ func (s *Server) RequestExtendAuthSession(
|
||||
}
|
||||
|
||||
// the daemon has no graphical session of its own, only the caller can answer this
|
||||
oAuthFlow, err := auth.NewOAuthFlow(ctx, config, msg.GetHasGraphicalSession(), false, hint)
|
||||
oAuthFlow, err := auth.NewOAuthFlow(ctx, config, msg.GetHasGraphicalSession(), false, hint, true)
|
||||
if err != nil {
|
||||
return nil, gstatus.Errorf(codes.Internal, "failed to create OAuth flow: %v", err)
|
||||
}
|
||||
|
||||
87
management/internals/shared/grpc/pkce_flow_test.go
Normal file
87
management/internals/shared/grpc/pkce_flow_test.go
Normal file
@@ -0,0 +1,87 @@
|
||||
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 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: "extend replaces the none flag so the extend is not silent",
|
||||
flow: &proto.PKCEAuthorizationFlow{
|
||||
ProviderConfig: &proto.ProviderConfig{
|
||||
DisablePromptLogin: false,
|
||||
LoginFlag: uint32(common.LoginFlagNone),
|
||||
},
|
||||
},
|
||||
sessionExtend: true,
|
||||
disablePromptLogin: false,
|
||||
loginFlag: uint32(common.LoginFlagPromptLogin),
|
||||
},
|
||||
{
|
||||
name: "extend respects DisablePromptLogin",
|
||||
flow: &proto.PKCEAuthorizationFlow{
|
||||
ProviderConfig: &proto.ProviderConfig{
|
||||
DisablePromptLogin: true,
|
||||
LoginFlag: uint32(common.LoginFlagMaxAge0),
|
||||
},
|
||||
},
|
||||
sessionExtend: true,
|
||||
disablePromptLogin: true,
|
||||
loginFlag: uint32(common.LoginFlagMaxAge0),
|
||||
},
|
||||
{
|
||||
name: "login keeps the configured flow untouched",
|
||||
flow: &proto.PKCEAuthorizationFlow{
|
||||
ProviderConfig: &proto.ProviderConfig{
|
||||
DisablePromptLogin: false,
|
||||
LoginFlag: uint32(common.LoginFlagMaxAge0),
|
||||
},
|
||||
},
|
||||
sessionExtend: false,
|
||||
disablePromptLogin: false,
|
||||
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)
|
||||
})
|
||||
}
|
||||
@@ -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,40 @@ 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.
|
||||
//
|
||||
// DisablePromptLogin is left alone. It is set for IdPs that break on
|
||||
// prompt=login — Authentik triggers a double authentication, and social logins
|
||||
// fail outright — so overriding it would trade a recoverable session extend for
|
||||
// a login that cannot complete at all. Those deployments keep the silent flow
|
||||
// and, with several accounts signed in, an extend answered from the wrong one
|
||||
// still fails the user match.
|
||||
//
|
||||
// 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
|
||||
}
|
||||
cfg := flow.GetProviderConfig()
|
||||
if cfg == nil || cfg.GetDisablePromptLogin() {
|
||||
return
|
||||
}
|
||||
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) {
|
||||
|
||||
@@ -21,7 +21,7 @@ type Client interface {
|
||||
// is not eligible for session extension.
|
||||
ExtendAuthSession(sysInfo *system.Info, jwtToken string) (*proto.ExtendAuthSessionResponse, error)
|
||||
GetDeviceAuthorizationFlow() (*proto.DeviceAuthorizationFlow, error)
|
||||
GetPKCEAuthorizationFlow() (*proto.PKCEAuthorizationFlow, error)
|
||||
GetPKCEAuthorizationFlow(sessionExtend bool) (*proto.PKCEAuthorizationFlow, error)
|
||||
GetServerURL() string
|
||||
// IsHealthy returns the current connection status without blocking.
|
||||
// Used by the engine to monitor connectivity in the background.
|
||||
|
||||
@@ -595,7 +595,12 @@ func Test_GetPKCEAuthorizationFlow(t *testing.T) {
|
||||
},
|
||||
}
|
||||
|
||||
var gotRequest mgmtProto.PKCEAuthorizationFlowRequest
|
||||
mgmtMockServer.GetPKCEAuthorizationFlowFunc = func(ctx context.Context, req *mgmtProto.EncryptedMessage) (*mgmtProto.EncryptedMessage, error) {
|
||||
if err := encryption.DecryptMessage(client.key.PublicKey(), serverKey, req.Body, &gotRequest); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
encryptedResp, err := encryption.EncryptMessage(client.key.PublicKey(), serverKey, expectedFlowInfo)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -608,11 +613,13 @@ func Test_GetPKCEAuthorizationFlow(t *testing.T) {
|
||||
}, nil
|
||||
}
|
||||
|
||||
flowInfo, err := client.GetPKCEAuthorizationFlow()
|
||||
flowInfo, err := client.GetPKCEAuthorizationFlow(true)
|
||||
if err != nil {
|
||||
t.Error("error while retrieving pkce auth flow information")
|
||||
}
|
||||
|
||||
assert.True(t, gotRequest.GetSessionExtend(), "session extend should reach the server")
|
||||
|
||||
assert.Equal(t, expectedFlowInfo.ProviderConfig.ClientID, flowInfo.ProviderConfig.ClientID, "provider configured client ID should match")
|
||||
assert.Equal(t, expectedFlowInfo.ProviderConfig.ClientSecret, flowInfo.ProviderConfig.ClientSecret, "provider configured client secret should match") //nolint:staticcheck
|
||||
}
|
||||
|
||||
@@ -701,7 +701,11 @@ func (c *GrpcClient) GetDeviceAuthorizationFlow() (*proto.DeviceAuthorizationFlo
|
||||
|
||||
// GetPKCEAuthorizationFlow returns a pkce authorization flow information.
|
||||
// It also takes care of encrypting and decrypting messages.
|
||||
func (c *GrpcClient) GetPKCEAuthorizationFlow() (*proto.PKCEAuthorizationFlow, error) {
|
||||
//
|
||||
// sessionExtend tells the server the flow will renew an existing peer's session
|
||||
// rather than log one in, so it can rule out a configuration that would let the
|
||||
// IdP answer from an unrelated account. See PKCEAuthorizationFlowRequest.
|
||||
func (c *GrpcClient) GetPKCEAuthorizationFlow(sessionExtend bool) (*proto.PKCEAuthorizationFlow, error) {
|
||||
if !c.ready() {
|
||||
return nil, fmt.Errorf("no connection to management in order to get pkce authorization flow")
|
||||
}
|
||||
@@ -714,7 +718,7 @@ func (c *GrpcClient) GetPKCEAuthorizationFlow() (*proto.PKCEAuthorizationFlow, e
|
||||
mgmCtx, cancel := context.WithTimeout(c.ctx, time.Second*2)
|
||||
defer cancel()
|
||||
|
||||
message := &proto.PKCEAuthorizationFlowRequest{}
|
||||
message := &proto.PKCEAuthorizationFlowRequest{SessionExtend: sessionExtend}
|
||||
encryptedMSG, err := encryption.EncryptMessage(*serverKey, c.key, message)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
|
||||
@@ -16,7 +16,7 @@ type MockClient struct {
|
||||
LoginFunc func(info *system.Info, sshKey []byte, dnsLabels domain.List) (*proto.LoginResponse, error)
|
||||
ExtendAuthSessionFunc func(info *system.Info, jwtToken string) (*proto.ExtendAuthSessionResponse, error)
|
||||
GetDeviceAuthorizationFlowFunc func() (*proto.DeviceAuthorizationFlow, error)
|
||||
GetPKCEAuthorizationFlowFunc func() (*proto.PKCEAuthorizationFlow, error)
|
||||
GetPKCEAuthorizationFlowFunc func(sessionExtend bool) (*proto.PKCEAuthorizationFlow, error)
|
||||
GetServerURLFunc func() string
|
||||
HealthCheckFunc func() error
|
||||
SyncMetaFunc func(sysInfo *system.Info) error
|
||||
@@ -80,11 +80,11 @@ func (m *MockClient) GetDeviceAuthorizationFlow() (*proto.DeviceAuthorizationFlo
|
||||
return m.GetDeviceAuthorizationFlowFunc()
|
||||
}
|
||||
|
||||
func (m *MockClient) GetPKCEAuthorizationFlow() (*proto.PKCEAuthorizationFlow, error) {
|
||||
func (m *MockClient) GetPKCEAuthorizationFlow(sessionExtend bool) (*proto.PKCEAuthorizationFlow, error) {
|
||||
if m.GetPKCEAuthorizationFlowFunc == nil {
|
||||
return nil, nil
|
||||
}
|
||||
return m.GetPKCEAuthorizationFlowFunc()
|
||||
return m.GetPKCEAuthorizationFlowFunc(sessionExtend)
|
||||
}
|
||||
|
||||
func (m *MockClient) HealthCheck() error {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -530,8 +530,18 @@ message DeviceAuthorizationFlow {
|
||||
}
|
||||
}
|
||||
|
||||
// PKCEAuthorizationFlowRequest empty struct for future expansion
|
||||
message PKCEAuthorizationFlowRequest {}
|
||||
// PKCEAuthorizationFlowRequest asks for the PKCE flow configuration to use for
|
||||
// an upcoming authorization request.
|
||||
message PKCEAuthorizationFlowRequest {
|
||||
// SessionExtend indicates the flow will renew the SSO session of a peer that
|
||||
// is already registered, rather than log in or register one. An extend is
|
||||
// bound to the account that peer belongs to, so the server must not answer it
|
||||
// with a configuration that lets the IdP reply from whatever session is
|
||||
// already active: with several accounts signed in at the IdP that need not be
|
||||
// the peer's own, and the resulting token is rejected as a peer/user mismatch
|
||||
// with no way for the user to correct it.
|
||||
bool SessionExtend = 1;
|
||||
}
|
||||
|
||||
// PKCEAuthorizationFlow represents Authorization Code Flow information
|
||||
// that can be used by the client to login initiate a Oauth 2.0 authorization code grant flow
|
||||
|
||||
Reference in New Issue
Block a user