-
Notifications
You must be signed in to change notification settings - Fork 0
fix(oidc): portal-origin logins provision customer-type users #19
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -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 | ||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||
|
|
@@ -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/") | ||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
| 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") | ||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||
|
|
@@ -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 | ||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||
|
|
@@ -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
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 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 (
Suggested change
|
||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
| if err = repositories.UserIdentityRepository.Updates(ctx.Tx, identity.ID, map[string]any{ | ||||||||||||||||||||||||||||||
| "provider_name": enums.GetThirdProviderLabel(enums.ThirdProviderOIDC), | ||||||||||||||||||||||||||||||
|
|
@@ -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, | ||||||||||||||||||||||||||||||
|
|
@@ -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 | ||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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) | ||
| } | ||
|
|
@@ -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) | ||
| } | ||
|
|
@@ -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) | ||
| } | ||
|
|
@@ -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
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Add test cases to verify that {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) | ||
| } | ||
| } | ||
| } | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The current implementation of
isSupportPortalNextdoes not handle query parameters or URL fragments (e.g.,/support?ticket=123or/support/tickets?resolved=true#anchor). If a customer logs in via the support portal with any query parameters or fragments in the redirect path,isSupportPortalNextwill returnfalse. 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.