[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

301
idp/dex/config.go Normal file
View File

@@ -0,0 +1,301 @@
package dex
import (
"encoding/base64"
"encoding/json"
"fmt"
"log/slog"
"os"
"time"
"golang.org/x/crypto/bcrypt"
"gopkg.in/yaml.v3"
"github.com/dexidp/dex/server"
"github.com/dexidp/dex/storage"
"github.com/dexidp/dex/storage/sql"
"github.com/netbirdio/netbird/idp/dex/web"
)
// parseDuration parses a duration string (e.g., "6h", "24h", "168h").
func parseDuration(s string) (time.Duration, error) {
return time.ParseDuration(s)
}
// YAMLConfig represents the YAML configuration file format (mirrors dex's config format)
type YAMLConfig struct {
Issuer string `yaml:"issuer" json:"issuer"`
Storage Storage `yaml:"storage" json:"storage"`
Web Web `yaml:"web" json:"web"`
GRPC GRPC `yaml:"grpc" json:"grpc"`
OAuth2 OAuth2 `yaml:"oauth2" json:"oauth2"`
Expiry Expiry `yaml:"expiry" json:"expiry"`
Logger Logger `yaml:"logger" json:"logger"`
Frontend Frontend `yaml:"frontend" json:"frontend"`
// StaticConnectors are user defined connectors specified in the config file
StaticConnectors []Connector `yaml:"connectors" json:"connectors"`
// StaticClients cause the server to use this list of clients rather than
// querying the storage. Write operations, like creating a client, will fail.
StaticClients []storage.Client `yaml:"staticClients" json:"staticClients"`
// If enabled, the server will maintain a list of passwords which can be used
// to identify a user.
EnablePasswordDB bool `yaml:"enablePasswordDB" json:"enablePasswordDB"`
// StaticPasswords cause the server use this list of passwords rather than
// querying the storage.
StaticPasswords []Password `yaml:"staticPasswords" json:"staticPasswords"`
}
// Web is the config format for the HTTP server.
type Web struct {
HTTP string `yaml:"http" json:"http"`
HTTPS string `yaml:"https" json:"https"`
AllowedOrigins []string `yaml:"allowedOrigins" json:"allowedOrigins"`
AllowedHeaders []string `yaml:"allowedHeaders" json:"allowedHeaders"`
}
// GRPC is the config for the gRPC API.
type GRPC struct {
Addr string `yaml:"addr" json:"addr"`
TLSCert string `yaml:"tlsCert" json:"tlsCert"`
TLSKey string `yaml:"tlsKey" json:"tlsKey"`
TLSClientCA string `yaml:"tlsClientCA" json:"tlsClientCA"`
}
// OAuth2 describes enabled OAuth2 extensions.
type OAuth2 struct {
SkipApprovalScreen bool `yaml:"skipApprovalScreen" json:"skipApprovalScreen"`
AlwaysShowLoginScreen bool `yaml:"alwaysShowLoginScreen" json:"alwaysShowLoginScreen"`
PasswordConnector string `yaml:"passwordConnector" json:"passwordConnector"`
ResponseTypes []string `yaml:"responseTypes" json:"responseTypes"`
GrantTypes []string `yaml:"grantTypes" json:"grantTypes"`
}
// Expiry holds configuration for the validity period of components.
type Expiry struct {
SigningKeys string `yaml:"signingKeys" json:"signingKeys"`
IDTokens string `yaml:"idTokens" json:"idTokens"`
AuthRequests string `yaml:"authRequests" json:"authRequests"`
DeviceRequests string `yaml:"deviceRequests" json:"deviceRequests"`
RefreshTokens RefreshTokensExpiry `yaml:"refreshTokens" json:"refreshTokens"`
}
// RefreshTokensExpiry holds configuration for refresh token expiry.
type RefreshTokensExpiry struct {
ReuseInterval string `yaml:"reuseInterval" json:"reuseInterval"`
ValidIfNotUsedFor string `yaml:"validIfNotUsedFor" json:"validIfNotUsedFor"`
AbsoluteLifetime string `yaml:"absoluteLifetime" json:"absoluteLifetime"`
DisableRotation bool `yaml:"disableRotation" json:"disableRotation"`
}
// Logger holds configuration required to customize logging.
type Logger struct {
Level string `yaml:"level" json:"level"`
Format string `yaml:"format" json:"format"`
}
// Frontend holds the server's frontend templates and assets config.
type Frontend struct {
Dir string `yaml:"dir" json:"dir"`
Theme string `yaml:"theme" json:"theme"`
Issuer string `yaml:"issuer" json:"issuer"`
LogoURL string `yaml:"logoURL" json:"logoURL"`
Extra map[string]string `yaml:"extra" json:"extra"`
}
// Storage holds app's storage configuration.
type Storage struct {
Type string `yaml:"type" json:"type"`
Config map[string]interface{} `yaml:"config" json:"config"`
}
// Password represents a static user configuration
type Password storage.Password
func (p *Password) UnmarshalYAML(node *yaml.Node) error {
var data struct {
Email string `yaml:"email"`
Username string `yaml:"username"`
UserID string `yaml:"userID"`
Hash string `yaml:"hash"`
HashFromEnv string `yaml:"hashFromEnv"`
}
if err := node.Decode(&data); err != nil {
return err
}
*p = Password(storage.Password{
Email: data.Email,
Username: data.Username,
UserID: data.UserID,
})
if len(data.Hash) == 0 && len(data.HashFromEnv) > 0 {
data.Hash = os.Getenv(data.HashFromEnv)
}
if len(data.Hash) == 0 {
return fmt.Errorf("no password hash provided for user %s", data.Email)
}
// If this value is a valid bcrypt, use it.
_, bcryptErr := bcrypt.Cost([]byte(data.Hash))
if bcryptErr == nil {
p.Hash = []byte(data.Hash)
return nil
}
// For backwards compatibility try to base64 decode this value.
hashBytes, err := base64.StdEncoding.DecodeString(data.Hash)
if err != nil {
return fmt.Errorf("malformed bcrypt hash: %v", bcryptErr)
}
if _, err := bcrypt.Cost(hashBytes); err != nil {
return fmt.Errorf("malformed bcrypt hash: %v", err)
}
p.Hash = hashBytes
return nil
}
// Connector is a connector configuration that can unmarshal YAML dynamically.
type Connector struct {
Type string `yaml:"type" json:"type"`
Name string `yaml:"name" json:"name"`
ID string `yaml:"id" json:"id"`
Config map[string]interface{} `yaml:"config" json:"config"`
}
// ToStorageConnector converts a Connector to storage.Connector type.
func (c *Connector) ToStorageConnector() (storage.Connector, error) {
data, err := json.Marshal(c.Config)
if err != nil {
return storage.Connector{}, fmt.Errorf("failed to marshal connector config: %v", err)
}
return storage.Connector{
ID: c.ID,
Type: c.Type,
Name: c.Name,
Config: data,
}, nil
}
// StorageConfig is a configuration that can create a storage.
type StorageConfig interface {
Open(logger *slog.Logger) (storage.Storage, error)
}
// OpenStorage opens a storage based on the config
func (s *Storage) OpenStorage(logger *slog.Logger) (storage.Storage, error) {
switch s.Type {
case "sqlite3":
file, _ := s.Config["file"].(string)
if file == "" {
return nil, fmt.Errorf("sqlite3 storage requires 'file' config")
}
return (&sql.SQLite3{File: file}).Open(logger)
default:
return nil, fmt.Errorf("unsupported storage type: %s", s.Type)
}
}
// Validate validates the configuration
func (c *YAMLConfig) Validate() error {
if c.Issuer == "" {
return fmt.Errorf("no issuer specified in config file")
}
if c.Storage.Type == "" {
return fmt.Errorf("no storage type specified in config file")
}
if c.Web.HTTP == "" && c.Web.HTTPS == "" {
return fmt.Errorf("must supply a HTTP/HTTPS address to listen on")
}
if !c.EnablePasswordDB && len(c.StaticPasswords) != 0 {
return fmt.Errorf("cannot specify static passwords without enabling password db")
}
return nil
}
// ToServerConfig converts YAMLConfig to dex server.Config
func (c *YAMLConfig) ToServerConfig(stor storage.Storage, logger *slog.Logger) server.Config {
cfg := server.Config{
Issuer: c.Issuer,
Storage: stor,
Logger: logger,
SkipApprovalScreen: c.OAuth2.SkipApprovalScreen,
AllowedOrigins: c.Web.AllowedOrigins,
AllowedHeaders: c.Web.AllowedHeaders,
Web: server.WebConfig{
Issuer: c.Frontend.Issuer,
LogoURL: c.Frontend.LogoURL,
Theme: c.Frontend.Theme,
Dir: c.Frontend.Dir,
Extra: c.Frontend.Extra,
},
}
// Use embedded NetBird-styled templates if no custom dir specified
if c.Frontend.Dir == "" {
cfg.Web.WebFS = web.FS()
}
if len(c.OAuth2.ResponseTypes) > 0 {
cfg.SupportedResponseTypes = c.OAuth2.ResponseTypes
}
// Apply expiry settings
if c.Expiry.SigningKeys != "" {
if d, err := parseDuration(c.Expiry.SigningKeys); err == nil {
cfg.RotateKeysAfter = d
}
}
if c.Expiry.IDTokens != "" {
if d, err := parseDuration(c.Expiry.IDTokens); err == nil {
cfg.IDTokensValidFor = d
}
}
if c.Expiry.AuthRequests != "" {
if d, err := parseDuration(c.Expiry.AuthRequests); err == nil {
cfg.AuthRequestsValidFor = d
}
}
if c.Expiry.DeviceRequests != "" {
if d, err := parseDuration(c.Expiry.DeviceRequests); err == nil {
cfg.DeviceRequestsValidFor = d
}
}
return cfg
}
// GetRefreshTokenPolicy creates a RefreshTokenPolicy from the expiry config.
// This should be called after ToServerConfig and the policy set on the config.
func (c *YAMLConfig) GetRefreshTokenPolicy(logger *slog.Logger) (*server.RefreshTokenPolicy, error) {
return server.NewRefreshTokenPolicy(
logger,
c.Expiry.RefreshTokens.DisableRotation,
c.Expiry.RefreshTokens.ValidIfNotUsedFor,
c.Expiry.RefreshTokens.AbsoluteLifetime,
c.Expiry.RefreshTokens.ReuseInterval,
)
}
// LoadConfig loads configuration from a YAML file
func LoadConfig(path string) (*YAMLConfig, error) {
data, err := os.ReadFile(path)
if err != nil {
return nil, fmt.Errorf("failed to read config file: %w", err)
}
var cfg YAMLConfig
if err := yaml.Unmarshal(data, &cfg); err != nil {
return nil, fmt.Errorf("failed to parse config file: %w", err)
}
if err := cfg.Validate(); err != nil {
return nil, err
}
return &cfg, nil
}

934
idp/dex/provider.go Normal file
View File

@@ -0,0 +1,934 @@
// Package dex provides an embedded Dex OIDC identity provider.
package dex
import (
"context"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"log/slog"
"net"
"net/http"
"os"
"path/filepath"
"strings"
"sync"
"time"
dexapi "github.com/dexidp/dex/api/v2"
"github.com/dexidp/dex/server"
"github.com/dexidp/dex/storage"
"github.com/dexidp/dex/storage/sql"
"github.com/google/uuid"
"github.com/prometheus/client_golang/prometheus"
"golang.org/x/crypto/bcrypt"
"google.golang.org/grpc"
)
// Config matches what management/internals/server/server.go expects
type Config struct {
Issuer string
Port int
DataDir string
DevMode bool
// GRPCAddr is the address for the gRPC API (e.g., ":5557"). Empty disables gRPC.
GRPCAddr string
}
// Provider wraps a Dex server
type Provider struct {
config *Config
yamlConfig *YAMLConfig
dexServer *server.Server
httpServer *http.Server
listener net.Listener
grpcServer *grpc.Server
grpcListener net.Listener
storage storage.Storage
logger *slog.Logger
mu sync.Mutex
running bool
}
// NewProvider creates and initializes the Dex server
func NewProvider(ctx context.Context, config *Config) (*Provider, error) {
if config.Issuer == "" {
return nil, fmt.Errorf("issuer is required")
}
if config.Port <= 0 {
return nil, fmt.Errorf("invalid port")
}
if config.DataDir == "" {
return nil, fmt.Errorf("data directory is required")
}
logger := slog.New(slog.NewTextHandler(os.Stderr, nil))
// Ensure data directory exists
if err := os.MkdirAll(config.DataDir, 0700); err != nil {
return nil, fmt.Errorf("failed to create data directory: %w", err)
}
// Initialize SQLite storage
dbPath := filepath.Join(config.DataDir, "oidc.db")
sqliteConfig := &sql.SQLite3{File: dbPath}
stor, err := sqliteConfig.Open(logger)
if err != nil {
return nil, fmt.Errorf("failed to open storage: %w", err)
}
// Ensure a local connector exists (for password authentication)
if err := ensureLocalConnector(ctx, stor); err != nil {
stor.Close()
return nil, fmt.Errorf("failed to ensure local connector: %w", err)
}
// Ensure issuer ends with /oauth2 for proper path mounting
issuer := strings.TrimSuffix(config.Issuer, "/")
if !strings.HasSuffix(issuer, "/oauth2") {
issuer += "/oauth2"
}
// Build refresh token policy (required to avoid nil pointer panics)
refreshPolicy, err := server.NewRefreshTokenPolicy(logger, false, "", "", "")
if err != nil {
stor.Close()
return nil, fmt.Errorf("failed to create refresh token policy: %w", err)
}
// Build Dex server config - use Dex's types directly
dexConfig := server.Config{
Issuer: issuer,
Storage: stor,
SkipApprovalScreen: true,
SupportedResponseTypes: []string{"code"},
Logger: logger,
PrometheusRegistry: prometheus.NewRegistry(),
RotateKeysAfter: 6 * time.Hour,
IDTokensValidFor: 24 * time.Hour,
RefreshTokenPolicy: refreshPolicy,
Web: server.WebConfig{
Issuer: "NetBird",
},
}
dexSrv, err := server.NewServer(ctx, dexConfig)
if err != nil {
stor.Close()
return nil, fmt.Errorf("failed to create dex server: %w", err)
}
return &Provider{
config: config,
dexServer: dexSrv,
storage: stor,
logger: logger,
}, nil
}
// NewProviderFromYAML creates and initializes the Dex server from a YAMLConfig
func NewProviderFromYAML(ctx context.Context, yamlConfig *YAMLConfig) (*Provider, error) {
logger := slog.New(slog.NewTextHandler(os.Stderr, nil))
stor, err := yamlConfig.Storage.OpenStorage(logger)
if err != nil {
return nil, fmt.Errorf("failed to open storage: %w", err)
}
if err := initializeStorage(ctx, stor, yamlConfig); err != nil {
stor.Close()
return nil, err
}
dexConfig := buildDexConfig(yamlConfig, stor, logger)
dexConfig.RefreshTokenPolicy, err = yamlConfig.GetRefreshTokenPolicy(logger)
if err != nil {
stor.Close()
return nil, fmt.Errorf("failed to create refresh token policy: %w", err)
}
dexSrv, err := server.NewServer(ctx, dexConfig)
if err != nil {
stor.Close()
return nil, fmt.Errorf("failed to create dex server: %w", err)
}
return &Provider{
config: &Config{Issuer: yamlConfig.Issuer, GRPCAddr: yamlConfig.GRPC.Addr},
yamlConfig: yamlConfig,
dexServer: dexSrv,
storage: stor,
logger: logger,
}, nil
}
// initializeStorage sets up connectors, passwords, and clients in storage
func initializeStorage(ctx context.Context, stor storage.Storage, cfg *YAMLConfig) error {
if cfg.EnablePasswordDB {
if err := ensureLocalConnector(ctx, stor); err != nil {
return fmt.Errorf("failed to ensure local connector: %w", err)
}
}
if err := ensureStaticPasswords(ctx, stor, cfg.StaticPasswords); err != nil {
return err
}
if err := ensureStaticClients(ctx, stor, cfg.StaticClients); err != nil {
return err
}
return ensureStaticConnectors(ctx, stor, cfg.StaticConnectors)
}
// ensureStaticPasswords creates or updates static passwords in storage
func ensureStaticPasswords(ctx context.Context, stor storage.Storage, passwords []Password) error {
for _, pw := range passwords {
existing, err := stor.GetPassword(ctx, pw.Email)
if errors.Is(err, storage.ErrNotFound) {
if err := stor.CreatePassword(ctx, storage.Password(pw)); err != nil {
return fmt.Errorf("failed to create password for %s: %w", pw.Email, err)
}
continue
}
if err != nil {
return fmt.Errorf("failed to get password for %s: %w", pw.Email, err)
}
if string(existing.Hash) != string(pw.Hash) {
if err := stor.UpdatePassword(ctx, pw.Email, func(old storage.Password) (storage.Password, error) {
old.Hash = pw.Hash
old.Username = pw.Username
return old, nil
}); err != nil {
return fmt.Errorf("failed to update password for %s: %w", pw.Email, err)
}
}
}
return nil
}
// ensureStaticClients creates or updates static clients in storage
func ensureStaticClients(ctx context.Context, stor storage.Storage, clients []storage.Client) error {
for _, client := range clients {
_, err := stor.GetClient(ctx, client.ID)
if errors.Is(err, storage.ErrNotFound) {
if err := stor.CreateClient(ctx, client); err != nil {
return fmt.Errorf("failed to create client %s: %w", client.ID, err)
}
continue
}
if err != nil {
return fmt.Errorf("failed to get client %s: %w", client.ID, err)
}
if err := stor.UpdateClient(ctx, client.ID, func(old storage.Client) (storage.Client, error) {
old.RedirectURIs = client.RedirectURIs
old.Name = client.Name
old.Public = client.Public
return old, nil
}); err != nil {
return fmt.Errorf("failed to update client %s: %w", client.ID, err)
}
}
return nil
}
// ensureStaticConnectors creates or updates static connectors in storage
func ensureStaticConnectors(ctx context.Context, stor storage.Storage, connectors []Connector) error {
for _, conn := range connectors {
storConn, err := conn.ToStorageConnector()
if err != nil {
return fmt.Errorf("failed to convert connector %s: %w", conn.ID, err)
}
_, err = stor.GetConnector(ctx, conn.ID)
if errors.Is(err, storage.ErrNotFound) {
if err := stor.CreateConnector(ctx, storConn); err != nil {
return fmt.Errorf("failed to create connector %s: %w", conn.ID, err)
}
continue
}
if err != nil {
return fmt.Errorf("failed to get connector %s: %w", conn.ID, err)
}
if err := stor.UpdateConnector(ctx, conn.ID, func(old storage.Connector) (storage.Connector, error) {
old.Name = storConn.Name
old.Config = storConn.Config
return old, nil
}); err != nil {
return fmt.Errorf("failed to update connector %s: %w", conn.ID, err)
}
}
return nil
}
// buildDexConfig creates a server.Config with defaults applied
func buildDexConfig(yamlConfig *YAMLConfig, stor storage.Storage, logger *slog.Logger) server.Config {
cfg := yamlConfig.ToServerConfig(stor, logger)
cfg.PrometheusRegistry = prometheus.NewRegistry()
if cfg.RotateKeysAfter == 0 {
cfg.RotateKeysAfter = 24 * 30 * time.Hour
}
if cfg.IDTokensValidFor == 0 {
cfg.IDTokensValidFor = 24 * time.Hour
}
if cfg.Web.Issuer == "" {
cfg.Web.Issuer = "NetBird"
}
if len(cfg.SupportedResponseTypes) == 0 {
cfg.SupportedResponseTypes = []string{"code"}
}
return cfg
}
// Start starts the HTTP server and optionally the gRPC API server
func (p *Provider) Start(_ context.Context) error {
p.mu.Lock()
defer p.mu.Unlock()
if p.running {
return fmt.Errorf("already running")
}
// Determine listen address from config
var addr string
if p.yamlConfig != nil {
addr = p.yamlConfig.Web.HTTP
if addr == "" {
addr = p.yamlConfig.Web.HTTPS
}
} else if p.config != nil && p.config.Port > 0 {
addr = fmt.Sprintf(":%d", p.config.Port)
}
if addr == "" {
return fmt.Errorf("no listen address configured")
}
listener, err := net.Listen("tcp", addr)
if err != nil {
return fmt.Errorf("failed to listen on %s: %w", addr, err)
}
p.listener = listener
// Mount Dex at /oauth2/ path for reverse proxy compatibility
// Don't strip the prefix - Dex's issuer includes /oauth2 so it expects the full path
mux := http.NewServeMux()
mux.Handle("/oauth2/", p.dexServer)
p.httpServer = &http.Server{Handler: mux}
p.running = true
go func() {
if err := p.httpServer.Serve(listener); err != nil && err != http.ErrServerClosed {
p.logger.Error("http server error", "error", err)
}
}()
// Start gRPC API server if configured
if p.config.GRPCAddr != "" {
if err := p.startGRPCServer(); err != nil {
// Clean up HTTP server on failure
_ = p.httpServer.Close()
_ = p.listener.Close()
return fmt.Errorf("failed to start gRPC server: %w", err)
}
}
p.logger.Info("HTTP server started", "addr", addr)
return nil
}
// startGRPCServer starts the gRPC API server using Dex's built-in API
func (p *Provider) startGRPCServer() error {
grpcListener, err := net.Listen("tcp", p.config.GRPCAddr)
if err != nil {
return fmt.Errorf("failed to listen on %s: %w", p.config.GRPCAddr, err)
}
p.grpcListener = grpcListener
p.grpcServer = grpc.NewServer()
// Use Dex's built-in API server implementation
// server.NewAPI(storage, logger, version, dexServer)
dexapi.RegisterDexServer(p.grpcServer, server.NewAPI(p.storage, p.logger, "netbird-dex", p.dexServer))
go func() {
if err := p.grpcServer.Serve(grpcListener); err != nil {
p.logger.Error("grpc server error", "error", err)
}
}()
p.logger.Info("gRPC API server started", "addr", p.config.GRPCAddr)
return nil
}
// Stop gracefully shuts down
func (p *Provider) Stop(ctx context.Context) error {
p.mu.Lock()
defer p.mu.Unlock()
if !p.running {
return nil
}
var errs []error
// Stop gRPC server first
if p.grpcServer != nil {
p.grpcServer.GracefulStop()
p.grpcServer = nil
}
if p.grpcListener != nil {
p.grpcListener.Close()
p.grpcListener = nil
}
if p.httpServer != nil {
if err := p.httpServer.Shutdown(ctx); err != nil {
errs = append(errs, err)
}
}
// Explicitly close listener as fallback (Shutdown should do this, but be safe)
if p.listener != nil {
if err := p.listener.Close(); err != nil {
// Ignore "use of closed network connection" - expected after Shutdown
if !strings.Contains(err.Error(), "use of closed") {
errs = append(errs, err)
}
}
p.listener = nil
}
if p.storage != nil {
if err := p.storage.Close(); err != nil {
errs = append(errs, err)
}
}
p.httpServer = nil
p.running = false
if len(errs) > 0 {
return fmt.Errorf("shutdown errors: %v", errs)
}
return nil
}
// EnsureDefaultClients creates dashboard and CLI OAuth clients
// Uses Dex's storage.Client directly - no custom wrappers
func (p *Provider) EnsureDefaultClients(ctx context.Context, dashboardURIs, cliURIs []string) error {
clients := []storage.Client{
{
ID: "netbird-dashboard",
Name: "NetBird Dashboard",
RedirectURIs: dashboardURIs,
Public: true,
},
{
ID: "netbird-cli",
Name: "NetBird CLI",
RedirectURIs: cliURIs,
Public: true,
},
}
for _, client := range clients {
_, err := p.storage.GetClient(ctx, client.ID)
if err == storage.ErrNotFound {
if err := p.storage.CreateClient(ctx, client); err != nil {
return fmt.Errorf("failed to create client %s: %w", client.ID, err)
}
continue
}
if err != nil {
return fmt.Errorf("failed to get client %s: %w", client.ID, err)
}
// Update if exists
if err := p.storage.UpdateClient(ctx, client.ID, func(old storage.Client) (storage.Client, error) {
old.RedirectURIs = client.RedirectURIs
return old, nil
}); err != nil {
return fmt.Errorf("failed to update client %s: %w", client.ID, err)
}
}
p.logger.Info("default OIDC clients ensured")
return nil
}
// Storage returns the underlying Dex storage for direct access
// Users can use storage.Client, storage.Password, storage.Connector directly
func (p *Provider) Storage() storage.Storage {
return p.storage
}
// Handler returns the Dex server as an http.Handler for embedding in another server.
// The handler expects requests with path prefix "/oauth2/".
func (p *Provider) Handler() http.Handler {
return p.dexServer
}
// CreateUser creates a new user with the given email, username, and password.
// Returns the encoded user ID in Dex's format (base64-encoded protobuf with connector ID).
func (p *Provider) CreateUser(ctx context.Context, email, username, password string) (string, error) {
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
if err != nil {
return "", fmt.Errorf("failed to hash password: %w", err)
}
userID := uuid.New().String()
err = p.storage.CreatePassword(ctx, storage.Password{
Email: email,
Username: username,
UserID: userID,
Hash: hash,
})
if err != nil {
return "", err
}
// Encode the user ID in Dex's format: base64(protobuf{user_id, connector_id})
// This matches the format Dex uses in JWT tokens
encodedID := EncodeDexUserID(userID, "local")
return encodedID, nil
}
// EncodeDexUserID encodes user ID and connector ID into Dex's base64-encoded protobuf format.
// Dex uses this format for the 'sub' claim in JWT tokens.
// Format: base64(protobuf message with field 1 = user_id, field 2 = connector_id)
func EncodeDexUserID(userID, connectorID string) string {
// Manually encode protobuf: field 1 (user_id) and field 2 (connector_id)
// Wire type 2 (length-delimited) for strings
var buf []byte
// Field 1: user_id (tag = 0x0a = field 1, wire type 2)
buf = append(buf, 0x0a)
buf = append(buf, byte(len(userID)))
buf = append(buf, []byte(userID)...)
// Field 2: connector_id (tag = 0x12 = field 2, wire type 2)
buf = append(buf, 0x12)
buf = append(buf, byte(len(connectorID)))
buf = append(buf, []byte(connectorID)...)
return base64.RawStdEncoding.EncodeToString(buf)
}
// DecodeDexUserID decodes Dex's base64-encoded user ID back to the raw user ID and connector ID.
func DecodeDexUserID(encodedID string) (userID, connectorID string, err error) {
// Try RawStdEncoding first, then StdEncoding (with padding)
buf, err := base64.RawStdEncoding.DecodeString(encodedID)
if err != nil {
buf, err = base64.StdEncoding.DecodeString(encodedID)
if err != nil {
return "", "", fmt.Errorf("failed to decode base64: %w", err)
}
}
// Parse protobuf manually
i := 0
for i < len(buf) {
if i >= len(buf) {
break
}
tag := buf[i]
i++
fieldNum := tag >> 3
wireType := tag & 0x07
if wireType != 2 { // We only expect length-delimited strings
return "", "", fmt.Errorf("unexpected wire type %d", wireType)
}
if i >= len(buf) {
return "", "", fmt.Errorf("truncated message")
}
length := int(buf[i])
i++
if i+length > len(buf) {
return "", "", fmt.Errorf("truncated string field")
}
value := string(buf[i : i+length])
i += length
switch fieldNum {
case 1:
userID = value
case 2:
connectorID = value
}
}
return userID, connectorID, nil
}
// GetUser returns a user by email
func (p *Provider) GetUser(ctx context.Context, email string) (storage.Password, error) {
return p.storage.GetPassword(ctx, email)
}
// GetUserByID returns a user by user ID.
// The userID can be either an encoded Dex ID (base64 protobuf) or a raw UUID.
// Note: This requires iterating through all users since dex storage doesn't index by userID.
func (p *Provider) GetUserByID(ctx context.Context, userID string) (storage.Password, error) {
// Try to decode the user ID in case it's encoded
rawUserID, _, err := DecodeDexUserID(userID)
if err != nil {
// If decoding fails, assume it's already a raw UUID
rawUserID = userID
}
users, err := p.storage.ListPasswords(ctx)
if err != nil {
return storage.Password{}, fmt.Errorf("failed to list users: %w", err)
}
for _, user := range users {
if user.UserID == rawUserID {
return user, nil
}
}
return storage.Password{}, storage.ErrNotFound
}
// DeleteUser removes a user by email
func (p *Provider) DeleteUser(ctx context.Context, email string) error {
return p.storage.DeletePassword(ctx, email)
}
// ListUsers returns all users
func (p *Provider) ListUsers(ctx context.Context) ([]storage.Password, error) {
return p.storage.ListPasswords(ctx)
}
// ensureLocalConnector creates a local (password) connector if none exists
func ensureLocalConnector(ctx context.Context, stor storage.Storage) error {
connectors, err := stor.ListConnectors(ctx)
if err != nil {
return fmt.Errorf("failed to list connectors: %w", err)
}
// If any connector exists, we're good
if len(connectors) > 0 {
return nil
}
// Create a local connector for password authentication
localConnector := storage.Connector{
ID: "local",
Type: "local",
Name: "Email",
}
if err := stor.CreateConnector(ctx, localConnector); err != nil {
return fmt.Errorf("failed to create local connector: %w", err)
}
return nil
}
// ConnectorConfig represents the configuration for an identity provider connector
type ConnectorConfig struct {
// ID is the unique identifier for the connector
ID string
// Name is a human-readable name for the connector
Name string
// Type is the connector type (oidc, google, microsoft)
Type string
// Issuer is the OIDC issuer URL (for OIDC-based connectors)
Issuer string
// ClientID is the OAuth2 client ID
ClientID string
// ClientSecret is the OAuth2 client secret
ClientSecret string
// RedirectURI is the OAuth2 redirect URI
RedirectURI string
}
// CreateConnector creates a new connector in Dex storage.
// It maps the connector config to the appropriate Dex connector type and configuration.
func (p *Provider) CreateConnector(ctx context.Context, cfg *ConnectorConfig) (*ConnectorConfig, error) {
// Fill in the redirect URI if not provided
if cfg.RedirectURI == "" {
cfg.RedirectURI = p.GetRedirectURI()
}
storageConn, err := p.buildStorageConnector(cfg)
if err != nil {
return nil, fmt.Errorf("failed to build connector: %w", err)
}
if err := p.storage.CreateConnector(ctx, storageConn); err != nil {
return nil, fmt.Errorf("failed to create connector: %w", err)
}
p.logger.Info("connector created", "id", cfg.ID, "type", cfg.Type)
return cfg, nil
}
// GetConnector retrieves a connector by ID from Dex storage.
func (p *Provider) GetConnector(ctx context.Context, id string) (*ConnectorConfig, error) {
conn, err := p.storage.GetConnector(ctx, id)
if err != nil {
if err == storage.ErrNotFound {
return nil, err
}
return nil, fmt.Errorf("failed to get connector: %w", err)
}
return p.parseStorageConnector(conn)
}
// ListConnectors returns all connectors from Dex storage (excluding the local connector).
func (p *Provider) ListConnectors(ctx context.Context) ([]*ConnectorConfig, error) {
connectors, err := p.storage.ListConnectors(ctx)
if err != nil {
return nil, fmt.Errorf("failed to list connectors: %w", err)
}
result := make([]*ConnectorConfig, 0, len(connectors))
for _, conn := range connectors {
// Skip the local password connector
if conn.ID == "local" && conn.Type == "local" {
continue
}
cfg, err := p.parseStorageConnector(conn)
if err != nil {
p.logger.Warn("failed to parse connector", "id", conn.ID, "error", err)
continue
}
result = append(result, cfg)
}
return result, nil
}
// UpdateConnector updates an existing connector in Dex storage.
func (p *Provider) UpdateConnector(ctx context.Context, cfg *ConnectorConfig) error {
storageConn, err := p.buildStorageConnector(cfg)
if err != nil {
return fmt.Errorf("failed to build connector: %w", err)
}
if err := p.storage.UpdateConnector(ctx, cfg.ID, func(old storage.Connector) (storage.Connector, error) {
return storageConn, nil
}); err != nil {
return fmt.Errorf("failed to update connector: %w", err)
}
p.logger.Info("connector updated", "id", cfg.ID, "type", cfg.Type)
return nil
}
// DeleteConnector removes a connector from Dex storage.
func (p *Provider) DeleteConnector(ctx context.Context, id string) error {
// Prevent deletion of the local connector
if id == "local" {
return fmt.Errorf("cannot delete the local password connector")
}
if err := p.storage.DeleteConnector(ctx, id); err != nil {
return fmt.Errorf("failed to delete connector: %w", err)
}
p.logger.Info("connector deleted", "id", id)
return nil
}
// buildStorageConnector creates a storage.Connector from ConnectorConfig.
// It handles the type-specific configuration for each connector type.
func (p *Provider) buildStorageConnector(cfg *ConnectorConfig) (storage.Connector, error) {
redirectURI := p.resolveRedirectURI(cfg.RedirectURI)
var dexType string
var configData []byte
var err error
switch cfg.Type {
case "oidc", "zitadel", "entra", "okta", "pocketid", "authentik", "keycloak":
dexType = "oidc"
configData, err = buildOIDCConnectorConfig(cfg, redirectURI)
case "google":
dexType = "google"
configData, err = buildOAuth2ConnectorConfig(cfg, redirectURI)
case "microsoft":
dexType = "microsoft"
configData, err = buildOAuth2ConnectorConfig(cfg, redirectURI)
default:
return storage.Connector{}, fmt.Errorf("unsupported connector type: %s", cfg.Type)
}
if err != nil {
return storage.Connector{}, err
}
return storage.Connector{ID: cfg.ID, Type: dexType, Name: cfg.Name, Config: configData}, nil
}
// resolveRedirectURI returns the redirect URI, using a default if not provided
func (p *Provider) resolveRedirectURI(redirectURI string) string {
if redirectURI != "" || p.config == nil {
return redirectURI
}
issuer := strings.TrimSuffix(p.config.Issuer, "/")
if !strings.HasSuffix(issuer, "/oauth2") {
issuer += "/oauth2"
}
return issuer + "/callback"
}
// buildOIDCConnectorConfig creates config for OIDC-based connectors
func buildOIDCConnectorConfig(cfg *ConnectorConfig, redirectURI string) ([]byte, error) {
oidcConfig := map[string]interface{}{
"issuer": cfg.Issuer,
"clientID": cfg.ClientID,
"clientSecret": cfg.ClientSecret,
"redirectURI": redirectURI,
"scopes": []string{"openid", "profile", "email"},
}
switch cfg.Type {
case "zitadel":
oidcConfig["getUserInfo"] = true
case "entra":
oidcConfig["insecureSkipEmailVerified"] = true
oidcConfig["claimMapping"] = map[string]string{"email": "preferred_username"}
case "okta":
oidcConfig["insecureSkipEmailVerified"] = true
}
return encodeConnectorConfig(oidcConfig)
}
// buildOAuth2ConnectorConfig creates config for OAuth2 connectors (google, microsoft)
func buildOAuth2ConnectorConfig(cfg *ConnectorConfig, redirectURI string) ([]byte, error) {
return encodeConnectorConfig(map[string]interface{}{
"clientID": cfg.ClientID,
"clientSecret": cfg.ClientSecret,
"redirectURI": redirectURI,
})
}
// parseStorageConnector converts a storage.Connector back to ConnectorConfig.
// It infers the original identity provider type from the Dex connector type and ID.
func (p *Provider) parseStorageConnector(conn storage.Connector) (*ConnectorConfig, error) {
cfg := &ConnectorConfig{
ID: conn.ID,
Name: conn.Name,
}
if len(conn.Config) == 0 {
cfg.Type = conn.Type
return cfg, nil
}
var configMap map[string]interface{}
if err := decodeConnectorConfig(conn.Config, &configMap); err != nil {
return nil, fmt.Errorf("failed to parse connector config: %w", err)
}
// Extract common fields
if v, ok := configMap["clientID"].(string); ok {
cfg.ClientID = v
}
if v, ok := configMap["clientSecret"].(string); ok {
cfg.ClientSecret = v
}
if v, ok := configMap["redirectURI"].(string); ok {
cfg.RedirectURI = v
}
if v, ok := configMap["issuer"].(string); ok {
cfg.Issuer = v
}
// Infer the original identity provider type from Dex connector type and ID
cfg.Type = inferIdentityProviderType(conn.Type, conn.ID, configMap)
return cfg, nil
}
// inferIdentityProviderType determines the original identity provider type
// based on the Dex connector type, connector ID, and configuration.
func inferIdentityProviderType(dexType, connectorID string, _ map[string]interface{}) string {
if dexType != "oidc" {
return dexType
}
return inferOIDCProviderType(connectorID)
}
// inferOIDCProviderType infers the specific OIDC provider from connector ID
func inferOIDCProviderType(connectorID string) string {
connectorIDLower := strings.ToLower(connectorID)
for _, provider := range []string{"pocketid", "zitadel", "entra", "okta", "authentik", "keycloak"} {
if strings.Contains(connectorIDLower, provider) {
return provider
}
}
return "oidc"
}
// encodeConnectorConfig serializes connector config to JSON bytes.
func encodeConnectorConfig(config map[string]interface{}) ([]byte, error) {
return json.Marshal(config)
}
// decodeConnectorConfig deserializes connector config from JSON bytes.
func decodeConnectorConfig(data []byte, v interface{}) error {
return json.Unmarshal(data, v)
}
// GetRedirectURI returns the default redirect URI for connectors.
func (p *Provider) GetRedirectURI() string {
if p.config == nil {
return ""
}
issuer := strings.TrimSuffix(p.config.Issuer, "/")
if !strings.HasSuffix(issuer, "/oauth2") {
issuer += "/oauth2"
}
return issuer + "/callback"
}
// GetIssuer returns the OIDC issuer URL.
func (p *Provider) GetIssuer() string {
if p.config == nil {
return ""
}
issuer := strings.TrimSuffix(p.config.Issuer, "/")
if !strings.HasSuffix(issuer, "/oauth2") {
issuer += "/oauth2"
}
return issuer
}
// GetKeysLocation returns the JWKS endpoint URL for token validation.
func (p *Provider) GetKeysLocation() string {
issuer := p.GetIssuer()
if issuer == "" {
return ""
}
return issuer + "/keys"
}
// GetTokenEndpoint returns the OAuth2 token endpoint URL.
func (p *Provider) GetTokenEndpoint() string {
issuer := p.GetIssuer()
if issuer == "" {
return ""
}
return issuer + "/token"
}
// GetDeviceAuthEndpoint returns the OAuth2 device authorization endpoint URL.
func (p *Provider) GetDeviceAuthEndpoint() string {
issuer := p.GetIssuer()
if issuer == "" {
return ""
}
return issuer + "/device/code"
}
// GetAuthorizationEndpoint returns the OAuth2 authorization endpoint URL.
func (p *Provider) GetAuthorizationEndpoint() string {
issuer := p.GetIssuer()
if issuer == "" {
return ""
}
return issuer + "/auth"
}

197
idp/dex/provider_test.go Normal file
View File

@@ -0,0 +1,197 @@
package dex
import (
"context"
"os"
"path/filepath"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestUserCreationFlow(t *testing.T) {
ctx := context.Background()
// Create a temporary directory for the test
tmpDir, err := os.MkdirTemp("", "dex-test-*")
require.NoError(t, err)
defer os.RemoveAll(tmpDir)
// Create provider with minimal config
config := &Config{
Issuer: "http://localhost:5556/dex",
Port: 5556,
DataDir: tmpDir,
}
provider, err := NewProvider(ctx, config)
require.NoError(t, err)
defer func() { _ = provider.Stop(ctx) }()
// Test user data
email := "test@example.com"
username := "testuser"
password := "testpassword123"
// Create the user
encodedID, err := provider.CreateUser(ctx, email, username, password)
require.NoError(t, err)
require.NotEmpty(t, encodedID)
t.Logf("Created user with encoded ID: %s", encodedID)
// Verify the encoded ID can be decoded
rawUserID, connectorID, err := DecodeDexUserID(encodedID)
require.NoError(t, err)
assert.NotEmpty(t, rawUserID)
assert.Equal(t, "local", connectorID)
t.Logf("Decoded: rawUserID=%s, connectorID=%s", rawUserID, connectorID)
// Verify we can look up the user by encoded ID
user, err := provider.GetUserByID(ctx, encodedID)
require.NoError(t, err)
assert.Equal(t, email, user.Email)
assert.Equal(t, username, user.Username)
assert.Equal(t, rawUserID, user.UserID)
// Verify we can also look up by raw UUID (backwards compatibility)
user2, err := provider.GetUserByID(ctx, rawUserID)
require.NoError(t, err)
assert.Equal(t, email, user2.Email)
// Verify we can look up by email
user3, err := provider.GetUser(ctx, email)
require.NoError(t, err)
assert.Equal(t, rawUserID, user3.UserID)
// Verify encoding produces consistent format
reEncodedID := EncodeDexUserID(rawUserID, "local")
assert.Equal(t, encodedID, reEncodedID)
}
func TestDecodeDexUserID(t *testing.T) {
tests := []struct {
name string
encodedID string
wantUserID string
wantConnID string
wantErr bool
}{
{
name: "valid encoded ID",
encodedID: "CiQ3YWFkOGMwNS0zMjg3LTQ3M2YtYjQyYS0zNjU1MDRiZjI1ZTcSBWxvY2Fs",
wantUserID: "7aad8c05-3287-473f-b42a-365504bf25e7",
wantConnID: "local",
wantErr: false,
},
{
name: "invalid base64",
encodedID: "not-valid-base64!!!",
wantUserID: "",
wantConnID: "",
wantErr: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
userID, connID, err := DecodeDexUserID(tt.encodedID)
if tt.wantErr {
assert.Error(t, err)
return
}
require.NoError(t, err)
assert.Equal(t, tt.wantUserID, userID)
assert.Equal(t, tt.wantConnID, connID)
})
}
}
func TestEncodeDexUserID(t *testing.T) {
userID := "7aad8c05-3287-473f-b42a-365504bf25e7"
connectorID := "local"
encoded := EncodeDexUserID(userID, connectorID)
assert.NotEmpty(t, encoded)
// Verify round-trip
decodedUserID, decodedConnID, err := DecodeDexUserID(encoded)
require.NoError(t, err)
assert.Equal(t, userID, decodedUserID)
assert.Equal(t, connectorID, decodedConnID)
}
func TestEncodeDexUserID_MatchesDexFormat(t *testing.T) {
// This is an actual ID from Dex - verify our encoding matches
knownEncodedID := "CiQ3YWFkOGMwNS0zMjg3LTQ3M2YtYjQyYS0zNjU1MDRiZjI1ZTcSBWxvY2Fs"
knownUserID := "7aad8c05-3287-473f-b42a-365504bf25e7"
knownConnectorID := "local"
// Decode the known ID
userID, connID, err := DecodeDexUserID(knownEncodedID)
require.NoError(t, err)
assert.Equal(t, knownUserID, userID)
assert.Equal(t, knownConnectorID, connID)
// Re-encode and verify it matches
reEncoded := EncodeDexUserID(knownUserID, knownConnectorID)
assert.Equal(t, knownEncodedID, reEncoded)
}
func TestCreateUserInTempDB(t *testing.T) {
ctx := context.Background()
// Create temp directory
tmpDir, err := os.MkdirTemp("", "dex-create-user-*")
require.NoError(t, err)
defer os.RemoveAll(tmpDir)
// Create YAML config for the test
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)
// Load config and create provider
yamlConfig, err := LoadConfig(configPath)
require.NoError(t, err)
provider, err := NewProviderFromYAML(ctx, yamlConfig)
require.NoError(t, err)
defer func() { _ = provider.Stop(ctx) }()
// Create user
email := "newuser@example.com"
username := "newuser"
password := "securepassword123"
encodedID, err := provider.CreateUser(ctx, email, username, password)
require.NoError(t, err)
t.Logf("Created user: email=%s, encodedID=%s", email, encodedID)
// Verify lookup works with encoded ID
user, err := provider.GetUserByID(ctx, encodedID)
require.NoError(t, err)
assert.Equal(t, email, user.Email)
assert.Equal(t, username, user.Username)
// Decode and verify format
rawID, connID, err := DecodeDexUserID(encodedID)
require.NoError(t, err)
assert.Equal(t, "local", connID)
assert.Equal(t, rawID, user.UserID)
t.Logf("User lookup successful: rawID=%s, connectorID=%s", rawID, connID)
}

