Thanks to visit codestin.com
Credit goes to github.com

Skip to content

Commit 7a84f1a

Browse files
feat: generic OIDC SSO and password-login disable flag (#152)
* feat: generic OIDC SSO and password-login disable flag Adds support for any OpenID Connect provider (Authentik, Keycloak, Dex, Okta, etc.) alongside the existing Google and GitHub OAuth providers, and a new flag to enforce SSO-only login. OIDC provider: - New OIDC_CLIENT_ID / OIDC_CLIENT_SECRET / OIDC_DISCOVERY_URL env vars enable any OIDC-compliant provider via goth's openidConnect backend - OIDC_DISPLAY_NAME sets a custom label on the login button (default: SSO) - OIDC_AUTO_CREATE_USERS=true bypasses the invite requirement in self-hosted mode, automatically provisioning accounts and adding users to an org on first login - OIDC_ORG_CLAIM routes users to a named org via a custom JWT claim for multi-org instances; falls back to the first org if absent - OIDC_EXTRA_SCOPES requests additional scopes needed to expose custom claims - Switched session store from CookieStore to FilesystemStore to avoid the 4096-byte cookie size limit hit by large OIDC ID tokens (e.g. Authentik) - Providers endpoint now returns providerLabels so the frontend can show the correct display name on the SSO button Password-login disable flag: - DISABLE_PASSWORD_LOGIN=true blocks POST /api/login and /api/register with 403 and hides the email/password form on the login and register pages; the "or" divider between SSO buttons and the form is also removed - Login page skips the /register redirect (first-time setup) when password login is disabled — onboarding happens through the SSO flow instead - Register page redirects to /login immediately when password login is disabled * feat: expand OIDC SSO with role mapping, manual endpoints, and flash fix Add OIDC_ROLE_CLAIM and OIDC_ROLE_MAP env vars to map IdP roles/groups to Traceway roles on every login. Supports dot-notation paths for nested claims (e.g. Keycloak's realm_access.roles). The highest-priority match wins (admin > user > readonly); owner is never auto-assigned and existing owners are protected from demotion on re-login. Add OIDC_AUTH_URL, OIDC_TOKEN_URL, and OIDC_USER_INFO_URL as an alternative to OIDC_DISCOVERY_URL for providers that do not expose a well-known configuration endpoint. A short-lived local server synthesises the discovery document at startup so the existing goth flow is unchanged. Fix a flash on the login and register pages where the email/password form was briefly visible before the /api/auth/providers response arrived when password login is disabled. Update SSO docs with Keycloak setup guide, role mapping examples (including Authentik and generic groups claim), manual endpoint configuration, and the new env var reference.
1 parent bd9f6ea commit 7a84f1a

10 files changed

Lines changed: 869 additions & 49 deletions

File tree

backend/app/config/config.go

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,21 @@ type Cfg struct {
5252
GitHubClientID string
5353
GitHubClientSecret string
5454
OAuthSessionSecret string
55+
56+
OIDCClientID string
57+
OIDCClientSecret string
58+
OIDCDiscoveryURL string
59+
OIDCDisplayName string
60+
OIDCAutoCreateUsers string
61+
OIDCOrgClaim string
62+
OIDCExtraScopes string
63+
OIDCRoleClaim string
64+
OIDCRoleMap string
65+
OIDCAuthURL string
66+
OIDCTokenURL string
67+
OIDCUserInfoURL string
68+
69+
DisablePasswordLogin string
5570
}
5671

5772
var Config *Cfg
@@ -109,5 +124,20 @@ func LoadFromEnv() *Cfg {
109124
GitHubClientID: os.Getenv("GITHUB_CLIENT_ID"),
110125
GitHubClientSecret: os.Getenv("GITHUB_CLIENT_SECRET"),
111126
OAuthSessionSecret: os.Getenv("OAUTH_SESSION_SECRET"),
127+
128+
OIDCClientID: os.Getenv("OIDC_CLIENT_ID"),
129+
OIDCClientSecret: os.Getenv("OIDC_CLIENT_SECRET"),
130+
OIDCDiscoveryURL: os.Getenv("OIDC_DISCOVERY_URL"),
131+
OIDCDisplayName: os.Getenv("OIDC_DISPLAY_NAME"),
132+
OIDCAutoCreateUsers: os.Getenv("OIDC_AUTO_CREATE_USERS"),
133+
OIDCOrgClaim: os.Getenv("OIDC_ORG_CLAIM"),
134+
OIDCExtraScopes: os.Getenv("OIDC_EXTRA_SCOPES"),
135+
OIDCRoleClaim: os.Getenv("OIDC_ROLE_CLAIM"),
136+
OIDCRoleMap: os.Getenv("OIDC_ROLE_MAP"),
137+
OIDCAuthURL: os.Getenv("OIDC_AUTH_URL"),
138+
OIDCTokenURL: os.Getenv("OIDC_TOKEN_URL"),
139+
OIDCUserInfoURL: os.Getenv("OIDC_USER_INFO_URL"),
140+
141+
DisablePasswordLogin: os.Getenv("DISABLE_PASSWORD_LOGIN"),
112142
}
113143
}

