mirror of
https://github.com/netbirdio/netbird.git
synced 2026-09-12 17:59:06 +02:00
[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:
@@ -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)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user