mirror of
https://github.com/phishingclub/phishingclub.git
synced 2026-07-09 21:58:42 +02:00
@@ -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).
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
Vendored
+12
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user