Files
netbird/shared/auth/jwt/extractor_test.go
Misha Bragin e586c20e36 [management, infrastructure, idp] Simplified IdP Management - Embedded IdP (#5008)
Embed Dex as a built-in IdP to simplify self-hosting setup.
Adds an embedded OIDC Identity Provider (Dex) with local user management and optional external IdP connectors (Google/GitHub/OIDC/SAML), plus device-auth flow for CLI login. Introduces instance onboarding/setup endpoints (including owner creation), field-level encryption for sensitive user data, a streamlined self-hosting provisioning script, and expanded APIs + test coverage for IdP management.

more at https://github.com/netbirdio/netbird/pull/5008#issuecomment-3718987393
2026-01-07 14:52:32 +01:00

323 lines
8.4 KiB
Go

package jwt
import (
"testing"
"time"
"github.com/golang-jwt/jwt/v5"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestClaimsExtractor_ToUserAuth_ExtractsEmailAndName(t *testing.T) {
tests := []struct {
name string
claims jwt.MapClaims
userIDClaim string
audience string
expectedUserID string
expectedEmail string
expectedName string
expectError bool
}{
{
name: "extracts email and name from standard claims",
claims: jwt.MapClaims{
"sub": "user-123",
"email": "test@example.com",
"name": "Test User",
},
userIDClaim: "sub",
expectedUserID: "user-123",
expectedEmail: "test@example.com",
expectedName: "Test User",
},
{
name: "extracts Dex encoded user ID",
claims: jwt.MapClaims{
"sub": "CiQ3YWFkOGMwNS0zMjg3LTQ3M2YtYjQyYS0zNjU1MDRiZjI1ZTcSBWxvY2Fs",
"email": "dex-user@example.com",
"name": "Dex User",
},
userIDClaim: "sub",
expectedUserID: "CiQ3YWFkOGMwNS0zMjg3LTQ3M2YtYjQyYS0zNjU1MDRiZjI1ZTcSBWxvY2Fs",
expectedEmail: "dex-user@example.com",
expectedName: "Dex User",
},
{
name: "handles missing email claim",
claims: jwt.MapClaims{
"sub": "user-456",
"name": "User Without Email",
},
userIDClaim: "sub",
expectedUserID: "user-456",
expectedEmail: "",
expectedName: "User Without Email",
},
{
name: "handles missing name claim",
claims: jwt.MapClaims{
"sub": "user-789",
"email": "noname@example.com",
},
userIDClaim: "sub",
expectedUserID: "user-789",
expectedEmail: "noname@example.com",
expectedName: "",
},
{
name: "handles missing both email and name",
claims: jwt.MapClaims{
"sub": "user-minimal",
},
userIDClaim: "sub",
expectedUserID: "user-minimal",
expectedEmail: "",
expectedName: "",
},
{
name: "extracts preferred_username",
claims: jwt.MapClaims{
"sub": "user-pref",
"email": "pref@example.com",
"name": "Preferred User",
"preferred_username": "prefuser",
},
userIDClaim: "sub",
expectedUserID: "user-pref",
expectedEmail: "pref@example.com",
expectedName: "Preferred User",
},
{
name: "fails when user ID claim is empty",
claims: jwt.MapClaims{
"email": "test@example.com",
"name": "Test User",
},
userIDClaim: "sub",
expectError: true,
},
{
name: "uses custom user ID claim",
claims: jwt.MapClaims{
"user_id": "custom-user-id",
"email": "custom@example.com",
"name": "Custom User",
},
userIDClaim: "user_id",
expectedUserID: "custom-user-id",
expectedEmail: "custom@example.com",
expectedName: "Custom User",
},
{
name: "extracts account ID with audience prefix",
claims: jwt.MapClaims{
"sub": "user-with-account",
"email": "account@example.com",
"name": "Account User",
"https://api.netbird.io/wt_account_id": "account-123",
"https://api.netbird.io/wt_account_domain": "example.com",
},
userIDClaim: "sub",
audience: "https://api.netbird.io",
expectedUserID: "user-with-account",
expectedEmail: "account@example.com",
expectedName: "Account User",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
// Create extractor with options
opts := []ClaimsExtractorOption{}
if tt.userIDClaim != "" {
opts = append(opts, WithUserIDClaim(tt.userIDClaim))
}
if tt.audience != "" {
opts = append(opts, WithAudience(tt.audience))
}
extractor := NewClaimsExtractor(opts...)
// Create a mock token with the claims
token := &jwt.Token{
Claims: tt.claims,
}
// Extract user auth
userAuth, err := extractor.ToUserAuth(token)
if tt.expectError {
assert.Error(t, err)
return
}
require.NoError(t, err)
assert.Equal(t, tt.expectedUserID, userAuth.UserId)
assert.Equal(t, tt.expectedEmail, userAuth.Email)
assert.Equal(t, tt.expectedName, userAuth.Name)
})
}
}
func TestClaimsExtractor_ToUserAuth_PreferredUsername(t *testing.T) {
extractor := NewClaimsExtractor(WithUserIDClaim("sub"))
claims := jwt.MapClaims{
"sub": "user-123",
"email": "test@example.com",
"name": "Test User",
"preferred_username": "testuser",
}
token := &jwt.Token{Claims: claims}
userAuth, err := extractor.ToUserAuth(token)
require.NoError(t, err)
assert.Equal(t, "user-123", userAuth.UserId)
assert.Equal(t, "test@example.com", userAuth.Email)
assert.Equal(t, "Test User", userAuth.Name)
assert.Equal(t, "testuser", userAuth.PreferredName)
}
func TestClaimsExtractor_ToUserAuth_LastLogin(t *testing.T) {
extractor := NewClaimsExtractor(
WithUserIDClaim("sub"),
WithAudience("https://api.netbird.io"),
)
expectedTime := time.Date(2025, 1, 15, 10, 30, 0, 0, time.UTC)
claims := jwt.MapClaims{
"sub": "user-123",
"email": "test@example.com",
"https://api.netbird.io/nb_last_login": expectedTime.Format(time.RFC3339),
}
token := &jwt.Token{Claims: claims}
userAuth, err := extractor.ToUserAuth(token)
require.NoError(t, err)
assert.Equal(t, expectedTime, userAuth.LastLogin)
}
func TestClaimsExtractor_ToUserAuth_Invited(t *testing.T) {
extractor := NewClaimsExtractor(
WithUserIDClaim("sub"),
WithAudience("https://api.netbird.io"),
)
claims := jwt.MapClaims{
"sub": "user-123",
"email": "invited@example.com",
"https://api.netbird.io/nb_invited": true,
}
token := &jwt.Token{Claims: claims}
userAuth, err := extractor.ToUserAuth(token)
require.NoError(t, err)
assert.True(t, userAuth.Invited)
}
func TestClaimsExtractor_ToGroups(t *testing.T) {
extractor := NewClaimsExtractor(WithUserIDClaim("sub"))
tests := []struct {
name string
claims jwt.MapClaims
groupClaimName string
expectedGroups []string
}{
{
name: "extracts groups from claim",
claims: jwt.MapClaims{
"sub": "user-123",
"groups": []interface{}{"admin", "users", "developers"},
},
groupClaimName: "groups",
expectedGroups: []string{"admin", "users", "developers"},
},
{
name: "returns empty slice when claim missing",
claims: jwt.MapClaims{
"sub": "user-123",
},
groupClaimName: "groups",
expectedGroups: []string{},
},
{
name: "handles custom claim name",
claims: jwt.MapClaims{
"sub": "user-123",
"user_roles": []interface{}{"role1", "role2"},
},
groupClaimName: "user_roles",
expectedGroups: []string{"role1", "role2"},
},
{
name: "filters non-string values",
claims: jwt.MapClaims{
"sub": "user-123",
"groups": []interface{}{"admin", 123, "users", true},
},
groupClaimName: "groups",
expectedGroups: []string{"admin", "users"},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
token := &jwt.Token{Claims: tt.claims}
groups := extractor.ToGroups(token, tt.groupClaimName)
assert.Equal(t, tt.expectedGroups, groups)
})
}
}
func TestClaimsExtractor_DefaultUserIDClaim(t *testing.T) {
// When no user ID claim is specified, it should default to "sub"
extractor := NewClaimsExtractor()
claims := jwt.MapClaims{
"sub": "default-user-id",
"email": "default@example.com",
}
token := &jwt.Token{Claims: claims}
userAuth, err := extractor.ToUserAuth(token)
require.NoError(t, err)
assert.Equal(t, "default-user-id", userAuth.UserId)
}
func TestClaimsExtractor_DexUserIDFormat(t *testing.T) {
// Test that the extractor correctly handles Dex's encoded user ID format
// Dex encodes user IDs as base64(protobuf{user_id, connector_id})
extractor := NewClaimsExtractor(WithUserIDClaim("sub"))
// This is an actual Dex-encoded user ID
dexEncodedID := "CiQ3YWFkOGMwNS0zMjg3LTQ3M2YtYjQyYS0zNjU1MDRiZjI1ZTcSBWxvY2Fs"
claims := jwt.MapClaims{
"sub": dexEncodedID,
"email": "dex@example.com",
"name": "Dex User",
}
token := &jwt.Token{Claims: claims}
userAuth, err := extractor.ToUserAuth(token)
require.NoError(t, err)
// The extractor should pass through the encoded ID as-is
// Decoding is done elsewhere (e.g., in the Dex provider)
assert.Equal(t, dexEncodedID, userAuth.UserId)
assert.Equal(t, "dex@example.com", userAuth.Email)
assert.Equal(t, "Dex User", userAuth.Name)
}