[client] Match accounts only on the email claim of the ID token

The name-claim fallback in the ID token parsing is kept for the login
hint and display, but account matching now only considers a value that
came from the email claim, so a token without one no longer produces a
false account mismatch.
This commit is contained in:
Zoltán Papp
2026-08-27 10:41:47 +02:00
parent 2487cfcf24
commit 8282012235
4 changed files with 43 additions and 29 deletions
+23 -14
View File
@@ -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)
})
}
}
+4 -3
View File
@@ -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
+15 -11
View File
@@ -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 {
+1 -1
View File
@@ -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,