mirror of
https://github.com/coder/coder.git
synced 2025-07-06 15:41:45 +00:00
* chore: create type for unique role names Using `string` was confusing when something should be combined with org context, and when not to. Naming this new name, "RoleIdentifier"
799 lines
24 KiB
Go
799 lines
24 KiB
Go
package coderd_test
|
|
|
|
import (
|
|
"context"
|
|
"net/http"
|
|
"regexp"
|
|
"testing"
|
|
|
|
"github.com/golang-jwt/jwt/v4"
|
|
"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/dbauthz"
|
|
"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"
|
|
)
|
|
|
|
// nolint:bodyclose
|
|
func TestUserOIDC(t *testing.T) {
|
|
t.Parallel()
|
|
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
|
|
cfg.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{})
|
|
})
|
|
|
|
// 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
|
|
cfg.UserRoleField = "roles"
|
|
cfg.UserRoleMapping = 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()})
|
|
})
|
|
|
|
// 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
|
|
cfg.UserRoleField = "roles"
|
|
cfg.UserRoleMapping = 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{})
|
|
})
|
|
|
|
// 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
|
|
cfg.UserRoleField = "roles"
|
|
cfg.UserRoleMapping = 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{})
|
|
})
|
|
|
|
// 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
|
|
cfg.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
|
|
cfg.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})
|
|
})
|
|
|
|
// 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
|
|
cfg.GroupField = groupClaim
|
|
cfg.GroupMapping = 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})
|
|
})
|
|
|
|
// 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
|
|
cfg.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{})
|
|
})
|
|
|
|
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
|
|
cfg.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{})
|
|
})
|
|
|
|
// 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
|
|
cfg.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
|
|
cfg.GroupField = groupClaim
|
|
cfg.CreateMissingGroups = 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
|
|
cfg.GroupField = groupClaim
|
|
cfg.CreateMissingGroups = 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
|
|
cfg.GroupField = groupClaim
|
|
cfg.GroupAllowList = map[string]bool{allowedGroup: true}
|
|
},
|
|
})
|
|
|
|
// 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
|
|
cfg.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)
|
|
// 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",
|
|
modCfg: func(cfg *coderd.OIDCConfig) {
|
|
// Disable group sync
|
|
cfg.GroupField = ""
|
|
cfg.GroupFilter = 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",
|
|
modCfg: func(cfg *coderd.OIDCConfig) {
|
|
cfg.GroupMapping = 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",
|
|
modCfg: func(cfg *coderd.OIDCConfig) {
|
|
cfg.GroupFilter = 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",
|
|
modCfg: func(cfg *coderd.OIDCConfig) {
|
|
cfg.CreateMissingGroups = 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",
|
|
modCfg: func(cfg *coderd.OIDCConfig) {
|
|
cfg.CreateMissingGroups = true
|
|
// Only single letter groups
|
|
cfg.GroupFilter = regexp.MustCompile("^[a-z]$")
|
|
cfg.GroupMapping = map[string]string{
|
|
// Does not match the filter, but does after being mapped!
|
|
"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) {
|
|
cfg.GroupField = "groups"
|
|
tc.modCfg(cfg)
|
|
},
|
|
})
|
|
|
|
// 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)
|
|
}
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// 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)
|
|
FakeOpts []oidctest.FakeIDPOpt
|
|
}
|
|
|
|
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)
|
|
owner, _, api, _ := coderdenttest.NewWithAPI(t, &coderdenttest.Options{
|
|
Options: &coderdtest.Options{
|
|
OIDCConfig: cfg,
|
|
},
|
|
LicenseOptions: &coderdenttest.LicenseOptions{
|
|
Features: license.Features{
|
|
codersdk.FeatureUserRoleManagement: 1,
|
|
codersdk.FeatureTemplateRBAC: 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)
|
|
},
|
|
}
|
|
}
|