Compare commits

...

2 Commits

Author SHA1 Message Date
braginini
7fa02e55b3 Fix device auth flow 2025-12-29 00:34:07 -05:00
braginini
12c7faf222 Refactor embeddedidp instantiation 2025-12-29 00:15:12 -05:00
12 changed files with 377 additions and 247 deletions

View File

@@ -924,19 +924,29 @@ func (p *Provider) GetKeysLocation() string {
return issuer + "/keys"
}
// GetClientIDs returns the OAuth2 client IDs configured for this provider.
func (p *Provider) GetClientIDs() []string {
if p.yamlConfig != nil && len(p.yamlConfig.StaticClients) > 0 {
clientIDs := make([]string, 0, len(p.yamlConfig.StaticClients))
for _, client := range p.yamlConfig.StaticClients {
clientIDs = append(clientIDs, client.ID)
}
return clientIDs
// GetTokenEndpoint returns the OAuth2 token endpoint URL.
func (p *Provider) GetTokenEndpoint() string {
issuer := p.GetIssuer()
if issuer == "" {
return ""
}
// Default client IDs if not configured via YAML
return []string{"netbird-dashboard", "netbird-cli"}
return issuer + "/token"
}
func (p *Provider) GetUserIDClaim() string {
return "sub"
// 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"
}

View File

@@ -102,7 +102,7 @@ func (s *BaseServer) EventStore() activity.Store {
func (s *BaseServer) APIHandler() http.Handler {
return Create(s, func() http.Handler {
httpAPIHandler, err := nbhttp.NewAPIHandler(context.Background(), s.AccountManager(), s.NetworksManager(), s.ResourcesManager(), s.RoutesManager(), s.GroupsManager(), s.GeoLocationManager(), s.AuthManager(), s.Metrics(), s.IntegratedValidator(), s.ProxyController(), s.PermissionsManager(), s.PeersManager(), s.SettingsManager(), s.NetworkMapController())
httpAPIHandler, err := nbhttp.NewAPIHandler(context.Background(), s.AccountManager(), s.NetworksManager(), s.ResourcesManager(), s.RoutesManager(), s.GroupsManager(), s.GeoLocationManager(), s.AuthManager(), s.Metrics(), s.IntegratedValidator(), s.ProxyController(), s.PermissionsManager(), s.PeersManager(), s.SettingsManager(), s.NetworkMapController(), s.IdpManager())
if err != nil {
log.Fatalf("failed to create API handler: %v", err)
}
@@ -154,7 +154,7 @@ func (s *BaseServer) GRPCServer() *grpc.Server {
}
gRPCAPIHandler := grpc.NewServer(gRPCOpts...)
srv, err := nbgrpc.NewServer(s.Config, s.AccountManager(), s.SettingsManager(), s.SecretsManager(), s.Metrics(), s.AuthManager(), s.IntegratedValidator(), s.NetworkMapController())
srv, err := nbgrpc.NewServer(s.Config, s.AccountManager(), s.SettingsManager(), s.SecretsManager(), s.Metrics(), s.AuthManager(), s.IntegratedValidator(), s.NetworkMapController(), s.OAuthConfigProvider())
if err != nil {
log.Fatalf("failed to create management server: %v", err)
}

View File

@@ -1,12 +1,8 @@
package config
import (
"fmt"
"net/netip"
"github.com/dexidp/dex/storage"
"github.com/google/uuid"
"github.com/netbirdio/netbird/idp/dex"
"github.com/netbirdio/netbird/management/server/idp"
"github.com/netbirdio/netbird/management/server/types"
"github.com/netbirdio/netbird/shared/management/client/common"
@@ -64,49 +60,7 @@ type Config struct {
// EmbeddedIdP contains configuration for the embedded Dex OIDC provider.
// When set, Dex will be embedded in the management server and serve requests at /oauth2/
EmbeddedIdP *EmbeddedIdPConfig
}
// 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
EmbeddedIdP *idp.EmbeddedIdPConfig
}
// GetAuthAudiences returns the audience from the http config and device authorization flow config
@@ -124,73 +78,6 @@ func (c Config) GetAuthAudiences() []string {
return audiences
}
// 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")
}
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: "netbird-dashboard",
Name: "NetBird Dashboard",
Public: true,
RedirectURIs: c.DashboardRedirectURIs,
},
{
ID: "netbird-cli",
Name: "NetBird CLI",
Public: true,
RedirectURIs: c.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
}
// TURNConfig is a config of the TURNCredentialsManager
type TURNConfig struct {
TimeBasedCredentials bool

View File

@@ -61,17 +61,19 @@ func (s *BaseServer) AuthManager() auth.Manager {
signingKeyRefreshEnabled := s.Config.HttpConfig.IdpSignKeyRefreshEnabled
issuer := s.Config.HttpConfig.AuthIssuer
userIDClaim := s.Config.HttpConfig.AuthUserIDClaim
if s.embeddedIdp != nil {
// Use embedded IdP provider's methods to extract configuration
audiences = s.embeddedIdp.GetClientIDs()
// Use embedded IdP configuration if available
if oauthProvider := s.OAuthConfigProvider(); oauthProvider != nil {
audiences = oauthProvider.GetClientIDs()
if len(audiences) > 0 {
audience = audiences[0] // Use the first client ID as the primary audience
}
keysLocation = s.embeddedIdp.GetKeysLocation()
keysLocation = oauthProvider.GetKeysLocation()
signingKeyRefreshEnabled = true
issuer = s.embeddedIdp.GetIssuer()
userIDClaim = s.embeddedIdp.GetUserIDClaim()
issuer = oauthProvider.GetIssuer()
userIDClaim = oauthProvider.GetUserIDClaim()
}
return Create(s, func() auth.Manager {
return auth.NewManager(s.Store(),
issuer,

View File

@@ -96,10 +96,10 @@ func (s *BaseServer) IdpManager() idp.Manager {
var idpManager idp.Manager
var err error
// Use embedded IdP manager if embedded Dex is configured.
// Use embedded IdP manager if embedded Dex is configured and enabled.
// Legacy IdpManager won't be used anymore even if configured.
if s.embeddedIdp != nil {
idpManager, err = idp.NewEmbeddedIdPManager(s.embeddedIdp, s.Metrics())
if s.Config.EmbeddedIdP != nil && s.Config.EmbeddedIdP.Enabled {
idpManager, err = idp.NewEmbeddedIdPManager(context.Background(), s.Config.EmbeddedIdP, s.Metrics())
if err != nil {
log.Fatalf("failed to create embedded IDP manager: %v", err)
}
@@ -117,6 +117,20 @@ func (s *BaseServer) IdpManager() idp.Manager {
})
}
func (s *BaseServer) OAuthConfigProvider() idp.OAuthConfigProvider {
return Create(s, func() idp.OAuthConfigProvider {
if s.Config.EmbeddedIdP == nil || !s.Config.EmbeddedIdP.Enabled {
return nil
}
// Reuse the EmbeddedIdPManager instance from IdpManager
// EmbeddedIdPManager implements both idp.Manager and idp.OAuthConfigProvider
if provider, ok := s.IdpManager().(idp.OAuthConfigProvider); ok {
return provider
}
return nil
})
}
func (s *BaseServer) GroupsManager() groups.Manager {
return Create(s, func() groups.Manager {
return groups.NewManager(s.Store(), s.PermissionsManager(), s.AccountManager())

View File

@@ -11,6 +11,7 @@ import (
"time"
"github.com/google/uuid"
"github.com/netbirdio/netbird/management/server/idp"
log "github.com/sirupsen/logrus"
"go.opentelemetry.io/otel/metric"
"golang.org/x/crypto/acme/autocert"
@@ -19,7 +20,6 @@ import (
"google.golang.org/grpc"
"github.com/netbirdio/netbird/encryption"
"github.com/netbirdio/netbird/idp/dex"
nbconfig "github.com/netbirdio/netbird/management/internals/server/config"
"github.com/netbirdio/netbird/management/server/metrics"
"github.com/netbirdio/netbird/management/server/store"
@@ -63,9 +63,6 @@ type BaseServer struct {
certManager *autocert.Manager
update *version.Update
// embeddedIdp is the embedded Dex OIDC identity provider
embeddedIdp *dex.Provider
errCh chan error
wg sync.WaitGroup
cancel context.CancelFunc
@@ -137,19 +134,6 @@ func (s *BaseServer) Start(ctx context.Context) error {
go metricsWorker.Run(srvCtx)
}
// Initialize embedded IDP if configured
if s.Config.EmbeddedIdP != nil && s.Config.EmbeddedIdP.Enabled {
yamlConfig, err := s.Config.EmbeddedIdP.ToYAMLConfig()
if err != nil {
return fmt.Errorf("failed to create embedded IDP config: %v", err)
}
s.embeddedIdp, err = dex.NewProviderFromYAML(srvCtx, yamlConfig)
if err != nil {
return fmt.Errorf("failed to create embedded IDP: %v", err)
}
log.WithContext(srvCtx).Infof("embedded Dex IDP initialized with issuer: %s", yamlConfig.Issuer)
}
var compatListener net.Listener
if s.mgmtPort != ManagementLegacyPort {
// The Management gRPC server was running on port 33073 previously. Old agents that are already connected to it
@@ -232,8 +216,9 @@ func (s *BaseServer) Stop() error {
if s.update != nil {
s.update.StopWatch()
}
if s.embeddedIdp != nil {
_ = s.embeddedIdp.Stop(ctx)
// Stop embedded IdP if configured
if embeddedIdP, ok := s.IdpManager().(*idp.EmbeddedIdPManager); ok {
_ = embeddedIdP.Stop(ctx)
}
select {
@@ -280,23 +265,6 @@ func (s *BaseServer) handlerFunc(_ context.Context, gRPCHandler *grpc.Server, ht
gRPCHandler.ServeHTTP(writer, request)
case request.URL.Path == wsproxy.ProxyPath+wsproxy.ManagementComponent:
wsProxy.Handler().ServeHTTP(writer, request)
case strings.HasPrefix(request.URL.Path, "/oauth2/"):
// Add CORS headers for OAuth2 endpoints (needed for browser-based OIDC flows)
writer.Header().Set("Access-Control-Allow-Origin", "*")
writer.Header().Set("Access-Control-Allow-Methods", "GET, POST, OPTIONS")
writer.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization")
// Handle preflight OPTIONS request
if request.Method == http.MethodOptions {
writer.WriteHeader(http.StatusOK)
return
}
if s.embeddedIdp != nil {
s.embeddedIdp.Handler().ServeHTTP(writer, request)
} else {
http.Error(writer, "Embedded IDP not configured", http.StatusNotFound)
}
default:
httpHandler.ServeHTTP(writer, request)
}

View File

@@ -24,6 +24,7 @@ import (
"github.com/netbirdio/netbird/management/internals/controllers/network_map"
nbconfig "github.com/netbirdio/netbird/management/internals/server/config"
"github.com/netbirdio/netbird/management/server/idp"
"github.com/netbirdio/netbird/management/server/integrations/integrated_validator"
"github.com/netbirdio/netbird/management/server/store"
@@ -69,6 +70,8 @@ type Server struct {
networkMapController network_map.Controller
oAuthConfigProvider idp.OAuthConfigProvider
syncSem atomic.Int32
syncLim int32
}
@@ -83,6 +86,7 @@ func NewServer(
authManager auth.Manager,
integratedPeerValidator integrated_validator.IntegratedValidator,
networkMapController network_map.Controller,
oAuthConfigProvider idp.OAuthConfigProvider,
) (*Server, error) {
if appMetrics != nil {
// update gauge based on number of connected peers which is equal to open gRPC streams
@@ -119,6 +123,7 @@ func NewServer(
blockPeersWithSameConfig: blockPeersWithSameConfig,
integratedPeerValidator: integratedPeerValidator,
networkMapController: networkMapController,
oAuthConfigProvider: oAuthConfigProvider,
loginFilter: newLoginFilter(),
@@ -752,32 +757,49 @@ func (s *Server) GetDeviceAuthorizationFlow(ctx context.Context, req *proto.Encr
return nil, status.Error(codes.InvalidArgument, errMSG)
}
if s.config.DeviceAuthorizationFlow == nil || s.config.DeviceAuthorizationFlow.Provider == string(nbconfig.NONE) {
return nil, status.Error(codes.NotFound, "no device authorization flow information available")
}
var flowInfoResp *proto.DeviceAuthorizationFlow
provider, ok := proto.DeviceAuthorizationFlowProvider_value[strings.ToUpper(s.config.DeviceAuthorizationFlow.Provider)]
if !ok {
return nil, status.Errorf(codes.InvalidArgument, "no provider found in the protocol for %s", s.config.DeviceAuthorizationFlow.Provider)
}
// Use embedded IdP configuration if available
if s.oAuthConfigProvider != nil {
flowInfoResp = &proto.DeviceAuthorizationFlow{
Provider: proto.DeviceAuthorizationFlow_HOSTED,
ProviderConfig: &proto.ProviderConfig{
ClientID: s.oAuthConfigProvider.GetCLIClientID(),
Audience: s.oAuthConfigProvider.GetCLIClientID(),
DeviceAuthEndpoint: s.oAuthConfigProvider.GetDeviceAuthEndpoint(),
TokenEndpoint: s.oAuthConfigProvider.GetTokenEndpoint(),
Scope: s.oAuthConfigProvider.GetDefaultScopes(),
UseIDToken: true,
},
}
} else {
if s.config.DeviceAuthorizationFlow == nil || s.config.DeviceAuthorizationFlow.Provider == string(nbconfig.NONE) {
return nil, status.Error(codes.NotFound, "no device authorization flow information available")
}
flowInfoResp := &proto.DeviceAuthorizationFlow{
Provider: proto.DeviceAuthorizationFlowProvider(provider),
ProviderConfig: &proto.ProviderConfig{
ClientID: s.config.DeviceAuthorizationFlow.ProviderConfig.ClientID,
ClientSecret: s.config.DeviceAuthorizationFlow.ProviderConfig.ClientSecret,
Domain: s.config.DeviceAuthorizationFlow.ProviderConfig.Domain,
Audience: s.config.DeviceAuthorizationFlow.ProviderConfig.Audience,
DeviceAuthEndpoint: s.config.DeviceAuthorizationFlow.ProviderConfig.DeviceAuthEndpoint,
TokenEndpoint: s.config.DeviceAuthorizationFlow.ProviderConfig.TokenEndpoint,
Scope: s.config.DeviceAuthorizationFlow.ProviderConfig.Scope,
UseIDToken: s.config.DeviceAuthorizationFlow.ProviderConfig.UseIDToken,
},
provider, ok := proto.DeviceAuthorizationFlowProvider_value[strings.ToUpper(s.config.DeviceAuthorizationFlow.Provider)]
if !ok {
return nil, status.Errorf(codes.InvalidArgument, "no provider found in the protocol for %s", s.config.DeviceAuthorizationFlow.Provider)
}
flowInfoResp = &proto.DeviceAuthorizationFlow{
Provider: proto.DeviceAuthorizationFlowProvider(provider),
ProviderConfig: &proto.ProviderConfig{
ClientID: s.config.DeviceAuthorizationFlow.ProviderConfig.ClientID,
ClientSecret: s.config.DeviceAuthorizationFlow.ProviderConfig.ClientSecret,
Domain: s.config.DeviceAuthorizationFlow.ProviderConfig.Domain,
Audience: s.config.DeviceAuthorizationFlow.ProviderConfig.Audience,
DeviceAuthEndpoint: s.config.DeviceAuthorizationFlow.ProviderConfig.DeviceAuthEndpoint,
TokenEndpoint: s.config.DeviceAuthorizationFlow.ProviderConfig.TokenEndpoint,
Scope: s.config.DeviceAuthorizationFlow.ProviderConfig.Scope,
UseIDToken: s.config.DeviceAuthorizationFlow.ProviderConfig.UseIDToken,
},
}
}
encryptedResp, err := encryption.EncryptMessage(peerKey, key, flowInfoResp)
if err != nil {
return nil, status.Error(codes.Internal, "failed to encrypt no device authorization flow information")
return nil, status.Error(codes.Internal, "failed to encrypt device authorization flow information")
}
return &proto.EncryptedMessage{
@@ -811,30 +833,47 @@ func (s *Server) GetPKCEAuthorizationFlow(ctx context.Context, req *proto.Encryp
return nil, status.Error(codes.InvalidArgument, errMSG)
}
if s.config.PKCEAuthorizationFlow == nil {
return nil, status.Error(codes.NotFound, "no pkce authorization flow information available")
}
var initInfoFlow *proto.PKCEAuthorizationFlow
initInfoFlow := &proto.PKCEAuthorizationFlow{
ProviderConfig: &proto.ProviderConfig{
Audience: s.config.PKCEAuthorizationFlow.ProviderConfig.Audience,
ClientID: s.config.PKCEAuthorizationFlow.ProviderConfig.ClientID,
ClientSecret: s.config.PKCEAuthorizationFlow.ProviderConfig.ClientSecret,
TokenEndpoint: s.config.PKCEAuthorizationFlow.ProviderConfig.TokenEndpoint,
AuthorizationEndpoint: s.config.PKCEAuthorizationFlow.ProviderConfig.AuthorizationEndpoint,
Scope: s.config.PKCEAuthorizationFlow.ProviderConfig.Scope,
RedirectURLs: s.config.PKCEAuthorizationFlow.ProviderConfig.RedirectURLs,
UseIDToken: s.config.PKCEAuthorizationFlow.ProviderConfig.UseIDToken,
DisablePromptLogin: s.config.PKCEAuthorizationFlow.ProviderConfig.DisablePromptLogin,
LoginFlag: uint32(s.config.PKCEAuthorizationFlow.ProviderConfig.LoginFlag),
},
// Use embedded IdP configuration if available
if s.oAuthConfigProvider != nil {
initInfoFlow = &proto.PKCEAuthorizationFlow{
ProviderConfig: &proto.ProviderConfig{
Audience: s.oAuthConfigProvider.GetCLIClientID(),
ClientID: s.oAuthConfigProvider.GetCLIClientID(),
TokenEndpoint: s.oAuthConfigProvider.GetTokenEndpoint(),
AuthorizationEndpoint: s.oAuthConfigProvider.GetAuthorizationEndpoint(),
Scope: s.oAuthConfigProvider.GetDefaultScopes(),
RedirectURLs: s.oAuthConfigProvider.GetCLIRedirectURLs(),
UseIDToken: true,
},
}
} else {
if s.config.PKCEAuthorizationFlow == nil {
return nil, status.Error(codes.NotFound, "no pkce authorization flow information available")
}
initInfoFlow = &proto.PKCEAuthorizationFlow{
ProviderConfig: &proto.ProviderConfig{
Audience: s.config.PKCEAuthorizationFlow.ProviderConfig.Audience,
ClientID: s.config.PKCEAuthorizationFlow.ProviderConfig.ClientID,
ClientSecret: s.config.PKCEAuthorizationFlow.ProviderConfig.ClientSecret,
TokenEndpoint: s.config.PKCEAuthorizationFlow.ProviderConfig.TokenEndpoint,
AuthorizationEndpoint: s.config.PKCEAuthorizationFlow.ProviderConfig.AuthorizationEndpoint,
Scope: s.config.PKCEAuthorizationFlow.ProviderConfig.Scope,
RedirectURLs: s.config.PKCEAuthorizationFlow.ProviderConfig.RedirectURLs,
UseIDToken: s.config.PKCEAuthorizationFlow.ProviderConfig.UseIDToken,
DisablePromptLogin: s.config.PKCEAuthorizationFlow.ProviderConfig.DisablePromptLogin,
LoginFlag: uint32(s.config.PKCEAuthorizationFlow.ProviderConfig.LoginFlag),
},
}
}
flowInfoResp := s.integratedPeerValidator.ValidateFlowResponse(ctx, peerKey.String(), initInfoFlow)
encryptedResp, err := encryption.EncryptMessage(peerKey, key, flowInfoResp)
if err != nil {
return nil, status.Error(codes.Internal, "failed to encrypt no pkce authorization flow information")
return nil, status.Error(codes.Internal, "failed to encrypt pkce authorization flow information")
}
return &proto.EncryptedMessage{

View File

@@ -243,7 +243,7 @@ func BuildManager(
am.externalCacheManager = nbcache.NewUserDataCache(cacheStore)
am.cacheManager = nbcache.NewAccountUserDataCache(am.loadAccount, cacheStore)
if !isNil(am.idpManager) && !isEmbeddedIdp(am.idpManager) {
if !IsNil(am.idpManager) && !IsEmbeddedIdp(am.idpManager) {
go func() {
err := am.warmupIDPCache(ctx, cacheStore)
if err != nil {
@@ -763,14 +763,14 @@ func (am *DefaultAccountManager) GetAccountIDByUserID(ctx context.Context, userI
return accountID, nil
}
func isNil(i idp.Manager) bool {
func IsNil(i idp.Manager) bool {
return i == nil || reflect.ValueOf(i).IsNil()
}
// isEmbeddedIdp checks if the IDP manager is an embedded IDP (data stored locally in DB).
// IsEmbeddedIdp checks if the IDP manager is an embedded IDP (data stored locally in DB).
// When true, user cache should be skipped and data fetched directly from the IDP manager.
func isEmbeddedIdp(i idp.Manager) bool {
if isNil(i) {
func IsEmbeddedIdp(i idp.Manager) bool {
if IsNil(i) {
return false
}
_, ok := i.(*idp.EmbeddedIdPManager)
@@ -779,7 +779,7 @@ func isEmbeddedIdp(i idp.Manager) bool {
// addAccountIDToIDPAppMeta update user's app metadata in idp manager
func (am *DefaultAccountManager) addAccountIDToIDPAppMeta(ctx context.Context, userID string, accountID string) error {
if !isNil(am.idpManager) && !isEmbeddedIdp(am.idpManager) {
if !IsNil(am.idpManager) && !IsEmbeddedIdp(am.idpManager) {
// user can be nil if it wasn't found (e.g., just created)
user, err := am.lookupUserInCache(ctx, userID, accountID)
if err != nil {
@@ -1025,7 +1025,7 @@ func (am *DefaultAccountManager) isCacheFresh(ctx context.Context, accountUsers
}
func (am *DefaultAccountManager) removeUserFromCache(ctx context.Context, accountID, userID string) error {
if isEmbeddedIdp(am.idpManager) {
if IsEmbeddedIdp(am.idpManager) {
return nil
}
data, err := am.getAccountFromCache(ctx, accountID, false)

View File

@@ -9,6 +9,7 @@ import (
"time"
"github.com/gorilla/mux"
idpmanager "github.com/netbirdio/netbird/management/server/idp"
"github.com/rs/cors"
log "github.com/sirupsen/logrus"
@@ -52,23 +53,7 @@ const (
)
// NewAPIHandler creates the Management service HTTP API handler registering all the available endpoints.
func NewAPIHandler(
ctx context.Context,
accountManager account.Manager,
networksManager nbnetworks.Manager,
resourceManager resources.Manager,
routerManager routers.Manager,
groupsManager nbgroups.Manager,
LocationManager geolocation.Geolocation,
authManager auth.Manager,
appMetrics telemetry.AppMetrics,
integratedValidator integrated_validator.IntegratedValidator,
proxyController port_forwarding.Controller,
permissionsManager permissions.Manager,
peersManager nbpeers.Manager,
settingsManager settings.Manager,
networkMapController network_map.Controller,
) (http.Handler, error) {
func NewAPIHandler(ctx context.Context, accountManager account.Manager, networksManager nbnetworks.Manager, resourceManager resources.Manager, routerManager routers.Manager, groupsManager nbgroups.Manager, LocationManager geolocation.Geolocation, authManager auth.Manager, appMetrics telemetry.AppMetrics, integratedValidator integrated_validator.IntegratedValidator, proxyController port_forwarding.Controller, permissionsManager permissions.Manager, peersManager nbpeers.Manager, settingsManager settings.Manager, networkMapController network_map.Controller, idpManager idpmanager.Manager) (http.Handler, error) {
var rateLimitingConfig *middleware.RateLimiterConfig
if os.Getenv(rateLimitingEnabledKey) == "true" {
@@ -137,5 +122,10 @@ func NewAPIHandler(
networks.AddEndpoints(networksManager, resourceManager, routerManager, groupsManager, accountManager, router)
idp.AddEndpoints(accountManager, router)
// Mount embedded IdP handler at /oauth2 path if configured
if embeddedIdP, ok := idpManager.(*idpmanager.EmbeddedIdPManager); ok {
rootRouter.PathPrefix("/oauth2").Handler(corsMiddleware.Handler(embeddedIdP.Handler()))
}
return rootRouter, nil
}

View File

@@ -94,7 +94,7 @@ func BuildApiBlackBoxWithDBState(t testing_tools.TB, sqlFile string, expectedPee
groupsManagerMock := groups.NewManagerMock()
peersManager := peers.NewManager(store, permissionsManager)
apiHandler, err := http2.NewAPIHandler(context.Background(), am, networksManagerMock, resourcesManagerMock, routersManagerMock, groupsManagerMock, geoMock, authManagerMock, metrics, validatorMock, proxyController, permissionsManager, peersManager, settingsManager, networkMapController)
apiHandler, err := http2.NewAPIHandler(context.Background(), am, networksManagerMock, resourcesManagerMock, routersManagerMock, groupsManagerMock, geoMock, authManagerMock, metrics, validatorMock, proxyController, permissionsManager, peersManager, settingsManager, networkMapController, nil)
if err != nil {
t.Fatalf("Failed to create API handler: %v", err)
}

View File

@@ -4,35 +4,207 @@ 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 with an existing provider.
func NewEmbeddedIdPManager(provider *dex.Provider, appMetrics telemetry.AppMetrics) (*EmbeddedIdPManager, error) {
if provider == nil {
return nil, fmt.Errorf("embedded IdP provider is required")
// NewEmbeddedIdPManager creates a new instance of EmbeddedIdPManager from a configuration.
// It instantiates the underlying Dex provider internally.
func NewEmbeddedIdPManager(ctx context.Context, config *EmbeddedIdPConfig, appMetrics telemetry.AppMetrics) (*EmbeddedIdPManager, error) {
if config == nil {
return nil, fmt.Errorf("embedded IdP config is required")
}
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
@@ -237,7 +409,55 @@ func (m *EmbeddedIdPManager) DeleteConnector(ctx context.Context, id string) err
return m.provider.DeleteConnector(ctx, id)
}
// GetRedirectURI returns the Dex callback redirect URI for configuring connectors.
func (m *EmbeddedIdPManager) GetRedirectURI() string {
return m.provider.GetRedirectURI()
// GetIssuer returns the OIDC issuer URL.
func (m *EmbeddedIdPManager) GetIssuer() string {
return m.provider.GetIssuer()
}
// GetTokenEndpoint returns the OAuth2 token endpoint URL.
func (m *EmbeddedIdPManager) GetTokenEndpoint() string {
return m.provider.GetTokenEndpoint()
}
// GetDeviceAuthEndpoint returns the OAuth2 device authorization endpoint URL.
func (m *EmbeddedIdPManager) GetDeviceAuthEndpoint() string {
return m.provider.GetDeviceAuthEndpoint()
}
// GetAuthorizationEndpoint returns the OAuth2 authorization endpoint URL.
func (m *EmbeddedIdPManager) GetAuthorizationEndpoint() string {
return m.provider.GetAuthorizationEndpoint()
}
// GetDefaultScopes returns the default OAuth2 scopes for authentication.
func (m *EmbeddedIdPManager) GetDefaultScopes() string {
return defaultScopes
}
// GetCLIClientID returns the client ID for CLI authentication.
func (m *EmbeddedIdPManager) GetCLIClientID() string {
return staticClientCLI
}
// GetCLIRedirectURLs returns the redirect URLs configured for the CLI client.
func (m *EmbeddedIdPManager) GetCLIRedirectURLs() []string {
if len(m.config.CLIRedirectURIs) == 0 {
return []string{defaultCLIRedirectURL1, defaultCLIRedirectURL2}
}
return m.config.CLIRedirectURIs
}
// GetKeysLocation returns the JWKS endpoint URL for token validation.
func (m *EmbeddedIdPManager) GetKeysLocation() string {
return m.provider.GetKeysLocation()
}
// GetClientIDs returns the OAuth2 client IDs configured for this provider.
func (m *EmbeddedIdPManager) GetClientIDs() []string {
return []string{staticClientDashboard, staticClientCLI}
}
// GetUserIDClaim returns the JWT claim name used for user identification.
func (m *EmbeddedIdPManager) GetUserIDClaim() string {
return defaultUserIDClaim
}

View File

@@ -106,7 +106,7 @@ func (am *DefaultAccountManager) inviteNewUser(ctx context.Context, accountID, u
}
var idpUser *idp.UserData
if isEmbeddedIdp(am.idpManager) {
if IsEmbeddedIdp(am.idpManager) {
idpUser, err = am.createEmbeddedIdpUser(ctx, accountID, inviterID, invite)
} else {
idpUser, err = am.createNewIdpUser(ctx, accountID, inviterID, invite)
@@ -131,7 +131,7 @@ func (am *DefaultAccountManager) inviteNewUser(ctx context.Context, accountID, u
return nil, err
}
if !isEmbeddedIdp(am.idpManager) {
if !IsEmbeddedIdp(am.idpManager) {
_, err = am.refreshCache(ctx, accountID)
if err != nil {
return nil, err
@@ -799,7 +799,7 @@ func handleOwnerRoleTransfer(ctx context.Context, transaction store.Store, initi
// If the AccountManager has a non-nil idpManager and the User is not a service user,
// it will attempt to look up the UserData from the cache.
func (am *DefaultAccountManager) getUserInfo(ctx context.Context, user *types.User, accountID string) (*types.UserInfo, error) {
if !isNil(am.idpManager) && !user.IsServiceUser && !isEmbeddedIdp(am.idpManager) {
if !IsNil(am.idpManager) && !user.IsServiceUser && !IsEmbeddedIdp(am.idpManager) {
userData, err := am.lookupUserInCache(ctx, user.Id, accountID)
if err != nil {
return nil, err
@@ -927,7 +927,7 @@ func (am *DefaultAccountManager) BuildUserInfosForAccount(ctx context.Context, a
var err error
// embedded IdP ensures that we have user data (email and name) stored in the database.
if !isNil(am.idpManager) && !isEmbeddedIdp(am.idpManager) {
if !IsNil(am.idpManager) && !IsEmbeddedIdp(am.idpManager) {
users := make(map[string]userLoggedInOnce, len(accountUsers))
usersFromIntegration := make([]*idp.UserData, 0)
for _, user := range accountUsers {
@@ -1139,7 +1139,7 @@ func (am *DefaultAccountManager) DeleteRegularUsers(ctx context.Context, account
// deleteRegularUser deletes a specified user and their related peers from the account.
func (am *DefaultAccountManager) deleteRegularUser(ctx context.Context, accountID, initiatorUserID string, targetUserInfo *types.UserInfo) (bool, error) {
if !isNil(am.idpManager) {
if !IsNil(am.idpManager) {
// Delete if the user already exists in the IdP. Necessary in cases where a user account
// was created where a user account was provisioned but the user did not sign in
_, err := am.idpManager.GetUserDataByID(ctx, targetUserInfo.ID, idp.AppMetadata{WTAccountID: accountID})