Files
coder/enterprise/coderd/userauth_test.go
Steven Masley c3c23ed3d9 chore: add query to fetch top level idp claim fields (#15525)
Adds an api endpoint to grab all available sync field options for IDP
sync. This is for autocomplete on idp sync forms. This is required for
organization admins to have some insight into the claim fields available
when configuring group/role sync.
2024-11-18 14:31:39 -06:00

1194 lines
37 KiB
Go

package coderd_test
import (
"context"
"net/http"
"regexp"
"testing"
"github.com/golang-jwt/jwt/v4"
"github.com/google/uuid"
"github.com/stretchr/testify/require"
"golang.org/x/xerrors"
"github.com/coder/coder/v2/coderd"
"github.com/coder/coder/v2/coderd/coderdtest"
"github.com/coder/coder/v2/coderd/coderdtest/oidctest"
"github.com/coder/coder/v2/coderd/database"
"github.com/coder/coder/v2/coderd/database/db2sdk"
"github.com/coder/coder/v2/coderd/database/dbauthz"
"github.com/coder/coder/v2/coderd/database/dbgen"
"github.com/coder/coder/v2/coderd/database/dbtestutil"
"github.com/coder/coder/v2/coderd/rbac"
"github.com/coder/coder/v2/coderd/util/slice"
"github.com/coder/coder/v2/codersdk"
coderden "github.com/coder/coder/v2/enterprise/coderd"
"github.com/coder/coder/v2/enterprise/coderd/coderdenttest"
"github.com/coder/coder/v2/enterprise/coderd/license"
"github.com/coder/coder/v2/testutil"
"github.com/coder/serpent"
)
// nolint:bodyclose
func TestUserOIDC(t *testing.T) {
t.Parallel()
t.Run("OrganizationSync", func(t *testing.T) {
t.Parallel()
t.Run("SingleOrgDeployment", func(t *testing.T) {
t.Parallel()
runner := setupOIDCTest(t, oidcTestConfig{
Config: func(cfg *coderd.OIDCConfig) {
cfg.AllowSignups = true
},
DeploymentValues: func(dv *codersdk.DeploymentValues) {
dv.OIDC.UserRoleField = "roles"
},
})
claims := jwt.MapClaims{
"email": "alice@coder.com",
}
// Login a new client that signs up
client, resp := runner.Login(t, claims)
require.Equal(t, http.StatusOK, resp.StatusCode)
runner.AssertOrganizations(t, "alice", true, nil)
// Force a refresh, and assert nothing has changes
runner.ForceRefresh(t, client, claims)
runner.AssertOrganizations(t, "alice", true, nil)
})
t.Run("MultiOrgNoSync", func(t *testing.T) {
t.Parallel()
runner := setupOIDCTest(t, oidcTestConfig{
Config: func(cfg *coderd.OIDCConfig) {
cfg.AllowSignups = true
},
})
ctx := testutil.Context(t, testutil.WaitMedium)
second, err := runner.AdminClient.CreateOrganization(ctx, codersdk.CreateOrganizationRequest{
Name: "second",
DisplayName: "",
Description: "",
Icon: "",
})
require.NoError(t, err)
claims := jwt.MapClaims{
"email": "alice@coder.com",
}
// Login a new client that signs up
_, resp := runner.Login(t, claims)
require.Equal(t, http.StatusOK, resp.StatusCode)
runner.AssertOrganizations(t, "alice", true, nil)
// Add alice to new org
_, err = runner.AdminClient.PostOrganizationMember(ctx, second.ID, "alice")
require.NoError(t, err)
// Log in again to refresh the sync. The user should not be removed
// from the second organization.
runner.Login(t, claims)
runner.AssertOrganizations(t, "alice", true, []uuid.UUID{second.ID})
})
t.Run("MultiOrgWithDefault", func(t *testing.T) {
t.Parallel()
// Given: 4 organizations: default, second, third, and fourth
runner := setupOIDCTest(t, oidcTestConfig{
Config: func(cfg *coderd.OIDCConfig) {
cfg.AllowSignups = true
},
DeploymentValues: func(dv *codersdk.DeploymentValues) {
// Will be overwritten by dynamic value
dv.OIDC.OrganizationAssignDefault = false
dv.OIDC.OrganizationField = "organization"
dv.OIDC.OrganizationMapping = serpent.Struct[map[string][]uuid.UUID]{
Value: map[string][]uuid.UUID{},
}
},
})
ctx := testutil.Context(t, testutil.WaitMedium)
orgOne, err := runner.AdminClient.CreateOrganization(ctx, codersdk.CreateOrganizationRequest{
Name: "one",
DisplayName: "One",
Description: "",
Icon: "",
})
require.NoError(t, err)
orgTwo, err := runner.AdminClient.CreateOrganization(ctx, codersdk.CreateOrganizationRequest{
Name: "two",
DisplayName: "two",
Description: "",
Icon: "",
})
require.NoError(t, err)
orgThree, err := runner.AdminClient.CreateOrganization(ctx, codersdk.CreateOrganizationRequest{
Name: "three",
DisplayName: "three",
})
require.NoError(t, err)
expectedSettings := codersdk.OrganizationSyncSettings{
Field: "organization",
Mapping: map[string][]uuid.UUID{
"first": {orgOne.ID},
"second": {orgTwo.ID},
},
AssignDefault: true,
}
settings, err := runner.AdminClient.PatchOrganizationIDPSyncSettings(ctx, expectedSettings)
require.NoError(t, err)
require.Equal(t, expectedSettings.Field, settings.Field)
claims := jwt.MapClaims{
"email": "alice@coder.com",
"organization": []string{"first", "second"},
}
// Then: a new user logs in with claims "second" and "third", they
// should belong to [default, second, third].
userClient, resp := runner.Login(t, claims)
require.Equal(t, http.StatusOK, resp.StatusCode)
runner.AssertOrganizations(t, "alice", true, []uuid.UUID{orgOne.ID, orgTwo.ID})
user, err := userClient.User(ctx, codersdk.Me)
require.NoError(t, err)
// Then: the available sync fields should be "email" and "organization"
fields, err := runner.AdminClient.GetAvailableIDPSyncFields(ctx)
require.NoError(t, err)
require.ElementsMatch(t, []string{
"aud", "exp", "iss", // Always included from jwt
"email", "organization",
}, fields)
// This should be the same as above
orgFields, err := runner.AdminClient.GetOrganizationAvailableIDPSyncFields(ctx, orgOne.ID.String())
require.NoError(t, err)
require.ElementsMatch(t, fields, orgFields)
// When: they are manually added to the fourth organization, a new sync
// should remove them.
_, err = runner.AdminClient.PostOrganizationMember(ctx, orgThree.ID, "alice")
require.ErrorContains(t, err, "Organization sync is enabled")
runner.AssertOrganizations(t, "alice", true, []uuid.UUID{orgOne.ID, orgTwo.ID})
// Go around the block to add the user to see if they are removed.
dbgen.OrganizationMember(t, runner.API.Database, database.OrganizationMember{
UserID: user.ID,
OrganizationID: orgThree.ID,
})
runner.AssertOrganizations(t, "alice", true, []uuid.UUID{orgOne.ID, orgTwo.ID, orgThree.ID})
// Then: Log in again will resync the orgs to their updated
// claims.
runner.Login(t, jwt.MapClaims{
"email": "alice@coder.com",
"organization": []string{"second"},
})
runner.AssertOrganizations(t, "alice", true, []uuid.UUID{orgTwo.ID})
})
t.Run("MultiOrgWithoutDefault", func(t *testing.T) {
t.Parallel()
second := uuid.New()
third := uuid.New()
// Given: 4 organizations: default, second, third, and fourth
runner := setupOIDCTest(t, oidcTestConfig{
Config: func(cfg *coderd.OIDCConfig) {
cfg.AllowSignups = true
},
DeploymentValues: func(dv *codersdk.DeploymentValues) {
dv.OIDC.OrganizationAssignDefault = false
dv.OIDC.OrganizationField = "organization"
dv.OIDC.OrganizationMapping = serpent.Struct[map[string][]uuid.UUID]{
Value: map[string][]uuid.UUID{
"second": {second},
"third": {third},
},
}
},
})
dbgen.Organization(t, runner.API.Database, database.Organization{
ID: second,
})
dbgen.Organization(t, runner.API.Database, database.Organization{
ID: third,
})
fourth := dbgen.Organization(t, runner.API.Database, database.Organization{})
ctx := testutil.Context(t, testutil.WaitMedium)
claims := jwt.MapClaims{
"email": "alice@coder.com",
"organization": []string{"second", "third"},
}
// Then: a new user logs in with claims "second" and "third", they
// should belong to [ second, third].
userClient, resp := runner.Login(t, claims)
require.Equal(t, http.StatusOK, resp.StatusCode)
runner.AssertOrganizations(t, "alice", false, []uuid.UUID{second, third})
user, err := userClient.User(ctx, codersdk.Me)
require.NoError(t, err)
// When: they are manually added to the fourth organization, a new sync
// should remove them.
dbgen.OrganizationMember(t, runner.API.Database, database.OrganizationMember{
UserID: user.ID,
OrganizationID: fourth.ID,
})
runner.AssertOrganizations(t, "alice", false, []uuid.UUID{second, third, fourth.ID})
// Then: Log in again will resync the orgs to their updated
// claims.
runner.Login(t, jwt.MapClaims{
"email": "alice@coder.com",
"organization": []string{"third"},
})
runner.AssertOrganizations(t, "alice", false, []uuid.UUID{third})
})
})
t.Run("RoleSync", func(t *testing.T) {
t.Parallel()
// NoRoles is the "control group". It has claims with 0 roles
// assigned, and asserts that the user has no roles.
t.Run("NoRoles", func(t *testing.T) {
t.Parallel()
runner := setupOIDCTest(t, oidcTestConfig{
Config: func(cfg *coderd.OIDCConfig) {
cfg.AllowSignups = true
},
DeploymentValues: func(dv *codersdk.DeploymentValues) {
dv.OIDC.UserRoleField = "roles"
},
})
claims := jwt.MapClaims{
"email": "alice@coder.com",
}
// Login a new client that signs up
client, resp := runner.Login(t, claims)
require.Equal(t, http.StatusOK, resp.StatusCode)
// User should be in 0 groups.
runner.AssertRoles(t, "alice", []string{})
// Force a refresh, and assert nothing has changes
runner.ForceRefresh(t, client, claims)
runner.AssertRoles(t, "alice", []string{})
runner.AssertOrganizations(t, "alice", true, nil)
})
// Some IDPs (ADFS) send the "string" type vs "[]string" if only
// 1 role exists.
t.Run("SingleRoleString", func(t *testing.T) {
t.Parallel()
const oidcRoleName = "TemplateAuthor"
runner := setupOIDCTest(t, oidcTestConfig{
Config: func(cfg *coderd.OIDCConfig) {
cfg.AllowSignups = true
},
DeploymentValues: func(dv *codersdk.DeploymentValues) {
dv.OIDC.UserRoleField = "roles"
dv.OIDC.UserRoleMapping = serpent.Struct[map[string][]string]{
Value: map[string][]string{
oidcRoleName: {rbac.RoleTemplateAdmin().String()},
},
}
},
})
// User starts with the owner role
_, resp := runner.Login(t, jwt.MapClaims{
"email": "alice@coder.com",
// This is sent as a **string** intentionally instead
// of an array.
"roles": oidcRoleName,
})
require.Equal(t, http.StatusOK, resp.StatusCode)
runner.AssertRoles(t, "alice", []string{rbac.RoleTemplateAdmin().String()})
runner.AssertOrganizations(t, "alice", true, nil)
})
// A user has some roles, then on an oauth refresh will lose said
// roles from an updated claim.
t.Run("NewUserAndRemoveRolesOnRefresh", func(t *testing.T) {
// TODO: Implement new feature to update roles/groups on OIDC
// refresh tokens. https://github.com/coder/coder/issues/9312
t.Skip("Refreshing tokens does not update roles :(")
t.Parallel()
const oidcRoleName = "TemplateAuthor"
runner := setupOIDCTest(t, oidcTestConfig{
Userinfo: jwt.MapClaims{oidcRoleName: []string{rbac.RoleTemplateAdmin().String(), rbac.RoleUserAdmin().String()}},
Config: func(cfg *coderd.OIDCConfig) {
cfg.AllowSignups = true
},
DeploymentValues: func(dv *codersdk.DeploymentValues) {
dv.OIDC.UserRoleField = "roles"
dv.OIDC.UserRoleMapping = serpent.Struct[map[string][]string]{
Value: map[string][]string{
oidcRoleName: {rbac.RoleTemplateAdmin().String(), rbac.RoleUserAdmin().String()},
},
}
},
})
// User starts with the owner role
client, resp := runner.Login(t, jwt.MapClaims{
"email": "alice@coder.com",
"roles": []string{"random", oidcRoleName, rbac.RoleOwner().String()},
})
require.Equal(t, http.StatusOK, resp.StatusCode)
runner.AssertRoles(t, "alice", []string{rbac.RoleTemplateAdmin().String(), rbac.RoleUserAdmin().String(), rbac.RoleOwner().String()})
// Now refresh the oauth, and check the roles are removed.
// Force a refresh, and assert nothing has changes
runner.ForceRefresh(t, client, jwt.MapClaims{
"email": "alice@coder.com",
"roles": []string{"random"},
})
runner.AssertRoles(t, "alice", []string{})
runner.AssertOrganizations(t, "alice", true, nil)
})
// A user has some roles, then on another oauth login will lose said
// roles from an updated claim.
t.Run("NewUserAndRemoveRolesOnReAuth", func(t *testing.T) {
t.Parallel()
const oidcRoleName = "TemplateAuthor"
runner := setupOIDCTest(t, oidcTestConfig{
Userinfo: jwt.MapClaims{oidcRoleName: []string{rbac.RoleTemplateAdmin().String(), rbac.RoleUserAdmin().String()}},
Config: func(cfg *coderd.OIDCConfig) {
cfg.AllowSignups = true
},
DeploymentValues: func(dv *codersdk.DeploymentValues) {
dv.OIDC.UserRoleField = "roles"
dv.OIDC.UserRoleMapping = serpent.Struct[map[string][]string]{
Value: map[string][]string{
oidcRoleName: {rbac.RoleTemplateAdmin().String(), rbac.RoleUserAdmin().String()},
},
}
},
})
// User starts with the owner role
_, resp := runner.Login(t, jwt.MapClaims{
"email": "alice@coder.com",
"roles": []string{"random", oidcRoleName, rbac.RoleOwner().String()},
})
require.Equal(t, http.StatusOK, resp.StatusCode)
runner.AssertRoles(t, "alice", []string{rbac.RoleTemplateAdmin().String(), rbac.RoleUserAdmin().String(), rbac.RoleOwner().String()})
// Now login with oauth again, and check the roles are removed.
_, resp = runner.Login(t, jwt.MapClaims{
"email": "alice@coder.com",
"roles": []string{"random"},
})
require.Equal(t, http.StatusOK, resp.StatusCode)
runner.AssertRoles(t, "alice", []string{})
runner.AssertOrganizations(t, "alice", true, nil)
})
// All manual role updates should fail when role sync is enabled.
t.Run("BlockAssignRoles", func(t *testing.T) {
t.Parallel()
runner := setupOIDCTest(t, oidcTestConfig{
Config: func(cfg *coderd.OIDCConfig) {
cfg.AllowSignups = true
},
DeploymentValues: func(dv *codersdk.DeploymentValues) {
dv.OIDC.UserRoleField = "roles"
},
})
_, resp := runner.Login(t, jwt.MapClaims{
"email": "alice@coder.com",
"roles": []string{},
})
require.Equal(t, http.StatusOK, resp.StatusCode)
// Try to manually update user roles, even though controlled by oidc
// role sync.
ctx := testutil.Context(t, testutil.WaitShort)
_, err := runner.AdminClient.UpdateUserRoles(ctx, "alice", codersdk.UpdateRoles{
Roles: []string{
rbac.RoleTemplateAdmin().String(),
},
})
require.Error(t, err)
require.ErrorContains(t, err, "Cannot modify roles for OIDC users when role sync is enabled.")
})
})
t.Run("Groups", func(t *testing.T) {
t.Parallel()
// Assigns does a simple test of assigning a user to a group based
// on the oidc claims.
t.Run("Assigns", func(t *testing.T) {
t.Parallel()
const groupClaim = "custom-groups"
const groupName = "bingbong"
runner := setupOIDCTest(t, oidcTestConfig{
Config: func(cfg *coderd.OIDCConfig) {
cfg.AllowSignups = true
},
DeploymentValues: func(dv *codersdk.DeploymentValues) {
dv.OIDC.GroupField = groupClaim
},
})
ctx := testutil.Context(t, testutil.WaitShort)
group, err := runner.AdminClient.CreateGroup(ctx, runner.AdminUser.OrganizationIDs[0], codersdk.CreateGroupRequest{
Name: groupName,
})
require.NoError(t, err)
require.Len(t, group.Members, 0)
_, resp := runner.Login(t, jwt.MapClaims{
"email": "alice@coder.com",
groupClaim: []string{groupName},
})
require.Equal(t, http.StatusOK, resp.StatusCode)
runner.AssertGroups(t, "alice", []string{groupName})
runner.AssertOrganizations(t, "alice", true, nil)
})
// Tests the group mapping feature.
t.Run("AssignsMapped", func(t *testing.T) {
t.Parallel()
const groupClaim = "custom-groups"
const oidcGroupName = "pingpong"
const coderGroupName = "bingbong"
runner := setupOIDCTest(t, oidcTestConfig{
Config: func(cfg *coderd.OIDCConfig) {
cfg.AllowSignups = true
},
DeploymentValues: func(dv *codersdk.DeploymentValues) {
dv.OIDC.GroupField = groupClaim
dv.OIDC.GroupMapping = serpent.Struct[map[string]string]{Value: map[string]string{oidcGroupName: coderGroupName}}
},
})
ctx := testutil.Context(t, testutil.WaitShort)
group, err := runner.AdminClient.CreateGroup(ctx, runner.AdminUser.OrganizationIDs[0], codersdk.CreateGroupRequest{
Name: coderGroupName,
})
require.NoError(t, err)
require.Len(t, group.Members, 0)
_, resp := runner.Login(t, jwt.MapClaims{
"email": "alice@coder.com",
groupClaim: []string{oidcGroupName},
})
require.Equal(t, http.StatusOK, resp.StatusCode)
runner.AssertGroups(t, "alice", []string{coderGroupName})
runner.AssertOrganizations(t, "alice", true, nil)
})
// User is in a group, then on an oauth refresh will lose said
// group.
t.Run("AddThenRemoveOnRefresh", func(t *testing.T) {
t.Parallel()
// TODO: Implement new feature to update roles/groups on OIDC
// refresh tokens. https://github.com/coder/coder/issues/9312
t.Skip("Refreshing tokens does not update groups :(")
const groupClaim = "custom-groups"
const groupName = "bingbong"
runner := setupOIDCTest(t, oidcTestConfig{
Config: func(cfg *coderd.OIDCConfig) {
cfg.AllowSignups = true
},
DeploymentValues: func(dv *codersdk.DeploymentValues) {
dv.OIDC.GroupField = groupClaim
},
})
ctx := testutil.Context(t, testutil.WaitShort)
group, err := runner.AdminClient.CreateGroup(ctx, runner.AdminUser.OrganizationIDs[0], codersdk.CreateGroupRequest{
Name: groupName,
})
require.NoError(t, err)
require.Len(t, group.Members, 0)
client, resp := runner.Login(t, jwt.MapClaims{
"email": "alice@coder.com",
groupClaim: []string{groupName},
})
require.Equal(t, http.StatusOK, resp.StatusCode)
runner.AssertGroups(t, "alice", []string{groupName})
// Refresh without the group claim
runner.ForceRefresh(t, client, jwt.MapClaims{
"email": "alice@coder.com",
})
runner.AssertGroups(t, "alice", []string{})
runner.AssertOrganizations(t, "alice", true, nil)
})
t.Run("AddThenRemoveOnReAuth", func(t *testing.T) {
t.Parallel()
const groupClaim = "custom-groups"
const groupName = "bingbong"
runner := setupOIDCTest(t, oidcTestConfig{
Config: func(cfg *coderd.OIDCConfig) {
cfg.AllowSignups = true
},
DeploymentValues: func(dv *codersdk.DeploymentValues) {
dv.OIDC.GroupField = groupClaim
},
})
ctx := testutil.Context(t, testutil.WaitShort)
group, err := runner.AdminClient.CreateGroup(ctx, runner.AdminUser.OrganizationIDs[0], codersdk.CreateGroupRequest{
Name: groupName,
})
require.NoError(t, err)
require.Len(t, group.Members, 0)
_, resp := runner.Login(t, jwt.MapClaims{
"email": "alice@coder.com",
groupClaim: []string{groupName},
})
require.Equal(t, http.StatusOK, resp.StatusCode)
runner.AssertGroups(t, "alice", []string{groupName})
// Refresh without the group claim
_, resp = runner.Login(t, jwt.MapClaims{
"email": "alice@coder.com",
})
require.Equal(t, http.StatusOK, resp.StatusCode)
runner.AssertGroups(t, "alice", []string{})
runner.AssertOrganizations(t, "alice", true, nil)
})
// Updating groups where the claimed group does not exist.
t.Run("NoneMatch", func(t *testing.T) {
t.Parallel()
const groupClaim = "custom-groups"
runner := setupOIDCTest(t, oidcTestConfig{
Config: func(cfg *coderd.OIDCConfig) {
cfg.AllowSignups = true
},
DeploymentValues: func(dv *codersdk.DeploymentValues) {
dv.OIDC.GroupField = groupClaim
},
})
_, resp := runner.Login(t, jwt.MapClaims{
"email": "alice@coder.com",
groupClaim: []string{"not-exists"},
})
require.Equal(t, http.StatusOK, resp.StatusCode)
runner.AssertGroups(t, "alice", []string{})
})
// Updating groups where the claimed group does not exist creates
// the group.
t.Run("AutoCreate", func(t *testing.T) {
t.Parallel()
const groupClaim = "custom-groups"
const groupName = "make-me"
runner := setupOIDCTest(t, oidcTestConfig{
Config: func(cfg *coderd.OIDCConfig) {
cfg.AllowSignups = true
},
DeploymentValues: func(dv *codersdk.DeploymentValues) {
dv.OIDC.GroupField = groupClaim
dv.OIDC.GroupAutoCreate = true
},
})
_, resp := runner.Login(t, jwt.MapClaims{
"email": "alice@coder.com",
groupClaim: []string{groupName},
})
require.Equal(t, http.StatusOK, resp.StatusCode)
runner.AssertGroups(t, "alice", []string{groupName})
})
// Some IDPs (ADFS) send the "string" type vs "[]string" if only
// 1 group exists.
t.Run("SingleRoleGroup", func(t *testing.T) {
t.Parallel()
const groupClaim = "custom-groups"
const groupName = "bingbong"
runner := setupOIDCTest(t, oidcTestConfig{
Config: func(cfg *coderd.OIDCConfig) {
cfg.AllowSignups = true
},
DeploymentValues: func(dv *codersdk.DeploymentValues) {
dv.OIDC.GroupField = groupClaim
dv.OIDC.GroupAutoCreate = true
},
})
// User starts with the owner role
_, resp := runner.Login(t, jwt.MapClaims{
"email": "alice@coder.com",
// This is sent as a **string** intentionally instead
// of an array.
groupClaim: groupName,
})
require.Equal(t, http.StatusOK, resp.StatusCode)
runner.AssertGroups(t, "alice", []string{groupName})
})
t.Run("GroupAllowList", func(t *testing.T) {
t.Parallel()
const groupClaim = "custom-groups"
const allowedGroup = "foo"
runner := setupOIDCTest(t, oidcTestConfig{
Config: func(cfg *coderd.OIDCConfig) {
cfg.AllowSignups = true
},
DeploymentValues: func(dv *codersdk.DeploymentValues) {
dv.OIDC.GroupField = groupClaim
dv.OIDC.GroupAllowList = []string{allowedGroup}
},
})
// Test forbidden
_, resp := runner.AttemptLogin(t, jwt.MapClaims{
"email": "alice@coder.com",
groupClaim: []string{"not-allowed"},
})
require.Equal(t, http.StatusForbidden, resp.StatusCode)
// Test allowed
client, _ := runner.Login(t, jwt.MapClaims{
"email": "alice@coder.com",
groupClaim: []string{allowedGroup},
})
ctx := testutil.Context(t, testutil.WaitShort)
_, err := client.User(ctx, codersdk.Me)
require.NoError(t, err)
})
})
t.Run("Refresh", func(t *testing.T) {
t.Run("RefreshTokensMultiple", func(t *testing.T) {
t.Parallel()
runner := setupOIDCTest(t, oidcTestConfig{
Config: func(cfg *coderd.OIDCConfig) {
cfg.AllowSignups = true
},
DeploymentValues: func(dv *codersdk.DeploymentValues) {
dv.OIDC.UserRoleField = "roles"
},
})
claims := jwt.MapClaims{
"email": "alice@coder.com",
}
// Login a new client that signs up
client, resp := runner.Login(t, claims)
require.Equal(t, http.StatusOK, resp.StatusCode)
// Refresh multiple times.
for i := 0; i < 3; i++ {
runner.ForceRefresh(t, client, claims)
}
})
t.Run("FailedRefresh", func(t *testing.T) {
t.Parallel()
runner := setupOIDCTest(t, oidcTestConfig{
FakeOpts: []oidctest.FakeIDPOpt{
oidctest.WithRefresh(func(_ string) error {
// Always "expired" refresh token.
return xerrors.New("refresh token is expired")
}),
},
Config: func(cfg *coderd.OIDCConfig) {
cfg.AllowSignups = true
},
})
claims := jwt.MapClaims{
"email": "alice@coder.com",
}
// Login a new client that signs up
client, resp := runner.Login(t, claims)
require.Equal(t, http.StatusOK, resp.StatusCode)
// Expire the token, cause a refresh
runner.ExpireOauthToken(t, client)
// This should fail because the oauth token refresh should fail.
_, err := client.User(context.Background(), codersdk.Me)
require.Error(t, err)
var apiError *codersdk.Error
require.ErrorAs(t, err, &apiError)
require.Equal(t, http.StatusUnauthorized, apiError.StatusCode())
require.ErrorContains(t, apiError, "refresh")
})
})
}
// nolint:bodyclose
func TestGroupSync(t *testing.T) {
t.Parallel()
testCases := []struct {
name string
modCfg func(cfg *coderd.OIDCConfig)
modDV func(dv *codersdk.DeploymentValues)
// initialOrgGroups is initial groups in the org
initialOrgGroups []string
// initialUserGroups is initial groups for the user
initialUserGroups []string
// expectedUserGroups is expected groups for the user
expectedUserGroups []string
// expectedOrgGroups is expected all groups on the system
expectedOrgGroups []string
claims jwt.MapClaims
}{
{
name: "NoGroups",
modCfg: func(cfg *coderd.OIDCConfig) {
},
initialOrgGroups: []string{},
expectedUserGroups: []string{},
expectedOrgGroups: []string{},
claims: jwt.MapClaims{},
},
{
name: "GroupSyncDisabled",
modDV: func(dv *codersdk.DeploymentValues) {
// Disable group sync
dv.OIDC.GroupField = ""
dv.OIDC.GroupRegexFilter = serpent.Regexp(*regexp.MustCompile(".*"))
},
initialOrgGroups: []string{"a", "b", "c", "d"},
initialUserGroups: []string{"b", "c", "d"},
expectedUserGroups: []string{"b", "c", "d"},
expectedOrgGroups: []string{"a", "b", "c", "d"},
claims: jwt.MapClaims{},
},
{
// From a,c,b -> b,c,d
name: "ChangeUserGroups",
modDV: func(dv *codersdk.DeploymentValues) {
dv.OIDC.GroupMapping = serpent.Struct[map[string]string]{Value: map[string]string{"D": "d"}}
},
initialOrgGroups: []string{"a", "b", "c", "d"},
initialUserGroups: []string{"a", "b", "c"},
expectedUserGroups: []string{"b", "c", "d"},
expectedOrgGroups: []string{"a", "b", "c", "d"},
claims: jwt.MapClaims{
// D -> d mapped
"groups": []string{"b", "c", "D"},
},
},
{
// From a,c,b -> []
name: "RemoveAllGroups",
modDV: func(dv *codersdk.DeploymentValues) {
dv.OIDC.GroupRegexFilter = serpent.Regexp(*regexp.MustCompile(".*"))
},
initialOrgGroups: []string{"a", "b", "c", "d"},
initialUserGroups: []string{"a", "b", "c"},
expectedUserGroups: []string{},
expectedOrgGroups: []string{"a", "b", "c", "d"},
claims: jwt.MapClaims{
// No claim == no groups
},
},
{
// From a,c,b -> b,c,d,e,f
name: "CreateMissingGroups",
modDV: func(dv *codersdk.DeploymentValues) {
dv.OIDC.GroupAutoCreate = true
},
initialOrgGroups: []string{"a", "b", "c", "d"},
initialUserGroups: []string{"a", "b", "c"},
expectedUserGroups: []string{"b", "c", "d", "e", "f"},
expectedOrgGroups: []string{"a", "b", "c", "d", "e", "f"},
claims: jwt.MapClaims{
"groups": []string{"b", "c", "d", "e", "f"},
},
},
{
// From a,c,b -> b,c,d,e,f
name: "CreateMissingGroupsFilter",
modDV: func(dv *codersdk.DeploymentValues) {
dv.OIDC.GroupAutoCreate = true
// Only single letter groups
dv.OIDC.GroupRegexFilter = serpent.Regexp(*regexp.MustCompile("^[a-z]$"))
dv.OIDC.GroupMapping = serpent.Struct[map[string]string]{Value: map[string]string{"zebra": "z"}}
},
initialOrgGroups: []string{"a", "b", "c", "d"},
initialUserGroups: []string{"a", "b", "c"},
expectedUserGroups: []string{"b", "c", "d", "e", "f", "z"},
expectedOrgGroups: []string{"a", "b", "c", "d", "e", "f", "z"},
claims: jwt.MapClaims{
"groups": []string{
"b", "c", "d", "e", "f",
// These groups are ignored
"excess", "ignore", "dumb", "foobar", "zebra",
},
},
},
}
for _, tc := range testCases {
tc := tc
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
runner := setupOIDCTest(t, oidcTestConfig{
Config: func(cfg *coderd.OIDCConfig) {
if tc.modCfg != nil {
tc.modCfg(cfg)
}
},
DeploymentValues: func(dv *codersdk.DeploymentValues) {
dv.OIDC.GroupField = "groups"
if tc.modDV != nil {
tc.modDV(dv)
}
},
})
// Setup
ctx := testutil.Context(t, testutil.WaitLong)
org := runner.AdminUser.OrganizationIDs[0]
initialGroups := make(map[string]codersdk.Group)
for _, group := range tc.initialOrgGroups {
newGroup, err := runner.AdminClient.CreateGroup(ctx, org, codersdk.CreateGroupRequest{
Name: group,
})
require.NoError(t, err)
require.Len(t, newGroup.Members, 0)
initialGroups[group] = newGroup
}
// Create the user and add them to their initial groups
_, user := coderdtest.CreateAnotherUser(t, runner.AdminClient, org)
for _, group := range tc.initialUserGroups {
_, err := runner.AdminClient.PatchGroup(ctx, initialGroups[group].ID, codersdk.PatchGroupRequest{
AddUsers: []string{user.ID.String()},
})
require.NoError(t, err)
}
// nolint:gocritic
_, err := runner.API.Database.UpdateUserLoginType(dbauthz.AsSystemRestricted(ctx), database.UpdateUserLoginTypeParams{
NewLoginType: database.LoginTypeOIDC,
UserID: user.ID,
})
require.NoError(t, err, "user must be oidc type")
// Log in the new user
tc.claims["email"] = user.Email
_, resp := runner.Login(t, tc.claims)
require.Equal(t, http.StatusOK, resp.StatusCode)
// Check group sources
orgGroups, err := runner.AdminClient.GroupsByOrganization(ctx, org)
require.NoError(t, err)
for _, group := range orgGroups {
if slice.Contains(tc.initialOrgGroups, group.Name) || group.IsEveryone() {
require.Equal(t, group.Source, codersdk.GroupSourceUser)
} else {
require.Equal(t, group.Source, codersdk.GroupSourceOIDC)
}
}
orgGroupsMap := make(map[string]struct{})
for _, group := range orgGroups {
orgGroupsMap[group.Name] = struct{}{}
}
for _, expected := range tc.expectedOrgGroups {
if _, ok := orgGroupsMap[expected]; !ok {
t.Errorf("expected group %s not found", expected)
}
delete(orgGroupsMap, expected)
}
delete(orgGroupsMap, database.EveryoneGroup)
require.Empty(t, orgGroupsMap, "unexpected groups found")
expectedUserGroups := make(map[string]struct{})
for _, group := range tc.expectedUserGroups {
expectedUserGroups[group] = struct{}{}
}
for _, group := range orgGroups {
userInGroup := slice.ContainsCompare(group.Members, codersdk.ReducedUser{Email: user.Email}, func(a, b codersdk.ReducedUser) bool {
return a.Email == b.Email
})
if group.IsEveryone() {
require.True(t, userInGroup, "user cannot be removed from 'Everyone' group")
} else if _, ok := expectedUserGroups[group.Name]; ok {
require.Truef(t, userInGroup, "user should be in group %s", group.Name)
} else {
require.Falsef(t, userInGroup, "user should not be in group %s", group.Name)
}
}
})
}
}
func TestEnterpriseUserLogin(t *testing.T) {
t.Parallel()
// Login to a user with a custom organization role set.
t.Run("CustomRole", func(t *testing.T) {
t.Parallel()
dv := coderdtest.DeploymentValues(t)
ownerClient, owner := coderdenttest.New(t, &coderdenttest.Options{
Options: &coderdtest.Options{
DeploymentValues: dv,
},
LicenseOptions: &coderdenttest.LicenseOptions{
Features: license.Features{
codersdk.FeatureCustomRoles: 1,
},
},
})
ctx := testutil.Context(t, testutil.WaitShort)
//nolint:gocritic // owner required
customRole, err := ownerClient.CreateOrganizationRole(ctx, codersdk.Role{
Name: "custom-role",
OrganizationID: owner.OrganizationID.String(),
OrganizationPermissions: []codersdk.Permission{},
})
require.NoError(t, err, "create custom role")
anotherClient, anotherUser := coderdtest.CreateAnotherUserMutators(t, ownerClient, owner.OrganizationID, []rbac.RoleIdentifier{
{
Name: customRole.Name,
OrganizationID: owner.OrganizationID,
},
}, func(r *codersdk.CreateUserRequestWithOrgs) {
r.Password = "SomeSecurePassword!"
r.UserLoginType = codersdk.LoginTypePassword
})
_, err = anotherClient.LoginWithPassword(ctx, codersdk.LoginWithPasswordRequest{
Email: anotherUser.Email,
Password: "SomeSecurePassword!",
})
require.NoError(t, err)
})
// Login to a user with a custom organization role that no longer exists
t.Run("DeletedRole", func(t *testing.T) {
t.Parallel()
// The dbauthz layer protects against deleted roles. So use the underlying
// database directly to corrupt it.
rawDB, pubsub := dbtestutil.NewDB(t)
ownerClient, owner := coderdenttest.New(t, &coderdenttest.Options{
Options: &coderdtest.Options{
Database: rawDB,
Pubsub: pubsub,
},
LicenseOptions: &coderdenttest.LicenseOptions{
Features: license.Features{
codersdk.FeatureCustomRoles: 1,
},
},
})
anotherClient, anotherUser := coderdtest.CreateAnotherUserMutators(t, ownerClient, owner.OrganizationID, nil, func(r *codersdk.CreateUserRequestWithOrgs) {
r.Password = "SomeSecurePassword!"
r.UserLoginType = codersdk.LoginTypePassword
})
ctx := testutil.Context(t, testutil.WaitShort)
_, err := rawDB.UpdateMemberRoles(ctx, database.UpdateMemberRolesParams{
GrantedRoles: []string{"not-exists"},
UserID: anotherUser.ID,
OrgID: owner.OrganizationID,
})
require.NoError(t, err, "assign not-exists role")
_, err = anotherClient.LoginWithPassword(ctx, codersdk.LoginWithPasswordRequest{
Email: anotherUser.Email,
Password: "SomeSecurePassword!",
})
require.NoError(t, err)
})
}
// oidcTestRunner is just a helper to setup and run oidc tests.
// An actual Coderd instance is used to run the tests.
type oidcTestRunner struct {
AdminClient *codersdk.Client
AdminUser codersdk.User
API *coderden.API
// Login will call the OIDC flow with an unauthenticated client.
// The IDP will return the idToken claims.
Login func(t *testing.T, idToken jwt.MapClaims) (*codersdk.Client, *http.Response)
AttemptLogin func(t *testing.T, idToken jwt.MapClaims) (*codersdk.Client, *http.Response)
// ForceRefresh will use an authenticated codersdk.Client, and force their
// OIDC token to be expired and require a refresh. The refresh will use the claims provided.
// It just calls the /users/me endpoint to trigger the refresh.
ForceRefresh func(t *testing.T, client *codersdk.Client, idToken jwt.MapClaims)
ExpireOauthToken func(t *testing.T, client *codersdk.Client)
}
type oidcTestConfig struct {
Userinfo jwt.MapClaims
// Config allows modifying the Coderd OIDC configuration.
Config func(cfg *coderd.OIDCConfig)
DeploymentValues func(dv *codersdk.DeploymentValues)
FakeOpts []oidctest.FakeIDPOpt
}
func (r *oidcTestRunner) AssertOrganizations(t *testing.T, userIdent string, includeDefault bool, expected []uuid.UUID) {
t.Helper()
ctx := testutil.Context(t, testutil.WaitMedium)
userOrgs, err := r.AdminClient.OrganizationsByUser(ctx, userIdent)
require.NoError(t, err)
cpy := make([]uuid.UUID, 0, len(expected))
cpy = append(cpy, expected...)
hasDefault := false
userOrgIDs := db2sdk.List(userOrgs, func(o codersdk.Organization) uuid.UUID {
if o.IsDefault {
hasDefault = true
cpy = append(cpy, o.ID)
}
return o.ID
})
require.Equal(t, includeDefault, hasDefault, "expected default org")
require.ElementsMatch(t, cpy, userOrgIDs, "expected orgs")
}
func (r *oidcTestRunner) AssertRoles(t *testing.T, userIdent string, roles []string) {
t.Helper()
ctx := testutil.Context(t, testutil.WaitMedium)
user, err := r.AdminClient.User(ctx, userIdent)
require.NoError(t, err)
roleNames := []string{}
for _, role := range user.Roles {
roleNames = append(roleNames, role.Name)
}
require.ElementsMatch(t, roles, roleNames, "expected roles")
}
func (r *oidcTestRunner) AssertGroups(t *testing.T, userIdent string, groups []string) {
t.Helper()
if !slice.Contains(groups, database.EveryoneGroup) {
var cpy []string
cpy = append(cpy, groups...)
// always include everyone group
cpy = append(cpy, database.EveryoneGroup)
groups = cpy
}
ctx := testutil.Context(t, testutil.WaitMedium)
user, err := r.AdminClient.User(ctx, userIdent)
require.NoError(t, err)
allGroups, err := r.AdminClient.GroupsByOrganization(ctx, user.OrganizationIDs[0])
require.NoError(t, err)
userInGroups := []string{}
for _, g := range allGroups {
for _, mem := range g.Members {
if mem.ID == user.ID {
userInGroups = append(userInGroups, g.Name)
}
}
}
require.ElementsMatch(t, groups, userInGroups, "expected groups")
}
func setupOIDCTest(t *testing.T, settings oidcTestConfig) *oidcTestRunner {
t.Helper()
fake := oidctest.NewFakeIDP(t,
append([]oidctest.FakeIDPOpt{
oidctest.WithStaticUserInfo(settings.Userinfo),
oidctest.WithLogging(t, nil),
// Run fake IDP on a real webserver
oidctest.WithServing(),
}, settings.FakeOpts...)...,
)
ctx := testutil.Context(t, testutil.WaitMedium)
cfg := fake.OIDCConfig(t, nil, settings.Config)
dv := coderdtest.DeploymentValues(t)
if settings.DeploymentValues != nil {
settings.DeploymentValues(dv)
}
owner, _, api, _ := coderdenttest.NewWithAPI(t, &coderdenttest.Options{
Options: &coderdtest.Options{
OIDCConfig: cfg,
DeploymentValues: dv,
},
LicenseOptions: &coderdenttest.LicenseOptions{
Features: license.Features{
codersdk.FeatureUserRoleManagement: 1,
codersdk.FeatureTemplateRBAC: 1,
codersdk.FeatureMultipleOrganizations: 1,
},
},
})
admin, err := owner.User(ctx, "me")
require.NoError(t, err)
helper := oidctest.NewLoginHelper(owner, fake)
return &oidcTestRunner{
AdminClient: owner,
AdminUser: admin,
API: api,
Login: helper.Login,
AttemptLogin: helper.AttemptLogin,
ForceRefresh: func(t *testing.T, client *codersdk.Client, idToken jwt.MapClaims) {
helper.ForceRefresh(t, api.Database, client, idToken)
},
ExpireOauthToken: func(t *testing.T, client *codersdk.Client) {
helper.ExpireOauthToken(t, api.Database, client)
},
}
}