From a2059e1ed327df884fbf4f62d4958e98245e49f5 Mon Sep 17 00:00:00 2001
From: JOY <5027251+JOY@users.noreply.github.com>
Date: Tue, 22 Sep 2026 00:29:16 +0700
Subject: [PATCH] feat(auth): portal redirect-only login, surface-aware OIDC
error bounces, password-ops guard
- /support/login auto-redirects to OIDC when it is the only enabled
transport, mirroring the staff login; suppressed on an ?oidcError bounce
and the break-glass ?direct=1 form. Portal-origin logins provision
customer-type users, so the redirect can never mint staff accounts
- both auto-redirects wait for the session probe (ready && !session), so
an already-signed-in visitor is routed to their destination instead of
being shipped to the IdP for a needless re-authentication
- failed OIDC round-trips bounce back to the surface that started them:
/support/login?oidcError= for /support/* targets, /dashboard/login
otherwise (recovered from the signed state on callback failures), so a
portal customer is never stranded on the staff login page
- both login pages render a persistent DOS ID failure notice with a retry
action instead of a transient toast
- while password login is disabled, password mutations
(/api/dashboard/user/reset_password, /api/dashboard/change_password)
reject any target that is not on the break-glass allowlist, so SSO-only
deployments never accumulate dormant local passwords
- tests: error-redirect surface routing, password-ops guard matrix
---
docs/ARCHITECTURE.md | 2 +-
internal/handlers/api/auth_handler.go | 23 ++-
.../api/auth_handler_internal_test.go | 41 +++++
internal/handlers/dashboard/user_handler.go | 5 +-
internal/pkg/i18nx/locales/en-US.yml | 1 +
internal/pkg/i18nx/locales/zh-CN.yml | 1 +
internal/services/oidc_login_service.go | 21 ++-
internal/services/oidc_login_service_test.go | 2 +-
internal/services/user_service.go | 18 +-
.../user_service_password_guard_test.go | 160 ++++++++++++++++++
.../support/login/_components/login-page.tsx | 45 ++++-
web/components/login-form.tsx | 37 ++--
web/messages/en-US.json | 1 +
web/messages/vi-VN.json | 1 +
web/messages/zh-CN.json | 1 +
15 files changed, 322 insertions(+), 37 deletions(-)
create mode 100644 internal/handlers/api/auth_handler_internal_test.go
create mode 100644 internal/services/user_service_password_guard_test.go
diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md
index 8a1e97a7..69e1dfb1 100644
--- a/docs/ARCHITECTURE.md
+++ b/docs/ARCHITECTURE.md
@@ -206,7 +206,7 @@ Application roles are derived from claims, never defaulted to admin:
##### SSO-Only Mode & Break-Glass
-When `passwordLoginEnabled: false` and OIDC is the only staff transport, `/dashboard/login` auto-redirects to the provider. The redirect is suppressed when the provider bounced back with `?oidcError=` (prevents a loop), in WxWork-only environments, or via `?direct=1` with the break-glass allowlist configured (`auth.breakGlassEmails`, env `BREAK_GLASS_LOGIN_EMAILS`): allowlisted admin emails keep password login reachable for IdP outages. In this mode the default-password bootstrap admin (`admin` / `ChangeMe123!`) is not seeded, and its emailless account can never pass the email-only allowlist. The support portal is never auto-redirected (it serves customer accounts); note that customer portal sign-in shares the staff password endpoint, so in SSO-only mode only break-glass principals can password-sign in there.
+When `passwordLoginEnabled: false` and OIDC is the only staff transport, `/dashboard/login` auto-redirects to the provider. The redirect is suppressed when the provider bounced back with `?oidcError=` (prevents a loop), in WxWork-only environments, or via `?direct=1` with the break-glass allowlist configured (`auth.breakGlassEmails`, env `BREAK_GLASS_LOGIN_EMAILS`): allowlisted admin emails keep password login reachable for IdP outages. In this mode the default-password bootstrap admin (`admin` / `ChangeMe123!`) is not seeded, and its emailless account can never pass the email-only allowlist. The support portal follows the same rule: with OIDC as its only enabled transport, `/support/login` auto-redirects as well, and portal OIDC logins provision customer-type users (see the next section), so the redirect can never mint staff accounts. A failed round-trip bounces back to the surface that started it, `/support/login?oidcError=` for portal targets and `/dashboard/login?oidcError=` otherwise, so the error guard cannot loop across surfaces. The break-glass `?direct=1` form stays reachable on both surfaces, and while password login is disabled the password-mutation endpoints (`/api/dashboard/user/reset_password`, `/api/dashboard/change_password`) reject any target that is not on the break-glass allowlist, so an SSO-only deployment never accumulates dormant local passwords that would come alive if the switch were flipped back.
##### Portal OIDC Login Provisions Customers
diff --git a/internal/handlers/api/auth_handler.go b/internal/handlers/api/auth_handler.go
index c81029e7..98473844 100644
--- a/internal/handlers/api/auth_handler.go
+++ b/internal/handlers/api/auth_handler.go
@@ -100,9 +100,10 @@ func WxWorkExchange(ctx *gin.Context) {
}
func OIDCLogin(ctx *gin.Context) {
- loginURL, err := services.OIDCLoginService.BuildOIDCLoginURL(ctx.Query("next"))
+ next := ctx.Query("next")
+ loginURL, err := services.OIDCLoginService.BuildOIDCLoginURL(next)
if err != nil {
- ctx.Redirect(http.StatusFound, "/dashboard/login?oidcError="+url.QueryEscape(loginErrorMessage(err.Error())))
+ ctx.Redirect(http.StatusFound, oidcLoginErrorRedirect(next, loginErrorMessage(err.Error())))
return
}
ctx.Redirect(http.StatusFound, loginURL)
@@ -116,7 +117,8 @@ func OIDCCallback(ctx *gin.Context) {
if desc != "" {
errMsg += ": " + desc
}
- ctx.Redirect(http.StatusFound, "/dashboard/login?oidcError="+url.QueryEscape(errMsg))
+ ctx.Redirect(http.StatusFound, oidcLoginErrorRedirect(
+ services.OIDCLoginService.NextFromState(ctx.Query("state")), errMsg))
return
}
@@ -137,7 +139,8 @@ func OIDCCallback(ctx *gin.Context) {
)
if err != nil {
slog.Error("oidc callback login failed", "error", err)
- ctx.Redirect(http.StatusFound, "/dashboard/login?oidcError="+url.QueryEscape(loginErrorMessage(err.Error())))
+ ctx.Redirect(http.StatusFound, oidcLoginErrorRedirect(
+ services.OIDCLoginService.NextFromState(ctx.Query("state")), loginErrorMessage(err.Error())))
return
}
ctx.Redirect(http.StatusFound, "/dashboard/login/oidc/callback?ticket="+url.QueryEscape(ticket)+"&next="+url.QueryEscape(next))
@@ -217,6 +220,18 @@ func wxWorkErrorMessage(message string) string {
return loginErrorMessage(message)
}
+// oidcLoginErrorRedirect routes a failed OIDC round-trip back to the login
+// surface that started it: the support portal for /support/* targets, the
+// staff dashboard otherwise. This keeps the IdP error bounce and the
+// auto-redirect loop guard on the same page.
+func oidcLoginErrorRedirect(next, message string) string {
+ loginPath := "/dashboard/login"
+ if services.IsSupportPortalNext(next) {
+ loginPath = "/support/login"
+ }
+ return loginPath + "?oidcError=" + url.QueryEscape(message)
+}
+
func loginErrorMessage(message string) string {
if idx := strings.Index(message, ": "); idx >= 0 {
message = message[idx+2:]
diff --git a/internal/handlers/api/auth_handler_internal_test.go b/internal/handlers/api/auth_handler_internal_test.go
new file mode 100644
index 00000000..c0cd1613
--- /dev/null
+++ b/internal/handlers/api/auth_handler_internal_test.go
@@ -0,0 +1,41 @@
+package api
+
+import (
+ "net/url"
+ "testing"
+)
+
+// A failed OIDC round-trip must bounce back to the login surface that started
+// it: the support portal for /support/* targets, the staff dashboard
+// otherwise. Routing a portal failure to the staff page would strand the
+// customer there, and retrying from that page would provision them as an
+// employee instead of a customer.
+func TestOIDCLoginErrorRedirectTargetsOriginSurface(t *testing.T) {
+ const plainMessage = "sign-in failed"
+ cases := []struct {
+ name string
+ next string
+ message string
+ portal bool
+ }{
+ {name: "staff target", next: "/dashboard", message: plainMessage},
+ {name: "empty next falls back to staff", next: "", message: plainMessage},
+ {name: "portal path", next: "/support/community", message: plainMessage, portal: true},
+ {name: "portal root", next: "/support", message: plainMessage, portal: true},
+ {name: "padded portal path", next: " /support/tickets", message: plainMessage, portal: true},
+ {name: "portal lookalike is not portal", next: "/supportevil", message: plainMessage},
+ {name: "untrusted next is never reflected", next: "https://evil.example", message: plainMessage},
+ {name: "message is escaped", next: "/support", message: "boom & breakdown", portal: true},
+ }
+
+ for _, tc := range cases {
+ loginPath := "/dashboard/login"
+ if tc.portal {
+ loginPath = "/support/login"
+ }
+ expected := loginPath + "?oidcError=" + url.QueryEscape(tc.message)
+ if got := oidcLoginErrorRedirect(tc.next, tc.message); got != expected {
+ t.Fatalf("%s: oidcLoginErrorRedirect(%q) = %q, want %q", tc.name, tc.next, got, expected)
+ }
+ }
+}
diff --git a/internal/handlers/dashboard/user_handler.go b/internal/handlers/dashboard/user_handler.go
index d0127d47..f71b98e0 100644
--- a/internal/handlers/dashboard/user_handler.go
+++ b/internal/handlers/dashboard/user_handler.go
@@ -2,6 +2,7 @@ package dashboard
import (
"agent-desk/internal/builders"
+ "agent-desk/internal/pkg/config"
"agent-desk/internal/pkg/constants"
"agent-desk/internal/pkg/dto/request"
"agent-desk/internal/pkg/dto/response"
@@ -171,7 +172,7 @@ func UserPostReset_password(ctx *gin.Context) {
httpx.WriteJSON(ctx, err)
return
}
- password, err := services.UserService.ResetPassword(req.UserID, operator)
+ password, err := services.UserService.ResetPassword(req.UserID, operator, config.Current().Auth)
if err != nil {
httpx.WriteJSON(ctx, err)
return
@@ -200,7 +201,7 @@ func UserPostChange_password(ctx *gin.Context) {
httpx.WriteJSON(ctx, err)
return
}
- if err := services.UserService.ChangeOwnPassword(req.Password, principal); err != nil {
+ if err := services.UserService.ChangeOwnPassword(req.Password, principal, config.Current().Auth); err != nil {
httpx.WriteJSON(ctx, err)
return
}
diff --git a/internal/pkg/i18nx/locales/en-US.yml b/internal/pkg/i18nx/locales/en-US.yml
index 1ba2cbe5..5d339d4e 100644
--- a/internal/pkg/i18nx/locales/en-US.yml
+++ b/internal/pkg/i18nx/locales/en-US.yml
@@ -231,6 +231,7 @@ error.e0231: "No matching WeCom channel was found."
error.e0232: "No schedules were generated."
error.auth.expired: "Your session has expired. Please sign in again."
error.auth.passwordLoginDisabled: "Username and password login is disabled. Please use SSO to sign in."
+error.auth.passwordChangeDisabled: "Password sign-in is disabled, so only break-glass accounts may keep a local password."
error.auth.invalidSignature: "Invalid request signature or secret token."
error.e0234: "No available AI configuration is configured."
error.e0235: "No available embedding model is configured."
diff --git a/internal/pkg/i18nx/locales/zh-CN.yml b/internal/pkg/i18nx/locales/zh-CN.yml
index 699be781..089f4651 100644
--- a/internal/pkg/i18nx/locales/zh-CN.yml
+++ b/internal/pkg/i18nx/locales/zh-CN.yml
@@ -231,6 +231,7 @@ error.e0231: "未找到匹配的企业微信接入渠道"
error.e0232: "未生成任何排班"
error.auth.expired: "未登录或登录已过期"
error.auth.passwordLoginDisabled: "账号密码登录已禁用,请使用第三方登录。"
+error.auth.passwordChangeDisabled: "账号密码登录已禁用,仅 break-glass 应急账号可保留本地密码。"
error.auth.invalidSignature: "请求签名或凭证 Token 无效"
error.e0234: "未配置可用的 AI 配置"
error.e0235: "未配置可用的 Embedding 模型"
diff --git a/internal/services/oidc_login_service.go b/internal/services/oidc_login_service.go
index bd75e7dd..31684fd8 100644
--- a/internal/services/oidc_login_service.go
+++ b/internal/services/oidc_login_service.go
@@ -47,7 +47,7 @@ func (s *oidcLoginService) LoginByOIDC(ctx context.Context, code, state string,
if err != nil {
return "", "", err
}
- loginResp, err := s.loginWithOIDCProfile(profile, authCfg, clientIP, userAgent, isSupportPortalNext(next))
+ loginResp, err := s.loginWithOIDCProfile(profile, authCfg, clientIP, userAgent, IsSupportPortalNext(next))
if err != nil {
return "", "", err
}
@@ -62,13 +62,26 @@ func (s *oidcLoginService) ExchangeOIDCLoginTicket(ticket string) (*response.Log
return oidcclient.ConsumeLoginTicket(ticket)
}
-// isSupportPortalNext reports whether the OIDC round-trip started from the
+// NextFromState recovers the sanitized redirect target carried by a signed
+// OIDC state. Handlers use it after a failed round-trip so an IdP error
+// bounces back to the login surface that started the flow. It returns an
+// empty string when the state is missing, expired, or fails verification.
+func (s *oidcLoginService) NextFromState(state string) string {
+ next, _, err := oidcclient.ParseState(state)
+ if err != nil {
+ return ""
+ }
+ return next
+}
+
+// IsSupportPortalNext reports whether the OIDC round-trip started from the
// customer support portal rather than the staff dashboard. The portal always
// targets /support/* paths (see getSupportLoginDestination), so the signed
// state's next path identifies the entry surface without extra parameters.
// Portal-origin logins provision customer-type users without staff roles;
-// only the staff surface (/dashboard/login) grants staff access.
-func isSupportPortalNext(next string) bool {
+// only the staff surface (/dashboard/login) grants staff access. Handlers
+// also use it to route failed round-trips back to the originating surface.
+func IsSupportPortalNext(next string) bool {
next = strings.TrimSpace(next)
return next == "/support" || strings.HasPrefix(next, "/support/")
}
diff --git a/internal/services/oidc_login_service_test.go b/internal/services/oidc_login_service_test.go
index 9030c50c..60398fe1 100644
--- a/internal/services/oidc_login_service_test.go
+++ b/internal/services/oidc_login_service_test.go
@@ -284,7 +284,7 @@ func TestIsSupportPortalNext(t *testing.T) {
}
for _, tc := range cases {
- if got := isSupportPortalNext(tc.next); got != tc.expected {
+ if got := IsSupportPortalNext(tc.next); got != tc.expected {
t.Fatalf("isSupportPortalNext(%q) = %v, want %v", tc.next, got, tc.expected)
}
}
diff --git a/internal/services/user_service.go b/internal/services/user_service.go
index 86c3e4d4..446e27e7 100644
--- a/internal/services/user_service.go
+++ b/internal/services/user_service.go
@@ -2,6 +2,7 @@ package services
import (
"agent-desk/internal/models"
+ "agent-desk/internal/pkg/config"
"agent-desk/internal/pkg/constants"
"agent-desk/internal/pkg/dto"
"agent-desk/internal/pkg/dto/request"
@@ -220,22 +221,22 @@ func (s *userService) UpdateStatus(id int64, status int, operator *dto.AuthPrinc
return nil
}
-func (s *userService) ResetPassword(userID int64, operator *dto.AuthPrincipal) (string, error) {
+func (s *userService) ResetPassword(userID int64, operator *dto.AuthPrincipal, authCfg config.AuthConfig) (string, error) {
password, err := utils.GenerateRandomPassword(12)
if err != nil {
return "", err
}
- if err = s.changePassword(userID, password, operator); err != nil {
+ if err = s.changePassword(userID, password, operator, authCfg); err != nil {
return "", err
}
return password, nil
}
-func (s *userService) ChangeOwnPassword(password string, operator *dto.AuthPrincipal) error {
+func (s *userService) ChangeOwnPassword(password string, operator *dto.AuthPrincipal, authCfg config.AuthConfig) error {
if operator == nil || operator.UserID <= 0 {
return errorsx.UnauthorizedI18n("error.auth.expired")
}
- return s.changePassword(operator.UserID, password, operator)
+ return s.changePassword(operator.UserID, password, operator, authCfg)
}
func (s *userService) AssignRoles(userID int64, roleIDs []int64, operator *dto.AuthPrincipal) error {
@@ -282,11 +283,18 @@ func (s *userService) replaceUserRolesDB(db *gorm.DB, userID int64, roleIDs []in
return nil
}
-func (s *userService) changePassword(userID int64, password string, operator *dto.AuthPrincipal) error {
+func (s *userService) changePassword(userID int64, password string, operator *dto.AuthPrincipal, authCfg config.AuthConfig) error {
user := s.Get(userID)
if user == nil || user.DeletedAt != nil {
return errorsx.InvalidParamI18n("error.e0255")
}
+ // SSO-only invariant: a local password is only ever usable by break-glass
+ // principals, so never set one for anyone else. Otherwise password resets
+ // would plant dormant credentials that come alive the moment password
+ // login is re-enabled.
+ if !authCfg.IsPasswordLoginEnabled() && !isBreakGlassUser(authCfg, user) {
+ return errorsx.ForbiddenI18n("error.auth.passwordChangeDisabled")
+ }
if operator != nil && operator.UserID != userID && !slices.Contains(operator.Roles, string(constants.RoleCodeSuperAdmin)) {
var superAdminCount int64
if err := sqls.DB().Model(&models.UserRole{}).
diff --git a/internal/services/user_service_password_guard_test.go b/internal/services/user_service_password_guard_test.go
new file mode 100644
index 00000000..d1b64a81
--- /dev/null
+++ b/internal/services/user_service_password_guard_test.go
@@ -0,0 +1,160 @@
+package services
+
+import (
+ "testing"
+ "time"
+
+ "agent-desk/internal/models"
+ "agent-desk/internal/pkg/config"
+ "agent-desk/internal/pkg/dto"
+ "agent-desk/internal/pkg/enums"
+ "agent-desk/internal/pkg/errorsx"
+
+ "gorm.io/gorm"
+)
+
+func createPasswordGuardTestUser(t *testing.T, db *gorm.DB, username string, email *string) *models.User {
+ t.Helper()
+ now := time.Now()
+ user := &models.User{
+ Username: username,
+ Nickname: username,
+ Email: email,
+ Status: enums.StatusOk,
+ AuditFields: models.AuditFields{
+ CreatedAt: now,
+ UpdatedAt: now,
+ },
+ }
+ if err := db.Create(user).Error; err != nil {
+ t.Fatalf("create password guard test user: %v", err)
+ }
+ return user
+}
+
+func ssoOnlyAuthConfig(allowlist string) config.AuthConfig {
+ disabled := false
+ return config.AuthConfig{
+ PasswordLoginEnabled: &disabled,
+ BreakGlassEmails: allowlist,
+ TokenTTLHours: 2,
+ }
+}
+
+// In SSO-only mode a local password exists solely for break-glass principals.
+// Everyone else must be refused, otherwise password resets plant dormant
+// credentials that come alive the moment password login is re-enabled.
+func TestChangeOwnPasswordRejectedInSSOOnlyModeForNonBreakGlassUser(t *testing.T) {
+ db := setupAuthServiceTestDB(t)
+ email := "staff@dos.ai"
+ user := createPasswordGuardTestUser(t, db, "staffuser", &email)
+
+ err := UserService.ChangeOwnPassword("fresh-password", &dto.AuthPrincipal{
+ UserID: user.ID,
+ Username: user.Username,
+ }, ssoOnlyAuthConfig("joy@dos.ai"))
+ if !hasCode(err, errorsx.CodeAuthForbidden) {
+ t.Fatalf("expected forbidden error for non-break-glass user, got %v", err)
+ }
+
+ var reloaded models.User
+ if err := db.Take(&reloaded, "id = ?", user.ID).Error; err != nil {
+ t.Fatalf("reload user: %v", err)
+ }
+ if reloaded.Password != user.Password {
+ t.Fatal("password must stay unchanged when the guard rejects the change")
+ }
+}
+
+// The emailless seeded bootstrap admin can never satisfy the email-only
+// allowlist, so its password can no longer be rotated in SSO-only mode.
+func TestChangeOwnPasswordRejectedInSSOOnlyModeForEmaillessUser(t *testing.T) {
+ db := setupAuthServiceTestDB(t)
+ user := createPasswordGuardTestUser(t, db, "admin", nil)
+
+ err := UserService.ChangeOwnPassword("fresh-password", &dto.AuthPrincipal{
+ UserID: user.ID,
+ Username: user.Username,
+ }, ssoOnlyAuthConfig("joy@dos.ai"))
+ if !hasCode(err, errorsx.CodeAuthForbidden) {
+ t.Fatalf("expected forbidden error for emailless user, got %v", err)
+ }
+}
+
+func TestChangeOwnPasswordAllowedForBreakGlassUserInSSOOnlyMode(t *testing.T) {
+ db := setupAuthServiceTestDB(t)
+ email := "joy@dos.ai"
+ user := createPasswordGuardTestUser(t, db, "joy", &email)
+
+ if err := UserService.ChangeOwnPassword("fresh-password", &dto.AuthPrincipal{
+ UserID: user.ID,
+ Username: user.Username,
+ }, ssoOnlyAuthConfig("joy@dos.ai")); err != nil {
+ t.Fatalf("allowlisted admin must keep the break-glass password path: %v", err)
+ }
+
+ var reloaded models.User
+ if err := db.Take(&reloaded, "id = ?", user.ID).Error; err != nil {
+ t.Fatalf("reload user: %v", err)
+ }
+ if reloaded.Password == user.Password || reloaded.Password == "" {
+ t.Fatal("password must be updated for the allowlisted break-glass user")
+ }
+}
+
+// With password login enabled (mixed mode) the guard must not interfere.
+func TestChangeOwnPasswordUnaffectedInMixedMode(t *testing.T) {
+ db := setupAuthServiceTestDB(t)
+ email := "staff@dos.ai"
+ user := createPasswordGuardTestUser(t, db, "staffuser", &email)
+
+ if err := UserService.ChangeOwnPassword("fresh-password", &dto.AuthPrincipal{
+ UserID: user.ID,
+ Username: user.Username,
+ }, config.AuthConfig{TokenTTLHours: 2}); err != nil {
+ t.Fatalf("mixed mode password change must keep working: %v", err)
+ }
+}
+
+// The admin-driven reset endpoint goes through the same choke point: in
+// SSO-only mode only break-glass targets may receive a local password.
+func TestResetPasswordRejectedInSSOOnlyModeForNonBreakGlassTarget(t *testing.T) {
+ db := setupAuthServiceTestDB(t)
+ targetEmail := "staff@dos.ai"
+ target := createPasswordGuardTestUser(t, db, "target", &targetEmail)
+ operator := createPasswordGuardTestUser(t, db, "operator", nil)
+
+ _, err := UserService.ResetPassword(target.ID, &dto.AuthPrincipal{
+ UserID: operator.ID,
+ Username: operator.Username,
+ }, ssoOnlyAuthConfig("joy@dos.ai"))
+ if !hasCode(err, errorsx.CodeAuthForbidden) {
+ t.Fatalf("expected forbidden error for non-break-glass reset target, got %v", err)
+ }
+
+ var reloaded models.User
+ if err := db.Take(&reloaded, "id = ?", target.ID).Error; err != nil {
+ t.Fatalf("reload target: %v", err)
+ }
+ if reloaded.Password != target.Password {
+ t.Fatal("target password must stay unchanged when the guard rejects the reset")
+ }
+}
+
+func TestResetPasswordAllowedInSSOOnlyModeForBreakGlassTarget(t *testing.T) {
+ db := setupAuthServiceTestDB(t)
+ targetEmail := "joy@dos.ai"
+ target := createPasswordGuardTestUser(t, db, "target", &targetEmail)
+ operator := createPasswordGuardTestUser(t, db, "operator", nil)
+
+ password, err := UserService.ResetPassword(target.ID, &dto.AuthPrincipal{
+ UserID: operator.ID,
+ Username: operator.Username,
+ }, ssoOnlyAuthConfig("joy@dos.ai"))
+ if err != nil {
+ t.Fatalf("reset for a break-glass target must succeed: %v", err)
+ }
+ if password == "" {
+ t.Fatal("expected a generated password to be returned")
+ }
+}
diff --git a/web/app/(support)/support/login/_components/login-page.tsx b/web/app/(support)/support/login/_components/login-page.tsx
index b6c2e4c3..0757c065 100644
--- a/web/app/(support)/support/login/_components/login-page.tsx
+++ b/web/app/(support)/support/login/_components/login-page.tsx
@@ -41,13 +41,33 @@ export function SupportLoginPage() {
const oidcError = searchParams.get("oidcError")
const passwordLoginEnabled = publicConfig?.passwordLoginEnabled !== false
// Break-glass door: with password login disabled suite-wide, an allowlisted
- // admin can still reach the password form via ?direct=1. The portal is
- // never auto-redirected: it primarily serves customer accounts.
+ // admin can still reach the password form via ?direct=1.
const isBreakGlassRequested =
searchParams.get("direct") === "1" && publicConfig?.breakGlassLoginEnabled === true
const showPasswordForm = passwordLoginEnabled || isBreakGlassRequested
const providerCount = Number(publicConfig?.wxworkEnabled) + Number(publicConfig?.oidcEnabled)
const hasAnyLoginMethod = showPasswordForm || providerCount > 0
+ // Redirect-only mode: when OIDC is the only login transport, skip the
+ // chooser and go straight to the provider, exactly like the staff login.
+ // Suppressed for the break-glass form, an IdP error bounce (otherwise
+ // this would loop), and while the session probe is still in flight (an
+ // already-signed-in customer goes to their destination instead of the
+ // IdP). Portal OIDC logins provision customer-type users.
+ const shouldRedirectToOIDC = Boolean(
+ ready &&
+ !session &&
+ publicConfig &&
+ publicConfig.oidcEnabled &&
+ !publicConfig.passwordLoginEnabled &&
+ !publicConfig.wxworkEnabled &&
+ !isBreakGlassRequested &&
+ !oidcError,
+ )
+
+ useEffect(() => {
+ if (!shouldRedirectToOIDC) return
+ window.location.href = `/api/auth/oidc_login?next=${encodeURIComponent(nextDestination)}`
+ }, [shouldRedirectToOIDC, nextDestination])
useEffect(() => {
if (ready && session) router.replace(nextDestination)
@@ -61,10 +81,6 @@ export function SupportLoginPage() {
if (wxworkError) toast.error(wxworkError)
}, [wxworkError])
- useEffect(() => {
- if (oidcError) toast.error(oidcError)
- }, [oidcError])
-
useEffect(() => {
setIsWxWorkEnv(detectWxWorkEnvironment())
}, [])
@@ -128,11 +144,26 @@ export function SupportLoginPage() {
{publicConfigError ? (
{oidcError}
+ +{oidcError}
+ +