[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
This commit is contained in:
Misha Bragin
2026-01-07 08:52:32 -05:00
committed by GitHub
parent 5393ad948f
commit e586c20e36
90 changed files with 7702 additions and 517 deletions

View File

@@ -0,0 +1,511 @@
package idp
import (
"context"
"errors"
"fmt"
"net/http"
"github.com/dexidp/dex/storage"
"github.com/google/uuid"
log "github.com/sirupsen/logrus"
"github.com/netbirdio/netbird/idp/dex"
"github.com/netbirdio/netbird/management/server/telemetry"
)
const (
staticClientDashboard = "netbird-dashboard"
staticClientCLI = "netbird-cli"
defaultCLIRedirectURL1 = "http://localhost:53000/"
defaultCLIRedirectURL2 = "http://localhost:54000/"
defaultScopes = "openid profile email offline_access"
defaultUserIDClaim = "sub"
)
// EmbeddedIdPConfig contains configuration for the embedded Dex OIDC identity provider
type EmbeddedIdPConfig struct {
// Enabled indicates whether the embedded IDP is enabled
Enabled bool
// Issuer is the OIDC issuer URL (e.g., "http://localhost:3002/oauth2")
Issuer string
// Storage configuration for the IdP database
Storage EmbeddedStorageConfig
// DashboardRedirectURIs are the OAuth2 redirect URIs for the dashboard client
DashboardRedirectURIs []string
// DashboardRedirectURIs are the OAuth2 redirect URIs for the dashboard client
CLIRedirectURIs []string
// Owner is the initial owner/admin user (optional, can be nil)
Owner *OwnerConfig
// SignKeyRefreshEnabled enables automatic key rotation for signing keys
SignKeyRefreshEnabled bool
}
// EmbeddedStorageConfig holds storage configuration for the embedded IdP.
type EmbeddedStorageConfig struct {
// Type is the storage type (currently only "sqlite3" is supported)
Type string
// Config contains type-specific configuration
Config EmbeddedStorageTypeConfig
}
// EmbeddedStorageTypeConfig contains type-specific storage configuration.
type EmbeddedStorageTypeConfig struct {
// File is the path to the SQLite database file (for sqlite3 type)
File string
}
// OwnerConfig represents the initial owner/admin user for the embedded IdP.
type OwnerConfig struct {
// Email is the user's email address (required)
Email string
// Hash is the bcrypt hash of the user's password (required)
Hash string
// Username is the display name for the user (optional, defaults to email)
Username string
}
// ToYAMLConfig converts EmbeddedIdPConfig to dex.YAMLConfig.
func (c *EmbeddedIdPConfig) ToYAMLConfig() (*dex.YAMLConfig, error) {
if c.Issuer == "" {
return nil, fmt.Errorf("issuer is required")
}
if c.Storage.Type == "" {
c.Storage.Type = "sqlite3"
}
if c.Storage.Type == "sqlite3" && c.Storage.Config.File == "" {
return nil, fmt.Errorf("storage file is required for sqlite3")
}
// Build CLI redirect URIs including the device callback (both relative and absolute)
cliRedirectURIs := c.CLIRedirectURIs
cliRedirectURIs = append(cliRedirectURIs, "/device/callback")
cliRedirectURIs = append(cliRedirectURIs, c.Issuer+"/device/callback")
cfg := &dex.YAMLConfig{
Issuer: c.Issuer,
Storage: dex.Storage{
Type: c.Storage.Type,
Config: map[string]interface{}{
"file": c.Storage.Config.File,
},
},
Web: dex.Web{
AllowedOrigins: []string{"*"},
AllowedHeaders: []string{"Authorization", "Content-Type"},
},
OAuth2: dex.OAuth2{
SkipApprovalScreen: true,
},
Frontend: dex.Frontend{
Issuer: "NetBird",
Theme: "light",
},
EnablePasswordDB: true,
StaticClients: []storage.Client{
{
ID: staticClientDashboard,
Name: "NetBird Dashboard",
Public: true,
RedirectURIs: c.DashboardRedirectURIs,
},
{
ID: staticClientCLI,
Name: "NetBird CLI",
Public: true,
RedirectURIs: cliRedirectURIs,
},
},
}
// Add owner user if provided
if c.Owner != nil && c.Owner.Email != "" && c.Owner.Hash != "" {
username := c.Owner.Username
if username == "" {
username = c.Owner.Email
}
cfg.StaticPasswords = []dex.Password{
{
Email: c.Owner.Email,
Hash: []byte(c.Owner.Hash),
Username: username,
UserID: uuid.New().String(),
},
}
}
return cfg, nil
}
// Compile-time check that EmbeddedIdPManager implements Manager interface
var _ Manager = (*EmbeddedIdPManager)(nil)
// Compile-time check that EmbeddedIdPManager implements OAuthConfigProvider interface
var _ OAuthConfigProvider = (*EmbeddedIdPManager)(nil)
// OAuthConfigProvider defines the interface for OAuth configuration needed by auth flows.
type OAuthConfigProvider interface {
GetIssuer() string
GetKeysLocation() string
GetClientIDs() []string
GetUserIDClaim() string
GetTokenEndpoint() string
GetDeviceAuthEndpoint() string
GetAuthorizationEndpoint() string
GetDefaultScopes() string
GetCLIClientID() string
GetCLIRedirectURLs() []string
}
// EmbeddedIdPManager implements the Manager interface using the embedded Dex IdP.
type EmbeddedIdPManager struct {
provider *dex.Provider
appMetrics telemetry.AppMetrics
config EmbeddedIdPConfig
}
// NewEmbeddedIdPManager creates a new instance of EmbeddedIdPManager from a configuration.
// It instantiates the underlying Dex provider internally.
// Note: Storage defaults are applied in config loading (applyEmbeddedIdPConfig) based on Datadir.
func NewEmbeddedIdPManager(ctx context.Context, config *EmbeddedIdPConfig, appMetrics telemetry.AppMetrics) (*EmbeddedIdPManager, error) {
if config == nil {
return nil, fmt.Errorf("embedded IdP config is required")
}
// Apply defaults for CLI redirect URIs
if len(config.CLIRedirectURIs) == 0 {
config.CLIRedirectURIs = []string{defaultCLIRedirectURL1, defaultCLIRedirectURL2}
}
// there are some properties create when creating YAML config (e.g., auth clients)
yamlConfig, err := config.ToYAMLConfig()
if err != nil {
return nil, err
}
provider, err := dex.NewProviderFromYAML(ctx, yamlConfig)
if err != nil {
return nil, fmt.Errorf("failed to create embedded IdP provider: %w", err)
}
log.WithContext(ctx).Infof("embedded Dex IDP initialized with issuer: %s", yamlConfig.Issuer)
return &EmbeddedIdPManager{
provider: provider,
appMetrics: appMetrics,
config: *config,
}, nil
}
// Handler returns the HTTP handler for serving OIDC requests.
func (m *EmbeddedIdPManager) Handler() http.Handler {
return m.provider.Handler()
}
// Stop gracefully shuts down the embedded IdP provider.
func (m *EmbeddedIdPManager) Stop(ctx context.Context) error {
return m.provider.Stop(ctx)
}
// UpdateUserAppMetadata updates user app metadata based on userID and metadata map.
func (m *EmbeddedIdPManager) UpdateUserAppMetadata(ctx context.Context, userID string, appMetadata AppMetadata) error {
// TODO: implement
return nil
}
// GetUserDataByID requests user data from the embedded IdP via user ID.
func (m *EmbeddedIdPManager) GetUserDataByID(ctx context.Context, userID string, appMetadata AppMetadata) (*UserData, error) {
user, err := m.provider.GetUserByID(ctx, userID)
if err != nil {
if m.appMetrics != nil {
m.appMetrics.IDPMetrics().CountRequestError()
}
return nil, fmt.Errorf("failed to get user by ID: %w", err)
}
return &UserData{
Email: user.Email,
Name: user.Username,
ID: user.UserID,
AppMetadata: appMetadata,
}, nil
}
// GetAccount returns all the users for a given account.
// Note: Embedded dex doesn't store account metadata, so this returns all users.
func (m *EmbeddedIdPManager) GetAccount(ctx context.Context, accountID string) ([]*UserData, error) {
users, err := m.provider.ListUsers(ctx)
if err != nil {
if m.appMetrics != nil {
m.appMetrics.IDPMetrics().CountRequestError()
}
return nil, fmt.Errorf("failed to list users: %w", err)
}
result := make([]*UserData, 0, len(users))
for _, user := range users {
result = append(result, &UserData{
Email: user.Email,
Name: user.Username,
ID: user.UserID,
AppMetadata: AppMetadata{
WTAccountID: accountID,
},
})
}
return result, nil
}
// GetAllAccounts gets all registered accounts with corresponding user data.
// Note: Embedded dex doesn't store account metadata, so all users are indexed under UnsetAccountID.
func (m *EmbeddedIdPManager) GetAllAccounts(ctx context.Context) (map[string][]*UserData, error) {
if m.appMetrics != nil {
m.appMetrics.IDPMetrics().CountGetAllAccounts()
}
users, err := m.provider.ListUsers(ctx)
if err != nil {
if m.appMetrics != nil {
m.appMetrics.IDPMetrics().CountRequestError()
}
return nil, fmt.Errorf("failed to list users: %w", err)
}
indexedUsers := make(map[string][]*UserData)
for _, user := range users {
indexedUsers[UnsetAccountID] = append(indexedUsers[UnsetAccountID], &UserData{
Email: user.Email,
Name: user.Username,
ID: user.UserID,
})
}
return indexedUsers, nil
}
// CreateUser creates a new user in the embedded IdP.
func (m *EmbeddedIdPManager) CreateUser(ctx context.Context, email, name, accountID, invitedByEmail string) (*UserData, error) {
if m.appMetrics != nil {
m.appMetrics.IDPMetrics().CountCreateUser()
}
// Check if user already exists
_, err := m.provider.GetUser(ctx, email)
if err == nil {
return nil, fmt.Errorf("user with email %s already exists", email)
}
if !errors.Is(err, storage.ErrNotFound) {
if m.appMetrics != nil {
m.appMetrics.IDPMetrics().CountRequestError()
}
return nil, fmt.Errorf("failed to check existing user: %w", err)
}
// Generate a random password for the new user
password := GeneratePassword(16, 2, 2, 2)
// Create the user via provider (handles hashing and ID generation)
// The provider returns an encoded user ID in Dex's format (base64 protobuf with connector ID)
userID, err := m.provider.CreateUser(ctx, email, name, password)
if err != nil {
if m.appMetrics != nil {
m.appMetrics.IDPMetrics().CountRequestError()
}
return nil, fmt.Errorf("failed to create user in embedded IdP: %w", err)
}
log.WithContext(ctx).Debugf("created user %s in embedded IdP", email)
return &UserData{
Email: email,
Name: name,
ID: userID,
Password: password,
AppMetadata: AppMetadata{
WTAccountID: accountID,
WTInvitedBy: invitedByEmail,
},
}, nil
}
// GetUserByEmail searches users with a given email.
func (m *EmbeddedIdPManager) GetUserByEmail(ctx context.Context, email string) ([]*UserData, error) {
user, err := m.provider.GetUser(ctx, email)
if err != nil {
if errors.Is(err, storage.ErrNotFound) {
return nil, nil // Return empty slice for not found
}
if m.appMetrics != nil {
m.appMetrics.IDPMetrics().CountRequestError()
}
return nil, fmt.Errorf("failed to get user by email: %w", err)
}
return []*UserData{
{
Email: user.Email,
Name: user.Username,
ID: user.UserID,
},
}, nil
}
// CreateUserWithPassword creates a new user in the embedded IdP with a provided password.
// Unlike CreateUser which auto-generates a password, this method uses the provided password.
// This is useful for instance setup where the user provides their own password.
func (m *EmbeddedIdPManager) CreateUserWithPassword(ctx context.Context, email, password, name string) (*UserData, error) {
if m.appMetrics != nil {
m.appMetrics.IDPMetrics().CountCreateUser()
}
// Check if user already exists
_, err := m.provider.GetUser(ctx, email)
if err == nil {
return nil, fmt.Errorf("user with email %s already exists", email)
}
if !errors.Is(err, storage.ErrNotFound) {
if m.appMetrics != nil {
m.appMetrics.IDPMetrics().CountRequestError()
}
return nil, fmt.Errorf("failed to check existing user: %w", err)
}
// Create the user via provider with the provided password
userID, err := m.provider.CreateUser(ctx, email, name, password)
if err != nil {
if m.appMetrics != nil {
m.appMetrics.IDPMetrics().CountRequestError()
}
return nil, fmt.Errorf("failed to create user in embedded IdP: %w", err)
}
log.WithContext(ctx).Debugf("created user %s in embedded IdP with provided password", email)
return &UserData{
Email: email,
Name: name,
ID: userID,
}, nil
}
// InviteUserByID resends an invitation to a user.
func (m *EmbeddedIdPManager) InviteUserByID(ctx context.Context, userID string) error {
// TODO: implement
return fmt.Errorf("not implemented")
}
// DeleteUser deletes a user from the embedded IdP by user ID.
func (m *EmbeddedIdPManager) DeleteUser(ctx context.Context, userID string) error {
if m.appMetrics != nil {
m.appMetrics.IDPMetrics().CountDeleteUser()
}
// Get user by ID to retrieve email (provider.DeleteUser requires email)
user, err := m.provider.GetUserByID(ctx, userID)
if err != nil {
if m.appMetrics != nil {
m.appMetrics.IDPMetrics().CountRequestError()
}
return fmt.Errorf("failed to get user for deletion: %w", err)
}
err = m.provider.DeleteUser(ctx, user.Email)
if err != nil {
if m.appMetrics != nil {
m.appMetrics.IDPMetrics().CountRequestError()
}
return fmt.Errorf("failed to delete user from embedded IdP: %w", err)
}
log.WithContext(ctx).Debugf("deleted user %s from embedded IdP", user.Email)
return nil
}
// CreateConnector creates a new identity provider connector in Dex.
// Returns the created connector config with the redirect URL populated.
func (m *EmbeddedIdPManager) CreateConnector(ctx context.Context, cfg *dex.ConnectorConfig) (*dex.ConnectorConfig, error) {
return m.provider.CreateConnector(ctx, cfg)
}
// GetConnector retrieves an identity provider connector by ID.
func (m *EmbeddedIdPManager) GetConnector(ctx context.Context, id string) (*dex.ConnectorConfig, error) {
return m.provider.GetConnector(ctx, id)
}
// ListConnectors returns all identity provider connectors.
func (m *EmbeddedIdPManager) ListConnectors(ctx context.Context) ([]*dex.ConnectorConfig, error) {
return m.provider.ListConnectors(ctx)
}
// UpdateConnector updates an existing identity provider connector.
func (m *EmbeddedIdPManager) UpdateConnector(ctx context.Context, cfg *dex.ConnectorConfig) error {
// Preserve existing secret if not provided in update
if cfg.ClientSecret == "" {
existing, err := m.provider.GetConnector(ctx, cfg.ID)
if err != nil {
return fmt.Errorf("failed to get existing connector: %w", err)
}
cfg.ClientSecret = existing.ClientSecret
}
return m.provider.UpdateConnector(ctx, cfg)
}
// DeleteConnector removes an identity provider connector.
func (m *EmbeddedIdPManager) DeleteConnector(ctx context.Context, id string) error {
return m.provider.DeleteConnector(ctx, id)
}
// GetIssuer returns the OIDC issuer URL.
func (m *EmbeddedIdPManager) GetIssuer() string {
return m.provider.GetIssuer()
}
// GetTokenEndpoint returns the OAuth2 token endpoint URL.
func (m *EmbeddedIdPManager) GetTokenEndpoint() string {
return m.provider.GetTokenEndpoint()
}
// GetDeviceAuthEndpoint returns the OAuth2 device authorization endpoint URL.
func (m *EmbeddedIdPManager) GetDeviceAuthEndpoint() string {
return m.provider.GetDeviceAuthEndpoint()
}
// GetAuthorizationEndpoint returns the OAuth2 authorization endpoint URL.
func (m *EmbeddedIdPManager) GetAuthorizationEndpoint() string {
return m.provider.GetAuthorizationEndpoint()
}
// GetDefaultScopes returns the default OAuth2 scopes for authentication.
func (m *EmbeddedIdPManager) GetDefaultScopes() string {
return defaultScopes
}
// GetCLIClientID returns the client ID for CLI authentication.
func (m *EmbeddedIdPManager) GetCLIClientID() string {
return staticClientCLI
}
// GetCLIRedirectURLs returns the redirect URLs configured for the CLI client.
func (m *EmbeddedIdPManager) GetCLIRedirectURLs() []string {
if len(m.config.CLIRedirectURIs) == 0 {
return []string{defaultCLIRedirectURL1, defaultCLIRedirectURL2}
}
return m.config.CLIRedirectURIs
}
// GetKeysLocation returns the JWKS endpoint URL for token validation.
func (m *EmbeddedIdPManager) GetKeysLocation() string {
return m.provider.GetKeysLocation()
}
// GetClientIDs returns the OAuth2 client IDs configured for this provider.
func (m *EmbeddedIdPManager) GetClientIDs() []string {
return []string{staticClientDashboard, staticClientCLI}
}
// GetUserIDClaim returns the JWT claim name used for user identification.
func (m *EmbeddedIdPManager) GetUserIDClaim() string {
return defaultUserIDClaim
}

View File

@@ -0,0 +1,249 @@
package idp
import (
"context"
"os"
"path/filepath"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/netbirdio/netbird/idp/dex"
)
func TestEmbeddedIdPManager_CreateUser_EndToEnd(t *testing.T) {
ctx := context.Background()
// Create a temporary directory for the test
tmpDir, err := os.MkdirTemp("", "embedded-idp-test-*")
require.NoError(t, err)
defer os.RemoveAll(tmpDir)
// Create the embedded IDP config
config := &EmbeddedIdPConfig{
Enabled: true,
Issuer: "http://localhost:5556/dex",
Storage: EmbeddedStorageConfig{
Type: "sqlite3",
Config: EmbeddedStorageTypeConfig{
File: filepath.Join(tmpDir, "dex.db"),
},
},
}
// Create the embedded IDP manager
manager, err := NewEmbeddedIdPManager(ctx, config, nil)
require.NoError(t, err)
defer func() { _ = manager.Stop(ctx) }()
// Test data
email := "newuser@example.com"
name := "New User"
accountID := "test-account-id"
invitedByEmail := "admin@example.com"
// Create the user
userData, err := manager.CreateUser(ctx, email, name, accountID, invitedByEmail)
require.NoError(t, err)
require.NotNil(t, userData)
t.Logf("Created user: ID=%s, Email=%s, Name=%s, Password=%s",
userData.ID, userData.Email, userData.Name, userData.Password)
// Verify user data
assert.Equal(t, email, userData.Email)
assert.Equal(t, name, userData.Name)
assert.NotEmpty(t, userData.ID)
assert.NotEmpty(t, userData.Password)
assert.Equal(t, accountID, userData.AppMetadata.WTAccountID)
assert.Equal(t, invitedByEmail, userData.AppMetadata.WTInvitedBy)
// Verify the user ID is in Dex's encoded format (base64 protobuf)
rawUserID, connectorID, err := dex.DecodeDexUserID(userData.ID)
require.NoError(t, err)
assert.NotEmpty(t, rawUserID)
assert.Equal(t, "local", connectorID)
t.Logf("Decoded user ID: rawUserID=%s, connectorID=%s", rawUserID, connectorID)
// Verify we can look up the user by the encoded ID
lookedUpUser, err := manager.GetUserDataByID(ctx, userData.ID, AppMetadata{WTAccountID: accountID})
require.NoError(t, err)
assert.Equal(t, email, lookedUpUser.Email)
// Verify we can look up by email
users, err := manager.GetUserByEmail(ctx, email)
require.NoError(t, err)
require.Len(t, users, 1)
assert.Equal(t, email, users[0].Email)
// Verify creating duplicate user fails
_, err = manager.CreateUser(ctx, email, name, accountID, invitedByEmail)
assert.Error(t, err)
assert.Contains(t, err.Error(), "already exists")
}
func TestEmbeddedIdPManager_GetUserDataByID_WithEncodedID(t *testing.T) {
ctx := context.Background()
tmpDir, err := os.MkdirTemp("", "embedded-idp-test-*")
require.NoError(t, err)
defer os.RemoveAll(tmpDir)
config := &EmbeddedIdPConfig{
Enabled: true,
Issuer: "http://localhost:5556/dex",
Storage: EmbeddedStorageConfig{
Type: "sqlite3",
Config: EmbeddedStorageTypeConfig{
File: filepath.Join(tmpDir, "dex.db"),
},
},
}
manager, err := NewEmbeddedIdPManager(ctx, config, nil)
require.NoError(t, err)
defer func() { _ = manager.Stop(ctx) }()
// Create a user first
userData, err := manager.CreateUser(ctx, "test@example.com", "Test User", "account1", "admin@example.com")
require.NoError(t, err)
// The returned ID should be encoded
encodedID := userData.ID
// Lookup should work with the encoded ID
lookedUp, err := manager.GetUserDataByID(ctx, encodedID, AppMetadata{WTAccountID: "account1"})
require.NoError(t, err)
assert.Equal(t, "test@example.com", lookedUp.Email)
assert.Equal(t, "Test User", lookedUp.Name)
}
func TestEmbeddedIdPManager_DeleteUser(t *testing.T) {
ctx := context.Background()
tmpDir, err := os.MkdirTemp("", "embedded-idp-test-*")
require.NoError(t, err)
defer os.RemoveAll(tmpDir)
config := &EmbeddedIdPConfig{
Enabled: true,
Issuer: "http://localhost:5556/dex",
Storage: EmbeddedStorageConfig{
Type: "sqlite3",
Config: EmbeddedStorageTypeConfig{
File: filepath.Join(tmpDir, "dex.db"),
},
},
}
manager, err := NewEmbeddedIdPManager(ctx, config, nil)
require.NoError(t, err)
defer func() { _ = manager.Stop(ctx) }()
// Create a user
userData, err := manager.CreateUser(ctx, "delete-me@example.com", "Delete Me", "account1", "admin@example.com")
require.NoError(t, err)
// Delete the user using the encoded ID
err = manager.DeleteUser(ctx, userData.ID)
require.NoError(t, err)
// Verify user no longer exists
_, err = manager.GetUserDataByID(ctx, userData.ID, AppMetadata{})
assert.Error(t, err)
}
func TestEmbeddedIdPManager_GetAccount(t *testing.T) {
ctx := context.Background()
tmpDir, err := os.MkdirTemp("", "embedded-idp-test-*")
require.NoError(t, err)
defer os.RemoveAll(tmpDir)
config := &EmbeddedIdPConfig{
Enabled: true,
Issuer: "http://localhost:5556/dex",
Storage: EmbeddedStorageConfig{
Type: "sqlite3",
Config: EmbeddedStorageTypeConfig{
File: filepath.Join(tmpDir, "dex.db"),
},
},
}
manager, err := NewEmbeddedIdPManager(ctx, config, nil)
require.NoError(t, err)
defer func() { _ = manager.Stop(ctx) }()
// Create multiple users
_, err = manager.CreateUser(ctx, "user1@example.com", "User 1", "account1", "admin@example.com")
require.NoError(t, err)
_, err = manager.CreateUser(ctx, "user2@example.com", "User 2", "account1", "admin@example.com")
require.NoError(t, err)
// Get all users for the account
users, err := manager.GetAccount(ctx, "account1")
require.NoError(t, err)
assert.Len(t, users, 2)
emails := make([]string, len(users))
for i, u := range users {
emails[i] = u.Email
}
assert.Contains(t, emails, "user1@example.com")
assert.Contains(t, emails, "user2@example.com")
}
func TestEmbeddedIdPManager_UserIDFormat_MatchesJWT(t *testing.T) {
// This test verifies that the user ID returned by CreateUser
// matches the format that Dex uses in JWT tokens (the 'sub' claim)
ctx := context.Background()
tmpDir, err := os.MkdirTemp("", "embedded-idp-test-*")
require.NoError(t, err)
defer os.RemoveAll(tmpDir)
config := &EmbeddedIdPConfig{
Enabled: true,
Issuer: "http://localhost:5556/dex",
Storage: EmbeddedStorageConfig{
Type: "sqlite3",
Config: EmbeddedStorageTypeConfig{
File: filepath.Join(tmpDir, "dex.db"),
},
},
}
manager, err := NewEmbeddedIdPManager(ctx, config, nil)
require.NoError(t, err)
defer func() { _ = manager.Stop(ctx) }()
// Create a user
userData, err := manager.CreateUser(ctx, "jwt-test@example.com", "JWT Test", "account1", "admin@example.com")
require.NoError(t, err)
// The ID should be in the format: base64(protobuf{user_id, connector_id})
// Example: CiQ3YWFkOGMwNS0zMjg3LTQ3M2YtYjQyYS0zNjU1MDRiZjI1ZTcSBWxvY2Fs
// Verify it can be decoded
rawUserID, connectorID, err := dex.DecodeDexUserID(userData.ID)
require.NoError(t, err)
// Raw user ID should be a UUID
assert.Regexp(t, `^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$`, rawUserID)
// Connector ID should be "local" for password-based auth
assert.Equal(t, "local", connectorID)
// Re-encoding should produce the same result
reEncoded := dex.EncodeDexUserID(rawUserID, connectorID)
assert.Equal(t, userData.ID, reEncoded)
t.Logf("User ID format verified:")
t.Logf(" Encoded ID: %s", userData.ID)
t.Logf(" Raw UUID: %s", rawUserID)
t.Logf(" Connector: %s", connectorID)
}

View File

@@ -72,6 +72,7 @@ type UserData struct {
Name string `json:"name"`
ID string `json:"user_id"`
AppMetadata AppMetadata `json:"app_metadata"`
Password string `json:"-"` // Plain password, only set on user creation, excluded from JSON
}
func (u *UserData) MarshalBinary() (data []byte, err error) {