diff --git a/client/internal/auth/account_match_test.go b/client/internal/auth/account_match_test.go index c95a868f8..b879cf9ab 100644 --- a/client/internal/auth/account_match_test.go +++ b/client/internal/auth/account_match_test.go @@ -18,31 +18,37 @@ func TestTokenInfoMatchesAccount(t *testing.T) { }{ { name: "same account", - token: TokenInfo{Email: "user@example.com"}, + token: TokenInfo{EmailClaim: "user@example.com"}, hint: "user@example.com", match: true, }, { name: "different account", - token: TokenInfo{Email: "other@example.com"}, + token: TokenInfo{EmailClaim: "other@example.com"}, hint: "user@example.com", match: false, }, { name: "case differences are the same account", - token: TokenInfo{Email: "User@Example.com"}, + token: TokenInfo{EmailClaim: "User@Example.com"}, hint: "user@example.com", match: true, }, { name: "no hint leaves the choice to the IdP", - token: TokenInfo{Email: "other@example.com"}, + token: TokenInfo{EmailClaim: "other@example.com"}, hint: "", match: true, }, { - name: "token without an email is not judged", - token: TokenInfo{Email: ""}, + name: "token without an email claim is not judged", + token: TokenInfo{EmailClaim: ""}, + hint: "user@example.com", + match: true, + }, + { + name: "name fallback does not trigger matching", + token: TokenInfo{Email: "Some One"}, hint: "user@example.com", match: true, }, @@ -57,15 +63,17 @@ func TestTokenInfoMatchesAccount(t *testing.T) { func TestParseEmailFromIDToken(t *testing.T) { tests := []struct { - name string - claims map[string]interface{} - wantValue string - wantErr bool + name string + claims map[string]interface{} + wantValue string + wantFromEmail bool + wantErr bool }{ { - name: "email claim", - claims: map[string]interface{}{"email": "user@example.com", "name": "Some One"}, - wantValue: "user@example.com", + name: "email claim", + claims: map[string]interface{}{"email": "user@example.com", "name": "Some One"}, + wantValue: "user@example.com", + wantFromEmail: true, }, { name: "name fallback", @@ -81,13 +89,14 @@ func TestParseEmailFromIDToken(t *testing.T) { for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { - value, err := parseEmailFromIDToken(idTokenWithClaims(t, tc.claims)) + value, fromEmailClaim, err := parseEmailFromIDToken(idTokenWithClaims(t, tc.claims)) if tc.wantErr { require.Error(t, err) return } require.NoError(t, err) assert.Equal(t, tc.wantValue, value) + assert.Equal(t, tc.wantFromEmail, fromEmailClaim) }) } } diff --git a/client/internal/auth/oauth.go b/client/internal/auth/oauth.go index 1a4f1e94d..54ecb5d86 100644 --- a/client/internal/auth/oauth.go +++ b/client/internal/auth/oauth.go @@ -58,11 +58,12 @@ type TokenInfo struct { ExpiresIn int `json:"expires_in"` UseIDToken bool `json:"-"` Email string `json:"-"` + EmailClaim 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 +// that carried no email claim — 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 @@ -70,10 +71,10 @@ type TokenInfo struct { // an IdP that echoes a differently-cased address would otherwise fail every // login. func (t TokenInfo) MatchesAccount(hint string) bool { - if hint == "" || t.Email == "" { + if hint == "" || t.EmailClaim == "" { return true } - return strings.EqualFold(t.Email, hint) + return strings.EqualFold(t.EmailClaim, hint) } // GetTokenToUse returns either the access or id token based on UseIDToken field diff --git a/client/internal/auth/pkce_flow.go b/client/internal/auth/pkce_flow.go index 9e08cc0ed..1c8a6d376 100644 --- a/client/internal/auth/pkce_flow.go +++ b/client/internal/auth/pkce_flow.go @@ -324,11 +324,14 @@ func (p *PKCEAuthorizationFlow) parseOAuthToken(token *oauth2.Token) (TokenInfo, return TokenInfo{}, fmt.Errorf("authentication failed: invalid access token - %w", err) } - email, err := parseEmailFromIDToken(tokenInfo.IDToken) + email, fromEmailClaim, err := parseEmailFromIDToken(tokenInfo.IDToken) if err != nil { log.Warnf("failed to parse email from ID token: %v", err) } else { tokenInfo.Email = email + if fromEmailClaim { + tokenInfo.EmailClaim = email + } } return tokenInfo, nil @@ -336,32 +339,33 @@ 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: 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) { +// login hint and is displayed. Account matching (see MatchesAccount) only uses +// it when it came from the email claim, which fromEmailClaim reports. It never +// grants anything — the authoritative identity is established server-side from +// the signature-verified token. +func parseEmailFromIDToken(token string) (value string, fromEmailClaim bool, err error) { parts := strings.Split(token, ".") if len(parts) < 2 { - return "", fmt.Errorf("invalid token format") + return "", false, fmt.Errorf("invalid token format") } data, err := base64.RawURLEncoding.DecodeString(parts[1]) if err != nil { - return "", fmt.Errorf("failed to decode payload: %w", err) + return "", false, fmt.Errorf("failed to decode payload: %w", err) } var claims map[string]interface{} if err := json.Unmarshal(data, &claims); err != nil { - return "", fmt.Errorf("json unmarshal error: %w", err) + return "", false, fmt.Errorf("json unmarshal error: %w", err) } if email, ok := claims["email"].(string); ok { - return email, nil + return email, true, nil } if name, ok := claims["name"].(string); ok { - return name, nil + return name, false, nil } - return "", fmt.Errorf("email or name field not found in token payload") + return "", false, fmt.Errorf("email or name field not found in token payload") } func createCodeChallenge(codeVerifier string) string { diff --git a/client/server/login_account_test.go b/client/server/login_account_test.go index 8d88c75f0..b8b8a1ba8 100644 --- a/client/server/login_account_test.go +++ b/client/server/login_account_test.go @@ -115,7 +115,7 @@ func newSSOTestServer(t *testing.T, hint string, accountPrompted bool, tokenEmai t.Helper() s := New(internal.CtxInitState(context.Background()), "console", "", false, false, false, false) s.oauthAuthFlow = oauthAuthFlow{ - flow: &stubOAuthFlow{token: auth.TokenInfo{Email: tokenEmail}}, + flow: &stubOAuthFlow{token: auth.TokenInfo{Email: tokenEmail, EmailClaim: tokenEmail}}, info: auth.AuthFlowInfo{UserCode: "code"}, expiresAt: time.Now().Add(time.Minute), hint: hint,