initial stab

Signed-off-by: Ronni Skansing <rskansing@gmail.com>
This commit is contained in:
Ronni Skansing
2026-03-01 11:11:04 +01:00
parent 6f939c9ceb
commit 74092b1eb5
16 changed files with 1111 additions and 110 deletions
+15 -3
View File
@@ -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).
+4 -1
View File
@@ -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,
+82 -8
View File
@@ -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")
}
+47
View File
@@ -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
}
+3
View File
@@ -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
+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.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=
+23 -7
View File
@@ -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,
+95 -9
View File
@@ -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 {
+116
View File
@@ -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,
}
}
+69
View File
@@ -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
}
+4 -2
View File
@@ -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 {
+312 -80
View File
@@ -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)
}
}
+15
View File
@@ -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 {
+293
View File
@@ -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
}
+15
View File
@@ -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
}
+12
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.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