diff --git a/backend/app/administration.go b/backend/app/administration.go index 8c02172..8b16c20 100644 --- a/backend/app/administration.go +++ b/backend/app/administration.go @@ -53,11 +53,17 @@ const ( ROUTE_V1_USER_SESSIONS = "/api/v1/user/sessions" ROUTE_V1_USER_SESSIONS_INVALIDATE = "/api/v1/user/sessions/invalidate" ROUTE_V1_USER_API = "/api/v1/user/api" - // sso + // sso — legacy entra id (kept for backwards compatibility) ROUTE_V1_SSO_ENTRA_ID = "/api/v1/sso/entra-id" ROUTE_V1_SSO_ENTRA_ID_ENABLED = "/api/v1/sso/entra-id/enabled" ROUTE_V1_SSO_ENTRA_ID_LOGIN = "/api/v1/sso/entra-id/login" ROUTE_V1_SSO_ENTRA_ID_CALLBACK = "/api/v1/sso/entra-id/auth" + // sso — generic oidc + ROUTE_V1_SSO_OIDC_ENABLED = "/api/v1/sso/oidc/enabled" + ROUTE_V1_SSO_OIDC_LOGIN = "/api/v1/sso/oidc/login" + ROUTE_V1_SSO_OIDC_CALLBACK = "/api/v1/sso/oidc/auth" + // sso — combined status (true when either provider is active) + ROUTE_V1_SSO_ANY_ENABLED = "/api/v1/sso/enabled" // mfa ROUTE_V1_USER_MFA_TOTP_SETUP = "/api/v1/user/mfa/totp/setup" ROUTE_V1_USER_MFA_TOTP_SETUP_VERIFY = "/api/v1/user/mfa/totp/setup/verify" @@ -287,11 +293,17 @@ func setupRoutes( GET(ROUTE_V1_USER_API, middleware.SessionHandler, controllers.User.GetMaskedAPIKey). POST(ROUTE_V1_USER_API, middleware.SessionHandler, controllers.User.UpsertAPIKey). DELETE(ROUTE_V1_USER_API, middleware.SessionHandler, controllers.User.RemoveAPIKey). - // sso - GET(ROUTE_V1_SSO_ENTRA_ID_ENABLED, controllers.SSO.IsEnabled). + // sso — legacy entra id + GET(ROUTE_V1_SSO_ENTRA_ID_ENABLED, controllers.SSO.IsLegacyEnabled). POST(ROUTE_V1_SSO_ENTRA_ID, middleware.SessionHandler, controllers.SSO.Upsert). GET(ROUTE_V1_SSO_ENTRA_ID_LOGIN, controllers.SSO.EntreIDLogin). GET(ROUTE_V1_SSO_ENTRA_ID_CALLBACK, controllers.SSO.EntreIDCallBack). + // sso — generic oidc + GET(ROUTE_V1_SSO_OIDC_ENABLED, controllers.SSO.IsOIDCEnabled). + GET(ROUTE_V1_SSO_OIDC_LOGIN, controllers.SSO.OIDCLogin). + GET(ROUTE_V1_SSO_OIDC_CALLBACK, controllers.SSO.OIDCCallback). + // sso — combined + GET(ROUTE_V1_SSO_ANY_ENABLED, controllers.SSO.IsEnabled). // user mfa GET(ROUTE_V1_USER_MFA_TOTP, middleware.SessionHandler, controllers.User.IsTOTPEnabled). POST(ROUTE_V1_USER_MFA_TOTP_SETUP, middleware.LoginRateLimiter, middleware.SessionHandler, controllers.User.SetupTOTP). diff --git a/backend/app/services.go b/backend/app/services.go index 6051caf..eda78ce 100644 --- a/backend/app/services.go +++ b/backend/app/services.go @@ -2,6 +2,7 @@ package app import ( "github.com/caddyserver/certmagic" + "github.com/phishingclub/phishingclub/repository" "github.com/phishingclub/phishingclub/service" "go.uber.org/zap" "gorm.io/gorm" @@ -100,6 +101,7 @@ func NewServices( RoleRepository: repositories.Role, CompanyRepository: repositories.Company, PasswordHasher: utilities.PasswordHasher, + OptionRepository: repositories.Option, } recipient := &service.Recipient{ Common: common, @@ -228,7 +230,8 @@ func NewServices( OptionsService: optionService, UserService: userService, SessionService: sessionService, - // MSALClient: msalClient, this dependency is set AFTER this function + SSOStateRepo: &repository.SSOState{DB: db}, + // MSALClient and OIDCClient are set AFTER this function on startup } backupService := &service.Backup{ Common: common, diff --git a/backend/controller/sso.go b/backend/controller/sso.go index 273c71a..7f9c68f 100644 --- a/backend/controller/sso.go +++ b/backend/controller/sso.go @@ -41,15 +41,24 @@ func (s *SSO) Upsert(g *gin.Context) { s.Response.OK(g, gin.H{}) } +// IsEnabled reports whether any SSO provider (legacy Entra ID or generic OIDC) +// is currently active. func (s *SSO) IsEnabled(g *gin.Context) { - // if no sso client is setup, then it is not enabled - if s.SSO.MSALClient == nil { - s.Response.OK(g, false) - return - } - s.Response.OK(g, true) + s.Response.OK(g, s.SSO.IsEnabled()) } +// IsLegacyEnabled reports whether the legacy Microsoft Entra ID path is active. +func (s *SSO) IsLegacyEnabled(g *gin.Context) { + s.Response.OK(g, s.SSO.IsLegacyEnabled()) +} + +// IsOIDCEnabled reports whether the generic OIDC path is active. +func (s *SSO) IsOIDCEnabled(g *gin.Context) { + s.Response.OK(g, s.SSO.IsOIDCEnabled()) +} + +// EntreIDLogin initiates the legacy Microsoft Entra ID login flow by +// redirecting the browser to the Microsoft authorization endpoint. func (s *SSO) EntreIDLogin(g *gin.Context) { authURL, err := s.SSO.EntreIDLogin(g) if errors.Is(err, errs.ErrSSODisabled) { @@ -63,6 +72,8 @@ func (s *SSO) EntreIDLogin(g *gin.Context) { g.Redirect(http.StatusTemporaryRedirect, authURL) } +// EntreIDCallBack handles the authorization code callback from Microsoft for +// the legacy Entra ID flow. func (s *SSO) EntreIDCallBack(g *gin.Context) { code := g.Query("code") session, err := s.SSO.HandlEntraIDCallback(g, code) @@ -73,7 +84,71 @@ func (s *SSO) EntreIDCallBack(g *gin.Context) { if ok := s.handleErrors(g, err); !ok { return } - // Set the session in the cookie + setSessionCookie(g, session) + g.Redirect(http.StatusTemporaryRedirect, "/dashboard") +} + +// OIDCLogin initiates the generic OIDC login flow by generating a PKCE pair, +// persisting the state record and redirecting the browser to the provider's +// authorization endpoint. +func (s *SSO) OIDCLogin(g *gin.Context) { + authURL, err := s.SSO.OIDCLoginURL(g.Request.Context()) + if errors.Is(err, errs.ErrSSODisabled) { + s.Response.BadRequest(g) + return + } + if ok := s.handleErrors(g, err); !ok { + s.Response.BadRequest(g) + return + } + g.Redirect(http.StatusTemporaryRedirect, authURL) +} + +// OIDCCallback handles the authorization code callback from a generic OIDC +// provider. It validates the state token (CSRF), completes the PKCE token +// exchange, verifies the nonce, enforces role and ACR requirements, then +// creates or reuses a local account and sets a session cookie. +func (s *SSO) OIDCCallback(g *gin.Context) { + code := g.Query("code") + state := g.Query("state") + errorParam := g.Query("error") + + // surface provider-level errors without leaking detail to the user + if errorParam != "" { + errDesc := g.Query("error_description") + s.Logger.Warnw("oidc provider returned error on callback", + "error", errorParam, + "description", errDesc, + ) + g.Redirect(http.StatusTemporaryRedirect, "/login?ssoAuthError=1") + return + } + + if code == "" || state == "" { + s.Logger.Warnw("oidc callback missing required parameters") + g.Redirect(http.StatusTemporaryRedirect, "/login?ssoAuthError=1") + return + } + + session, err := s.SSO.HandleOIDCCallback( + g.Request.Context(), + code, + state, + g.ClientIP(), + ) + if err != nil { + s.Logger.Warnw("oidc callback failed", "error", err) + g.Redirect(http.StatusTemporaryRedirect, "/login?ssoAuthError=1") + return + } + + setSessionCookie(g, session) + g.Redirect(http.StatusTemporaryRedirect, "/dashboard") +} + +// setSessionCookie writes the session ID into a secure, HttpOnly, SameSite=Strict +// cookie on the response. +func setSessionCookie(g *gin.Context, session *model.Session) { cookie := &http.Cookie{ Name: data.SessionCookieKey, Value: session.ID.String(), @@ -84,5 +159,4 @@ func (s *SSO) EntreIDCallBack(g *gin.Context) { Expires: *session.MaxAgeAt, } http.SetCookie(g.Writer, cookie) - g.Redirect(http.StatusTemporaryRedirect, "/dashboard") } diff --git a/backend/database/ssoState.go b/backend/database/ssoState.go new file mode 100644 index 0000000..3454344 --- /dev/null +++ b/backend/database/ssoState.go @@ -0,0 +1,47 @@ +package database + +import "time" + +const ( + SSO_STATE_TABLE = "sso_states" +) + +// SSOState stores short-lived state tokens for the generic OIDC login flow. +// Each record ties together the CSRF state token, the PKCE code verifier and +// an optional nonce so that the callback handler can verify the round-trip and +// complete the token exchange securely. +type SSOState struct { + // ID is a random UUID primary key. + ID string `gorm:"primary_key;not null;unique;type:varchar(36)"` + + // StateToken is the value sent as the OAuth2 'state' parameter. + // It is compared on callback to prevent CSRF attacks. + StateToken string `gorm:"not null;uniqueIndex;type:varchar(255)"` + + // CodeVerifier is the plain-text PKCE verifier (RFC 7636). + // The SHA-256 hash of this value was sent to the provider as the + // code_challenge during the authorization request. + CodeVerifier string `gorm:"not null;type:varchar(255)"` + + // Nonce is the OIDC nonce embedded in the id_token claim. + // It is verified after token exchange to prevent replay attacks. + Nonce string `gorm:"not null;type:varchar(255)"` + + // CreatedAt is the creation timestamp. + CreatedAt *time.Time `gorm:"not null;index"` + + // ExpiresAt is when this record should be considered invalid. + // State tokens are valid for 10 minutes. + ExpiresAt *time.Time `gorm:"not null;index"` + + // Used marks the token as consumed so it cannot be replayed. + Used bool `gorm:"not null;default:false;index"` + + // UsedAt is the time the token was consumed. + UsedAt *time.Time `gorm:"index"` +} + +// TableName returns the table name for gorm. +func (SSOState) TableName() string { + return SSO_STATE_TABLE +} diff --git a/backend/go.mod b/backend/go.mod index 0d91aae..d1efd26 100644 --- a/backend/go.mod +++ b/backend/go.mod @@ -49,6 +49,7 @@ require ( github.com/charmbracelet/x/term v0.2.1 // indirect github.com/cloudwego/base64x v0.1.4 // indirect github.com/cloudwego/iasm v0.2.0 // indirect + github.com/coreos/go-oidc/v3 v3.17.0 // indirect github.com/davecgh/go-spew v1.1.1 // indirect github.com/enetx/g v1.0.194 // indirect github.com/enetx/http v1.0.19 // indirect @@ -61,6 +62,7 @@ require ( github.com/gaukas/clienthellod v0.4.2 // indirect github.com/gaukas/godicttls v0.0.4 // indirect github.com/gin-contrib/sse v0.1.0 // indirect + github.com/go-jose/go-jose/v4 v4.1.3 // indirect github.com/go-playground/locales v0.14.1 // indirect github.com/go-playground/universal-translator v0.18.1 // indirect github.com/go-playground/validator/v10 v10.22.0 // indirect @@ -106,6 +108,7 @@ require ( go.uber.org/multierr v1.11.0 // indirect golang.org/x/arch v0.9.0 // indirect golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 // indirect + golang.org/x/oauth2 v0.28.0 // indirect golang.org/x/sys v0.37.0 // indirect golang.org/x/text v0.30.0 // indirect golang.org/x/tools v0.38.0 // indirect diff --git a/backend/go.sum b/backend/go.sum index e90a2da..dbfc570 100644 --- a/backend/go.sum +++ b/backend/go.sum @@ -42,6 +42,8 @@ github.com/cloudwego/base64x v0.1.4 h1:jwCgWpFanWmN8xoIUHa2rtzmkd5J2plF/dnLS6Xd/ github.com/cloudwego/base64x v0.1.4/go.mod h1:0zlkT4Wn5C6NdauXdJRhSKRlJvmclQ1hhJgA0rcu/8w= github.com/cloudwego/iasm v0.2.0 h1:1KNIy1I1H9hNNFEEH3DVnI4UujN+1zjpuk6gwHLTssg= github.com/cloudwego/iasm v0.2.0/go.mod h1:8rXZaNYT2n95jn+zTI1sDr+IgcD2GVs0nlbbQPiEFhY= +github.com/coreos/go-oidc/v3 v3.17.0 h1:hWBGaQfbi0iVviX4ibC7bk8OKT5qNr4klBaCHVNvehc= +github.com/coreos/go-oidc/v3 v3.17.0/go.mod h1:wqPbKFrVnE90vty060SB40FCJ8fTHTxSwyXJqZH+sI8= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= @@ -85,6 +87,8 @@ github.com/gkampitakis/go-snaps v0.5.15 h1:amyJrvM1D33cPHwVrjo9jQxX8g/7E2wYdZ+01 github.com/gkampitakis/go-snaps v0.5.15/go.mod h1:HNpx/9GoKisdhw9AFOBT1N7DBs9DiHo/hGheFGBZ+mc= github.com/go-errors/errors v1.5.1 h1:ZwEMSLRCapFLflTpT7NKaAc7ukJ8ZPEjzlxt8rPN8bk= github.com/go-errors/errors v1.5.1/go.mod h1:sIVyrIiJhuEF+Pj9Ebtd6P/rEYROXFi3BopGUQ5a5Og= +github.com/go-jose/go-jose/v4 v4.1.3 h1:CVLmWDhDVRa6Mi/IgCgaopNosCaHz7zrMeF9MlZRkrs= +github.com/go-jose/go-jose/v4 v4.1.3/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08= github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s= @@ -266,6 +270,8 @@ golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug golang.org/x/net v0.7.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= golang.org/x/net v0.46.0 h1:giFlY12I07fugqwPuWJi68oOnpfqFnJIJzaIIm2JVV4= golang.org/x/net v0.46.0/go.mod h1:Q9BGdFy1y4nkUwiLvT5qtyhAnEHgnQ/zd8PfU6nc210= +golang.org/x/oauth2 v0.28.0 h1:CrgCKl8PPAVtLnU3c+EDw6x11699EWlsDeWNWKdIOkc= +golang.org/x/oauth2 v0.28.0/go.mod h1:onh5ek6nERTohokkhCD/y2cV4Do3fxFHFuAejCkRWT8= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.18.0 h1:kr88TuHDroi+UVf+0hZnirlk8o8T+4MrK6mr60WkH/I= diff --git a/backend/main.go b/backend/main.go index a989268..294bda5 100644 --- a/backend/main.go +++ b/backend/main.go @@ -232,19 +232,35 @@ func main() { certMagicCache, *flagFilePath, ) - // get entra-id options and setup msal client + // initialise sso clients based on the stored configuration ssoOpt, err := services.SSO.GetSSOOptionWithoutAuth(context.Background()) if err != nil { - logger.Errorw("failed to setup sso", "error", err) + logger.Errorw("failed to load sso configuration", "error", err) return } if ssoOpt.Enabled { - services.SSO.MSALClient, err = sso.NewEntreIDClient(ssoOpt) - if err != nil && !errors.Is(err, errs.ErrSSODisabled) { - logger.Errorw("failed to setup msal client", "error", err) - return + if ssoOpt.IsLegacyEntraID() { + // legacy microsoft entra id path via msal + services.SSO.MSALClient, err = sso.NewEntreIDClient(ssoOpt) + if err != nil && !errors.Is(err, errs.ErrSSODisabled) { + logger.Errorw("failed to setup msal client", "error", err) + return + } + } else if ssoOpt.IsOIDC() { + // generic oidc path + services.SSO.OIDCClient, err = sso.NewOIDCClient( + context.Background(), + ssoOpt.IssuerURL.String(), + ssoOpt.ClientID.String(), + ssoOpt.ClientSecret.String(), + ssoOpt.RedirectURL.String(), + []string{"openid", "profile", "email"}, + ) + if err != nil { + // log but do not abort startup — operator can reconfigure via the ui + logger.Errorw("failed to setup oidc client, sso will be unavailable", "error", err) + } } - } middlewares := app.NewMiddlewares( 1, diff --git a/backend/model/ssoOption.go b/backend/model/ssoOption.go index 89dddc2..e944a02 100644 --- a/backend/model/ssoOption.go +++ b/backend/model/ssoOption.go @@ -12,24 +12,107 @@ import ( "github.com/phishingclub/phishingclub/vo" ) +// SSOOption holds all SSO configuration. +// +// Legacy Entra ID fields (ClientID, TenantID, ClientSecret, RedirectURL) are +// preserved so existing installations continue to work without any admin +// intervention. When TenantID is non-empty the system treats the config as a +// legacy Entra ID installation and uses the MSAL path. +// +// For new generic OIDC providers the operator sets IssuerURL together with the +// shared ClientID / ClientSecret / RedirectURL fields and leaves TenantID +// empty. type SSOOption struct { - Enabled bool `json:"enabled"` - ClientID vo.OptionalString64 `json:"clientID"` - TenantID vo.OptionalString64 `json:"tenantID"` + // --- shared fields (legacy + oidc) --- + + Enabled bool `json:"enabled"` + + // ClientID is the OAuth2 / OIDC application client id + ClientID vo.OptionalString64 `json:"clientID"` + + // ClientSecret is the OAuth2 / OIDC application client secret ClientSecret vo.OptionalString1024 `json:"clientSecret"` - RedirectURL vo.OptionalString1024 `json:"redirectURL"` + + // RedirectURL is the callback URL registered with the provider + RedirectURL vo.OptionalString1024 `json:"redirectURL"` + + // --- legacy entra id fields --- + + // TenantID is the Azure AD tenant identifier. + // Non-empty value indicates that this is a legacy Entra ID configuration. + TenantID vo.OptionalString64 `json:"tenantID"` + + // --- generic oidc fields --- + + // IssuerURL is the OIDC provider issuer, e.g. + // "https://keycloak.example.com/realms/myrealm" + // The library appends /.well-known/openid-configuration automatically. + IssuerURL vo.OptionalString1024 `json:"issuerURL"` + + // --- authorization --- + + // RequiredRoleClaim is the JWT / userinfo claim name that contains the + // user's roles, e.g. "roles", "groups", "realm_access.roles". + // Empty means role-checking is disabled. + RequiredRoleClaim vo.OptionalString255 `json:"requiredRoleClaim"` + + // RequiredRoleValue is the value that must appear inside the claim + // identified by RequiredRoleClaim before login is permitted. + RequiredRoleValue vo.OptionalString255 `json:"requiredRoleValue"` + + // --- access policy --- + + // SSOOnly disables local username/password login when true. + // Admins should ensure at least one SSO-capable account exists before + // enabling this to avoid lockout. + SSOOnly bool `json:"ssoOnly"` + + // --- acr --- + + // ACRValues is the space-separated list of Authentication Context Class + // Reference values to request from the provider, e.g. "urn:mfa". + // Empty means no ACR is requested. + ACRValues vo.OptionalString255 `json:"acrValues"` } +// IsLegacyEntraID returns true when the configuration describes a legacy +// Microsoft Entra ID (Azure AD) setup identified by a non-empty TenantID. +func (s *SSOOption) IsLegacyEntraID() bool { + return s.TenantID.String() != "" +} + +// IsOIDC returns true when the configuration describes a generic OIDC provider. +func (s *SSOOption) IsOIDC() bool { + return s.IssuerURL.String() != "" && !s.IsLegacyEntraID() +} + +// HasRoleGating returns true when role-based login gating is configured. +func (s *SSOOption) HasRoleGating() bool { + return s.RequiredRoleClaim.String() != "" && s.RequiredRoleValue.String() != "" +} + +// HasACR returns true when an ACR value is configured. +func (s *SSOOption) HasACR() bool { + return s.ACRValues.String() != "" +} + +// NewSSOOptionDefault returns a zeroed SSOOption ready to be stored. func NewSSOOptionDefault() *SSOOption { return &SSOOption{ - Enabled: false, - ClientID: *vo.NewEmptyOptionalString64(), - TenantID: *vo.NewEmptyOptionalString64(), - ClientSecret: *vo.NewEmptyOptionalString1024(), - RedirectURL: *vo.NewEmptyOptionalString1024(), + Enabled: false, + ClientID: *vo.NewEmptyOptionalString64(), + TenantID: *vo.NewEmptyOptionalString64(), + ClientSecret: *vo.NewEmptyOptionalString1024(), + RedirectURL: *vo.NewEmptyOptionalString1024(), + IssuerURL: *vo.NewEmptyOptionalString1024(), + RequiredRoleClaim: *vo.NewEmptyOptionalString255(), + RequiredRoleValue: *vo.NewEmptyOptionalString255(), + SSOOnly: false, + ACRValues: *vo.NewEmptyOptionalString255(), } } +// NewSSOOptionFromJSON unmarshals a JSON blob into an SSOOption. func NewSSOOptionFromJSON(jsonData []byte) (*SSOOption, error) { option := &SSOOption{} err := json.Unmarshal(jsonData, option) @@ -44,6 +127,7 @@ func NewSSOOptionFromJSON(jsonData []byte) (*SSOOption, error) { return option, nil } +// NewSSOOptionFromOption converts a generic Option row into an SSOOption. func NewSSOOptionFromOption(option *Option) (*SSOOption, error) { if option == nil { return nil, fmt.Errorf("option cannot be nil") @@ -60,10 +144,12 @@ func NewSSOOptionFromOption(option *Option) (*SSOOption, error) { return ssooption, nil } +// ToJSON serialises the SSOOption to JSON. func (l *SSOOption) ToJSON() ([]byte, error) { return json.Marshal(l) } +// ToOption converts the SSOOption into a generic Option row for persistence. func (l *SSOOption) ToOption() (*Option, error) { json, err := l.ToJSON() if err != nil { diff --git a/backend/repository/ssoState.go b/backend/repository/ssoState.go new file mode 100644 index 0000000..8aab67c --- /dev/null +++ b/backend/repository/ssoState.go @@ -0,0 +1,116 @@ +package repository + +import ( + "context" + "time" + + "github.com/google/uuid" + "github.com/phishingclub/phishingclub/database" + "github.com/phishingclub/phishingclub/errs" + "gorm.io/gorm" +) + +// SSOState is the repository for SSO PKCE/CSRF state tokens. +type SSOState struct { + DB *gorm.DB +} + +// SSOStateRecord is a plain struct used to pass state data between +// the service layer and this repository without importing the database +// package in the service. +type SSOStateRecord struct { + ID string + StateToken string + CodeVerifier string + Nonce string + ExpiresAt *time.Time + Used bool + UsedAt *time.Time +} + +// Insert persists a new SSO state record and returns its ID. +func (r *SSOState) Insert( + ctx context.Context, + stateToken string, + codeVerifier string, + nonce string, + expiresAt *time.Time, +) (string, error) { + id := uuid.New().String() + now := time.Now() + record := &database.SSOState{ + ID: id, + StateToken: stateToken, + CodeVerifier: codeVerifier, + Nonce: nonce, + CreatedAt: &now, + ExpiresAt: expiresAt, + Used: false, + } + if err := r.DB.WithContext(ctx).Create(record).Error; err != nil { + return "", errs.Wrap(err) + } + return id, nil +} + +// GetByStateToken retrieves an unexpired, unused state record by its +// state token value. Returns gorm.ErrRecordNotFound when no match exists. +func (r *SSOState) GetByStateToken( + ctx context.Context, + stateToken string, +) (*SSOStateRecord, error) { + now := time.Now() + var record database.SSOState + err := r.DB.WithContext(ctx). + Where("state_token = ? AND used = ? AND expires_at > ?", stateToken, false, now). + First(&record).Error + if err != nil { + return nil, errs.Wrap(err) + } + return toSSOStateRecord(&record), nil +} + +// MarkAsUsed marks the record with the given ID as consumed so it cannot +// be replayed. +func (r *SSOState) MarkAsUsed( + ctx context.Context, + id string, +) error { + now := time.Now() + err := r.DB.WithContext(ctx). + Model(&database.SSOState{}). + Where("id = ?", id). + Updates(map[string]any{ + "used": true, + "used_at": &now, + }).Error + if err != nil { + return errs.Wrap(err) + } + return nil +} + +// DeleteExpired removes all records whose expiry time has passed. +func (r *SSOState) DeleteExpired(ctx context.Context) error { + now := time.Now() + err := r.DB.WithContext(ctx). + Where("expires_at < ?", now). + Delete(&database.SSOState{}).Error + if err != nil { + return errs.Wrap(err) + } + return nil +} + +// toSSOStateRecord maps a database row to the plain repository record type. +func toSSOStateRecord(d *database.SSOState) *SSOStateRecord { + return &SSOStateRecord{ + ID: d.ID, + StateToken: d.StateToken, + CodeVerifier: d.CodeVerifier, + Nonce: d.Nonce, + ExpiresAt: d.ExpiresAt, + Used: d.Used, + UsedAt: d.UsedAt, + } +} diff --git a/backend/seed/migrate.go b/backend/seed/migrate.go index ce2169b..ee26c66 100644 --- a/backend/seed/migrate.go +++ b/backend/seed/migrate.go @@ -12,6 +12,7 @@ import ( "github.com/phishingclub/phishingclub/database" "github.com/phishingclub/phishingclub/errs" "github.com/phishingclub/phishingclub/model" + "github.com/phishingclub/phishingclub/vo" "go.uber.org/zap" "gorm.io/gorm" ) @@ -57,6 +58,7 @@ func initialInstallAndSeed( &database.CampaignStats{}, &database.OAuthProvider{}, &database.OAuthState{}, + &database.SSOState{}, } // disable foreign key constraints temporarily for sqlite to allow table recreation @@ -134,6 +136,73 @@ func initialInstallAndSeed( errors.Errorf("failed to run data migrations: %w", err), ) } + // migrate legacy entra id config to include derived oidc issuer url + err = migrateLegacyEntraIDToOIDC(db, logger) + if err != nil { + return errs.Wrap( + errors.Errorf("failed to migrate legacy entra id sso config: %w", err), + ) + } + return nil +} + +// migrateLegacyEntraIDToOIDC inspects the sso_login option row and, when it +// contains a legacy Entra ID configuration (tenantID present, issuerURL absent), +// derives a standards-compliant OIDC issuer URL from the tenantID and writes it +// back. The tenantID field is preserved so IsLegacyEntraID() continues to +// return true and the MSAL client path remains active — the issuerURL is +// stored purely for informational purposes and future reference. +// +// This migration is idempotent: running it multiple times on an already- +// migrated row is safe. +func migrateLegacyEntraIDToOIDC(db *gorm.DB, logger *zap.SugaredLogger) error { + var opt database.Option + res := db.Where("key = ?", data.OptionKeyAdminSSOLogin).First(&opt) + if res.Error != nil { + if strings.Contains(res.Error.Error(), "record not found") { + // no sso option yet — nothing to migrate + return nil + } + return errs.Wrap(res.Error) + } + + ssoOpt, err := model.NewSSOOptionFromJSON([]byte(opt.Value)) + if err != nil { + // corrupted value — skip migration, do not fail startup + logger.Warnw("sso migration: failed to parse sso_login option, skipping", "error", err) + return nil + } + + // only migrate when tenantID is set and issuerURL is still empty + if ssoOpt.TenantID.String() == "" || ssoOpt.IssuerURL.String() != "" { + return nil + } + + // construct the standard OIDC issuer URL for Azure AD / Entra ID + // https://learn.microsoft.com/en-us/entra/identity-platform/v2-protocols-oidc + issuer := "https://login.microsoftonline.com/" + ssoOpt.TenantID.String() + "/v2.0" + issuerVO, err := vo.NewOptionalString1024(issuer) + if err != nil { + logger.Warnw("sso migration: failed to build issuer URL VO", "issuer", issuer, "error", err) + return nil + } + ssoOpt.IssuerURL = *issuerVO + + updated, err := ssoOpt.ToJSON() + if err != nil { + return errs.Wrap(err) + } + + res = db.Model(&database.Option{}). + Where("key = ?", data.OptionKeyAdminSSOLogin). + Update("value", string(updated)) + if res.Error != nil { + return errs.Wrap(res.Error) + } + + logger.Infow("sso migration: derived issuerURL from tenantID for legacy Entra ID config", + "issuer", issuer, + ) return nil } diff --git a/backend/service/option.go b/backend/service/option.go index f13f9fe..1f2e259 100644 --- a/backend/service/option.go +++ b/backend/service/option.go @@ -77,14 +77,16 @@ func (o *Option) GetOptionWithoutAuth( return opt, nil } -// MaskSSOSecret masks the sso secret +// MaskSSOSecret masks the sso secret before returning the option to callers. +// The ClientSecret field is zeroed so it is never sent over the wire. +// All other fields, including the new OIDC and policy fields, are preserved. func (o *Option) MaskSSOSecret(opt *model.Option) (*model.Option, error) { a, err := model.NewSSOOptionFromJSON([]byte(opt.Value.String())) if err != nil { o.Logger.Errorw("failed to read sso option", "error", err) return nil, errs.Wrap(err) } - // mask the key + // zero the secret — never returned over the api a.ClientSecret = *vo.NewOptionalString1024Must("") b, err := a.ToJSON() if err != nil { diff --git a/backend/service/sso.go b/backend/service/sso.go index 2a759f1..ddd3321 100644 --- a/backend/service/sso.go +++ b/backend/service/sso.go @@ -16,34 +16,46 @@ import ( "github.com/phishingclub/phishingclub/data" "github.com/phishingclub/phishingclub/errs" "github.com/phishingclub/phishingclub/model" + "github.com/phishingclub/phishingclub/repository" "github.com/phishingclub/phishingclub/sso" "github.com/phishingclub/phishingclub/vo" ) +// SSO is the service responsible for all single-sign-on operations. +// It supports two authentication paths: +// +// 1. Legacy Microsoft Entra ID (MSAL) — activated when the stored +// SSOOption has a non-empty TenantID. +// 2. Generic OIDC — activated when the stored SSOOption has a non-empty +// IssuerURL and an empty TenantID. type SSO struct { Common OptionsService *Option UserService *User SessionService *Session + SSOStateRepo *repository.SSOState MSALClient *confidential.Client + OIDCClient *sso.OIDCClient } +// MsGraphUserInfo is the subset of Microsoft Graph /me fields we care about. type MsGraphUserInfo struct { - DisplayName string `json:"displayName"` // Full name - Email string `json:"mail"` // Primary email - UserPrincipalName string `json:"userPrincipalName"` // Often email or login - GivenName string `json:"givenName"` // First name - Surname string `json:"surname"` // Last name - ID string `json:"id"` // Unique Azure AD ID + DisplayName string `json:"displayName"` + Email string `json:"mail"` + UserPrincipalName string `json:"userPrincipalName"` + GivenName string `json:"givenName"` + Surname string `json:"surname"` + ID string `json:"id"` } -// Get is the auth protected method for getting SSO details +// --- read helpers --- + +// Get is the auth-protected method for fetching SSO details. func (s *SSO) Get( ctx context.Context, session *model.Session, ) (*model.SSOOption, error) { ae := NewAuditEvent("SSO.Get", session) - // check permissions isAuthorized, err := IsAuthorized(session, data.PERMISSION_ALLOW_GLOBAL) if err != nil && !errors.Is(err, errs.ErrAuthorizationFailed) { s.LogAuthError(err) @@ -56,57 +68,8 @@ func (s *SSO) Get( return s.GetSSOOptionWithoutAuth(ctx) } -// Upsert upserts SSO config it also replaces the in memory SSO configuration -func (s *SSO) Upsert( - ctx context.Context, - session *model.Session, - ssoOpt *model.SSOOption, -) error { - ae := NewAuditEvent("SSO.Upsert", session) - // check permissions - isAuthorized, err := IsAuthorized(session, data.PERMISSION_ALLOW_GLOBAL) - if err != nil && !errors.Is(err, errs.ErrAuthorizationFailed) { - s.LogAuthError(err) - return errs.Wrap(err) - } - if !isAuthorized { - s.AuditLogNotAuthorized(ae) - return errs.ErrAuthorizationFailed - } - ssoOpt.Enabled = len(ssoOpt.ClientID.String()) > 0 && - len(ssoOpt.TenantID.String()) > 0 && - len(ssoOpt.ClientSecret.String()) > 0 - - // if the config is incomplete, we clear it - if !ssoOpt.Enabled { - ssoOpt.ClientID = *vo.NewEmptyOptionalString64() - ssoOpt.TenantID = *vo.NewEmptyOptionalString64() - ssoOpt.ClientSecret = *vo.NewEmptyOptionalString1024() - ssoOpt.RedirectURL = *vo.NewEmptyOptionalString1024() - } - opt, err := ssoOpt.ToOption() - if err != nil { - return errs.Wrap(err) - } - err = s.OptionsService.SetOptionByKey(ctx, session, opt) - if err != nil { - s.Logger.Errorw("failed to upsert sso option", "error", err) - return errs.Wrap(err) - } - s.AuditLogAuthorized(ae) - // replace the in memory msal client - if ssoOpt.Enabled { - s.MSALClient, err = sso.NewEntreIDClient(ssoOpt) - if err != nil { - return errs.Wrap(err) - } - } else { - s.MSALClient = nil - } - - return nil -} - +// GetSSOOptionWithoutAuth reads the SSOOption from the options store without +// requiring a session. Used on startup and in public login-flow handlers. func (s *SSO) GetSSOOptionWithoutAuth(ctx context.Context) (*model.SSOOption, error) { opt, err := s.OptionsService.GetOptionWithoutAuth(ctx, data.OptionKeyAdminSSOLogin) if err != nil { @@ -123,18 +86,125 @@ func (s *SSO) GetSSOOptionWithoutAuth(ctx context.Context) (*model.SSOOption, er return ssoOpt, nil } +// --- write helper --- + +// Upsert persists the SSO configuration and hot-swaps the in-memory clients. +func (s *SSO) Upsert( + ctx context.Context, + session *model.Session, + ssoOpt *model.SSOOption, +) error { + ae := NewAuditEvent("SSO.Upsert", session) + isAuthorized, err := IsAuthorized(session, data.PERMISSION_ALLOW_GLOBAL) + if err != nil && !errors.Is(err, errs.ErrAuthorizationFailed) { + s.LogAuthError(err) + return errs.Wrap(err) + } + if !isAuthorized { + s.AuditLogNotAuthorized(ae) + return errs.ErrAuthorizationFailed + } + + // determine whether the supplied config is complete enough to enable SSO + hasLegacyFields := ssoOpt.ClientID.String() != "" && + ssoOpt.TenantID.String() != "" && + ssoOpt.ClientSecret.String() != "" + hasOIDCFields := ssoOpt.ClientID.String() != "" && + ssoOpt.IssuerURL.String() != "" && + ssoOpt.ClientSecret.String() != "" && + ssoOpt.RedirectURL.String() != "" + + ssoOpt.Enabled = hasLegacyFields || hasOIDCFields + + // clear everything when the config is incomplete + if !ssoOpt.Enabled { + ssoOpt.ClientID = *vo.NewEmptyOptionalString64() + ssoOpt.TenantID = *vo.NewEmptyOptionalString64() + ssoOpt.ClientSecret = *vo.NewEmptyOptionalString1024() + ssoOpt.RedirectURL = *vo.NewEmptyOptionalString1024() + ssoOpt.IssuerURL = *vo.NewEmptyOptionalString1024() + ssoOpt.RequiredRoleClaim = *vo.NewEmptyOptionalString255() + ssoOpt.RequiredRoleValue = *vo.NewEmptyOptionalString255() + ssoOpt.ACRValues = *vo.NewEmptyOptionalString255() + ssoOpt.SSOOnly = false + } + + opt, err := ssoOpt.ToOption() + if err != nil { + return errs.Wrap(err) + } + if err = s.OptionsService.SetOptionByKey(ctx, session, opt); err != nil { + s.Logger.Errorw("failed to upsert sso option", "error", err) + return errs.Wrap(err) + } + s.AuditLogAuthorized(ae) + + // hot-swap in-memory clients + s.MSALClient = nil + s.OIDCClient = nil + if ssoOpt.Enabled { + if ssoOpt.IsLegacyEntraID() { + s.MSALClient, err = sso.NewEntreIDClient(ssoOpt) + if err != nil { + return errs.Wrap(err) + } + } else if ssoOpt.IsOIDC() { + s.OIDCClient, err = sso.NewOIDCClient( + ctx, + ssoOpt.IssuerURL.String(), + ssoOpt.ClientID.String(), + ssoOpt.ClientSecret.String(), + ssoOpt.RedirectURL.String(), + []string{"openid", "profile", "email"}, + ) + if err != nil { + return errs.Wrap(err) + } + } + } + return nil +} + +// --- status helpers --- + +// IsEnabled returns true when either the legacy MSAL client or the generic +// OIDC client is initialised. +func (s *SSO) IsEnabled() bool { + return s.MSALClient != nil || s.OIDCClient != nil +} + +// IsLegacyEnabled returns true when only the MSAL client is active. +func (s *SSO) IsLegacyEnabled() bool { + return s.MSALClient != nil +} + +// IsOIDCEnabled returns true when the generic OIDC client is active. +func (s *SSO) IsOIDCEnabled() bool { + return s.OIDCClient != nil +} + +// IsSSOOnly reports whether local-password login has been disabled. +func (s *SSO) IsSSOOnly(ctx context.Context) (bool, error) { + opt, err := s.GetSSOOptionWithoutAuth(ctx) + if err != nil { + return false, err + } + return opt.SSOOnly && opt.Enabled, nil +} + +// --- legacy Entra ID --- + +// EntreIDLogin returns the Microsoft authorization URL for the legacy MSAL +// flow. Returns errs.ErrSSODisabled when the MSAL client is not configured. func (s *SSO) EntreIDLogin(ctx context.Context) (string, error) { - // check if sso is enabled ssoOpt, err := s.GetSSOOptionWithoutAuth(ctx) if err != nil { return "", err } if !ssoOpt.Enabled { - s.Logger.Debugf("SSO login URL visited but it is disabed") + s.Logger.Debugf("SSO login URL visited but it is disabled") return "", errs.Wrap(errs.ErrSSODisabled) } - // the MSALCLient is set on application start up - // and when a upsert is done, replacing the old client with new details if s.MSALClient == nil { return "", errs.Wrap(errors.New("no MSAL client")) } @@ -150,7 +220,8 @@ func (s *SSO) EntreIDLogin(ctx context.Context) (string, error) { return authURL, nil } -// EntreIDCallBack checks if the callback is OK then requests user details from the graph API +// HandlEntraIDCallback completes the legacy Entra ID OAuth2 code exchange, +// fetches the user from MS Graph and creates (or reuses) a local account. func (s *SSO) HandlEntraIDCallback( g *gin.Context, code string, @@ -179,24 +250,20 @@ func (s *SSO) HandlEntraIDCallback( s.Logger.Debugw("failed to get /me graph info", "error", err) return nil, err } - // validate required fields if userInfo.Email == "" && userInfo.UserPrincipalName == "" { err := errors.New("no email provided from SSO") s.Logger.Debugw("no email or userPrincipalName from SSO", "error", err) return nil, errs.Wrap(err) } - // determine email (prefer mail over UPN) email := userInfo.Email if email == "" { email = userInfo.UserPrincipalName } - // determine name name := userInfo.DisplayName if name == "" { name = strings.TrimSpace(fmt.Sprintf("%s %s", userInfo.GivenName, userInfo.Surname)) } if name == "" { - // Fallback to email prefix if no name available name = strings.Split(email, "@")[0] } userID, err := s.UserService.CreateFromSSO(g, name, email, userInfo.ID) @@ -206,7 +273,6 @@ func (s *SSO) HandlEntraIDCallback( if userID == nil { return nil, errs.Wrap(errors.New("user ID is unexpectedly nil")) } - // get the user and create a session user, err := s.UserService.GetByIDWithoutAuth(g, userID) if err != nil { s.Logger.Debugf("failed to get SSO user", "error", err) @@ -221,14 +287,11 @@ func (s *SSO) HandlEntraIDCallback( } func (s *SSO) getMsGraphMe(ctx context.Context, accessToken string) (*MsGraphUserInfo, error) { - client := &http.Client{ - Timeout: 10 * time.Second, - } - req, err := http.NewRequestWithContext(ctx, "GET", "https://graph.microsoft.com/v1.0/me", nil) + client := &http.Client{Timeout: 10 * time.Second} + req, err := http.NewRequestWithContext(ctx, http.MethodGet, "https://graph.microsoft.com/v1.0/me", nil) if err != nil { return nil, errs.Wrap(err) } - req.Header.Add("Authorization", fmt.Sprintf("Bearer %s", accessToken)) resp, err := client.Do(req) @@ -240,25 +303,194 @@ func (s *SSO) getMsGraphMe(ctx context.Context, accessToken string) (*MsGraphUse if resp.StatusCode != http.StatusOK { return nil, errs.Wrap(fmt.Errorf("graph API returned status %d", resp.StatusCode)) } - - // Read and log raw response body, err := io.ReadAll(resp.Body) if err != nil { return nil, errs.Wrap(err) } - s.Logger.Debugw("Raw Microsoft Graph response", "body", string(body)) + s.Logger.Debugw("raw Microsoft Graph response", "body", string(body)) var userInfo MsGraphUserInfo if err := json.Unmarshal(body, &userInfo); err != nil { return nil, errs.Wrap(err) } - - s.Logger.Debugw("Parsed user info", + s.Logger.Debugw("parsed user info", "id", userInfo.ID, "email", userInfo.Email, "displayName", userInfo.DisplayName, "userPrincipalName", userInfo.UserPrincipalName, ) - return &userInfo, nil } + +// --- generic OIDC --- + +// OIDCLoginURL generates the authorization URL for the generic OIDC flow. +// It creates a PKCE pair, a state token and a nonce, persists them in +// sso_states and returns the authorization URL. +func (s *SSO) OIDCLoginURL(ctx context.Context) (string, error) { + ssoOpt, err := s.GetSSOOptionWithoutAuth(ctx) + if err != nil { + return "", err + } + if !ssoOpt.Enabled || !ssoOpt.IsOIDC() { + return "", errs.Wrap(errs.ErrSSODisabled) + } + if s.OIDCClient == nil { + return "", errs.Wrap(errors.New("OIDC client not initialised")) + } + + // generate PKCE pair + codeVerifier, codeChallenge, err := sso.GeneratePKCEPair() + if err != nil { + return "", errs.Wrap(err) + } + + // generate CSRF state token and nonce + state, err := sso.GenerateStateToken() + if err != nil { + return "", errs.Wrap(err) + } + nonce, err := sso.GenerateStateToken() + if err != nil { + return "", errs.Wrap(err) + } + + // persist state so the callback can verify and retrieve the verifier + expiry := time.Now().Add(sso.SSOStateExpiry) + if _, err = s.SSOStateRepo.Insert(ctx, state, codeVerifier, nonce, &expiry); err != nil { + return "", errs.Wrap(err) + } + + authURL := s.OIDCClient.AuthCodeURL(state, nonce, codeChallenge, ssoOpt.ACRValues.String()) + return authURL, nil +} + +// HandleOIDCCallback completes the generic OIDC authorisation code flow with +// PKCE, enforces role and ACR requirements, then creates or reuses a local +// user account and returns a new session. +func (s *SSO) HandleOIDCCallback( + ctx context.Context, + code string, + stateToken string, + clientIP string, +) (*model.Session, error) { + ssoOpt, err := s.GetSSOOptionWithoutAuth(ctx) + if err != nil { + return nil, err + } + if !ssoOpt.Enabled || !ssoOpt.IsOIDC() { + return nil, errs.Wrap(errs.ErrSSODisabled) + } + if s.OIDCClient == nil { + return nil, errs.Wrap(errors.New("OIDC client not initialised")) + } + + // look up and validate the state record + stateRecord, err := s.SSOStateRepo.GetByStateToken(ctx, stateToken) + if err != nil { + s.Logger.Warnw("oidc callback: state token not found or expired", "error", err) + return nil, errs.Wrap(errors.New("invalid or expired state token")) + } + + // mark it consumed immediately to prevent replay + if err = s.SSOStateRepo.MarkAsUsed(ctx, stateRecord.ID); err != nil { + return nil, errs.Wrap(err) + } + + // exchange code → tokens (includes id_token signature + nonce verification) + result, err := s.OIDCClient.ExchangeCode(ctx, code, stateRecord.CodeVerifier, stateRecord.Nonce) + if err != nil { + s.Logger.Warnw("oidc token exchange failed", "error", err) + return nil, errs.Wrap(err) + } + + // --- ACR verification --- + if ssoOpt.HasACR() { + rawClaims := result.RawClaims + acrClaim, _ := rawClaims["acr"].(string) + requiredACR := strings.TrimSpace(ssoOpt.ACRValues.String()) + // the ACR values field may contain multiple space-separated values; + // we require that the returned acr matches at least one of them + matched := false + for _, v := range strings.Fields(requiredACR) { + if acrClaim == v { + matched = true + break + } + } + if !matched { + s.Logger.Warnw("oidc acr mismatch", + "required", requiredACR, + "got", acrClaim, + ) + return nil, errs.Wrap(errors.New("ACR requirement not satisfied")) + } + } + + // --- role gating --- + if ssoOpt.HasRoleGating() { + if !sso.CheckRoleClaim( + result.RawClaims, + ssoOpt.RequiredRoleClaim.String(), + ssoOpt.RequiredRoleValue.String(), + ) { + s.Logger.Warnw("oidc login denied: required role not present", + "claim", ssoOpt.RequiredRoleClaim.String(), + "required", ssoOpt.RequiredRoleValue.String(), + ) + return nil, errs.Wrap(errs.ErrAuthorizationFailed) + } + } + + // --- fetch user identity --- + sub, email, name, err := s.OIDCClient.UserInfoEmail(ctx, result.AccessToken) + if err != nil { + // fall back to id_token claims on userinfo failure + s.Logger.Warnw("oidc userinfo failed, falling back to id_token claims", "error", err) + sub = result.IDToken.Subject + if v, ok := result.RawClaims["email"].(string); ok { + email = v + } + if v, ok := result.RawClaims["name"].(string); ok { + name = v + } + } + + if email == "" { + // last resort: use the subject as a pseudo-email + s.Logger.Warnw("oidc provider did not return an email address, using sub", "sub", sub) + email = sub + } + if name == "" { + name = strings.Split(email, "@")[0] + } + + userID, err := s.UserService.CreateFromSSO(ctx, name, email, sub) + if err != nil { + return nil, errs.Wrap(err) + } + if userID == nil { + return nil, errs.Wrap(errors.New("user ID is unexpectedly nil after CreateFromSSO")) + } + + user, err := s.UserService.GetByIDWithoutAuth(ctx, userID) + if err != nil { + s.Logger.Errorw("failed to get OIDC user after create", "error", err) + return nil, errs.Wrap(err) + } + + session, err := s.SessionService.Create(ctx, user, clientIP) + if err != nil { + s.Logger.Errorw("failed to create session from OIDC login", "error", err) + return nil, errs.Wrap(err) + } + return session, nil +} + +// CleanupExpiredSSOStates removes expired PKCE/state records from the store. +// It is safe to call at any time; errors are logged but not propagated. +func (s *SSO) CleanupExpiredSSOStates(ctx context.Context) { + if err := s.SSOStateRepo.DeleteExpired(ctx); err != nil { + s.Logger.Warnw("failed to clean up expired sso states", "error", err) + } +} diff --git a/backend/service/user.go b/backend/service/user.go index 11e9d0b..b41aab4 100644 --- a/backend/service/user.go +++ b/backend/service/user.go @@ -39,6 +39,8 @@ type User struct { CompanyRepository *repository.Company PasswordVerifier *password.Argon2Verifier PasswordHasher *password.Argon2Hasher + // OptionRepository is used to check the SSO-only policy on login. + OptionRepository *repository.Option } // Create creates a new user @@ -835,6 +837,19 @@ func (u *User) AuthenticateUsernameWithPassword( ae := NewAuditEvent("User.AuthenticateUsernameWithPassword", nil) ae.IP = ip ae.Details["username"] = username + + // enforce SSO-only policy: reject local logins when the feature is active + if u.OptionRepository != nil { + opt, optErr := u.OptionRepository.GetByKey(ctx, data.OptionKeyAdminSSOLogin) + if optErr == nil && opt != nil { + ssoOpt, parseErr := model.NewSSOOptionFromJSON([]byte(opt.Value.String())) + if parseErr == nil && ssoOpt.Enabled && ssoOpt.SSOOnly { + u.Logger.Debugw("local login rejected: sso-only mode is active", "username", username) + return nil, errs.ErrAuthenticationFailed + } + } + } + // check the entities are valid before doing anything usernameEntity, err := vo.NewUsername(username) if err != nil { diff --git a/backend/sso/oidc.go b/backend/sso/oidc.go new file mode 100644 index 0000000..e9c6fe0 --- /dev/null +++ b/backend/sso/oidc.go @@ -0,0 +1,293 @@ +package sso + +import ( + "context" + "crypto/sha256" + "encoding/base64" + "encoding/json" + "fmt" + "strings" + "time" + + gooidc "github.com/coreos/go-oidc/v3/oidc" + "golang.org/x/oauth2" +) + +// OIDCClient wraps the go-oidc provider and the oauth2 config so that +// the service layer has a single, stable type to work with. +type OIDCClient struct { + provider *gooidc.Provider + oauth2 oauth2.Config + verifier *gooidc.IDTokenVerifier +} + +// NewOIDCClient discovers the provider metadata from issuerURL and constructs +// an OIDCClient ready for use. The discovery document is fetched during +// construction so any network or configuration error is surfaced early. +func NewOIDCClient( + ctx context.Context, + issuerURL string, + clientID string, + clientSecret string, + redirectURL string, + scopes []string, +) (*OIDCClient, error) { + if issuerURL == "" { + return nil, fmt.Errorf("issuerURL must not be empty") + } + if clientID == "" { + return nil, fmt.Errorf("clientID must not be empty") + } + if redirectURL == "" { + return nil, fmt.Errorf("redirectURL must not be empty") + } + + provider, err := gooidc.NewProvider(ctx, issuerURL) + if err != nil { + return nil, fmt.Errorf("oidc provider discovery failed for %q: %w", issuerURL, err) + } + + // always include openid; callers may add profile and email + merged := mergeScopes([]string{gooidc.ScopeOpenID}, scopes) + + cfg := oauth2.Config{ + ClientID: clientID, + ClientSecret: clientSecret, + RedirectURL: redirectURL, + Endpoint: provider.Endpoint(), + Scopes: merged, + } + + verifier := provider.Verifier(&gooidc.Config{ + ClientID: clientID, + }) + + return &OIDCClient{ + provider: provider, + oauth2: cfg, + verifier: verifier, + }, nil +} + +// AuthCodeURL returns the authorization endpoint URL with the supplied state, +// nonce, PKCE challenge and optional ACR values. +func (c *OIDCClient) AuthCodeURL( + state string, + nonce string, + codeChallenge string, + acrValues string, +) string { + opts := []oauth2.AuthCodeOption{ + gooidc.Nonce(nonce), + oauth2.SetAuthURLParam("code_challenge", codeChallenge), + oauth2.SetAuthURLParam("code_challenge_method", "S256"), + } + if acrValues != "" { + opts = append(opts, oauth2.SetAuthURLParam("acr_values", acrValues)) + } + return c.oauth2.AuthCodeURL(state, opts...) +} + +// OIDCTokenResult is returned from ExchangeCode. +type OIDCTokenResult struct { + // AccessToken is the raw access token from the provider. + AccessToken string + // IDToken is the verified, parsed id_token. + IDToken *gooidc.IDToken + // RawClaims are all claims extracted from the id_token. + RawClaims map[string]any +} + +// ExchangeCode exchanges the authorisation code for tokens, verifies the +// id_token signature and nonce, and returns the result. +func (c *OIDCClient) ExchangeCode( + ctx context.Context, + code string, + codeVerifier string, + expectedNonce string, +) (*OIDCTokenResult, error) { + token, err := c.oauth2.Exchange( + ctx, + code, + oauth2.SetAuthURLParam("code_verifier", codeVerifier), + ) + if err != nil { + return nil, fmt.Errorf("token exchange failed: %w", err) + } + + rawIDToken, ok := token.Extra("id_token").(string) + if !ok { + return nil, fmt.Errorf("provider did not return an id_token") + } + + idToken, err := c.verifier.Verify(ctx, rawIDToken) + if err != nil { + return nil, fmt.Errorf("id_token verification failed: %w", err) + } + + if idToken.Nonce != expectedNonce { + return nil, fmt.Errorf("id_token nonce mismatch: got %q, want %q", idToken.Nonce, expectedNonce) + } + + var claims map[string]any + if err := idToken.Claims(&claims); err != nil { + return nil, fmt.Errorf("failed to extract id_token claims: %w", err) + } + + return &OIDCTokenResult{ + AccessToken: token.AccessToken, + IDToken: idToken, + RawClaims: claims, + }, nil +} + +// UserInfoEmail fetches the userinfo endpoint and returns the sub, email and +// name claims as plain strings. The access token from ExchangeCode is used. +func (c *OIDCClient) UserInfoEmail( + ctx context.Context, + accessToken string, +) (sub, email, name string, err error) { + src := oauth2.StaticTokenSource(&oauth2.Token{AccessToken: accessToken}) + info, infoErr := c.provider.UserInfo(ctx, src) + if infoErr != nil { + return "", "", "", fmt.Errorf("userinfo request failed: %w", infoErr) + } + + sub = info.Subject + + var extra struct { + Email string `json:"email"` + Name string `json:"name"` + } + if claimErr := info.Claims(&extra); claimErr != nil { + // non-fatal: we still have the subject + _ = claimErr + } + email = extra.Email + name = extra.Name + return sub, email, name, nil +} + +// CheckRoleClaim inspects rawClaims for the claim identified by claimPath and +// checks whether requiredValue is present. +// +// claimPath supports a single dot-separated path into nested objects, e.g. +// "realm_access.roles" will look up claims["realm_access"]["roles"]. The +// leaf value may be a string, a []interface{} (JSON array of strings), or a +// JSON-encoded array embedded inside another claim value. +// +// Returns true when the required value is found, false otherwise. +func CheckRoleClaim( + rawClaims map[string]any, + claimPath string, + requiredValue string, +) bool { + if claimPath == "" || requiredValue == "" { + return true // gating disabled + } + + parts := strings.SplitN(claimPath, ".", 2) + top, ok := rawClaims[parts[0]] + if !ok { + return false + } + + // descend one level for dotted paths, e.g. "realm_access.roles" + var leaf any = top + if len(parts) == 2 { + nested, isMap := top.(map[string]any) + if !isMap { + return false + } + leaf, ok = nested[parts[1]] + if !ok { + return false + } + } + + return containsValue(leaf, requiredValue) +} + +// containsValue returns true when v equals requiredValue or v is a slice +// that contains requiredValue. +func containsValue(v any, requiredValue string) bool { + switch typed := v.(type) { + case string: + return typed == requiredValue + case []any: + for _, item := range typed { + if s, ok := item.(string); ok && s == requiredValue { + return true + } + } + case []string: + for _, s := range typed { + if s == requiredValue { + return true + } + } + default: + // attempt JSON-encoded array stored as a plain string + if raw, ok := v.(string); ok { + var arr []string + if err := json.Unmarshal([]byte(raw), &arr); err == nil { + for _, s := range arr { + if s == requiredValue { + return true + } + } + } + } + } + return false +} + +// GeneratePKCEPair generates a cryptographically random code_verifier and +// derives the S256 code_challenge from it. +// +// The verifier is a 64-byte URL-safe base64-encoded random value (no padding). +// The challenge is the base64url encoding (no padding) of the SHA-256 digest +// of the verifier as required by RFC 7636 §4.2. +func GeneratePKCEPair() (verifier string, challenge string, err error) { + b, err := generateRandomBytes(64) + if err != nil { + return "", "", fmt.Errorf("failed to generate PKCE verifier bytes: %w", err) + } + verifier = base64.RawURLEncoding.EncodeToString(b) + sum := sha256.Sum256([]byte(verifier)) + challenge = base64.RawURLEncoding.EncodeToString(sum[:]) + return verifier, challenge, nil +} + +// GenerateStateToken generates a cryptographically random URL-safe state token +// suitable for use as the OAuth2 'state' and OIDC 'nonce' parameters. +func GenerateStateToken() (string, error) { + b, err := generateRandomBytes(32) + if err != nil { + return "", fmt.Errorf("failed to generate state token: %w", err) + } + return base64.RawURLEncoding.EncodeToString(b), nil +} + +// SSOStateExpiry is how long an SSO state token remains valid. +const SSOStateExpiry = 10 * time.Minute + +// mergeScopes returns a deduplicated slice with base prepended then extras +// appended, preserving order. +func mergeScopes(base, extra []string) []string { + seen := make(map[string]struct{}, len(base)+len(extra)) + out := make([]string, 0, len(base)+len(extra)) + for _, s := range base { + if _, ok := seen[s]; !ok { + seen[s] = struct{}{} + out = append(out, s) + } + } + for _, s := range extra { + if _, ok := seen[s]; !ok { + seen[s] = struct{}{} + out = append(out, s) + } + } + return out +} diff --git a/backend/sso/random.go b/backend/sso/random.go new file mode 100644 index 0000000..f09000d --- /dev/null +++ b/backend/sso/random.go @@ -0,0 +1,15 @@ +package sso + +import ( + "crypto/rand" + "fmt" +) + +// generateRandomBytes generates n cryptographically random bytes. +func generateRandomBytes(n int) ([]byte, error) { + b := make([]byte, n) + if _, err := rand.Read(b); err != nil { + return nil, fmt.Errorf("failed to read random bytes: %w", err) + } + return b, nil +} diff --git a/backend/vendor/modules.txt b/backend/vendor/modules.txt index a03a15a..33876e8 100644 --- a/backend/vendor/modules.txt +++ b/backend/vendor/modules.txt @@ -122,6 +122,9 @@ github.com/cloudwego/base64x ## explicit; go 1.16 github.com/cloudwego/iasm/expr github.com/cloudwego/iasm/x86_64 +# github.com/coreos/go-oidc/v3 v3.17.0 +## explicit; go 1.24.0 +github.com/coreos/go-oidc/v3/oidc # github.com/davecgh/go-spew v1.1.1 ## explicit github.com/davecgh/go-spew/spew @@ -235,6 +238,11 @@ github.com/gin-gonic/gin/render # github.com/go-errors/errors v1.5.1 ## explicit; go 1.14 github.com/go-errors/errors +# github.com/go-jose/go-jose/v4 v4.1.3 +## explicit; go 1.24.0 +github.com/go-jose/go-jose/v4 +github.com/go-jose/go-jose/v4/cipher +github.com/go-jose/go-jose/v4/json # github.com/go-playground/locales v0.14.1 ## explicit; go 1.17 github.com/go-playground/locales @@ -529,6 +537,10 @@ golang.org/x/net/ipv4 golang.org/x/net/ipv6 golang.org/x/net/proxy golang.org/x/net/publicsuffix +# golang.org/x/oauth2 v0.28.0 +## explicit; go 1.23.0 +golang.org/x/oauth2 +golang.org/x/oauth2/internal # golang.org/x/sync v0.18.0 ## explicit; go 1.24.0 golang.org/x/sync/errgroup