2
idp/dex/web/robots.txt Executable file
View File

@@ -0,0 +1,2 @@
User-agent: *
Disallow: /

1
idp/dex/web/static/main.css Executable file
View File

@@ -0,0 +1 @@
/* NetBird DEX Static CSS - main styles are inline in header.html */

View File

@@ -0,0 +1,26 @@
{{ template "header.html" . }}
<div class="nb-card">
<h1 class="nb-heading">Grant Access</h1>
<p class="nb-subheading">{{ .Client }} wants to access your account</p>
<form method="post">
<input type="hidden" name="req" value="{{ .ReqID }}"/>
<input type="hidden" name="approval" value="approve"/>
<button type="submit" class="nb-btn">
Allow Access
</button>
</form>
<div class="nb-divider"></div>
<form method="post">
<input type="hidden" name="req" value="{{ .ReqID }}"/>
<input type="hidden" name="approval" value="rejected"/>
<button type="submit" class="nb-btn-connector" style="margin-bottom:0">
Deny Access
</button>
</form>
</div>
{{ template "footer.html" . }}

View File

@@ -0,0 +1,34 @@
{{ template "header.html" . }}
<div class="nb-card">
<h1 class="nb-heading">Device Login</h1>
<p class="nb-subheading">Enter the code shown on your device</p>
<form method="post" action="{{ .PostURL }}">
{{ if .Invalid }}
<div class="nb-error">
Invalid user code.
</div>
{{ end }}
<div class="nb-form-group">
<label class="nb-label" for="user_code">Device Code</label>
<input
type="text"
id="user_code"
name="user_code"
class="nb-input"
placeholder="XXXX-XXXX"
{{ if .UserCode }}value="{{ .UserCode }}"{{ end }}
required
autofocus
>
</div>
<button type="submit" class="nb-btn">
Continue
</button>
</form>
</div>
{{ template "footer.html" . }}