backend/app/controllers/auth.controller.go

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,11 @@ func (a authController) HasOrganizations(c *gin.Context) {
3131
}
3232

3333
func (a authController) Login(c *gin.Context) {
34+
if config.Config.DisablePasswordLogin == "true" {
35+
c.JSON(http.StatusForbidden, gin.H{"error": "Password login is disabled. Please use SSO to sign in."})
36+
return
37+
}
38+
3439
var request models.LoginRequest
3540
if err := c.ShouldBindJSON(&request); err != nil {
3641
c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid email or password"})
@@ -81,6 +86,11 @@ func (a authController) Login(c *gin.Context) {
8186
}
8287

8388
func (a authController) Register(c *gin.Context) {
89+
if config.Config.DisablePasswordLogin == "true" {
90+
c.JSON(http.StatusForbidden, gin.H{"error": "Password login is disabled. Please use SSO to sign in."})
91+
return
92+
}
93+
8494
var request models.RegisterRequest
8595
if err := c.ShouldBindJSON(&request); err != nil {
8696
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request body"})

backend/app/controllers/oauth.controller.go

Lines changed: 78 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ package controllers
22

33
import (
44
"context"
5+
"database/sql"
56
"errors"
67
"fmt"
78
"net/http"
@@ -24,7 +25,9 @@ import (
2425
type oauthController struct{}
2526

2627
type oauthProvidersResponse struct {
27-
Providers []string `json:"providers"`
28+
Providers []string `json:"providers"`
29+
ProviderLabels map[string]string `json:"providerLabels"`
30+
PasswordLoginEnabled bool `json:"passwordLoginEnabled"`
2831
}
2932

3033
type finishOAuthSetupRequest struct {
@@ -35,11 +38,20 @@ type finishOAuthSetupRequest struct {
3538
}
3639

3740
func (a oauthController) ListProviders(c *gin.Context) {
41+
passwordEnabled := config.Config.DisablePasswordLogin != "true"
3842
if services.OAuthService == nil {
39-
c.JSON(http.StatusOK, oauthProvidersResponse{Providers: []string{}})
43+
c.JSON(http.StatusOK, oauthProvidersResponse{Providers: []string{}, ProviderLabels: map[string]string{}, PasswordLoginEnabled: passwordEnabled})
4044
return
4145
}
42-
c.JSON(http.StatusOK, oauthProvidersResponse{Providers: services.OAuthService.EnabledProviders()})
46+
labels := map[string]string{}
47+
if name := services.OAuthService.OIDCDisplayName(); name != "" {
48+
labels["oidc"] = name
49+
}
50+
c.JSON(http.StatusOK, oauthProvidersResponse{
51+
Providers: services.OAuthService.EnabledProviders(),
52+
ProviderLabels: labels,
53+
PasswordLoginEnabled: passwordEnabled,
54+
})
4355
}
4456

4557
func (a oauthController) Begin(c *gin.Context) {
@@ -49,7 +61,7 @@ func (a oauthController) Begin(c *gin.Context) {
4961
return
5062
}
5163

52-
req := c.Request.WithContext(context.WithValue(c.Request.Context(), gothic.ProviderParamKey, provider))
64+
req := c.Request.WithContext(context.WithValue(c.Request.Context(), gothic.ProviderParamKey, externalToGothProvider(provider)))
5365
gothic.BeginAuthHandler(c.Writer, req)
5466
}
5567

@@ -60,7 +72,7 @@ func (a oauthController) Callback(c *gin.Context) {
6072
return
6173
}
6274

63-
req := c.Request.WithContext(context.WithValue(c.Request.Context(), gothic.ProviderParamKey, provider))
75+
req := c.Request.WithContext(context.WithValue(c.Request.Context(), gothic.ProviderParamKey, externalToGothProvider(provider)))
6476
gothUser, err := gothic.CompleteUserAuth(c.Writer, req)
6577
if err != nil {
6678
traceway.CaptureException(fmt.Errorf("OAuth complete failed (provider=%s): %w", provider, err))
@@ -80,13 +92,47 @@ func (a oauthController) Callback(c *gin.Context) {
8092

8193
tx := middleware.GetTx(c)
8294

95+
var mappedRole string
96+
if provider == "oidc" && services.OAuthService.OIDCRoleClaimEnabled() {
97+
mappedRole = services.OAuthService.ResolveRole(gothUser.RawData)
98+
}
99+
83100
memberships, err := repositories.OrganizationRepository.FindByUserIdWithRoles(tx, user.Id)
84101
if err != nil {
85102
c.AbortWithError(http.StatusInternalServerError, traceway.NewStackTraceErrorf("OAuth callback: load memberships: %w", err))
86103
return
87104
}
88105
needsSetup := len(memberships) == 0
89106

107+
if needsSetup && config.Config.CloudMode != "true" && provider == "oidc" && services.OAuthService.OIDCAutoCreateEnabled() {
108+
org, err := a.resolveOIDCOrg(tx, gothUser.RawData)
109+
if err != nil {
110+
c.AbortWithError(http.StatusInternalServerError, traceway.NewStackTraceErrorf("OAuth callback: resolve org for auto-join: %w", err))
111+
return
112+
}
113+
if org != nil {
114+
role := "user"
115+
if mappedRole != "" {
116+
role = mappedRole
117+
}
118+
if _, err := repositories.OrganizationRepository.AddUser(tx, org.Id, user.Id, role); err != nil {
119+
c.AbortWithError(http.StatusInternalServerError, traceway.NewStackTraceErrorf("OAuth callback: auto-join org: %w", err))
120+
return
121+
}
122+
needsSetup = false
123+
}
124+
} else if mappedRole != "" && !needsSetup {
125+
for _, m := range memberships {
126+
if m.Role == "owner" {
127+
continue
128+
}
129+
if err := repositories.OrganizationRepository.UpdateUserRole(tx, m.Id, user.Id, mappedRole); err != nil {
130+
c.AbortWithError(http.StatusInternalServerError, traceway.NewStackTraceErrorf("OAuth callback: sync mapped role: %w", err))
131+
return
132+
}
133+
}
134+
}
135+
90136
jwt, err := services.GenerateToken(user.Id, user.Email)
91137
if err != nil {
92138
c.AbortWithError(http.StatusInternalServerError, traceway.NewStackTraceErrorf("OAuth callback: generate JWT: %w", err))
@@ -127,7 +173,8 @@ func (a oauthController) findOrCreateUser(c *gin.Context, provider string, gothU
127173
return existing, nil
128174
}
129175

130-
if config.Config.CloudMode != "true" {
176+
oidcAutoCreate := provider == "oidc" && services.OAuthService.OIDCAutoCreateEnabled()
177+
if config.Config.CloudMode != "true" && !oidcAutoCreate {
131178
hasOrg, err := repositories.OrganizationRepository.HasOrganizations(tracewayTx)
132179
if err != nil {
133180
c.AbortWithError(http.StatusInternalServerError, traceway.NewStackTraceErrorf("OAuth callback: count orgs: %w", err))
@@ -152,6 +199,7 @@ func (a oauthController) findOrCreateUser(c *gin.Context, provider string, gothU
152199
c.AbortWithError(http.StatusInternalServerError, traceway.NewStackTraceErrorf("OAuth callback: create user: %w", err))
153200
return nil, err
154201
}
202+
155203
return created, nil
156204
}
157205

@@ -271,4 +319,28 @@ func (a oauthController) redirectError(c *gin.Context, code string) {
271319
c.Redirect(http.StatusSeeOther, target)
272320
}
273321

322+
func (a oauthController) resolveOIDCOrg(tx *sql.Tx, rawData map[string]interface{}) (*models.Organization, error) {
323+
if claim := services.OAuthService.OIDCOrgClaim(); claim != "" {
324+
if val, ok := rawData[claim]; ok {
325+
if orgName, ok := val.(string); ok && orgName != "" {
326+
org, err := repositories.OrganizationRepository.FindByName(tx, orgName)
327+
if err != nil {
328+
return nil, err
329+
}
330+
if org != nil {
331+
return org, nil
332+
}
333+
}
334+
}
335+
}
336+
return repositories.OrganizationRepository.FindFirst(tx)
337+
}
338+
339+
func externalToGothProvider(provider string) string {
340+
if provider == "oidc" {
341+
return "openid-connect"
342+
}
343+
return provider
344+
}
345+
274346
var OAuthController = oauthController{}

backend/app/repositories/organization.repository.go

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,21 @@ func (r *organizationRepository) HasOrganizations(tx *sql.Tx) (bool, error) {
4444
return result.Count > 0, nil
4545
}
4646

47+
func (r *organizationRepository) FindFirst(tx *sql.Tx) (*models.Organization, error) {
48+
return lit.SelectSingle[models.Organization](
49+
tx,
50+
"SELECT id, name, timezone, created_at FROM organizations ORDER BY created_at ASC LIMIT 1",
51+
)
52+
}
53+
54+
func (r *organizationRepository) FindByName(tx *sql.Tx, name string) (*models.Organization, error) {
55+
return lit.SelectSingleNamed[models.Organization](
56+
tx,
57+
"SELECT id, name, timezone, created_at FROM organizations WHERE name = :name LIMIT 1",
58+
lit.P{"name": name},
59+
)
60+
}
61+
4762
func (r *organizationRepository) FindById(tx *sql.Tx, id int) (*models.Organization, error) {
4863
return lit.SelectSingleNamed[models.Organization](
4964
tx,

0 commit comments

Comments
 (0)