Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions docs/ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -208,6 +208,10 @@ Application roles are derived from claims, never defaulted to admin:

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.

##### Portal OIDC Login Provisions Customers

The support portal's OIDC button targets `/support/*` return paths, which the signed OIDC state carries through the round-trip. Portal-origin logins provision **customer-type** users (`UserTypeUser`) with no staff role, no organization/team provisioning, and no agent profile, and the break-glass allowlist never elevates on the portal surface. Only the staff surface (`/dashboard/login`) grants staff access; the dashboard middleware rejects non-employee users, so a portal customer cannot open the staff dashboard. The user type is decided at creation time from the entry surface and is never changed by a later login on the other surface (fail-closed: a staff account keeps its type wherever it signs in). If a staff member's very first OIDC login happens through a shared `/support/*` link, they are provisioned as a customer and the dashboard keeps rejecting them; no admin API changes user type today, so recovery is a one-row update (`UPDATE t_users SET user_type='employee' WHERE id=...`) or deleting the user to re-provision via the staff surface. The portal also renders a WxWork button whose login still provisions employee users (upstream behaviour, disabled in DOS deployments). Note this fixes the upstream default, where every OIDC first login created an employee user - upstream `huabeitech/agent-desk` also still grants the admin role to every first-time OIDC user (fixed here in the Wave 2 redirect-only change).