View File

@@ -0,0 +1,16 @@
{{ template "header.html" . }}
<div class="nb-card">
<div style="text-align:center;margin-bottom:24px">
<svg height="48" viewBox="0 0 100 100" xmlns="http://www.w3.org/2000/svg">
<circle cx="50" cy="50" fill="none" r="45" stroke="#5cb85c" stroke-width="3"/>
<path d="M30 50 L45 65 L70 35" fill="none" stroke="#5cb85c" stroke-width="5"/>
</svg>
</div>
<h1 class="nb-heading">Device Authorized</h1>
<p class="nb-subheading">
Your device has been successfully authorized. You can close this window.
</p>
</div>
{{ template "footer.html" . }}

View File

@@ -0,0 +1,16 @@
{{ template "header.html" . }}
<div class="nb-card">
<div style="text-align:center;margin-bottom:24px">
<svg height="48" viewBox="0 0 100 100" xmlns="http://www.w3.org/2000/svg">
<circle cx="50" cy="50" fill="none" r="45" stroke="#f87171" stroke-width="3"/>
<path d="M30 30 L70 70 M30 70 L70 30" fill="none" stroke="#f87171" stroke-width="3"/>
</svg>
</div>
<h1 class="nb-heading">{{ .ErrType }}</h1>
<div class="nb-error">
{{ .ErrMsg }}
</div>
</div>
{{ template "footer.html" . }}

