Skip to content

feat: add group allowlist for oidc #11070

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

Merged
merged 12 commits into from
Dec 8, 2023
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
10 changes: 10 additions & 0 deletions cli/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -147,6 +147,15 @@ func createOIDCConfig(ctx context.Context, vals *codersdk.DeploymentValues) (*co
}
useCfg = pkiCfg
}
if len(vals.OIDC.GroupAllowList) > 0 && vals.OIDC.GroupField == "" {
return nil, xerrors.Errorf("'oidc-group-field' must be set if 'oidc-allowed-groups' is set. Either unset 'oidc-allowed-groups' or set 'oidc-group-field'")
}

groupAllowList := make(map[string]bool)
for _, group := range vals.OIDC.GroupAllowList.Value() {
groupAllowList[group] = true
}

return &coderd.OIDCConfig{
OAuth2Config: useCfg,
Provider: oidcProvider,
Expand All @@ -161,6 +170,7 @@ func createOIDCConfig(ctx context.Context, vals *codersdk.DeploymentValues) (*co
IgnoreUserInfo: vals.OIDC.IgnoreUserInfo.Value(),
GroupField: vals.OIDC.GroupField.String(),
GroupFilter: vals.OIDC.GroupRegexFilter.Value(),
GroupAllowList: groupAllowList,
CreateMissingGroups: vals.OIDC.GroupAutoCreate.Value(),
GroupMapping: vals.OIDC.GroupMapping.Value,
UserRoleField: vals.OIDC.UserRoleField.String(),
Expand Down
6 changes: 6 additions & 0 deletions cli/testdata/coder_server_--help.golden
Original file line number Diff line number Diff line change
Expand Up @@ -333,6 +333,12 @@ OIDC OPTIONS:
--oidc-allow-signups bool, $CODER_OIDC_ALLOW_SIGNUPS (default: true)
Whether new users can sign up with OIDC.

--oidc-allowed-groups string-array, $CODER_OIDC_ALLOWED_GROUPS
If provided any group name not in the list will not be allowed to
authenticate. This allows for restricting access to a specific set of
groups. This filter is applied after the group mapping and before the
regex filter.

--oidc-auth-url-params struct[map[string]string], $CODER_OIDC_AUTH_URL_PARAMS (default: {"access_type": "offline"})
OIDC auth URL parameters to pass to the upstream provider.

Expand Down
5 changes: 5 additions & 0 deletions cli/testdata/server-config.yaml.golden
Original file line number Diff line number Diff line change
Expand Up @@ -323,6 +323,11 @@ oidc:
# mapping.
# (default: .*, type: regexp)
groupRegexFilter: .*
# If provided any group name not in the list will not be allowed to authenticate.
# This allows for restricting access to a specific set of groups. This filter is
# applied after the group mapping and before the regex filter.
# (default: <unset>, type: string-array)
groupAllowed: []
# This field must be set if using the user roles sync feature. Set this to the
# name of the claim used to store the user's role. The roles should be sent as an
# array of strings.
Expand Down
6 changes: 6 additions & 0 deletions coderd/apidoc/docs.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

6 changes: 6 additions & 0 deletions coderd/apidoc/swagger.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

8 changes: 8 additions & 0 deletions coderd/coderdtest/oidctest/helper.go
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,14 @@ func (h *LoginHelper) Login(t *testing.T, idTokenClaims jwt.MapClaims) (*codersd
return h.fake.Login(t, unauthenticatedClient, idTokenClaims)
}

// AttemptLogin does not assert a successful login.
func (h *LoginHelper) AttemptLogin(t *testing.T, idTokenClaims jwt.MapClaims) (*codersdk.Client, *http.Response) {
t.Helper()
unauthenticatedClient := codersdk.New(h.client.URL)

return h.fake.AttemptLogin(t, unauthenticatedClient, idTokenClaims)
}

// ExpireOauthToken expires the oauth token for the given user.
func (*LoginHelper) ExpireOauthToken(t *testing.T, db database.Store, user *codersdk.Client) database.UserLink {
t.Helper()
Expand Down
29 changes: 29 additions & 0 deletions coderd/userauth.go
Original file line number Diff line number Diff line change
Expand Up @@ -697,6 +697,10 @@ type OIDCConfig struct {
// the OIDC provider. Any group not matched by this regex will be ignored.
// If the group filter is nil, then no group filtering will occur.
GroupFilter *regexp.Regexp
// GroupAllowList is a list of groups that are allowed to log in.
// If the list length is 0, then the allow list will not be applied and
// this feature is disabled.
GroupAllowList map[string]bool
// GroupMapping controls how groups returned by the OIDC provider get mapped
// to groups within Coder.
// map[oidcGroupName]coderGroupName
Expand Down Expand Up @@ -921,6 +925,7 @@ func (api *API) userOIDC(rw http.ResponseWriter, r *http.Request) {
picture, _ = pictureRaw.(string)
}

ctx = slog.With(ctx, slog.F("email", email), slog.F("username", username))
usingGroups, groups, groupErr := api.oidcGroups(ctx, mergedClaims)
if groupErr != nil {
groupErr.Write(rw, r)
Expand Down Expand Up @@ -1010,6 +1015,10 @@ func (api *API) oidcGroups(ctx context.Context, mergedClaims map[string]interfac
// If the GroupField is the empty string, then groups from OIDC are not used.
// This is so we can support manual group assignment.
if api.OIDCConfig.GroupField != "" {
// If the allow list is empty, then the user is allowed to log in.
// Otherwise, they must belong to at least 1 group in the allow list.
inAllowList := len(api.OIDCConfig.GroupAllowList) == 0

usingGroups = true
groupsRaw, ok := mergedClaims[api.OIDCConfig.GroupField]
if ok {
Expand All @@ -1036,9 +1045,29 @@ func (api *API) oidcGroups(ctx context.Context, mergedClaims map[string]interfac
if mappedGroup, ok := api.OIDCConfig.GroupMapping[group]; ok {
group = mappedGroup
}
if _, ok := api.OIDCConfig.GroupAllowList[group]; ok {
inAllowList = true
}
groups = append(groups, group)
}
}

if !inAllowList {
logger.Debug(ctx, "oidc group claim not in allow list, rejecting login",
slog.F("allow_list_count", len(api.OIDCConfig.GroupAllowList)),
slog.F("user_group_count", len(groups)),
)
detail := "Ask an administrator to add one of your groups to the whitelist"
if len(groups) == 0 {
detail = "You are currently not a member of any groups! Ask an administrator to add you to an authorized group to login."
}
return usingGroups, groups, &httpError{
code: http.StatusForbidden,
msg: "Not a member of an allowed group",
detail: detail,
renderStaticPage: true,
}
}
}

// This conditional is purely to warn the user they might have misconfigured their OIDC
Expand Down
11 changes: 11 additions & 0 deletions codersdk/deployment.go
Original file line number Diff line number Diff line change
Expand Up @@ -291,6 +291,7 @@ type OIDCConfig struct {
IgnoreUserInfo clibase.Bool `json:"ignore_user_info" typescript:",notnull"`
GroupAutoCreate clibase.Bool `json:"group_auto_create" typescript:",notnull"`
GroupRegexFilter clibase.Regexp `json:"group_regex_filter" typescript:",notnull"`
GroupAllowList clibase.StringArray `json:"group_allow_list" typescript:",notnull"`
GroupField clibase.String `json:"groups_field" typescript:",notnull"`
GroupMapping clibase.Struct[map[string]string] `json:"group_mapping" typescript:",notnull"`
UserRoleField clibase.String `json:"user_role_field" typescript:",notnull"`
Expand Down Expand Up @@ -1187,6 +1188,16 @@ when required by your organization's security policy.`,
Group: &deploymentGroupOIDC,
YAML: "groupRegexFilter",
},
{
Name: "OIDC Allowed Groups",
Description: "If provided any group name not in the list will not be allowed to authenticate. This allows for restricting access to a specific set of groups. This filter is applied after the group mapping and before the regex filter.",
Flag: "oidc-allowed-groups",
Env: "CODER_OIDC_ALLOWED_GROUPS",
Default: "",
Value: &c.OIDC.GroupAllowList,
Group: &deploymentGroupOIDC,
YAML: "groupAllowed",
},
{
Name: "OIDC User Role Field",
Description: "This field must be set if using the user roles sync feature. Set this to the name of the claim used to store the user's role. The roles should be sent as an array of strings.",
Expand Down
1 change: 1 addition & 0 deletions docs/api/general.md

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 4 additions & 0 deletions docs/api/schemas.md

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

10 changes: 10 additions & 0 deletions docs/cli/server.md

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

6 changes: 6 additions & 0 deletions enterprise/cli/testdata/coder_server_--help.golden
Original file line number Diff line number Diff line change
Expand Up @@ -334,6 +334,12 @@ OIDC OPTIONS:
--oidc-allow-signups bool, $CODER_OIDC_ALLOW_SIGNUPS (default: true)
Whether new users can sign up with OIDC.

--oidc-allowed-groups string-array, $CODER_OIDC_ALLOWED_GROUPS
If provided any group name not in the list will not be allowed to
authenticate. This allows for restricting access to a specific set of
groups. This filter is applied after the group mapping and before the
regex filter.

--oidc-auth-url-params struct[map[string]string], $CODER_OIDC_AUTH_URL_PARAMS (default: {"access_type": "offline"})
OIDC auth URL parameters to pass to the upstream provider.

Expand Down
43 changes: 38 additions & 5 deletions enterprise/coderd/userauth_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -387,6 +387,37 @@ func TestUserOIDC(t *testing.T) {
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) {
Expand Down Expand Up @@ -661,7 +692,8 @@ type oidcTestRunner struct {

// 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)
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.
Expand Down Expand Up @@ -751,10 +783,11 @@ func setupOIDCTest(t *testing.T, settings oidcTestConfig) *oidcTestRunner {
helper := oidctest.NewLoginHelper(owner, fake)

return &oidcTestRunner{
AdminClient: owner,
AdminUser: admin,
API: api,
Login: helper.Login,
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)
},
Expand Down
1 change: 1 addition & 0 deletions site/src/api/typesGenerated.ts

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.