#### Phase 2: Real-time Event-Driven Webhooks (`X-DOS-Signature: sha256=...`)
When administrators create, update, or reorganize Teams/Projects in DOS.Me, webhook events are broadcast to member apps:
```json
Expand Down
46 changes: 35 additions & 11 deletions internal/services/oidc_login_service.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
loginResp, err := s.loginWithOIDCProfile(profile, authCfg, clientIP, userAgent, isSupportPortalNext(next))
if err != nil {
return "", "", err
}
Expand All @@ -62,7 +62,18 @@ func (s *oidcLoginService) ExchangeOIDCLoginTicket(ticket string) (*response.Log
return oidcclient.ConsumeLoginTicket(ticket)
}

func (s *oidcLoginService) loginWithOIDCProfile(profile *oidcLoginProfile, authCfg config.AuthConfig, clientIP, userAgent string) (*response.LoginResponse, error) {
// 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 {
next = strings.TrimSpace(next)
return next == "/support" || strings.HasPrefix(next, "/support/")
}
Comment on lines +71 to +74

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

security-critical critical

The current implementation of isSupportPortalNext does not handle query parameters or URL fragments (e.g., /support?ticket=123 or /support/tickets?resolved=true#anchor). If a customer logs in via the support portal with any query parameters or fragments in the redirect path, isSupportPortalNext will return false. This causes them to be incorrectly provisioned as an employee (UserTypeEmployee) and receive default staff roles, which is a critical security vulnerability (privilege escalation).

We should strip query parameters and fragments before performing the prefix and equality checks.

func isSupportPortalNext(next string) bool {
	next = strings.TrimSpace(next)
	if idx := strings.IndexAny(next, "?#"); idx != -1 {
		next = next[:idx]
	}
	return next == "/support" || strings.HasPrefix(next, "/support/")
}


func (s *oidcLoginService) loginWithOIDCProfile(profile *oidcLoginProfile, authCfg config.AuthConfig, clientIP, userAgent string, portalOrigin bool) (*response.LoginResponse, error) {
if profile == nil || strings.TrimSpace(profile.Subject) == "" {
return nil, errorsx.BusinessErrorI18n(2, "error.oidc.profileMissing")
}
Expand All @@ -75,7 +86,7 @@ func (s *oidcLoginService) loginWithOIDCProfile(profile *oidcLoginProfile, authC
err error
)
if identity == nil {
user, identity, err = s.createOIDCUser(ctx, profile)
user, identity, err = s.createOIDCUser(ctx, profile, portalOrigin)
if err != nil {
return err
}
Expand Down Expand Up @@ -112,11 +123,17 @@ func (s *oidcLoginService) loginWithOIDCProfile(profile *oidcLoginProfile, authC
return err
}

s.ensureDefaultOIDCRole(ctx.Tx, user)
s.ensureBreakGlassAdminRole(ctx.Tx, authCfg, user, profile)
s.syncOIDCUserOrganizations(ctx.Tx, user, profile)
s.syncOIDCUserTeams(ctx.Tx, user, profile)
_, _ = AgentProfileService.EnsureAgentProfileForUser(ctx.Tx, user)
// Staff provisioning (roles, org/team sync, agent profile) runs only
// for staff-surface logins. A customer signing in through the support
// portal must never receive a staff seat: the dashboard middleware
// rejects non-employee users, and that type is set at creation only.
if !portalOrigin {
s.ensureDefaultOIDCRole(ctx.Tx, user)
s.ensureBreakGlassAdminRole(ctx.Tx, authCfg, user, profile)
s.syncOIDCUserOrganizations(ctx.Tx, user, profile)
s.syncOIDCUserTeams(ctx.Tx, user, profile)
_, _ = AgentProfileService.EnsureAgentProfileForUser(ctx.Tx, user)
}
Comment on lines +130 to +136

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

As a defense-in-depth measure, we should ensure that staff provisioning (roles, organization/team sync, and agent profile creation) is only executed if the user is actually an employee (UserTypeEmployee). Currently, if an existing customer (UserTypeUser) attempts to log in via the staff surface (portalOrigin == false), they would still have staff roles, organizations, and agent profiles provisioned for them in the database, even though they are rejected by the dashboard middleware.

Suggested change
if !portalOrigin {
s.ensureDefaultOIDCRole(ctx.Tx, user)
s.ensureBreakGlassAdminRole(ctx.Tx, authCfg, user, profile)
s.syncOIDCUserOrganizations(ctx.Tx, user, profile)
s.syncOIDCUserTeams(ctx.Tx, user, profile)
_, _ = AgentProfileService.EnsureAgentProfileForUser(ctx.Tx, user)
}
if !portalOrigin && user.UserType == enums.UserTypeEmployee {
s.ensureDefaultOIDCRole(ctx.Tx, user)
s.ensureBreakGlassAdminRole(ctx.Tx, authCfg, user, profile)
s.syncOIDCUserOrganizations(ctx.Tx, user, profile)
s.syncOIDCUserTeams(ctx.Tx, user, profile)
_, _ = AgentProfileService.EnsureAgentProfileForUser(ctx.Tx, user)
}


if err = repositories.UserIdentityRepository.Updates(ctx.Tx, identity.ID, map[string]any{
"provider_name": enums.GetThirdProviderLabel(enums.ThirdProviderOIDC),
Expand All @@ -139,19 +156,24 @@ func (s *oidcLoginService) loginWithOIDCProfile(profile *oidcLoginProfile, authC
return ret, nil
}

func (s *oidcLoginService) createOIDCUser(ctx *sqls.TxContext, profile *oidcLoginProfile) (*models.User, *models.UserIdentity, error) {
func (s *oidcLoginService) createOIDCUser(ctx *sqls.TxContext, profile *oidcLoginProfile, portalOrigin bool) (*models.User, *models.UserIdentity, error) {
now := time.Now()
email := s.availableEmail(ctx.Tx, profile.Email)
username := s.availableUsername(ctx.Tx, profile)

userType := enums.UserTypeEmployee
if portalOrigin {
userType = enums.UserTypeUser
}

user := &models.User{
Username: username,
Nickname: s.resolveOIDCNickname("", profile),
Avatar: s.resolveOIDCAvatar("", profile),
Email: email,
Password: "",
PasswordSalt: "",
UserType: enums.UserTypeEmployee,
UserType: userType,
Status: enums.StatusOk,
AuditFields: models.AuditFields{
CreatedAt: now,
Expand Down Expand Up @@ -187,7 +209,9 @@ func (s *oidcLoginService) createOIDCUser(ctx *sqls.TxContext, profile *oidcLogi
if err := repositories.UserIdentityRepository.Create(ctx.Tx, identity); err != nil {
return nil, nil, err
}
s.ensureDefaultOIDCRole(ctx.Tx, user)
if !portalOrigin {
s.ensureDefaultOIDCRole(ctx.Tx, user)
}
return user, identity, nil
}

Expand Down
119 changes: 116 additions & 3 deletions internal/services/oidc_login_service_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ func TestOIDCLoginAutoCreatesSystemUser(t *testing.T) {
Name: "Ada Lovelace",
Picture: "https://example.com/ada.png",
RawProfile: `{"sub":"sub-123"}`,
}, config.AuthConfig{TokenTTLHours: 2}, "127.0.0.1", "go-test")
}, config.AuthConfig{TokenTTLHours: 2}, "127.0.0.1", "go-test", false)
if err != nil {
t.Fatalf("loginWithOIDCProfile() error = %v", err)
}
Expand Down Expand Up @@ -78,7 +78,7 @@ func TestOIDCLoginReusesExistingIdentity(t *testing.T) {
Name: "Updated Name",
Picture: "https://example.com/updated.png",
RawProfile: `{"sub":"sub-123"}`,
}, config.AuthConfig{TokenTTLHours: 2}, "127.0.0.1", "go-test")
}, config.AuthConfig{TokenTTLHours: 2}, "127.0.0.1", "go-test", false)
if err != nil {
t.Fatalf("loginWithOIDCProfile() error = %v", err)
}
Expand Down Expand Up @@ -133,7 +133,7 @@ func TestOIDCLoginSyncsOrganizationsAndTeams(t *testing.T) {
RawProfile: `{"sub":"7a3562bb-f529-45e0-bdfa-b73ca55ce8c8"}`,
}

ret, err := svc.loginWithOIDCProfile(profile, config.AuthConfig{TokenTTLHours: 2}, "127.0.0.1", "go-test")
ret, err := svc.loginWithOIDCProfile(profile, config.AuthConfig{TokenTTLHours: 2}, "127.0.0.1", "go-test", false)
if err != nil {
t.Fatalf("loginWithOIDCProfile() error = %v", err)
}
Expand Down Expand Up @@ -176,3 +176,116 @@ func TestOIDCLoginSyncsOrganizationsAndTeams(t *testing.T) {
t.Fatalf("expected priority level 10 for LEAD, got %d", agentProfile.PriorityLevel)
}
}

// A first-time OIDC login from the support portal must provision a
// customer-type user with no staff role, no agent profile, and no
// organization/team provisioning: the dashboard middleware rejects
// non-employee users, so the portal door cannot open the staff surface.
func TestOIDCLoginPortalOriginCreatesCustomerUserWithoutStaffProvisioning(t *testing.T) {
db := setupAuthServiceTestDB(t)
svc := newOIDCLoginService()

ret, err := svc.loginWithOIDCProfile(&oidcLoginProfile{
Subject: "customer-sub-1",
Email: "customer@example.com",
PreferredUsername: "portalcustomer",
Name: "Portal Customer",
RawProfile: `{"sub":"customer-sub-1"}`,
}, config.AuthConfig{TokenTTLHours: 2}, "127.0.0.1", "go-test", true)
if err != nil {
t.Fatalf("loginWithOIDCProfile() error = %v", err)
}
if ret == nil || !strings.HasPrefix(ret.AccessToken, "ak_") {
t.Fatalf("expected ak_ access token, got %+v", ret)
}

var user models.User
if err := db.Take(&user, "username = ?", "portalcustomer").Error; err != nil {
t.Fatalf("expected portal customer user to be created: %v", err)
}
if user.UserType != enums.UserTypeUser {
t.Fatalf("portal-origin user type = %q, want customer (%q)", user.UserType, enums.UserTypeUser)
}

var roleCount int64
if err := db.Model(&models.UserRole{}).Where("user_id = ?", user.ID).Count(&roleCount).Error; err != nil {
t.Fatalf("count user roles: %v", err)
}
if roleCount != 0 {
t.Fatalf("portal-origin user must not receive staff roles, got %d", roleCount)
}

var orgCount int64
if err := db.Model(&models.Organization{}).Count(&orgCount).Error; err != nil {
t.Fatalf("count organizations: %v", err)
}
if orgCount != 0 {
t.Fatalf("portal-origin login must not provision organizations, got %d", orgCount)
}

var profileCount int64
if err := db.Model(&models.AgentProfile{}).Where("user_id = ?", user.ID).Count(&profileCount).Error; err != nil {
t.Fatalf("count agent profiles: %v", err)
}
if profileCount != 0 {
t.Fatalf("portal-origin user must not receive an agent profile, got %d", profileCount)
}
}

// Even an allowlisted break-glass admin must stay a role-less customer when
// entering through the support portal: the allowlist elevates on the staff
// surface only.
func TestOIDCLoginPortalOriginIgnoresBreakGlassAllowlist(t *testing.T) {
db := setupAuthServiceTestDB(t)
svc := newOIDCLoginService()

ret, err := svc.loginWithOIDCProfile(&oidcLoginProfile{
Subject: "admin-sub-1",
Email: "joy@dos.ai",
PreferredUsername: "joy",
Name: "Joy",
RawProfile: `{"sub":"admin-sub-1"}`,
}, config.AuthConfig{TokenTTLHours: 2, BreakGlassEmails: "joy@dos.ai"}, "127.0.0.1", "go-test", true)
if err != nil {
t.Fatalf("loginWithOIDCProfile() error = %v", err)
}
if ret == nil {
t.Fatalf("expected non-nil login response")
}

var user models.User
if err := db.Take(&user, "username = ?", "joy").Error; err != nil {
t.Fatalf("expected portal user to be created: %v", err)
}
if user.UserType != enums.UserTypeUser {
t.Fatalf("portal-origin admin email user type = %q, want customer", user.UserType)
}

var roleCount int64
if err := db.Model(&models.UserRole{}).Where("user_id = ?", user.ID).Count(&roleCount).Error; err != nil {
t.Fatalf("count user roles: %v", err)
}
if roleCount != 0 {
t.Fatalf("break-glass allowlist must not elevate on the portal surface, got %d roles", roleCount)
}
}

func TestIsSupportPortalNext(t *testing.T) {
cases := []struct {
next string
expected bool
}{
{next: "/support/community", expected: true},
{next: "/support", expected: true},
{next: " /support/tickets ", expected: true},
{next: "/dashboard", expected: false},
{next: "", expected: false},
{next: "/supportevil", expected: false},
Comment on lines +278 to +283

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Add test cases to verify that isSupportPortalNext correctly handles query parameters and URL fragments.

		{next: "/support/community", expected: true},
		{next: "/support", expected: true},
		{next: " /support/tickets ", expected: true},
		{next: "/support?ticket=123", expected: true},
		{next: "/support/tickets?resolved=true#anchor", expected: true},
		{next: "/dashboard", expected: false},
		{next: "", expected: false},
		{next: "/supportevil", expected: false},

}

for _, tc := range cases {
if got := isSupportPortalNext(tc.next); got != tc.expected {
t.Fatalf("isSupportPortalNext(%q) = %v, want %v", tc.next, got, tc.expected)
}
}
}
Loading