View File

@@ -0,0 +1,3 @@
</div>
</body>
</html>

View File

@@ -0,0 +1,70 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge,chrome=1">
<title>{{ issuer }}</title>
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<link rel="icon" type="image/x-icon" href="{{ url .ReqPath "theme/favicon.ico" }}">
<style>
*,:after,:before{box-sizing:border-box;border:0 solid #e5e7eb}
html,body{margin:0;padding:0;font-family:ui-sans-serif,system-ui,sans-serif,Apple Color Emoji,Segoe UI Emoji;font-size:14px;line-height:1.5;background-color:#18191d;color:#e4e7e9;min-height:100vh}
.nb-container{max-width:820px;margin:0 auto;padding:40px 20px;display:flex;flex-direction:column;align-items:center;justify-content:center;min-height:100vh}
.nb-logo{width:180px;margin-bottom:40px}
.nb-card{background-color:#1b1f22;border:1px solid rgba(50,54,61,.5);border-radius:12px;padding:40px;width:100%;max-width:400px;box-shadow:0 20px 25px -5px rgba(0,0,0,.1),0 8px 10px -6px rgba(0,0,0,.1)}
.nb-heading{font-size:24px;font-weight:500;text-align:center;margin:0 0 24px 0;color:#fff}
.nb-subheading{font-size:14px;color:rgba(167,177,185,.8);text-align:center;margin-bottom:24px}
.nb-form-group{margin-bottom:16px}
.nb-label{display:block;font-size:13px;font-weight:500;color:#a7b1b9;margin-bottom:6px}
.nb-input{width:100%;padding:10px 14px;background-color:rgba(63,68,75,.5);border:1px solid rgba(63,68,75,.8);border-radius:8px;color:#e4e7e9;font-size:14px;outline:none;transition:border-color .2s}
.nb-input:focus{border-color:#f68330}
.nb-input::placeholder{color:rgba(167,177,185,.5)}
.nb-btn{width:100%;padding:12px 20px;background-color:#f68330;border:none;border-radius:8px;color:#fff;font-size:14px;font-weight:500;cursor:pointer;transition:background-color .2s}
.nb-btn:hover{background-color:#e5722a}
.nb-btn:disabled{opacity:.6;cursor:not-allowed}
.nb-btn-connector{width:100%;padding:12px 20px;background-color:rgba(63,68,75,.5);border:1px solid rgba(63,68,75,.8);border-radius:8px;color:#e4e7e9;font-size:14px;font-weight:500;cursor:pointer;transition:all .2s;display:flex;align-items:center;justify-content:flex-start;text-decoration:none;margin-bottom:12px;gap:12px}
.nb-btn-connector:hover{background-color:rgba(63,68,75,.8);border-color:rgba(63,68,75,1)}
.nb-btn-connector .nb-icon{width:20px;height:20px;flex-shrink:0;background-size:contain;background-position:center;background-repeat:no-repeat}
.nb-icon-google{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 48 48'%3E%3Cpath fill='%23FFC107' d='M43.611 20.083H42V20H24v8h11.303c-1.649 4.657-6.08 8-11.303 8-6.627 0-12-5.373-12-12s5.373-12 12-12c3.059 0 5.842 1.154 7.961 3.039l5.657-5.657C34.046 6.053 29.268 4 24 4 12.955 4 4 12.955 4 24s8.955 20 20 20 20-8.955 20-20c0-1.341-.138-2.65-.389-3.917z'/%3E%3Cpath fill='%23FF3D00' d='m6.306 14.691 6.571 4.819C14.655 15.108 18.961 12 24 12c3.059 0 5.842 1.154 7.961 3.039l5.657-5.657C34.046 6.053 29.268 4 24 4 16.318 4 9.656 8.337 6.306 14.691z'/%3E%3Cpath fill='%234CAF50' d='M24 44c5.166 0 9.86-1.977 13.409-5.192l-6.19-5.238A11.91 11.91 0 0 1 24 36c-5.202 0-9.619-3.317-11.283-7.946l-6.522 5.025C9.505 39.556 16.227 44 24 44z'/%3E%3Cpath fill='%231976D2' d='M43.611 20.083H42V20H24v8h11.303a12.04 12.04 0 0 1-4.087 5.571l.003-.002 6.19 5.238C36.971 39.205 44 34 44 24c0-1.341-.138-2.65-.389-3.917z'/%3E%3C/svg%3E")}
.nb-icon-github{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='1024' height='1024' fill='none'%3E%3Cpath fill='%23fff' fill-rule='evenodd' d='M512 0C229.12 0 0 229.12 0 512c0 226.56 146.56 417.92 350.08 485.76 25.6 4.48 35.2-10.88 35.2-24.32 0-12.16-.64-52.48-.64-95.36-128.64 23.68-161.92-31.36-172.16-60.16-5.76-14.72-30.72-60.16-52.48-72.32-17.92-9.6-43.52-33.28-.64-33.92 40.32-.64 69.12 37.12 78.72 52.48 46.08 77.44 119.68 55.68 149.12 42.24 4.48-33.28 17.92-55.68 32.64-68.48-113.92-12.8-232.96-56.96-232.96-252.8 0-55.68 19.84-101.76 52.48-137.6-5.12-12.8-23.04-65.28 5.12-135.68 0 0 42.88-13.44 140.8 52.48 40.96-11.52 84.48-17.28 128-17.28s87.04 5.76 128 17.28c97.92-66.56 140.8-52.48 140.8-52.48 28.16 70.4 10.24 122.88 5.12 135.68 32.64 35.84 52.48 81.28 52.48 137.6 0 196.48-119.68 240-233.6 252.8 18.56 16 34.56 46.72 34.56 94.72 0 68.48-.64 123.52-.64 140.8 0 13.44 9.6 29.44 35.2 24.32C877.44 929.92 1024 737.92 1024 512 1024 229.12 794.88 0 512 0' clip-rule='evenodd'/%3E%3C/svg%3E")}
.nb-icon-microsoft{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='221' height='221'%3E%3Cg fill='none'%3E%3Cpath fill='%23F1511B' d='M104.868 104.868H0V0h104.868z'/%3E%3Cpath fill='%2380CC28' d='M220.654 104.868H115.788V0h104.866z'/%3E%3Cpath fill='%2300ADEF' d='M104.865 220.695H0V115.828h104.865z'/%3E%3Cpath fill='%23FBBC09' d='M220.654 220.695H115.788V115.828h104.866z'/%3E%3C/g%3E%3C/svg%3E")}
.nb-icon-azure{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='150' height='150' viewBox='0 0 96 96'%3E%3Cdefs%3E%3ClinearGradient id='a' x1='-1032.172' x2='-1059.213' y1='145.312' y2='65.426' gradientTransform='matrix(1 0 0 -1 1075 158)' gradientUnits='userSpaceOnUse'%3E%3Cstop offset='0' stop-color='%23114a8b'/%3E%3Cstop offset='1' stop-color='%230669bc'/%3E%3C/linearGradient%3E%3ClinearGradient id='b' x1='-1023.725' x2='-1029.98' y1='108.083' y2='105.968' gradientTransform='matrix(1 0 0 -1 1075 158)' gradientUnits='userSpaceOnUse'%3E%3Cstop offset='0' stop-opacity='.3'/%3E%3Cstop offset='.071' stop-opacity='.2'/%3E%3Cstop offset='.321' stop-opacity='.1'/%3E%3Cstop offset='.623' stop-opacity='.05'/%3E%3Cstop offset='1' stop-opacity='0'/%3E%3C/linearGradient%3E%3ClinearGradient id='c' x1='-1027.165' x2='-997.482' y1='147.642' y2='68.561' gradientTransform='matrix(1 0 0 -1 1075 158)' gradientUnits='userSpaceOnUse'%3E%3Cstop offset='0' stop-color='%233ccbf4'/%3E%3Cstop offset='1' stop-color='%232892df'/%3E%3C/linearGradient%3E%3C/defs%3E%3Cpath fill='url(%23a)' d='M33.338 6.544h26.038l-27.03 80.087a4.15 4.15 0 0 1-3.933 2.824H8.149a4.145 4.145 0 0 1-3.928-5.47L29.404 9.368a4.15 4.15 0 0 1 3.934-2.825z'/%3E%3Cpath fill='%230078d4' d='M71.175 60.261h-41.29a1.911 1.911 0 0 0-1.305 3.309l26.532 24.764a4.17 4.17 0 0 0 2.846 1.121h23.38z'/%3E%3Cpath fill='url(%23b)' d='M33.338 6.544a4.12 4.12 0 0 0-3.943 2.879L4.252 83.917a4.14 4.14 0 0 0 3.908 5.538h20.787a4.44 4.44 0 0 0 3.41-2.9l5.014-14.777 17.91 16.705a4.24 4.24 0 0 0 2.666.972H81.24L71.024 60.261l-29.781.007L59.47 6.544z'/%3E%3Cpath fill='url(%23c)' d='M66.595 9.364a4.145 4.145 0 0 0-3.928-2.82H33.648a4.15 4.15 0 0 1 3.928 2.82l25.184 74.62a4.146 4.146 0 0 1-3.928 5.472h29.02a4.146 4.146 0 0 0 3.927-5.472z'/%3E%3C/svg%3E")}
.nb-icon-entra{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='18' height='18' data-name='Layer 1'%3E%3Cpath fill='%23225086' d='M3.802 14.032c.388.242 1.033.511 1.715.511.621 0 1.198-.18 1.676-.487l.002-.001L9 12.927V17a1.56 1.56 0 0 1-.824-.234z'/%3E%3Cpath fill='%236df' d='m7.853 1.507-7.5 8.46c-.579.654-.428 1.642.323 2.111l3.126 1.954c.388.242 1.033.511 1.715.511.621 0 1.198-.18 1.676-.487l.002-.001L9 12.927l-4.364-2.728 4.365-4.924V1c-.424 0-.847.169-1.147.507Z'/%3E%3Cpath fill='%23cbf8ff' d='m4.636 10.199.052.032L9 12.927h.001V5.276L9 5.275z'/%3E%3Cpath fill='%23074793' d='M17.324 12.078c.751-.469.902-1.457.323-2.111l-4.921-5.551a3.1 3.1 0 0 0-1.313-.291c-.925 0-1.752.399-2.302 1.026l-.109.123 4.364 4.924-4.365 2.728v4.073c.287 0 .573-.078.823-.234l7.5-4.688Z'/%3E%3Cpath fill='%230294e4' d='M9.001 1v4.275l.109-.123a3.05 3.05 0 0 1 2.302-1.026c.472 0 .916.107 1.313.291l-2.579-2.909A1.52 1.52 0 0 0 9 1.001Z'/%3E%3Cpath fill='%2396bcc2' d='M13.365 10.199 9.001 5.276v7.65z'/%3E%3C/svg%3E")}
.nb-icon-okta{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='36' height='36' fill='none'%3E%3Cpath fill='%23fff' fill-rule='evenodd' d='m19.8.26-.74 9.12c-.35-.04-.7-.06-1.06-.06-.45 0-.89.03-1.32.1L16.26 5c-.01-.14.1-.26.24-.26h.75L16.89.27c-.01-.14.1-.26.23-.26h2.45c.14 0 .25.12.23.26zm-6.18.45c-.04-.13-.18-.21-.31-.16l-2.3.84c-.13.05-.19.2-.13.32l1.87 4.08-.71.26c-.13.05-.19.2-.13.32l1.91 4.01c.69-.38 1.44-.67 2.23-.85L13.63.71zM7.98 3.25l5.29 7.46c-.67.44-1.28.96-1.8 1.56L8.3 9.15c-.1-.1-.09-.26.01-.35l.58-.48-3.15-3.19c-.1-.1-.09-.26.02-.35l1.87-1.57c.11-.09.26-.07.34.04zM3.54 7.57c-.11-.08-.27-.04-.34.08L1.98 9.77c-.07.12-.02.27.1.33l4.06 1.92-.38.65a.23.23 0 0 0 .11.33l4.04 1.85c.29-.75.68-1.45 1.16-2.08zM.55 13.33c.02-.14.16-.22.29-.19l8.85 2.31c-.23.75-.36 1.54-.38 2.36l-4.43-.36a.23.23 0 0 1-.21-.28l.13-.74-4.47-.42c-.14-.01-.23-.14-.21-.28l.42-2.41zm-.33 5.98c-.14.01-.23.14-.21.28L.44 22c.02.14.16.22.29.19l4.34-1.13.13.74c.02.14.16.22.29.19l4.28-1.18c-.25-.74-.41-1.53-.45-2.34l-9.11.84zm1.42 6.34a.236.236 0 0 1 .1-.33L10 21.4c.31.74.73 1.43 1.23 2.05l-3.62 2.58c-.11.08-.27.05-.34-.07l-.38-.66-3.69 2.55c-.11.08-.27.04-.34-.08l-1.23-2.12zm10.01-1.72-6.43 6.51c-.1.1-.09.26.02.35l1.88 1.57c.11.09.26.07.34-.04l2.6-3.66.58.49c.11.09.27.07.35-.05l2.52-3.66c-.68-.42-1.31-.93-1.85-1.51zm-1.27 10.45a.234.234 0 0 1-.13-.32l3.81-8.32c.7.36 1.46.63 2.25.78l-1.12 4.3c-.03.13-.18.21-.31.16l-.71-.26-1.19 4.33c-.04.13-.18.21-.31.16l-2.3-.84zm6.56-7.75-.74 9.12c-.01.14.1.26.23.26h2.45c.14 0 .25-.12.23-.26l-.36-4.47h.75c.14 0 .25-.12.24-.26l-.42-4.42c-.43.07-.87.1-1.32.1-.36 0-.71-.02-1.06-.07m8.82-24.69c.06-.13 0-.27-.13-.32l-2.3-.84c-.13-.05-.27.03-.31.16l-1.19 4.33-.71-.26c-.13-.05-.27.03-.31.16l-1.12 4.3c.8.16 1.55.43 2.25.78zm5.02 3.63-6.43 6.51a8.7 8.7 0 0 0-1.85-1.51l2.52-3.66c.08-.11.24-.14.35-.05l.58.49 2.6-3.66c.08-.11.24-.13.34-.04l1.88 1.57c.11.09.11.25.02.35zm3.48 5.12c.13-.06.17-.21.1-.33l-1.23-2.12a.246.246 0 0 0-.34-.08l-3.69 2.55-.38-.65c-.07-.12-.23-.16-.34-.07l-3.62 2.58c.5.62.91 1.31 1.23 2.05l8.26-3.92zm1.3 3.32.42 2.41c.02.14-.07.26-.21.28l-9.11.85c-.04-.82-.2-1.6-.45-2.34l4.28-1.18c.13-.04.27.05.29.19l.13.74 4.34-1.13c.13-.03.27.05.29.19zm-.41 8.85c.13.03.27-.05.29-.19l.42-2.41a.24.24 0 0 0-.21-.28l-4.47-.42.13-.74a.24.24 0 0 0-.21-.28l-4.43-.36c-.02.82-.15 1.61-.38 2.36l8.85 2.31zm-2.36 5.5c-.07.12-.23.15-.34.08l-7.53-5.2c.48-.63.87-1.33 1.16-2.08l4.04 1.85c.13.06.18.21.11.33l-.38.65 4.06 1.92c.12.06.17.21.1.33zm-10.07-3.07 5.29 7.46c.08.11.24.13.34.04l1.87-1.57c.11-.09.11-.25.02-.35l-3.15-3.19.58-.48c.11-.09.11-.25.01-.35l-3.17-3.12c-.53.6-1.13 1.13-1.8 1.56zm-.05 10.16c-.13.05-.27-.03-.31-.16l-2.42-8.82c.79-.18 1.54-.47 2.23-.85l1.91 4.01c.06.13 0 .28-.13.32l-.71.26 1.87 4.08c.06.13 0 .27-.13.32l-2.3.84z' clip-rule='evenodd'/%3E%3C/svg%3E")}
.nb-icon-jumpcloud{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='168' height='82' fill='none'%3E%3Cpath fill='%23fff' d='M167.627 58.455a22.705 22.705 0 0 1-22.707 22.707h-6.243c-.651-7.57-8.461-14.005-19.501-16.994a19.72 19.72 0 0 0 4.394-21.52 19.72 19.72 0 0 0-18.243-12.235 19.718 19.718 0 0 0-13.848 33.755 34.3 34.3 0 0 0-14.246 7.231 34.3 34.3 0 0 0-8.268-3.398 16.874 16.874 0 1 0-23.623 0C36.64 70.41 30.3 75.232 28.95 81.065h-6.243a22.73 22.73 0 0 1 0-45.438c2.89.01 5.753.567 8.437 1.64A22.66 22.66 0 0 1 51.85 24.08h1.64a29.601 29.601 0 0 1 54.429-9.642 24.1 24.1 0 0 1 21.003 3.439 24.11 24.11 0 0 1 10.092 18.738 22.66 22.66 0 0 1 28.613 21.935z'/%3E%3C/svg%3E")}
.nb-icon-pocketid{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 512 512'%3E%3Ccircle cx='256' cy='256' r='256' fill='%23fff'/%3E%3Cpath d='M268.6 102.4c64.4 0 116.8 52.4 116.8 116.7 0 25.3-8 49.4-23 69.6-14.8 19.9-35 34.3-58.4 41.7l-6.5 2-15.5-76.2 4.3-2c14-6.7 23-21.1 23-36.6 0-22.4-18.2-40.6-40.6-40.6S228 195.2 228 217.6c0 15.5 9 29.8 23 36.6l4.2 2-25 153.4h-69.5V102.4z' fill='%23191919'/%3E%3C/svg%3E")}
.nb-icon-zitadel{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='80' height='79' viewBox='0 0 80 79' fill='none'%3E%3Cdefs%3E%3ClinearGradient id='a' x1='3.86' x2='76.88' y1='47.89' y2='47.89' gradientUnits='userSpaceOnUse'%3E%3Cstop stop-color='%23FF8F00'/%3E%3Cstop offset='1' stop-color='%23FE00FF'/%3E%3C/linearGradient%3E%3C/defs%3E%3Cpath fill='url(%23a)' fill-rule='evenodd' d='M17.12 39.17l1.42 5.32-6.68 6.68 9.12 2.44 1.43 5.32-19.77-5.3L17.12 39.17zM58.82 22.41l-5.32-1.43-2.44-9.12-6.68 6.68-5.32-1.43 14.47-14.47 5.3 19.77zM52.65 67.11l3.89-3.89 9.12 2.44-2.44-9.12 3.9-3.9 5.29 19.77-19.76-5.3zM36.43 69.54l-1.18-12.07 8.23 2.21-7.05 9.86zM23 23.84l5.02 11.04 6.02-6.02L23 23.84zM69.32 36.2l-12.07-1.18 2.2 8.23 9.87-7.05z' clip-rule='evenodd'/%3E%3C/svg%3E")}
.nb-icon-authentik{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='-0.03 59.9 512.03 392.1'%3E%3Cpath fill='%23fd4b2d' d='M279.9 141h17.9v51.2h-17.9zm46.6-2.2h17.9v40h-17.9zM65.3 197.3c-24 0-46 13.2-57.4 34.3h30.4c13.5-11.6 33-15 47.1 0h32.2c-12.6-17.1-31.4-34.3-52.3-34.3'/%3E%3Cpath fill='%23fd4b2d' d='M108.7 262.4C66.8 350-6.6 275.3 38.3 231.5H7.9C-15.9 273 17 329 65.3 327.8c37.4 0 68.2-55.5 68.2-65.3 0-4.3-6-17.6-16-31H85.4c10.7 9.7 20 23.7 23.3 30.9'/%3E%3Cpath fill='%23fd4b2d' d='M512 140.3v231.3c0 44.3-36.1 80.4-80.4 80.4h-34.1v-78.8h-163V452h-34.1c-44.4 0-80.4-36.1-80.4-80.4v-72.8h258.4v-139H253.6V238H119.9v-97.6c0-3.1.2-6.2.5-9.2.4-3.7 1.1-7.3 2-10.8.3-1.1.6-2.3 1-3.4l.3-.8c.2-.6.4-1.1.5-1.7l.6-1.7.7-1.8.8-1.8c2-4.7 4.4-9.3 7.3-13.6l.1-.1 2.3-3.2 2-2.6 2.4-2.8 2.4-2.6.1-.1 1.4-1.4c3-2.9 6.2-5.6 9.6-8l2.8-1.9 3.3-2c2.1-1.2 4.2-2.4 6.5-3.4l2.1-1c3.1-1.3 6.2-2.5 9.4-3.4 1.2-.4 2.5-.7 3.7-1l1.8-.4c3.6-.8 7.2-1.3 10.9-1.6l1.6-.1h.8c1.2-.1 2.4-.1 3.7-.1h231.3c1.2 0 2.5 0 3.7.1h.8l1.6.1c3.7.3 7.3.8 10.9 1.6l1.8.4 3.7 1c3.2.9 6.3 2.1 9.4 3.4l2.1 1c2.2 1 4.4 2.2 6.5 3.4l3.3 2 2.8 1.9c3.9 2.8 7.6 6 11 9.4l2.4 2.6 2.4 2.8 2 2.6 2.3 3.2.1.1c2.9 4.3 5.3 8.8 7.3 13.6l.8 1.8.7 1.8.6 1.7.5 1.7.3.8 1 3.4c.9 3.6 1.6 7.2 2 10.8 0 3.1.2 6.1.2 9.2'/%3E%3Cpath fill='%23fd4b2d' d='M498.3 95.5H133.5c14.9-22.2 40-35.6 66.7-35.6h231.3c26.9 0 51.9 13.4 66.8 35.6m13.2 35.6H120.4c1.4-12.8 6-25 13.1-35.6h364.8c7.2 10.6 11.7 22.9 13.2 35.6m.5 9.2v26.4H378.3v-6.9H253.6v6.9H119.9v-26.4c0-3.1.2-6.2.5-9.2h391.1c.3 3.1.5 6.1.5 9.2M119.9 166.7h133.7v35.6H119.9zm258.4 0H512v35.6H378.3zm-258.4 35.6h133.7v35.6H119.9zm258.4 0H512v35.6H378.3z'/%3E%3C/svg%3E")}
.nb-icon-keycloak{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 512 512'%3E%3Cpath fill='%234d4d4d' d='M432.9 149.2c-1.4 0-2.7-.7-3.4-2L370.1 44.1c-.7-1.2-2-2-3.5-2H124.2c-1.4 0-2.7.7-3.4 2L58.9 150.9l23.9 34.9c-.7 1.2-6.2 24-5.5 25.2L58.9 360.9l61.9 106.9c.7 1.2 2 2 3.4 2h242.4c1.4 0 2.7-.7 3.5-2l59.4-103.2c.7-1.2 2-2 3.4-2h73.8c2.4 0 4.4-2 4.4-4.4V153.6c0-2.4-2-4.4-4.4-4.4z'/%3E%3Cpath fill='%2300b8e3' d='m223.9 151-59.7 103.4c-.3.5-.4 1.1-.4 1.7h-41.7l82-142q.75.45 1.2 1.2l18.6 32.3c.5 1.1.5 2.4 0 3.4'/%3E%3Cpath fill='%2333c6e9' d='M223.8 364.9 205.3 397q-.45.75-1.2 1.2l-82-142.2h41.7c0 .6.1 1.1.4 1.6l59.6 103.2c.8 1.2.9 2.9 0 4.1'/%3E%3Cpath fill='%23008aaa' d='m204 114.2-82 141.9-20.6 35.6-19.6-34c-.3-.5-.4-1-.4-1.6s.1-1.2.4-1.7l19.9-34.4 60.4-104.5c.6-1.1 1.8-1.8 3-1.8h37.2c.6 0 1.2.2 1.7.5'/%3E%3Cpath fill='%2300b8e3' d='M204 398.2c-.5.3-1.1.5-1.8.5h-37.1c-1.3 0-2.4-.7-3-1.8l-55.2-95.6-5.5-9.5 20.6-35.6z'/%3E%3Cpath fill='%23008aaa' d='m368.9 256.1-82 142q-.75-.45-1.2-1.2L267 364.7c-.5-1-.5-2.3 0-3.3L326.7 258c.3-.5.5-1.2.5-1.8z'/%3E%3Cpath fill='%2300b8e3' d='M409.4 256.1c0 .6-.2 1.3-.5 1.8l-80.3 139.3c-.6 1-1.8 1.7-3 1.6h-37c-.6 0-1.2-.2-1.8-.5L368.9 256l20.6-35.6 19.5 33.8c.3.7.4 1.3.4 1.9'/%3E%3Cpath fill='%2300b8e3' d='M368.9 256.1h-41.7c0-.6-.2-1.2-.5-1.8L267 151.2c-.6-1.1-.6-2.5 0-3.6l18.6-32.2q.45-.75 1.2-1.2z'/%3E%3Cpath fill='%2333c6e9' d='m389.4 220.5-20.6 35.6-82-142c.6-.3 1.2-.5 1.8-.5h37.1c1.2 0 2.3.6 3 1.6z'/%3E%3C/svg%3E")}
.nb-icon-email{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='%23a7b1b9' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Crect x='2' y='4' width='20' height='16' rx='2'/%3E%3Cpath d='m22 7-8.97 5.7a1.94 1.94 0 0 1-2.06 0L2 7'/%3E%3C/svg%3E")}
.nb-icon-default{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='%23a7b1b9' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M15 3h4a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2h-4'/%3E%3Cpolyline points='10 17 15 12 10 7'/%3E%3Cline x1='15' y1='12' x2='3' y2='12'/%3E%3C/svg%3E")}
.nb-error{background-color:rgba(153,27,27,.2);border:1px solid rgba(153,27,27,.5);border-radius:8px;padding:12px 16px;color:#f87171;font-size:13px;text-align:center;margin-bottom:16px}
.nb-link{color:#f68330;text-decoration:none;font-size:13px}
.nb-link:hover{text-decoration:underline}
.nb-back-link{text-align:center;margin-top:20px}
.nb-divider{height:1px;background-color:rgba(63,68,75,.5);margin:24px 0}
</style>
</head>
<body>
<div class="nb-container">
<div class="nb-logo">
<svg width="180" height="31" viewBox="0 0 133 23" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clip-path="url(#clip0)">
<path d="M46.9438 7.5013C48.1229 8.64688 48.7082 10.3025 48.7082 12.4683V21.6663H46.1411V12.8362C46.1411 11.2809 45.7481 10.0851 44.9704 9.26566C44.1928 8.43783 43.1308 8.0281 41.7846 8.0281C40.4383 8.0281 39.3345 8.45455 38.5234 9.30747C37.7123 10.1604 37.3109 11.4063 37.3109 13.0369V21.6663H34.7188V6.06305H37.3109V8.28732C37.821 7.49294 38.5234 6.87416 39.4014 6.43934C40.2878 6.00452 41.2578 5.78711 42.3197 5.78711C44.2179 5.78711 45.7565 6.36408 46.9355 7.50966L46.9438 7.5013Z" fill="#F2F2F2"/>
<path d="M67.1048 14.8344H54.6288C54.7208 16.373 55.2476 17.5771 56.2092 18.4384C57.1708 19.2997 58.3331 19.7345 59.6961 19.7345C60.8166 19.7345 61.7531 19.4753 62.4973 18.9485C63.2499 18.4301 63.7767 17.7277 64.0777 16.858H66.8706C66.4525 18.3548 65.6163 19.5756 64.3621 20.5205C63.1078 21.4571 61.5525 21.9337 59.6878 21.9337C58.2077 21.9337 56.8865 21.5992 55.7159 20.9386C54.5452 20.278 53.6337 19.3331 52.9648 18.1039C52.2958 16.8831 51.9697 15.4616 51.9697 13.8477C51.9697 12.2339 52.2958 10.8207 52.9397 9.60825C53.5836 8.39578 54.495 7.45924 55.6573 6.80702C56.828 6.15479 58.1659 5.82031 59.6878 5.82031C61.2096 5.82031 62.4806 6.14643 63.6178 6.79029C64.7551 7.43416 65.6331 8.32052 66.2518 9.44938C66.8706 10.5782 67.18 11.8576 67.18 13.2791C67.18 13.7725 67.1549 14.2909 67.0964 14.8428L67.1048 14.8344ZM63.8603 10.1769C63.4255 9.4661 62.8318 8.92258 62.0793 8.55465C61.3267 8.18673 60.4989 8.00277 59.5874 8.00277C58.2746 8.00277 57.1625 8.42086 56.2427 9.25705C55.3228 10.0932 54.796 11.2472 54.6623 12.7356H64.5126C64.5126 11.7489 64.2952 10.896 63.8603 10.1852V10.1769Z" fill="#F2F2F2"/>
<path d="M73.7695 8.20355V17.4016C73.7695 18.1626 73.9284 18.6977 74.2545 19.0071C74.5806 19.3165 75.1409 19.4754 75.9352 19.4754H77.8418V21.6662H75.5088C74.0622 21.6662 72.9835 21.3317 72.2644 20.6711C71.5452 20.0105 71.1857 18.9151 71.1857 17.3933V8.19519H69.1621V6.0629H71.1857V2.13281H73.7779V6.0629H77.8501V8.19519H73.7779L73.7695 8.20355Z" fill="#F2F2F2"/>
<path d="M85.9022 6.68902C86.9307 6.10369 88.093 5.80266 89.4058 5.80266C90.8106 5.80266 92.0732 6.13714 93.1937 6.79773C94.3142 7.46668 95.2006 8.39485 95.8444 9.59896C96.4883 10.8031 96.8144 12.2079 96.8144 13.7966C96.8144 15.3854 96.4883 16.7818 95.8444 18.011C95.2006 19.2486 94.3142 20.2018 93.1854 20.8875C92.0565 21.5732 90.7939 21.916 89.4141 21.916C88.0344 21.916 86.8805 21.6234 85.8687 21.0297C84.8569 20.4443 84.0876 19.6918 83.5775 18.7803V21.6568H80.9854V0.601562H83.5775V8.97182C84.1127 8.04365 84.8904 7.28272 85.9105 6.69738L85.9022 6.68902ZM93.4529 10.7362C92.9763 9.86654 92.3408 9.19759 91.5297 8.74605C90.7186 8.29451 89.8322 8.06037 88.8706 8.06037C87.909 8.06037 87.0394 8.29451 86.2366 8.75441C85.4255 9.22268 84.7817 9.89163 84.2967 10.778C83.8117 11.6643 83.5692 12.6845 83.5692 13.8384C83.5692 14.9924 83.8117 16.046 84.2967 16.9323C84.7817 17.8187 85.4255 18.4877 86.2366 18.9559C87.0394 19.4242 87.9174 19.65 88.8706 19.65C89.8239 19.65 90.727 19.4158 91.5297 18.9559C92.3324 18.4877 92.9763 17.8187 93.4529 16.9323C93.9296 16.046 94.1637 15.0091 94.1637 13.8134C94.1637 12.6176 93.9296 11.6142 93.4529 10.7362Z" fill="#F2F2F2"/>
<path d="M100.318 3.01864C99.9749 2.67581 99.8076 2.25771 99.8076 1.76436C99.8076 1.27101 99.9749 0.852913 100.318 0.510076C100.661 0.167238 101.079 0 101.572 0C102.065 0 102.45 0.167238 102.784 0.510076C103.119 0.852913 103.286 1.27101 103.286 1.76436C103.286 2.25771 103.119 2.67581 102.784 3.01864C102.45 3.36148 102.049 3.52872 101.572 3.52872C101.095 3.52872 100.661 3.36148 100.318 3.01864ZM102.826 6.06237V21.6657H100.234V6.06237H102.826Z" fill="#F2F2F2"/>
<path d="M111.773 6.52155C112.617 6.0282 113.646 5.77734 114.867 5.77734V8.45315H114.181C111.28 8.45315 109.825 10.0252 109.825 13.1776V21.6649H107.232V6.06165H109.825V8.5953C110.276 7.70058 110.928 7.00654 111.773 6.51319V6.52155Z" fill="#F2F2F2"/>
<path d="M117.861 9.60732C118.505 8.40321 119.391 7.46668 120.52 6.80609C121.649 6.1455 122.92 5.81102 124.325 5.81102C125.537 5.81102 126.666 6.09533 127.711 6.64721C128.757 7.20746 129.551 7.94331 130.103 8.85475V0.601562H132.72V21.6735H130.103V18.7385C129.593 19.6667 128.832 20.436 127.828 21.0297C126.825 21.6317 125.646 21.9244 124.3 21.9244C122.953 21.9244 121.657 21.5816 120.528 20.8959C119.4 20.2102 118.513 19.257 117.869 18.0194C117.226 16.7818 116.899 15.377 116.899 13.805C116.899 12.233 117.226 10.8114 117.869 9.60732H117.861ZM129.392 10.7613C128.915 9.89163 128.28 9.22268 127.469 8.75441C126.658 8.28614 125.771 8.06037 124.81 8.06037C123.848 8.06037 122.962 8.28614 122.159 8.74605C121.356 9.20595 120.729 9.86654 120.253 10.7362C119.776 11.6058 119.542 12.6343 119.542 13.8134C119.542 14.9924 119.776 16.046 120.253 16.9323C120.729 17.8187 121.365 18.4877 122.159 18.9559C122.953 19.4242 123.84 19.65 124.81 19.65C125.78 19.65 126.666 19.4158 127.469 18.9559C128.272 18.4877 128.915 17.8187 129.392 16.9323C129.869 16.046 130.103 15.0175 130.103 13.8384C130.103 12.6594 129.869 11.6393 129.392 10.7613Z" fill="#F2F2F2"/>
<path d="M21.4651 0.568359C17.8193 0.902835 16.0047 3.00167 15.3191 4.06363L4.66602 22.5183H17.5182L30.1949 0.568359H21.4651Z" fill="#F68330"/>
<path d="M17.5265 22.5187L0 3.9302C0 3.9302 19.8177 -1.39633 21.7493 15.2188L17.5265 22.5187Z" fill="#F68330"/>
<path d="M14.9255 4.75055L9.54883 14.0657L17.5177 22.5196L21.7405 15.2029C21.0715 9.49174 18.287 6.37276 14.9255 4.74219" fill="#F35E32"/>
</g>
<defs>
<clipPath id="clip0">
<rect width="132.72" height="22.5186" fill="white"/>
</clipPath>
</defs>
</svg>
</div>

View File

@@ -0,0 +1,56 @@
{{ template "header.html" . }}
<div class="nb-card">
<h1 class="nb-heading">Sign in</h1>
<p class="nb-subheading">Choose your login method</p>
{{/* First pass: render Email/Local connectors at the top */}}
{{ range $c := .Connectors }}
{{- $nameLower := lower $c.Name -}}
{{- $idLower := lower $c.ID -}}
{{- if or (contains "email" $nameLower) (contains "email" $idLower) (contains "local" $nameLower) (contains "local" $idLower) -}}
<a href="{{ $c.URL }}" class="nb-btn-connector">
<span class="nb-icon nb-icon-email"></span>
<span>Continue with {{ $c.Name }}</span>
</a>
{{- end -}}
{{ end }}
{{/* Second pass: render all other connectors */}}
{{ range $c := .Connectors }}
{{- $nameLower := lower $c.Name -}}
{{- $idLower := lower $c.ID -}}
{{- if not (or (contains "email" $nameLower) (contains "email" $idLower) (contains "local" $nameLower) (contains "local" $idLower)) -}}
<a href="{{ $c.URL }}" class="nb-btn-connector">
{{- $iconClass := "nb-icon-default" -}}
{{- if or (contains "google" $nameLower) (contains "google" $idLower) -}}
{{- $iconClass = "nb-icon-google" -}}
{{- else if or (contains "github" $nameLower) (contains "github" $idLower) -}}
{{- $iconClass = "nb-icon-github" -}}
{{- else if or (contains "entra" $nameLower) (contains "entra" $idLower) -}}
{{- $iconClass = "nb-icon-entra" -}}
{{- else if or (contains "azure" $nameLower) (contains "azure" $idLower) -}}
{{- $iconClass = "nb-icon-azure" -}}
{{- else if or (contains "microsoft" $nameLower) (contains "microsoft" $idLower) -}}
{{- $iconClass = "nb-icon-microsoft" -}}
{{- else if or (contains "okta" $nameLower) (contains "okta" $idLower) -}}
{{- $iconClass = "nb-icon-okta" -}}
{{- else if or (contains "jumpcloud" $nameLower) (contains "jumpcloud" $idLower) -}}
{{- $iconClass = "nb-icon-jumpcloud" -}}
{{- else if or (contains "pocket" $nameLower) (contains "pocket" $idLower) -}}
{{- $iconClass = "nb-icon-pocketid" -}}
{{- else if or (contains "zitadel" $nameLower) (contains "zitadel" $idLower) -}}
{{- $iconClass = "nb-icon-zitadel" -}}
{{- else if or (contains "authentik" $nameLower) (contains "authentik" $idLower) -}}
{{- $iconClass = "nb-icon-authentik" -}}
{{- else if or (contains "keycloak" $nameLower) (contains "keycloak" $idLower) -}}
{{- $iconClass = "nb-icon-keycloak" -}}
{{- end -}}
<span class="nb-icon {{ $iconClass }}"></span>
<span>Continue with {{ $c.Name }}</span>
</a>
{{- end -}}
{{ end }}
</div>
{{ template "footer.html" . }}

19
idp/dex/web/templates/oob.html Executable file
View File

@@ -0,0 +1,19 @@
{{ template "header.html" . }}
<div class="nb-card">
<div style="text-align:center;margin-bottom:24px">
<svg height="48" viewBox="0 0 100 100" xmlns="http://www.w3.org/2000/svg">
<circle cx="50" cy="50" fill="none" r="45" stroke="#5cb85c" stroke-width="3"/>
<path d="M30 50 L45 65 L70 35" fill="none" stroke="#5cb85c" stroke-width="5"/>
</svg>
</div>
<h1 class="nb-heading">Login Successful</h1>
<p class="nb-subheading">
Copy this code back to your application:
</p>
<div style="background-color:rgba(63,68,75,.5);border-radius:8px;padding:16px;text-align:center;font-family:monospace;font-size:16px;color:#f68330;margin-top:16px">
{{ .Code }}
</div>
</div>
{{ template "footer.html" . }}

View File

@@ -0,0 +1,58 @@
{{ template "header.html" . }}
<div class="nb-card">
<h1 class="nb-heading">Sign in</h1>
<p class="nb-subheading">Enter your credentials</p>
<form method="post" action="{{ .PostURL }}">
{{ if .Invalid }}
<div class="nb-error">
Invalid {{ .UsernamePrompt }} or password.
</div>
{{ end }}
<div class="nb-form-group">
<label class="nb-label" for="login">{{ .UsernamePrompt }}</label>
<input
type="text"
id="login"
name="login"
class="nb-input"
placeholder="Enter your {{ .UsernamePrompt | lower }}"
{{ if .Username }}value="{{ .Username }}"{{ else }}autofocus{{ end }}
required
>
</div>
<div class="nb-form-group">
<label class="nb-label" for="password">Password</label>
<input
type="password"
id="password"
name="password"
class="nb-input"
placeholder="Enter your password"
{{ if .Invalid }}autofocus{{ end }}
required
>
</div>
<button type="submit" id="submit-login" class="nb-btn">
Sign in
</button>
</form>
{{ if .BackLink }}
<div class="nb-back-link">
<a href="{{ .BackLink }}" class="nb-link">Choose another login method</a>
</div>
{{ end }}
</div>
<script>
document.querySelector('form').onsubmit = function() {
document.getElementById('submit-login').disabled = true;
};
</script>
{{ template "footer.html" . }}

Binary file not shown.

After

Width:  |  Height:  |  Size: 104 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 300 B

BIN
idp/dex/web/themes/light/logo.png Executable file

Binary file not shown.

After

Width:  |  Height:  |  Size: 300 B

View File

@@ -0,0 +1 @@
/* NetBird DEX Theme - styles loaded but CSS is inline in header.html */

14
idp/dex/web/web.go Normal file
View File

@@ -0,0 +1,14 @@
package web
import (
"embed"
"io/fs"
)
//go:embed static/* templates/* themes/* robots.txt
var files embed.FS
// FS returns the embedded web assets filesystem.
func FS() fs.FS {
return files
}

135
idp/sdk/sdk.go Normal file
View File

@@ -0,0 +1,135 @@
// Package sdk provides an embeddable SDK for the Dex OIDC identity provider.
package sdk
import (
"context"
"github.com/dexidp/dex/storage"
"github.com/netbirdio/netbird/idp/dex"
)
// DexIdP wraps the Dex provider with a builder pattern
type DexIdP struct {
provider *dex.Provider
config *dex.Config
yamlConfig *dex.YAMLConfig
}
// Option configures a DexIdP instance
type Option func(*dex.Config)
// WithIssuer sets the OIDC issuer URL
func WithIssuer(issuer string) Option {
return func(c *dex.Config) { c.Issuer = issuer }
}
// WithPort sets the HTTP port
func WithPort(port int) Option {
return func(c *dex.Config) { c.Port = port }
}
// WithDataDir sets the data directory for storage
func WithDataDir(dir string) Option {
return func(c *dex.Config) { c.DataDir = dir }
}
// WithDevMode enables development mode (allows HTTP)
func WithDevMode(dev bool) Option {
return func(c *dex.Config) { c.DevMode = dev }
}
// WithGRPCAddr sets the gRPC API address
func WithGRPCAddr(addr string) Option {
return func(c *dex.Config) { c.GRPCAddr = addr }
}
// New creates a new DexIdP instance with the given options
func New(opts ...Option) (*DexIdP, error) {
config := &dex.Config{
Port: 33081,
DevMode: true,
}
for _, opt := range opts {
opt(config)
}
return &DexIdP{config: config}, nil
}
// NewFromConfigFile creates a new DexIdP instance from a YAML config file
func NewFromConfigFile(path string) (*DexIdP, error) {
yamlConfig, err := dex.LoadConfig(path)
if err != nil {
return nil, err
}
return &DexIdP{yamlConfig: yamlConfig}, nil
}
// NewFromYAMLConfig creates a new DexIdP instance from a YAMLConfig
func NewFromYAMLConfig(yamlConfig *dex.YAMLConfig) (*DexIdP, error) {
return &DexIdP{yamlConfig: yamlConfig}, nil
}
// Start initializes and starts the embedded OIDC provider
func (d *DexIdP) Start(ctx context.Context) error {
var err error
if d.yamlConfig != nil {
d.provider, err = dex.NewProviderFromYAML(ctx, d.yamlConfig)
} else {
d.provider, err = dex.NewProvider(ctx, d.config)
}
if err != nil {
return err
}
return d.provider.Start(ctx)
}
// Stop gracefully shuts down the provider
func (d *DexIdP) Stop(ctx context.Context) error {
if d.provider != nil {
return d.provider.Stop(ctx)
}
return nil
}
// EnsureDefaultClients creates the default NetBird OAuth clients
func (d *DexIdP) EnsureDefaultClients(ctx context.Context, dashboardURIs, cliURIs []string) error {
return d.provider.EnsureDefaultClients(ctx, dashboardURIs, cliURIs)
}
// Storage exposes Dex storage for direct user/client/connector management
// Use storage.Client, storage.Password, storage.Connector directly
func (d *DexIdP) Storage() storage.Storage {
return d.provider.Storage()
}
// CreateUser creates a new user with the given email, username, and password.
// Returns the encoded user ID in Dex's format.
func (d *DexIdP) CreateUser(ctx context.Context, email, username, password string) (string, error) {
return d.provider.CreateUser(ctx, email, username, password)
}
// DeleteUser removes a user by email
func (d *DexIdP) DeleteUser(ctx context.Context, email string) error {
return d.provider.DeleteUser(ctx, email)
}
// ListUsers returns all users
func (d *DexIdP) ListUsers(ctx context.Context) ([]storage.Password, error) {
return d.provider.ListUsers(ctx)
}
// IssuerURL returns the OIDC issuer URL
func (d *DexIdP) IssuerURL() string {
if d.yamlConfig != nil {
return d.yamlConfig.Issuer
}
return d.config.Issuer
}
// DiscoveryEndpoint returns the OIDC discovery endpoint URL
func (d *DexIdP) DiscoveryEndpoint() string {
return d.IssuerURL() + "/.well-known/openid-configuration"
}