mirror of
https://github.com/netbirdio/netbird.git
synced 2026-04-16 15:26:40 +00:00
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
323 lines
8.4 KiB
Go
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)
|
|
}
|