mirror of
https://github.com/netbirdio/netbird.git
synced 2026-04-16 07:16:38 +00:00
[management] Legacy to embedded IdP migration tool (#5586)
This commit is contained in:
@@ -170,20 +170,66 @@ type Connector struct {
|
||||
}
|
||||
|
||||
// ToStorageConnector converts a Connector to storage.Connector type.
|
||||
// It maps custom connector types (e.g., "zitadel", "entra") to Dex-native types
|
||||
// and augments the config with OIDC defaults when needed.
|
||||
func (c *Connector) ToStorageConnector() (storage.Connector, error) {
|
||||
data, err := json.Marshal(c.Config)
|
||||
dexType, augmentedConfig := mapConnectorToDex(c.Type, c.Config)
|
||||
|
||||
data, err := json.Marshal(augmentedConfig)
|
||||
if err != nil {
|
||||
return storage.Connector{}, fmt.Errorf("failed to marshal connector config: %v", err)
|
||||
}
|
||||
|
||||
return storage.Connector{
|
||||
ID: c.ID,
|
||||
Type: c.Type,
|
||||
Type: dexType,
|
||||
Name: c.Name,
|
||||
Config: data,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// mapConnectorToDex maps custom connector types to Dex-native types and applies
|
||||
// OIDC defaults. This ensures static connectors from config files or env vars
|
||||
// are stored with types that Dex can open.
|
||||
func mapConnectorToDex(connType string, config map[string]interface{}) (string, map[string]interface{}) {
|
||||
switch connType {
|
||||
case "oidc", "zitadel", "entra", "okta", "pocketid", "authentik", "keycloak":
|
||||
return "oidc", applyOIDCDefaults(connType, config)
|
||||
default:
|
||||
return connType, config
|
||||
}
|
||||
}
|
||||
|
||||
// applyOIDCDefaults clones the config map, sets common OIDC defaults,
|
||||
// and applies provider-specific overrides.
|
||||
func applyOIDCDefaults(connType string, config map[string]interface{}) map[string]interface{} {
|
||||
augmented := make(map[string]interface{}, len(config)+4)
|
||||
for k, v := range config {
|
||||
augmented[k] = v
|
||||
}
|
||||
setDefault(augmented, "scopes", []string{"openid", "profile", "email"})
|
||||
setDefault(augmented, "insecureEnableGroups", true)
|
||||
setDefault(augmented, "insecureSkipEmailVerified", true)
|
||||
|
||||
switch connType {
|
||||
case "zitadel":
|
||||
setDefault(augmented, "getUserInfo", true)
|
||||
case "entra":
|
||||
setDefault(augmented, "claimMapping", map[string]string{"email": "preferred_username"})
|
||||
case "okta", "pocketid":
|
||||
augmented["scopes"] = []string{"openid", "profile", "email", "groups"}
|
||||
}
|
||||
|
||||
return augmented
|
||||
}
|
||||
|
||||
// setDefault sets a key in the map only if it doesn't already exist.
|
||||
func setDefault(m map[string]interface{}, key string, value interface{}) {
|
||||
if _, ok := m[key]; !ok {
|
||||
m[key] = value
|
||||
}
|
||||
}
|
||||
|
||||
// StorageConfig is a configuration that can create a storage.
|
||||
type StorageConfig interface {
|
||||
Open(logger *slog.Logger) (storage.Storage, error)
|
||||
|
||||
@@ -4,6 +4,7 @@ package dex
|
||||
import (
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
@@ -19,10 +20,13 @@ import (
|
||||
"github.com/dexidp/dex/server"
|
||||
"github.com/dexidp/dex/storage"
|
||||
"github.com/dexidp/dex/storage/sql"
|
||||
jose "github.com/go-jose/go-jose/v4"
|
||||
"github.com/google/uuid"
|
||||
"github.com/prometheus/client_golang/prometheus"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
"google.golang.org/grpc"
|
||||
|
||||
nbjwt "github.com/netbirdio/netbird/shared/auth/jwt"
|
||||
)
|
||||
|
||||
// Config matches what management/internals/server/server.go expects
|
||||
@@ -666,3 +670,46 @@ func (p *Provider) GetAuthorizationEndpoint() string {
|
||||
}
|
||||
return issuer + "/auth"
|
||||
}
|
||||
|
||||
// GetJWKS reads signing keys directly from Dex storage and returns them as Jwks.
|
||||
// This avoids HTTP round-trips when the embedded IDP is co-located with the management server.
|
||||
// The key retrieval mirrors Dex's own handlePublicKeys/ValidationKeys logic:
|
||||
// SigningKeyPub first, then all VerificationKeys, serialized via go-jose.
|
||||
func (p *Provider) GetJWKS(ctx context.Context) (*nbjwt.Jwks, error) {
|
||||
keys, err := p.storage.GetKeys(ctx)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get keys from storage: %w", err)
|
||||
}
|
||||
|
||||
if keys.SigningKeyPub == nil {
|
||||
return nil, fmt.Errorf("no public keys found in storage")
|
||||
}
|
||||
|
||||
// Build the key set exactly as Dex's localSigner.ValidationKeys does:
|
||||
// signing key first, then all verification (rotated) keys.
|
||||
joseKeys := make([]jose.JSONWebKey, 0, len(keys.VerificationKeys)+1)
|
||||
joseKeys = append(joseKeys, *keys.SigningKeyPub)
|
||||
for _, vk := range keys.VerificationKeys {
|
||||
if vk.PublicKey != nil {
|
||||
joseKeys = append(joseKeys, *vk.PublicKey)
|
||||
}
|
||||
}
|
||||
|
||||
// Serialize through go-jose (same as Dex's handlePublicKeys handler)
|
||||
// then deserialize into our Jwks type, so the JSON field mapping is identical
|
||||
// to what the /keys HTTP endpoint would return.
|
||||
joseSet := jose.JSONWebKeySet{Keys: joseKeys}
|
||||
data, err := json.Marshal(joseSet)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to marshal JWKS: %w", err)
|
||||
}
|
||||
|
||||
jwks := &nbjwt.Jwks{}
|
||||
if err := json.Unmarshal(data, jwks); err != nil {
|
||||
return nil, fmt.Errorf("failed to unmarshal JWKS: %w", err)
|
||||
}
|
||||
|
||||
jwks.ExpiresInTime = keys.NextRotation
|
||||
|
||||
return jwks, nil
|
||||
}
|
||||
|
||||
@@ -2,11 +2,14 @@ package dex
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"log/slog"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/dexidp/dex/storage"
|
||||
sqllib "github.com/dexidp/dex/storage/sql"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
@@ -197,6 +200,295 @@ enablePasswordDB: true
|
||||
t.Logf("User lookup successful: rawID=%s, connectorID=%s", rawID, connID)
|
||||
}
|
||||
|
||||
// openTestStorage creates a SQLite storage in the given directory for testing.
|
||||
func openTestStorage(t *testing.T, tmpDir string) storage.Storage {
|
||||
t.Helper()
|
||||
logger := slog.New(slog.NewTextHandler(os.Stderr, nil))
|
||||
stor, err := (&sqllib.SQLite3{File: filepath.Join(tmpDir, "dex.db")}).Open(logger)
|
||||
require.NoError(t, err)
|
||||
return stor
|
||||
}
|
||||
|
||||
func TestStaticConnectors_CreatedFromYAML(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
|
||||
tmpDir, err := os.MkdirTemp("", "dex-static-conn-*")
|
||||
require.NoError(t, err)
|
||||
defer os.RemoveAll(tmpDir)
|
||||
|
||||
yamlContent := `
|
||||
issuer: http://localhost:5556/dex
|
||||
storage:
|
||||
type: sqlite3
|
||||
config:
|
||||
file: ` + filepath.Join(tmpDir, "dex.db") + `
|
||||
web:
|
||||
http: 127.0.0.1:5556
|
||||
enablePasswordDB: true
|
||||
connectors:
|
||||
- type: oidc
|
||||
id: my-oidc
|
||||
name: My OIDC Provider
|
||||
config:
|
||||
issuer: https://accounts.example.com
|
||||
clientID: test-client-id
|
||||
clientSecret: test-client-secret
|
||||
redirectURI: http://localhost:5556/dex/callback
|
||||
`
|
||||
configPath := filepath.Join(tmpDir, "config.yaml")
|
||||
err = os.WriteFile(configPath, []byte(yamlContent), 0644)
|
||||
require.NoError(t, err)
|
||||
|
||||
yamlConfig, err := LoadConfig(configPath)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Open storage and run initializeStorage directly (avoids Dex server
|
||||
// trying to dial the OIDC issuer)
|
||||
stor := openTestStorage(t, tmpDir)
|
||||
defer stor.Close()
|
||||
|
||||
err = initializeStorage(ctx, stor, yamlConfig)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Verify connector was created in storage
|
||||
conn, err := stor.GetConnector(ctx, "my-oidc")
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "my-oidc", conn.ID)
|
||||
assert.Equal(t, "My OIDC Provider", conn.Name)
|
||||
assert.Equal(t, "oidc", conn.Type)
|
||||
|
||||
// Verify config fields were serialized correctly
|
||||
var configMap map[string]interface{}
|
||||
err = json.Unmarshal(conn.Config, &configMap)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "https://accounts.example.com", configMap["issuer"])
|
||||
assert.Equal(t, "test-client-id", configMap["clientID"])
|
||||
}
|
||||
|
||||
func TestStaticConnectors_UpdatedOnRestart(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
|
||||
tmpDir, err := os.MkdirTemp("", "dex-static-conn-update-*")
|
||||
require.NoError(t, err)
|
||||
defer os.RemoveAll(tmpDir)
|
||||
|
||||
dbFile := filepath.Join(tmpDir, "dex.db")
|
||||
|
||||
// First: load config with initial connector
|
||||
yamlContent1 := `
|
||||
issuer: http://localhost:5556/dex
|
||||
storage:
|
||||
type: sqlite3
|
||||
config:
|
||||
file: ` + dbFile + `
|
||||
web:
|
||||
http: 127.0.0.1:5556
|
||||
enablePasswordDB: true
|
||||
connectors:
|
||||
- type: oidc
|
||||
id: my-oidc
|
||||
name: Original Name
|
||||
config:
|
||||
issuer: https://accounts.example.com
|
||||
clientID: original-client-id
|
||||
clientSecret: original-secret
|
||||
`
|
||||
configPath := filepath.Join(tmpDir, "config.yaml")
|
||||
err = os.WriteFile(configPath, []byte(yamlContent1), 0644)
|
||||
require.NoError(t, err)
|
||||
|
||||
yamlConfig1, err := LoadConfig(configPath)
|
||||
require.NoError(t, err)
|
||||
|
||||
stor := openTestStorage(t, tmpDir)
|
||||
err = initializeStorage(ctx, stor, yamlConfig1)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Verify initial state
|
||||
conn, err := stor.GetConnector(ctx, "my-oidc")
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "Original Name", conn.Name)
|
||||
|
||||
var configMap1 map[string]interface{}
|
||||
err = json.Unmarshal(conn.Config, &configMap1)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "original-client-id", configMap1["clientID"])
|
||||
|
||||
// Close storage to simulate restart
|
||||
stor.Close()
|
||||
|
||||
// Second: load updated config against the same DB
|
||||
yamlContent2 := `
|
||||
issuer: http://localhost:5556/dex
|
||||
storage:
|
||||
type: sqlite3
|
||||
config:
|
||||
file: ` + dbFile + `
|
||||
web:
|
||||
http: 127.0.0.1:5556
|
||||
enablePasswordDB: true
|
||||
connectors:
|
||||
- type: oidc
|
||||
id: my-oidc
|
||||
name: Updated Name
|
||||
config:
|
||||
issuer: https://accounts.example.com
|
||||
clientID: updated-client-id
|
||||
clientSecret: updated-secret
|
||||
`
|
||||
err = os.WriteFile(configPath, []byte(yamlContent2), 0644)
|
||||
require.NoError(t, err)
|
||||
|
||||
yamlConfig2, err := LoadConfig(configPath)
|
||||
require.NoError(t, err)
|
||||
|
||||
stor2 := openTestStorage(t, tmpDir)
|
||||
defer stor2.Close()
|
||||
|
||||
err = initializeStorage(ctx, stor2, yamlConfig2)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Verify connector was updated, not duplicated
|
||||
allConnectors, err := stor2.ListConnectors(ctx)
|
||||
require.NoError(t, err)
|
||||
|
||||
nonLocalCount := 0
|
||||
for _, c := range allConnectors {
|
||||
if c.ID != "local" {
|
||||
nonLocalCount++
|
||||
}
|
||||
}
|
||||
assert.Equal(t, 1, nonLocalCount, "connector should be updated, not duplicated")
|
||||
|
||||
conn2, err := stor2.GetConnector(ctx, "my-oidc")
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "Updated Name", conn2.Name)
|
||||
|
||||
var configMap2 map[string]interface{}
|
||||
err = json.Unmarshal(conn2.Config, &configMap2)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "updated-client-id", configMap2["clientID"])
|
||||
}
|
||||
|
||||
func TestStaticConnectors_MultipleConnectors(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
|
||||
tmpDir, err := os.MkdirTemp("", "dex-static-conn-multi-*")
|
||||
require.NoError(t, err)
|
||||
defer os.RemoveAll(tmpDir)
|
||||
|
||||
yamlContent := `
|
||||
issuer: http://localhost:5556/dex
|
||||
storage:
|
||||
type: sqlite3
|
||||
config:
|
||||
file: ` + filepath.Join(tmpDir, "dex.db") + `
|
||||
web:
|
||||
http: 127.0.0.1:5556
|
||||
enablePasswordDB: true
|
||||
connectors:
|
||||
- type: oidc
|
||||
id: my-oidc
|
||||
name: My OIDC Provider
|
||||
config:
|
||||
issuer: https://accounts.example.com
|
||||
clientID: oidc-client-id
|
||||
clientSecret: oidc-secret
|
||||
- type: google
|
||||
id: my-google
|
||||
name: Google Login
|
||||
config:
|
||||
clientID: google-client-id
|
||||
clientSecret: google-secret
|
||||
`
|
||||
configPath := filepath.Join(tmpDir, "config.yaml")
|
||||
err = os.WriteFile(configPath, []byte(yamlContent), 0644)
|
||||
require.NoError(t, err)
|
||||
|
||||
yamlConfig, err := LoadConfig(configPath)
|
||||
require.NoError(t, err)
|
||||
|
||||
stor := openTestStorage(t, tmpDir)
|
||||
defer stor.Close()
|
||||
|
||||
err = initializeStorage(ctx, stor, yamlConfig)
|
||||
require.NoError(t, err)
|
||||
|
||||
allConnectors, err := stor.ListConnectors(ctx)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Build a map for easier assertion
|
||||
connByID := make(map[string]storage.Connector)
|
||||
for _, c := range allConnectors {
|
||||
connByID[c.ID] = c
|
||||
}
|
||||
|
||||
// Verify both static connectors exist
|
||||
oidcConn, ok := connByID["my-oidc"]
|
||||
require.True(t, ok, "oidc connector should exist")
|
||||
assert.Equal(t, "My OIDC Provider", oidcConn.Name)
|
||||
assert.Equal(t, "oidc", oidcConn.Type)
|
||||
|
||||
var oidcConfig map[string]interface{}
|
||||
err = json.Unmarshal(oidcConn.Config, &oidcConfig)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "oidc-client-id", oidcConfig["clientID"])
|
||||
|
||||
googleConn, ok := connByID["my-google"]
|
||||
require.True(t, ok, "google connector should exist")
|
||||
assert.Equal(t, "Google Login", googleConn.Name)
|
||||
assert.Equal(t, "google", googleConn.Type)
|
||||
|
||||
var googleConfig map[string]interface{}
|
||||
err = json.Unmarshal(googleConn.Config, &googleConfig)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "google-client-id", googleConfig["clientID"])
|
||||
|
||||
// Verify local connector still exists alongside them (enablePasswordDB: true)
|
||||
localConn, ok := connByID["local"]
|
||||
require.True(t, ok, "local connector should exist")
|
||||
assert.Equal(t, "local", localConn.Type)
|
||||
}
|
||||
|
||||
func TestStaticConnectors_EmptyList(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
|
||||
tmpDir, err := os.MkdirTemp("", "dex-static-conn-empty-*")
|
||||
require.NoError(t, err)
|
||||
defer os.RemoveAll(tmpDir)
|
||||
|
||||
yamlContent := `
|
||||
issuer: http://localhost:5556/dex
|
||||
storage:
|
||||
type: sqlite3
|
||||
config:
|
||||
file: ` + filepath.Join(tmpDir, "dex.db") + `
|
||||
web:
|
||||
http: 127.0.0.1:5556
|
||||
enablePasswordDB: true
|
||||
`
|
||||
configPath := filepath.Join(tmpDir, "config.yaml")
|
||||
err = os.WriteFile(configPath, []byte(yamlContent), 0644)
|
||||
require.NoError(t, err)
|
||||
|
||||
yamlConfig, err := LoadConfig(configPath)
|
||||
require.NoError(t, err)
|
||||
|
||||
provider, err := NewProviderFromYAML(ctx, yamlConfig)
|
||||
require.NoError(t, err)
|
||||
defer func() { _ = provider.Stop(ctx) }()
|
||||
|
||||
// No static connectors configured, so ListConnectors should return empty
|
||||
connectors, err := provider.ListConnectors(ctx)
|
||||
require.NoError(t, err)
|
||||
assert.Empty(t, connectors)
|
||||
|
||||
// But local connector should still exist
|
||||
localConn, err := provider.Storage().GetConnector(ctx, "local")
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "local", localConn.ID)
|
||||
}
|
||||
|
||||
func TestNewProvider_ContinueOnConnectorFailure(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
|
||||
|
||||
Reference in New Issue
Block a user