mirror of
https://github.com/netbirdio/netbird.git
synced 2026-04-18 08:16:39 +00:00
[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:
511
management/server/idp/embedded.go
Normal file
511
management/server/idp/embedded.go
Normal 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
|
||||
}
|
||||
249
management/server/idp/embedded_test.go
Normal file
249
management/server/idp/embedded_test.go
Normal 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)
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user