mirror of
https://github.com/netbirdio/netbird.git
synced 2026-10-15 01:49:08 +02:00
[client] Verify the SSO login came back for the hinted account
login_hint is a suggestion the IdP may ignore: with a silent flow configured (DisablePromptLogin or max_age=0) and a live IdP session for another account, the login completes with that account's token. On a registered peer the management server rejects it as a user mismatch, but on a fresh profile the peer silently registers under the wrong account and the profile is then bound to it — every later login follows the stored hint straight back. After the token exchange, compare the ID token's email against the hint the flow was sent with. On a mismatch, do not log in to management with the token; run one more round asking the IdP to re-decide the account (prompt=login, via ForceAccountPrompt — DisablePromptLogin still wins there). If the prompted round also comes back different, proceed with a warning: the address may legitimately have changed, and refusing forever would lock the user out of the profile while the management server still rejects a token that does not own the peer. A token or profile with no email to compare is not judged. The retry differs per platform because of who opens the browser: - CLI (netbird login foreground) and Android run the whole flow in one process, so the mismatch retries automatically: the browser reopens with the account prompt within the same login attempt. - On desktop the login is split between the daemon and the GUI: Login hands the authorize URL to the GUI, WaitSSOLogin blocks for the token, and only the GUI can open a browser. A new URL cannot be handed out from inside WaitSSOLogin (its response has no field for one, kept that way to avoid a proto change), so the daemon arms forceAccountPrompt, fails the round with "connect again to choose the account", and builds the next Login's flow with the prompt — the user's next connect is the retry. The flag and the flow annotations live in daemon memory only; SwitchProfile drops them so the previous profile's hint cannot judge the next profile's token. The device code flow has no prompt parameter (RFC 8628), so a prompted round there runs as-is and a repeated mismatch is let through with the warning rather than looping.
This commit is contained in:
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user