Add OIDC provider support.

Add exclusive SSO login support.

Signed-off-by: Ronni Skansing <rskansing@gmail.com>
This commit is contained in:
Ronni Skansing
2026-06-14 20:53:16 +02:00
parent 0504602ff5
commit df555820f9
24 changed files with 887 additions and 138 deletions
+10 -4
View File
@@ -54,10 +54,14 @@ const (
ROUTE_V1_USER_SESSIONS_INVALIDATE = "/api/v1/user/sessions/invalidate"
ROUTE_V1_USER_API = "/api/v1/user/api"
// sso
ROUTE_V1_SSO_ENTRA_ID = "/api/v1/sso/entra-id"
ROUTE_V1_SSO_ENTRA_ID_ENABLED = "/api/v1/sso/entra-id/enabled"
// the config upsert and the enabled status are provider neutral, they serve
// both Entra ID and generic OIDC
ROUTE_V1_SSO = "/api/v1/sso"
ROUTE_V1_SSO_ENABLED = "/api/v1/sso/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"
ROUTE_V1_SSO_OIDC_LOGIN = "/api/v1/sso/oidc/login"
ROUTE_V1_SSO_OIDC_CALLBACK = "/api/v1/sso/oidc/auth"
// 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"
@@ -338,10 +342,12 @@ func setupRoutes(
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).
POST(ROUTE_V1_SSO_ENTRA_ID, middleware.SessionHandler, controllers.SSO.Upsert).
GET(ROUTE_V1_SSO_ENABLED, controllers.SSO.IsEnabled).
POST(ROUTE_V1_SSO, 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).
GET(ROUTE_V1_SSO_OIDC_LOGIN, controllers.SSO.OIDCLogin).
GET(ROUTE_V1_SSO_OIDC_CALLBACK, controllers.SSO.OIDCCallback).
// 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).
+2
View File
@@ -124,6 +124,8 @@ func NewControllers(
user := &controller.User{
Common: common,
UserService: services.User,
SSOService: services.SSO,
Config: conf,
}
domain := &controller.Domain{
Common: common,
+24 -11
View File
@@ -78,19 +78,30 @@ type (
LogPath string
ErrLogPath string
IPSecurity IPSecurityConfig
RemoteBrowser RemoteBrowserServerConfig
IPSecurity IPSecurityConfig
RemoteBrowser RemoteBrowserServerConfig
Authentication AuthenticationConfig
}
// ConfigDTO config DTO
ConfigDTO struct {
ACME ACME `json:"acme"`
AdministrationServer AdministrationServer `json:"administration"`
PhishingServer PhishingServer `json:"phishing"`
Database Database `json:"database"`
Log Log `json:"log"`
IPSecurity IPSecurityConfig `json:"ip_security"`
RemoteBrowser RemoteBrowserServerConfig `json:"remote_browser"`
ACME ACME `json:"acme"`
AdministrationServer AdministrationServer `json:"administration"`
PhishingServer PhishingServer `json:"phishing"`
Database Database `json:"database"`
Log Log `json:"log"`
IPSecurity IPSecurityConfig `json:"ip_security"`
RemoteBrowser RemoteBrowserServerConfig `json:"remote_browser"`
Authentication AuthenticationConfig `json:"authentication"`
}
// AuthenticationConfig holds server level authentication settings.
AuthenticationConfig struct {
// LocalLoginBreakglass keeps username and password login available even
// when exclusive SSO is enabled. Set at the server level only as a
// recovery path if the identity provider is misconfigured and locks
// everyone out. Defaults to false.
LocalLoginBreakglass bool `json:"local_login_breakglass"`
}
Log struct {
@@ -495,6 +506,7 @@ func FromDTO(dto *ConfigDTO) (*Config, error) {
return nil, err
}
cfg.RemoteBrowser = dto.RemoteBrowser
cfg.Authentication = dto.Authentication
return cfg, nil
}
@@ -523,8 +535,9 @@ func (c *Config) ToDTO() *ConfigDTO {
Path: c.LogPath,
ErrorPath: c.ErrLogPath,
},
IPSecurity: c.IPSecurity,
RemoteBrowser: c.RemoteBrowser,
IPSecurity: c.IPSecurity,
RemoteBrowser: c.RemoteBrowser,
Authentication: c.Authentication,
}
}
+127 -5
View File
@@ -13,9 +13,14 @@ import (
"github.com/phishingclub/phishingclub/errs"
"github.com/phishingclub/phishingclub/model"
"github.com/phishingclub/phishingclub/service"
"github.com/phishingclub/phishingclub/sso"
)
const ssoStateCookieKey = "sso_state"
const (
ssoStateCookieKey = "sso_state"
ssoNonceCookieKey = "sso_nonce"
ssoVerifierCookieKey = "sso_pkce_verifier"
)
// SSO the single sign on controller
type SSO struct {
@@ -48,12 +53,129 @@ func (s *SSO) Upsert(g *gin.Context) {
}
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)
status, err := s.SSO.LoginStatus(g.Request.Context())
if err != nil {
// fail closed for the login page, report SSO as unavailable
s.Response.OK(g, &service.SSOLoginStatus{})
return
}
s.Response.OK(g, true)
s.Response.OK(g, status)
}
// setSSOCookie sets a short lived, http only, secure cookie used to carry the
// SSO state, nonce and PKCE verifier across the redirect to the provider.
func (s *SSO) setSSOCookie(g *gin.Context, name string, value string) {
http.SetCookie(g.Writer, &http.Cookie{
Name: name,
Value: value,
Path: "/",
MaxAge: int(5 * time.Minute / time.Second),
HttpOnly: true,
Secure: true,
SameSite: http.SameSiteLaxMode,
})
}
// clearSSOCookie removes a cookie previously set by setSSOCookie.
func (s *SSO) clearSSOCookie(g *gin.Context, name string) {
http.SetCookie(g.Writer, &http.Cookie{
Name: name,
Value: "",
Path: "/",
MaxAge: -1,
HttpOnly: true,
Secure: true,
SameSite: http.SameSiteLaxMode,
})
}
// ssoErrorReason maps a callback error to a stable reason code shown on the
// login page. Unknown errors map to a generic code so internal details are not
// leaked to the unauthenticated login page.
func ssoErrorReason(err error) string {
switch {
case errors.Is(err, errs.ErrSSOUserNotProvisioned):
return "not_provisioned"
case errors.Is(err, errs.ErrSSOEmailNotVerified):
return "email_not_verified"
case errors.Is(err, errs.ErrSSONoEmail):
return "no_email"
case errors.Is(err, errs.ErrSSOMFARequired):
return "mfa_required"
default:
return "generic"
}
}
// randomHex returns n random bytes hex encoded.
func randomHex(n int) (string, error) {
b := make([]byte, n)
if _, err := rand.Read(b); err != nil {
return "", err
}
return hex.EncodeToString(b), nil
}
// OIDCLogin starts the generic OIDC authorization code flow.
func (s *SSO) OIDCLogin(g *gin.Context) {
state, err := randomHex(32)
if err != nil {
s.Response.ServerError(g)
return
}
nonce, err := randomHex(32)
if err != nil {
s.Response.ServerError(g)
return
}
verifier := sso.NewPKCEVerifier()
authURL, err := s.SSO.OIDCAuthCodeURL(g.Request.Context(), state, nonce, verifier)
if err != nil {
s.Response.BadRequest(g)
return
}
s.setSSOCookie(g, ssoStateCookieKey, state)
s.setSSOCookie(g, ssoNonceCookieKey, nonce)
s.setSSOCookie(g, ssoVerifierCookieKey, verifier)
g.Redirect(http.StatusTemporaryRedirect, authURL)
}
// OIDCCallback completes the generic OIDC authorization code flow.
func (s *SSO) OIDCCallback(g *gin.Context) {
stateCookie, errState := g.Request.Cookie(ssoStateCookieKey)
nonceCookie, errNonce := g.Request.Cookie(ssoNonceCookieKey)
verifierCookie, errVerifier := g.Request.Cookie(ssoVerifierCookieKey)
// always clear the temporary cookies
s.clearSSOCookie(g, ssoStateCookieKey)
s.clearSSOCookie(g, ssoNonceCookieKey)
s.clearSSOCookie(g, ssoVerifierCookieKey)
stateParam := g.Query("state")
if errState != nil || errNonce != nil || errVerifier != nil ||
stateCookie.Value == "" || stateParam == "" ||
subtle.ConstantTimeCompare([]byte(stateCookie.Value), []byte(stateParam)) != 1 {
g.Redirect(http.StatusTemporaryRedirect, "/login?ssoAuthError=1")
return
}
code := g.Query("code")
session, err := s.SSO.HandleOIDCCallback(g, code, nonceCookie.Value, verifierCookie.Value)
if err != nil {
g.Redirect(http.StatusTemporaryRedirect, "/login?ssoAuthError=1&reason="+ssoErrorReason(err))
return
}
cookie := &http.Cookie{
Name: data.SessionCookieKey,
Value: session.ID.String(),
Path: "/",
SameSite: http.SameSiteStrictMode,
HttpOnly: true,
Secure: true,
Expires: *session.MaxAgeAt,
}
http.SetCookie(g.Writer, cookie)
g.Redirect(http.StatusTemporaryRedirect, "/dashboard")
}
func (s *SSO) EntreIDLogin(g *gin.Context) {
+16
View File
@@ -7,6 +7,7 @@ import (
"github.com/gin-gonic/gin"
"github.com/google/uuid"
"github.com/phishingclub/phishingclub/config"
"github.com/phishingclub/phishingclub/data"
"github.com/phishingclub/phishingclub/database"
"github.com/phishingclub/phishingclub/errs"
@@ -65,6 +66,8 @@ type UserLoginWithMFARecoveryCodeRequest struct {
type User struct {
Common
UserService *service.User
SSOService *service.SSO
Config *config.Config
}
// Create creates a new user
@@ -469,6 +472,19 @@ func (c *User) GetSessionsOnLoggedInUser(g *gin.Context) {
// Login logs in a user
func (c *User) Login(g *gin.Context) {
// when exclusive SSO is enabled local login is disabled, unless the server
// level break glass in config.json keeps it available for recovery
breakglass := c.Config.Authentication.LocalLoginBreakglass
blocked, err := c.SSOService.IsLocalLoginBlocked(g.Request.Context(), breakglass, g.ClientIP())
if err != nil {
c.Logger.Errorw("failed to check exclusive SSO login", "error", err)
c.Response.ServerError(g)
return
}
if blocked {
c.Response.Forbidden(g)
return
}
// parse req
var req UserLoginRequest
if ok := c.handleParseRequest(g, &req); !ok {
+9
View File
@@ -24,6 +24,15 @@ const (
OptionKeyAdminSSOLogin = "sso_login"
// SSOProviderEntra is the Microsoft Entra ID provider, also the default when
// the stored provider type is empty so existing configurations keep working
SSOProviderEntra = "entra"
// SSOProviderOIDC is a generic OpenID Connect provider such as Keycloak
SSOProviderOIDC = "oidc"
// SSODefaultScopes are the OIDC scopes requested when none are configured
SSODefaultScopes = "openid profile email"
OptionKeyProxyCookieName = "proxy_cookie_name"
// OptionKeyRemoteBrowserWSPath is the seeded random path segment used for the
+8
View File
@@ -56,6 +56,14 @@ var (
// ErrSSOUserNotProvisioned is returned when an SSO login matches no existing
// user, users are never auto provisioned and must be created by an admin first
ErrSSOUserNotProvisioned = goerrors.New("SSO user not provisioned")
// ErrSSOEmailNotVerified is returned when the provider has not verified the
// email claim, an unverified email is not trusted for account matching
ErrSSOEmailNotVerified = goerrors.New("SSO email not verified by provider")
// ErrSSONoEmail is returned when the provider returns no email claim
ErrSSONoEmail = goerrors.New("SSO provider returned no email")
// ErrSSOMFARequired is returned when the authentication context returned by
// the provider does not satisfy the required acr values
ErrSSOMFARequired = goerrors.New("SSO authentication context requirement not met")
)
// format messages
+3
View File
@@ -12,6 +12,7 @@ require (
github.com/charmbracelet/bubbles v0.20.0
github.com/charmbracelet/bubbletea v1.3.4
github.com/charmbracelet/lipgloss v1.1.0
github.com/coreos/go-oidc/v3 v3.11.0
github.com/dop251/goja v0.0.0-20260226184354-913bd86fb70c
github.com/enetx/surf v1.0.141
github.com/exaring/ja4plus v0.0.2
@@ -32,6 +33,7 @@ require (
golang.org/x/crypto v0.51.0
golang.org/x/mod v0.35.0
golang.org/x/net v0.55.0
golang.org/x/oauth2 v0.24.0
golang.org/x/sync v0.20.0
golang.org/x/time v0.14.0
gopkg.in/yaml.v3 v3.0.1
@@ -65,6 +67,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.0.2 // 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
+6
View File
@@ -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.11.0 h1:Ia3MxdwpSw702YW0xgfmP1GVCMA9aEFWu12XUZ3/OtI=
github.com/coreos/go-oidc/v3 v3.11.0/go.mod h1:gE3LgjOgFoHi9a4ce4/tJczr0Ai2/BoDhf0r5lltWI0=
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=
@@ -89,6 +91,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.0.2 h1:R3l3kkBds16bO7ZFAEEcofK0MkrAJt3jlJznWZG0nvk=
github.com/go-jose/go-jose/v4 v4.0.2/go.mod h1:WVf9LFMHh/QVrmqrOfqun0C45tMe3RoiKJMPvgWwLfY=
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=
@@ -290,6 +294,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.55.0 h1:bcvxaJn3e1U6InsFWt1JUq1aSjnRxLzT2rtD2KfkDF8=
golang.org/x/net v0.55.0/go.mod h1:L5U2KuzuOe1lY7Z+aWVIKK6qEeJXnXV9yzGA+WCHJww=
golang.org/x/oauth2 v0.24.0 h1:KTBBxWqUa0ykRPLtV69rRto9TLXcqYkeswu48x/gvNE=
golang.org/x/oauth2 v0.24.0/go.mod h1:XYTD2NtWslqkgxebSiOHnXEap4TF09sJSc7H1sXbhtI=
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.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
+16 -5
View File
@@ -242,12 +242,23 @@ func main() {
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
switch ssoOpt.Provider() {
case data.SSOProviderOIDC:
services.SSO.OIDCClient, err = sso.NewOIDCClient(context.Background(), ssoOpt)
if err != nil && !errors.Is(err, errs.ErrSSODisabled) {
// discovery can fail if the provider is unreachable at startup,
// log and continue so the rest of the server still comes up
logger.Errorw("failed to setup OIDC client", "error", err)
}
// the failure is recoverable, do not let it leak into later checks
err = nil
default:
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
}
}
}
middlewares := app.NewMiddlewares(
1,
+34 -1
View File
@@ -3,6 +3,7 @@ package model
import (
"encoding/json"
"fmt"
"strings"
"github.com/go-errors/errors"
@@ -13,23 +14,55 @@ import (
)
type SSOOption struct {
Enabled bool `json:"enabled"`
Enabled bool `json:"enabled"`
// ProviderType is "entra" or "oidc". An empty value is treated as "entra"
// so configurations stored before generic OIDC support keep working.
ProviderType string `json:"providerType"`
ClientID vo.OptionalString64 `json:"clientID"`
TenantID vo.OptionalString64 `json:"tenantID"`
ClientSecret vo.OptionalString1024 `json:"clientSecret"`
RedirectURL vo.OptionalString1024 `json:"redirectURL"`
// OIDC only fields
IssuerURL vo.OptionalString1024 `json:"issuerURL"`
Scopes vo.OptionalString1024 `json:"scopes"`
ACRValues vo.OptionalString1024 `json:"acrValues"`
// ExclusiveLogin disables username and password login when SSO is enabled.
// A server level break glass in config.json can still allow local login.
ExclusiveLogin bool `json:"exclusiveLogin"`
}
func NewSSOOptionDefault() *SSOOption {
return &SSOOption{
Enabled: false,
ProviderType: data.SSOProviderEntra,
ClientID: *vo.NewEmptyOptionalString64(),
TenantID: *vo.NewEmptyOptionalString64(),
ClientSecret: *vo.NewEmptyOptionalString1024(),
RedirectURL: *vo.NewEmptyOptionalString1024(),
IssuerURL: *vo.NewEmptyOptionalString1024(),
Scopes: *vo.NewEmptyOptionalString1024(),
ACRValues: *vo.NewEmptyOptionalString1024(),
}
}
// Provider returns the configured provider type, defaulting to Entra ID when
// the stored value is empty so older configurations keep working.
func (l *SSOOption) Provider() string {
if l.ProviderType == data.SSOProviderOIDC {
return data.SSOProviderOIDC
}
return data.SSOProviderEntra
}
// ScopesOrDefault returns the configured OIDC scopes or the default set.
func (l *SSOOption) ScopesOrDefault() string {
s := strings.TrimSpace(l.Scopes.String())
if s == "" {
return data.SSODefaultScopes
}
return s
}
func NewSSOOptionFromJSON(jsonData []byte) (*SSOOption, error) {
option := &SSOOption{}
err := json.Unmarshal(jsonData, option)
+3
View File
@@ -26,6 +26,9 @@ type User struct {
RoleID nullable.Nullable[uuid.UUID] `json:"roleID"`
Role *Role `json:"role"`
SSOID nullable.Nullable[string] `json:"ssoID"`
// HasPassword reports whether a password is set on the account, so the
// profile can offer a change or a set password flow. The hash is never output
HasPassword bool `json:"hasPassword"`
// apiKey is only get/set externally from this and never output except when created
}
+6 -4
View File
@@ -507,8 +507,10 @@ func (r *User) updateUsernameByID(
return nil
}
// UpdateUserToSSO removes the password hash and sets a sso id
func (r *User) UpdateUserToSSO(
// SetSSOID stores the SSO subject id on the user. The password hash is left
// intact so whether password login is allowed is governed by the SSO mode and
// not destroyed as a side effect of an SSO login.
func (r *User) SetSSOID(
ctx context.Context,
id *uuid.UUID,
ssoID string,
@@ -517,8 +519,7 @@ func (r *User) UpdateUserToSSO(
Table(database.USER_TABLE).
Where("id = ?", id.String()).
Updates(map[string]interface{}{
"password_hash": "",
"sso_id": ssoID,
"sso_id": ssoID,
})
if result.Error != nil {
@@ -758,5 +759,6 @@ func ToUser(row *database.User) (*model.User, error) {
CompanyID: companyID,
Company: company,
SSOID: ssoID,
HasPassword: len(row.PasswordHash) > 0,
}, nil
}
+15
View File
@@ -5565,6 +5565,9 @@ func (c *Campaign) SendCampaignReport(
if err != nil {
c.Logger.Errorw("failed to render report PDF", "error", err)
writeReportSendLog("failed", nil, fmt.Errorf("failed to render report PDF: %w", err))
if onDemand {
return errs.NewCustomError(fmt.Errorf("could not generate the report PDF: %w", err))
}
return errs.Wrap(err)
}
// load the smtp configuration to send through
@@ -5611,12 +5614,21 @@ func (c *Campaign) SendCampaignReport(
filename := fmt.Sprintf("report-%s.pdf", sanitizeReportFilename(campaignName))
m := mail.NewMsg(mail.WithNoDefaultUserAgent())
if err := m.EnvelopeFrom(senderEmail.String()); err != nil {
if onDemand {
return errs.NewCustomError(fmt.Errorf("the report sender email address is invalid: %w", err))
}
return errs.Wrap(err)
}
if err := m.From(senderEmail.String()); err != nil {
if onDemand {
return errs.NewCustomError(fmt.Errorf("the report sender email address is invalid: %w", err))
}
return errs.Wrap(err)
}
if err := m.To(toEmails...); err != nil {
if onDemand {
return errs.NewCustomError(fmt.Errorf("a report recipient email address is invalid: %w", err))
}
return errs.Wrap(err)
}
if headers := smtpConfig.Headers; headers != nil {
@@ -5649,6 +5661,9 @@ func (c *Campaign) SendCampaignReport(
if err := c.SMTPConfigService.SendMessages(ctx, smtpConfig, m); err != nil {
c.Logger.Errorw("failed to send report email", "error", err)
writeReportSendLog("failed", toEmails, err)
if onDemand {
return errs.NewCustomError(fmt.Errorf("could not send the report email, check the SMTP configuration: %w", err))
}
return errs.Wrap(err)
}
writeReportSendLog("sent", toEmails, nil)
+180 -12
View File
@@ -26,6 +26,7 @@ type SSO struct {
UserService *User
SessionService *Session
MSALClient *confidential.Client
OIDCClient *sso.OIDCClient
}
type MsGraphUserInfo struct {
@@ -73,9 +74,26 @@ func (s *SSO) Upsert(
s.AuditLogNotAuthorized(ae)
return errs.ErrAuthorizationFailed
}
ssoOpt.Enabled = len(ssoOpt.ClientID.String()) > 0 &&
len(ssoOpt.TenantID.String()) > 0 &&
len(ssoOpt.ClientSecret.String()) > 0
// the client secret is masked to blank when the config is read, so editing an
// enabled config submits a blank secret. Keep the stored secret when it is
// left blank and the provider is unchanged, so editing other fields does not
// wipe the secret
if ssoOpt.ClientSecret.String() == "" {
existing, err := s.GetSSOOptionWithoutAuth(ctx)
if err == nil && existing != nil && existing.Provider() == ssoOpt.Provider() {
ssoOpt.ClientSecret = existing.ClientSecret
}
}
switch ssoOpt.Provider() {
case data.SSOProviderOIDC:
// PKCE is always used, so a public client without a secret is valid
ssoOpt.Enabled = len(ssoOpt.IssuerURL.String()) > 0 &&
len(ssoOpt.ClientID.String()) > 0
default:
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 {
@@ -83,6 +101,41 @@ func (s *SSO) Upsert(
ssoOpt.TenantID = *vo.NewEmptyOptionalString64()
ssoOpt.ClientSecret = *vo.NewEmptyOptionalString1024()
ssoOpt.RedirectURL = *vo.NewEmptyOptionalString1024()
ssoOpt.IssuerURL = *vo.NewEmptyOptionalString1024()
ssoOpt.Scopes = *vo.NewEmptyOptionalString1024()
ssoOpt.ACRValues = *vo.NewEmptyOptionalString1024()
// never leave exclusive login on without a working SSO, it would lock
// everyone out of the local login as well
ssoOpt.ExclusiveLogin = false
}
// build the provider client before persisting so a misconfigured or
// unreachable provider is reported back to the admin as a readable error
// and a broken configuration is never saved as enabled
var (
oidcClient *sso.OIDCClient
msalClient *confidential.Client
)
if ssoOpt.Enabled {
switch ssoOpt.Provider() {
case data.SSOProviderOIDC:
oidcClient, err = sso.NewOIDCClient(ctx, ssoOpt)
if err != nil {
s.Logger.Debugw("failed to configure OIDC provider", "error", err)
return errs.NewCustomError(fmt.Errorf(
"could not reach the OpenID Connect provider, check the issuer URL is correct and reachable: %w",
err,
))
}
default:
msalClient, err = sso.NewEntreIDClient(ssoOpt)
if err != nil {
s.Logger.Debugw("failed to configure Entra ID provider", "error", err)
return errs.NewCustomError(fmt.Errorf(
"could not configure the Entra ID provider, check the tenant, client ID and secret: %w",
err,
))
}
}
}
opt, err := ssoOpt.ToOption()
if err != nil {
@@ -94,15 +147,9 @@ func (s *SSO) Upsert(
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
}
// swap the in memory clients only after the new config is persisted
s.MSALClient = msalClient
s.OIDCClient = oidcClient
return nil
}
@@ -262,3 +309,124 @@ func (s *SSO) getMsGraphMe(ctx context.Context, accessToken string) (*MsGraphUse
return &userInfo, nil
}
// SSOLoginStatus is returned to the login page so it can render the correct
// provider button and hide local login when exclusive SSO is on.
type SSOLoginStatus struct {
Enabled bool `json:"enabled"`
ProviderType string `json:"providerType"`
ExclusiveLogin bool `json:"exclusiveLogin"`
}
// LoginStatus reports whether SSO is active, which provider is configured and
// whether local login is exclusive.
func (s *SSO) LoginStatus(ctx context.Context) (*SSOLoginStatus, error) {
ssoOpt, err := s.GetSSOOptionWithoutAuth(ctx)
if err != nil {
return nil, errs.Wrap(err)
}
enabled := s.MSALClient != nil || s.OIDCClient != nil
return &SSOLoginStatus{
Enabled: enabled,
ProviderType: ssoOpt.Provider(),
ExclusiveLogin: enabled && ssoOpt.ExclusiveLogin,
}, nil
}
// IsExclusiveLoginEnabled reports whether local username and password login is
// disabled. It is only true when SSO is actually active in memory, so a broken
// or unconfigured SSO never locks out local login.
func (s *SSO) IsExclusiveLoginEnabled(ctx context.Context) (bool, error) {
ssoOpt, err := s.GetSSOOptionWithoutAuth(ctx)
if err != nil {
return false, errs.Wrap(err)
}
enabled := s.MSALClient != nil || s.OIDCClient != nil
return enabled && ssoOpt.ExclusiveLogin, nil
}
// IsLocalLoginBlocked reports whether a username and password login must be
// refused because exclusive SSO is enabled. The breakglass argument comes from
// the server config and keeps local login available for recovery. A blocked
// attempt is audited.
func (s *SSO) IsLocalLoginBlocked(ctx context.Context, breakglass bool, ip string) (bool, error) {
exclusive, err := s.IsExclusiveLoginEnabled(ctx)
if err != nil {
return false, errs.Wrap(err)
}
if !exclusive || breakglass {
return false, nil
}
ae := NewAuditEvent("User.LocalLoginDenied", nil)
ae.Details["ip"] = ip
s.AuditLogNotAuthorized(ae)
return true, nil
}
// OIDCAuthCodeURL builds the OIDC authorization request URL. The caller supplies
// the state, nonce and PKCE verifier so it can store them for the callback.
func (s *SSO) OIDCAuthCodeURL(
ctx context.Context,
state string,
nonce string,
verifier string,
) (string, error) {
ssoOpt, err := s.GetSSOOptionWithoutAuth(ctx)
if err != nil {
return "", err
}
if !ssoOpt.Enabled || ssoOpt.Provider() != data.SSOProviderOIDC {
return "", errs.Wrap(errs.ErrSSODisabled)
}
if s.OIDCClient == nil {
return "", errs.Wrap(errors.New("no OIDC client"))
}
return s.OIDCClient.AuthCodeURL(state, nonce, verifier), nil
}
// HandleOIDCCallback verifies the OIDC callback, resolves the pre provisioned
// user by their verified email and creates a session.
func (s *SSO) HandleOIDCCallback(
g *gin.Context,
code string,
nonce string,
verifier string,
) (*model.Session, error) {
ssoOpt, err := s.GetSSOOptionWithoutAuth(g)
if err != nil {
return nil, err
}
if !ssoOpt.Enabled || ssoOpt.Provider() != data.SSOProviderOIDC {
return nil, errs.Wrap(errs.ErrSSODisabled)
}
if s.OIDCClient == nil {
return nil, errors.New("no OIDC client in memory")
}
userInfo, err := s.OIDCClient.Exchange(g, code, nonce, verifier)
if err != nil {
s.Logger.Warnw("OIDC code exchange failed", "error", err)
return nil, err
}
name := userInfo.Name
if name == "" {
name = strings.Split(userInfo.Email, "@")[0]
}
userID, err := s.UserService.CreateFromSSO(g, name, userInfo.Email, userInfo.Subject)
if err != nil {
return nil, errs.Wrap(err)
}
if userID == nil {
return nil, errs.Wrap(errors.New("user ID is unexpectedly nil"))
}
user, err := s.UserService.GetByIDWithoutAuth(g, userID)
if err != nil {
s.Logger.Debugw("failed to get SSO user", "error", err)
return nil, errs.Wrap(err)
}
session, err := s.SessionService.Create(g, user, g.ClientIP())
if err != nil {
s.Logger.Debugw("failed to create session from SSO", "error", err)
return nil, errs.Wrap(err)
}
return session, nil
}
+6 -5
View File
@@ -165,11 +165,12 @@ func (u *User) CreateFromSSO(
return nil, errs.Wrap(errs.ErrSSOUserNotProvisioned)
}
uid := existingUser.ID.MustGet()
// make the account SSO only by removing the password hash and store the
// current provider subject id for reference, overwriting any previous value
// so a change of SSO method does not lock the account out
if err := u.UserRepository.UpdateUserToSSO(ctx, &uid, externalID); err != nil {
u.Logger.Errorw("failed to update user to SSO", "error", err)
// store the current provider subject id for reference, overwriting any
// previous value so a change of SSO method does not lock the account out.
// the password is left intact, whether password login is allowed is
// governed by the exclusive SSO mode, not by destroying the password here
if err := u.UserRepository.SetSSOID(ctx, &uid, externalID); err != nil {
u.Logger.Errorw("failed to set SSO id on user", "error", err)
return nil, errs.Wrap(err)
}
u.AuditLogAuthorized(ae)
+181
View File
@@ -0,0 +1,181 @@
package sso
import (
"context"
"fmt"
"strings"
"time"
"github.com/coreos/go-oidc/v3/oidc"
"github.com/go-errors/errors"
"golang.org/x/oauth2"
"github.com/phishingclub/phishingclub/errs"
"github.com/phishingclub/phishingclub/model"
)
// oidcNetworkTimeout bounds the outbound calls to the identity provider
// (discovery, token exchange and JWKS) so a slow or unreachable provider cannot
// hang a request goroutine.
const oidcNetworkTimeout = 15 * time.Second
// OIDCClient is a configured generic OpenID Connect relying party. It holds the
// discovered provider, the oauth2 config and the ID token verifier so logins and
// callbacks can be served without re running discovery each request.
type OIDCClient struct {
Provider *oidc.Provider
OAuth2 *oauth2.Config
Verifier *oidc.IDTokenVerifier
ACRValues string
}
// OIDCUserInfo holds the identity claims taken from a verified ID token.
type OIDCUserInfo struct {
Subject string
Email string
EmailVerified bool
Name string
}
// NewOIDCClient discovers the provider and builds the relying party from the SSO
// configuration. Discovery performs a network request to the issuer.
func NewOIDCClient(ctx context.Context, sso *model.SSOOption) (*OIDCClient, error) {
if !sso.Enabled {
return nil, errs.Wrap(errs.ErrSSODisabled)
}
issuer := strings.TrimSpace(sso.IssuerURL.String())
if issuer == "" {
return nil, errs.Wrap(errors.New("missing OIDC issuer URL"))
}
clientID := sso.ClientID.String()
ctx, cancel := context.WithTimeout(ctx, oidcNetworkTimeout)
defer cancel()
provider, err := oidc.NewProvider(ctx, issuer)
if err != nil {
return nil, errs.Wrap(errors.Errorf("failed to discover OIDC provider: %s", err))
}
oauth2Config := &oauth2.Config{
ClientID: clientID,
ClientSecret: sso.ClientSecret.String(),
Endpoint: provider.Endpoint(),
RedirectURL: sso.RedirectURL.String(),
Scopes: strings.Fields(sso.ScopesOrDefault()),
}
verifier := provider.Verifier(&oidc.Config{
ClientID: clientID,
})
return &OIDCClient{
Provider: provider,
OAuth2: oauth2Config,
Verifier: verifier,
ACRValues: strings.TrimSpace(sso.ACRValues.String()),
}, nil
}
// AuthCodeURL builds the authorization request URL. The nonce binds the ID token
// to this login, PKCE protects the code exchange and acr_values requests a given
// authentication context such as multi factor authentication.
func (c *OIDCClient) AuthCodeURL(state string, nonce string, verifier string) string {
opts := []oauth2.AuthCodeOption{
oidc.Nonce(nonce),
oauth2.S256ChallengeOption(verifier),
}
if c.ACRValues != "" {
opts = append(opts, oauth2.SetAuthURLParam("acr_values", c.ACRValues))
}
return c.OAuth2.AuthCodeURL(state, opts...)
}
// Exchange swaps the authorization code for tokens, verifies the ID token and
// returns the identity claims. It enforces the nonce, that the email is verified
// and, when configured, that the returned authentication context matches.
func (c *OIDCClient) Exchange(
ctx context.Context,
code string,
nonce string,
verifier string,
) (*OIDCUserInfo, error) {
ctx, cancel := context.WithTimeout(ctx, oidcNetworkTimeout)
defer cancel()
token, err := c.OAuth2.Exchange(ctx, code, oauth2.VerifierOption(verifier))
if err != nil {
return nil, errs.Wrap(err)
}
rawIDToken, ok := token.Extra("id_token").(string)
if !ok || rawIDToken == "" {
return nil, errs.Wrap(errors.New("no id_token in token response"))
}
idToken, err := c.Verifier.Verify(ctx, rawIDToken)
if err != nil {
return nil, errs.Wrap(err)
}
if idToken.Nonce != nonce {
return nil, errs.Wrap(errors.New("OIDC nonce mismatch"))
}
var claims struct {
Subject string `json:"sub"`
Email string `json:"email"`
EmailVerified flexBool `json:"email_verified"`
Name string `json:"name"`
ACR string `json:"acr"`
}
if err := idToken.Claims(&claims); err != nil {
return nil, errs.Wrap(err)
}
if claims.Email == "" {
return nil, errs.Wrap(errs.ErrSSONoEmail)
}
// a generic provider can hand out self set, unverified emails so the email
// is only trusted for account matching when the provider has verified it
if !bool(claims.EmailVerified) {
return nil, errs.Wrap(errs.ErrSSOEmailNotVerified)
}
if c.ACRValues != "" && !acrSatisfied(claims.ACR, c.ACRValues) {
return nil, errs.Wrap(fmt.Errorf(
"%w (provider returned acr %q, required one of %q)",
errs.ErrSSOMFARequired, claims.ACR, c.ACRValues,
))
}
return &OIDCUserInfo{
Subject: claims.Subject,
Email: claims.Email,
EmailVerified: bool(claims.EmailVerified),
Name: claims.Name,
}, nil
}
// NewPKCEVerifier returns a fresh high entropy PKCE code verifier (RFC 7636).
func NewPKCEVerifier() string {
return oauth2.GenerateVerifier()
}
// acrSatisfied reports whether the acr returned by the provider is one of the
// space separated values that were requested.
func acrSatisfied(returned string, requested string) bool {
returned = strings.TrimSpace(returned)
if returned == "" {
return false
}
for _, want := range strings.Fields(requested) {
if returned == want {
return true
}
}
return false
}
// flexBool accepts the email_verified claim as either a JSON boolean or a
// quoted string, since providers differ, and fails closed on anything else.
type flexBool bool
func (b *flexBool) UnmarshalJSON(data []byte) error {
switch strings.Trim(string(data), `"`) {
case "true":
*b = true
case "false", "", "null":
*b = false
default:
return fmt.Errorf("invalid boolean value for claim: %s", string(data))
}
return nil
}
+13
View File
@@ -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.11.0
## explicit; go 1.21
github.com/coreos/go-oidc/v3/oidc
# github.com/davecgh/go-spew v1.1.1
## explicit
github.com/davecgh/go-spew/spew
@@ -249,6 +252,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.0.2
## explicit; go 1.21
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
@@ -551,6 +559,7 @@ golang.org/x/crypto/hkdf
golang.org/x/crypto/internal/alias
golang.org/x/crypto/internal/poly1305
golang.org/x/crypto/ocsp
golang.org/x/crypto/pbkdf2
golang.org/x/crypto/sha3
# golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546
## explicit; go 1.24.0
@@ -583,6 +592,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.24.0
## explicit; go 1.18
golang.org/x/oauth2
golang.org/x/oauth2/internal
# golang.org/x/sync v0.20.0
## explicit; go 1.25.0
golang.org/x/sync/errgroup
+22
View File
@@ -0,0 +1,22 @@
{
"realm": "phishingclub",
"enabled": true,
"sslRequired": "none",
"registrationAllowed": false,
"loginWithEmailAllowed": true,
"clients": [
{
"clientId": "phishingclub",
"name": "Phishing Club",
"enabled": true,
"publicClient": true,
"standardFlowEnabled": true,
"directAccessGrantsEnabled": false,
"redirectUris": ["*"],
"webOrigins": ["*"],
"attributes": {
"pkce.code.challenge.method": "S256"
}
}
]
}
+8 -3
View File
@@ -3271,18 +3271,23 @@ export class API {
sso = {
/**
* @param {object} sso
* @param {string} [sso.providerType] - "entra" or "oidc"
* @param {string} sso.clientID
* @param {string} sso.tenantID
* @param {string} [sso.tenantID]
* @param {string} sso.clientSecret
* @param {string} sso.redirectURL
* @param {string} [sso.issuerURL]
* @param {string} [sso.scopes]
* @param {string} [sso.acrValues]
* @param {boolean} [sso.exclusiveLogin]
* @returns {Promise<ApiResponse>}
*/
upsert: async (sso) => {
return await postJSON(this.getPath(`/sso/entra-id`), sso);
return await postJSON(this.getPath(`/sso`), sso);
},
isEnabled: async () => {
return await getJSON(this.getPath(`/sso/entra-id/enabled`));
return await getJSON(this.getPath(`/sso/enabled`));
}
};
@@ -129,6 +129,13 @@
category: 'Development',
external: true
});
items.push({
label: 'Keycloak',
url: 'http://keycloak.test:8108',
category: 'Development',
external: true
});
}
return items;
+34 -10
View File
@@ -40,6 +40,8 @@
let inputType = 'password';
let isPasswordVisible = true;
let isSSOEnabled = false;
let ssoProviderType = 'entra';
let exclusiveLogin = false;
let isSubmitting = false;
@@ -57,16 +59,34 @@
}
refreshIsSSOEnabled();
if ($page.url.searchParams.get('ssoAuthError')) {
addToast('SSO login failed', 'Error');
addToast(ssoErrorMessage($page.url.searchParams.get('reason')), 'Error');
}
});
// maps the reason code from the SSO callback to a message shown to the user
const ssoErrorMessage = (reason) => {
switch (reason) {
case 'not_provisioned':
return 'SSO login failed: this account does not exist in Phishing Club. An administrator must create it first.';
case 'email_not_verified':
return 'SSO login failed: your email is not verified by the identity provider.';
case 'no_email':
return 'SSO login failed: the identity provider did not return an email address.';
case 'mfa_required':
return 'SSO login failed: multi factor authentication is required by the configured authentication context.';
default:
return 'SSO login failed';
}
};
const refreshIsSSOEnabled = async () => {
showIsLoading();
try {
const res = await api.sso.isEnabled();
if (res.data) {
if (res.data && res.data.enabled) {
isSSOEnabled = true;
ssoProviderType = res.data.providerType || 'entra';
exclusiveLogin = !!res.data.exclusiveLogin;
}
} catch (e) {
addToast('failed to check sso status', 'Error');
@@ -252,6 +272,7 @@
on:submit={(e) => onSubmitLogin('password', e)}
class="flex flex-col items-center justify-center w-full md:p-px lg:p-4"
>
{#if !exclusiveLogin}
<div class="flex flex-col w-full p-4 h-24">
<label
for="Username"
@@ -326,16 +347,19 @@
/>
</div>
</div>
<CTAbutton disabled={isSubmitting} />
{/if}
{#if !exclusiveLogin}
<CTAbutton disabled={isSubmitting} />
{/if}
{#if isSSOEnabled}
<div class="absolute bottom-12">
<div
class="text-center font-bold text-gray-900 dark:text-white transition-colors duration-200"
<div class="flex justify-center w-full p-4 {exclusiveLogin ? 'mt-8' : 'mt-2'}">
<a
href={ssoProviderType === 'oidc'
? '/api/v1/sso/oidc/login'
: '/api/v1/sso/entra-id/login'}
class="font-titilium font-semibold text-cta-blue dark:text-highlight-blue hover:underline transition-colors duration-200"
>
SSO
</div>
<a href="/api/v1/sso/entra-id/login">
<img src="/ms-login-light.svg" alt="Login with Microsoft" />
Sign in with SSO
</a>
</div>
{/if}
+5 -7
View File
@@ -74,7 +74,7 @@
totpRecoveryCode: ''
};
let isSSOUser = false;
let hasPassword = false;
// component logic
const resetMFAValues = () => {
@@ -110,9 +110,7 @@
}
changeUsernameFormValues.username = res2.data.username;
changeNameFormValues.fullname = res2.data.name;
if (res2.data.ssoID) {
isSSOUser = true;
}
hasPassword = !!res2.data.hasPassword;
return;
} catch (e) {
addToast('Failed to load user', 'Error');
@@ -413,7 +411,7 @@
>
Password Settings
</h2>
{#if !isSSOUser}
{#if hasPassword}
<Form on:submit={onClickChangePassword}>
<div class="flex flex-col h-full">
<div>
@@ -448,7 +446,7 @@
<div
class="bg-gray-50 dark:bg-gray-800 p-4 rounded-md text-gray-600 dark:text-gray-300"
>
Password changes are disabled for SSO users.
This account has no password set and signs in via SSO.
</div>
{/if}
</div>
@@ -461,7 +459,7 @@
>
Multi-Factor Authentication
</h2>
{#if !isSSOUser}
{#if hasPassword}
{#if isMFAEnabled}
<div class="flex flex-col h-full pt-5 w-60">
<div class="flex items-center justify-between bg-green-50 p-4 rounded-md">
+152 -71
View File
@@ -14,6 +14,8 @@
import FormFooter from '$lib/components/FormFooter.svelte';
import TextField from '$lib/components/TextField.svelte';
import PasswordField from '$lib/components/PasswordField.svelte';
import TextFieldSelect from '$lib/components/TextFieldSelect.svelte';
import CheckboxField from '$lib/components/CheckboxField.svelte';
let loaded = false;
let ssoForm = null;
@@ -22,19 +24,42 @@
let updateSSOError = '';
let isSubmitting = false;
let isSSOEnabled = false;
let ssoSettingsFormValues = {
const providerOptions = [
{ value: 'entra', label: 'Microsoft Entra ID' },
{ value: 'oidc', label: 'Generic OpenID Connect' }
];
const defaultSSOFormValues = () => ({
providerType: 'entra',
clientID: null,
tenantID: null,
redirectURL: null,
clientSecret: null
clientSecret: null,
issuerURL: null,
scopes: null,
acrValues: null,
exclusiveLogin: false
});
let ssoSettingsFormValues = defaultSSOFormValues();
let currentSSO = defaultSSOFormValues();
let isEditingSSO = false;
const ssoAuthPath = (providerType) =>
providerType === 'oidc' ? '/api/v1/sso/oidc/auth' : '/api/v1/sso/entra-id/auth';
const defaultRedirectURL = (providerType) => `${location.origin}${ssoAuthPath(providerType)}`;
const onProviderChange = (value) => {
const providerType = value || ssoSettingsFormValues.providerType;
ssoSettingsFormValues.providerType = providerType;
ssoSettingsFormValues.redirectURL = defaultRedirectURL(providerType);
};
onMount(async () => {
try {
await refreshSSO();
if (!ssoSettingsFormValues.redirectURL) {
ssoSettingsFormValues.redirectURL = `${location.origin}/api/v1/sso/entra-id/auth`;
}
} finally {
loaded = true;
}
@@ -48,7 +73,10 @@
}
const sso = JSON.parse(res.data.value);
sso.clientSecret = '';
ssoSettingsFormValues = sso;
currentSSO = { ...defaultSSOFormValues(), ...sso };
if (!currentSSO.providerType) {
currentSSO.providerType = 'entra';
}
isSSOEnabled = sso.enabled;
} catch (e) {
console.error('failed to get SSO configuration', e);
@@ -64,8 +92,10 @@
updateSSOError = res.error;
return;
}
const wasEditing = isEditingSSO;
closeSSOModal();
refreshSSO();
addToast(wasEditing ? 'SSO updated' : 'SSO enabled', 'Success');
} catch (e) {
addToast('Failed to update SSO configuration', 'Error');
console.error('failed to update SSO configuration', e);
@@ -74,19 +104,31 @@
}
};
const openSSOModal = async (e) => {
// open the modal for a fresh configuration from the disabled state
const openConfigureSSO = (e) => {
e.preventDefault();
isEditingSSO = false;
ssoSettingsFormValues = defaultSSOFormValues();
ssoSettingsFormValues.redirectURL = defaultRedirectURL(ssoSettingsFormValues.providerType);
isSSOModalVisible = true;
};
// open the modal prefilled to edit the current enabled configuration, the
// client secret stays blank and is only changed if a new one is entered
const openEditSSO = (e) => {
e.preventDefault();
isEditingSSO = true;
ssoSettingsFormValues = { ...currentSSO, clientSecret: '' };
if (!ssoSettingsFormValues.redirectURL) {
ssoSettingsFormValues.redirectURL = defaultRedirectURL(ssoSettingsFormValues.providerType);
}
isSSOModalVisible = true;
};
const closeSSOModal = () => {
updateSSOError = '';
ssoSettingsFormValues = {
clientID: null,
tenantID: null,
redirectURL: null,
clientSecret: null
};
isEditingSSO = false;
ssoSettingsFormValues = defaultSSOFormValues();
isSSOModalVisible = false;
};
@@ -103,8 +145,10 @@
throw res.error;
}
refreshSSO();
addToast('SSO disabled', 'Success');
})
.catch((e) => {
addToast('Failed to disable SSO', 'Error');
console.error('failed to remove SSO configuration:', e);
});
return action;
@@ -114,67 +158,86 @@
{#if !loaded}
<SettingsLoading />
{:else}
<div class="flex flex-wrap gap-6">
<SettingsCard title="Single Sign-On">
<div class="bg-gray-50 rounded-md p-3">
{#if isSSOEnabled}
<p class="text-sm font-medium text-green-600">
<span class="inline-block w-2 h-2 rounded-full bg-green-500 mr-2"></span>
Enabled
</p>
{:else}
<p class="text-sm text-gray-600">
<span class="inline-block w-2 h-2 rounded-full bg-gray-400 mr-2"></span>
Disabled
</p>
{/if}
</div>
<svelte:fragment slot="footer">
{#if isSSOEnabled}
<Button
size={'large'}
on:click={() => {
isSSODeleteAlertVisible = true;
}}>Disable SSO</Button
>
{:else}
<Button size={'large'} on:click={openSSOModal}>Configure SSO</Button>
{/if}
</svelte:fragment>
</SettingsCard>
</div>
<div class="flex flex-wrap gap-6">
<SettingsCard title="Single Sign-On">
<div class="bg-gray-50 rounded-md p-3">
{#if isSSOEnabled}
<p class="text-sm font-medium text-green-600">
<span class="inline-block w-2 h-2 rounded-full bg-green-500 mr-2"></span>
Enabled
</p>
{:else}
<p class="text-sm text-gray-600">
<span class="inline-block w-2 h-2 rounded-full bg-gray-400 mr-2"></span>
Disabled
</p>
{/if}
</div>
<svelte:fragment slot="footer">
{#if isSSOEnabled}
<div class="flex gap-2">
<Button size={'medium'} on:click={openEditSSO}>Edit SSO</Button>
<Button
size={'medium'}
backgroundColor="bg-red-600"
on:click={() => {
isSSODeleteAlertVisible = true;
}}>Disable SSO</Button
>
</div>
{:else}
<Button size={'large'} on:click={openConfigureSSO}>Configure SSO</Button>
{/if}
</svelte:fragment>
</SettingsCard>
</div>
{/if}
{#if isSSOModalVisible}
<Modal bind:visible={isSSOModalVisible} headerText="SSO configuration" onClose={closeSSOModal}>
<div class="mt-4">
<div>
<h3 class="text-xl font-semibold text-gray-700 dark:text-white">Microsoft SSO Setup</h3>
<p class="text-gray-600 dark:text-gray-300 mb-4">
Configure Single Sign-On with Microsoft Azure AD.
</p>
</div>
<div class="bg-gray-50 dark:bg-gray-700 p-4 rounded-md">
<p class="font-semibold text-gray-900 dark:text-white mb-2">Important:</p>
<p class="text-sm text-gray-700 dark:text-gray-300">
Accounts that login with SSO will no longer be able to use password login.
</p>
</div>
</div>
<Modal
bind:visible={isSSOModalVisible}
headerText={isEditingSSO ? 'Edit SSO' : 'SSO configuration'}
onClose={closeSSOModal}
>
<FormGrid on:submit={onSubmitSSO} bind:bindTo={ssoForm} {isSubmitting}>
<FormColumns>
<FormColumn>
<TextField
<TextFieldSelect
id="ssoProvider"
required
bind:value={ssoSettingsFormValues.clientID}
placeholder="e.g., 8adf8e7c-d3ef-4a1b-b6c5-12345678abcd">Client ID</TextField
>
<TextField
required
bind:value={ssoSettingsFormValues.tenantID}
placeholder="e.g., contoso.onmicrosoft.com">Tenant ID</TextField
options={providerOptions}
bind:value={ssoSettingsFormValues.providerType}
onSelect={onProviderChange}>Provider</TextFieldSelect
>
{#if ssoSettingsFormValues.providerType === 'oidc'}
<TextField
required
type="url"
bind:value={ssoSettingsFormValues.issuerURL}
placeholder="https://oidc.example.com/realms/myrealm">Issuer URL</TextField
>
<TextField
required
bind:value={ssoSettingsFormValues.clientID}
placeholder="e.g., phishingclub">Client ID</TextField
>
<TextField
optional
bind:value={ssoSettingsFormValues.scopes}
placeholder="openid profile email">Scopes</TextField
>
{:else}
<TextField
required
bind:value={ssoSettingsFormValues.clientID}
placeholder="e.g., 8adf8e7c-d3ef-4a1b-b6c5-12345678abcd">Client ID</TextField
>
<TextField
required
bind:value={ssoSettingsFormValues.tenantID}
placeholder="e.g., contoso.onmicrosoft.com">Tenant ID</TextField
>
{/if}
</FormColumn>
<FormColumn>
<TextField
@@ -185,14 +248,32 @@
>
<PasswordField
required
required={!isEditingSSO && ssoSettingsFormValues.providerType !== 'oidc'}
optional={isEditingSSO || ssoSettingsFormValues.providerType === 'oidc'}
bind:value={ssoSettingsFormValues.clientSecret}
placeholder="Enter your client secret">Client Secret</PasswordField
placeholder={isEditingSSO
? 'Leave blank to keep current secret'
: 'Enter your client secret'}>Client Secret</PasswordField
>
{#if ssoSettingsFormValues.providerType === 'oidc'}
<TextField optional bind:value={ssoSettingsFormValues.acrValues} placeholder="e.g., mfa"
>ACR Values</TextField
>
{/if}
<CheckboxField
inline
bind:value={ssoSettingsFormValues.exclusiveLogin}
toolTipText="Disable password login while SSO is enabled.">Exclusive SSO</CheckboxField
>
</FormColumn>
<FormError message={updateSSOError} />
</FormColumns>
<FormFooter closeModal={closeSSOModal} okText="Enable SSO" closeText="Cancel" {isSubmitting} />
<FormFooter
closeModal={closeSSOModal}
okText={isEditingSSO ? 'Save' : 'Enable SSO'}
closeText="Cancel"
{isSubmitting}
/>
</FormGrid>
</Modal>
{/if}
@@ -202,8 +283,8 @@
list={[
'SSO will be disabled',
'Configuration will be deleted',
'SSO users will no longer be able to log in',
'Be sure there is a administrative user without SSO'
'Accounts that sign in only via SSO (no password) will be locked out',
'Be sure an administrator has a password set'
]}
confirm
name={'SSO configuration'}