From 1b0b274862fdd4cad6d228be08b97ed052e3a2ca Mon Sep 17 00:00:00 2001 From: Cian Johnston Date: Fri, 31 Mar 2023 13:45:20 +0100 Subject: [PATCH 01/10] fix: coderd: do not hit oidc userinfo endpoint if all required claims are present in id_token --- coderd/userauth.go | 140 ++++++++++++++++++++++++++++++---------- coderd/userauth_test.go | 18 +++++- 2 files changed, 124 insertions(+), 34 deletions(-) diff --git a/coderd/userauth.go b/coderd/userauth.go index 558a44aa32c74..15a38295a7851 100644 --- a/coderd/userauth.go +++ b/coderd/userauth.go @@ -7,6 +7,7 @@ import ( "fmt" "net/http" "net/mail" + "sort" "strconv" "strings" @@ -551,46 +552,62 @@ func (api *API) userOIDC(rw http.ResponseWriter, r *http.Request) { return } + api.Logger.Debug(ctx, "got oidc claims", + slog.F("source", "id_token"), + slog.F("claim_fields", claimFields(claims)), + slog.F("blank", blankFields(claims)), + ) + // Not all claims are necessarily embedded in the `id_token`. // In GitLab, the username is left empty and must be fetched in UserInfo. // // The OIDC specification says claims can be in either place, so we fetch - // user info and merge the two claim sets to be sure we have all of - // the correct data. - userInfo, err := api.OIDCConfig.Provider.UserInfo(ctx, oauth2.StaticTokenSource(state.Token)) - if err == nil { - userInfoClaims := map[string]interface{}{} - err = userInfo.Claims(&userInfoClaims) - if err != nil { + // user info if required and merge the two claim sets to be sure we have + // all of the correct data. + // + // However, if we already have the required claims, we can just skip + // the UserInfo call. + needUserInfo := !requiredClaimsPresent(api.OIDCConfig, claims) + if needUserInfo { + userInfo, err := api.OIDCConfig.Provider.UserInfo(ctx, oauth2.StaticTokenSource(state.Token)) + if err == nil { + userInfoClaims := map[string]interface{}{} + err = userInfo.Claims(&userInfoClaims) + if err != nil { + httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ + Message: "Failed to unmarshal user info claims.", + Detail: err.Error(), + }) + return + } + api.Logger.Debug(ctx, "got oidc claims", + slog.F("source", "userinfo"), + slog.F("claim_fields", claimFields(userInfoClaims)), + slog.F("blank", blankFields(userInfoClaims)), + ) + + // Merge the claims from the ID token and the UserInfo endpoint. + // Information from UserInfo takes precedence. + claims = mergeClaims(claims, userInfoClaims) + + // Log all of the field names after merging. + api.Logger.Debug(ctx, "got oidc claims", + slog.F("source", "merged"), + slog.F("claim_fields", claimFields(claims)), + slog.F("blank", blankFields(claims)), + ) + } else if !strings.Contains(err.Error(), "user info endpoint is not supported by this provider") { httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ - Message: "Failed to unmarshal user info claims.", - Detail: err.Error(), + Message: "Failed to obtain user information claims.", + Detail: "The OIDC provider returned no claims as part of the `id_token`. The attempt to fetch claims via the UserInfo endpoint failed: " + err.Error(), }) return + } else { + // The OIDC provider does not support the UserInfo endpoint. + // This is not an error, but we should log it as it may mean + // that some claims are missing. + api.Logger.Warn(ctx, "OIDC provider does not support the user info endpoint, ensure that all required claims are present in the id_token") } - for k, v := range userInfoClaims { - claims[k] = v - } - } else if !strings.Contains(err.Error(), "user info endpoint is not supported by this provider") { - httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ - Message: "Failed to obtain user information claims.", - Detail: "The OIDC provider returned no claims as part of the `id_token`. The attempt to fetch claims via the UserInfo endpoint failed: " + err.Error(), - }) - return - } - - // Log all of the field names returned in the ID token claims, and the - // userinfo returned from the provider. - { - fields := make([]string, 0, len(claims)) - for f := range claims { - fields = append(fields, f) - } - - api.Logger.Debug(ctx, "got oidc claims", - slog.F("user_info", userInfo), - slog.F("claim_fields", fields), - ) } usernameRaw, ok := claims[api.OIDCConfig.UsernameField] @@ -657,7 +674,7 @@ func (api *API) userOIDC(rw http.ResponseWriter, r *http.Request) { group, ok := groupInterface.(string) if !ok { httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{ - Message: fmt.Sprintf("Invalid group type. Expected string, got: %t", emailRaw), + Message: fmt.Sprintf("Invalid group type. Expected string, got: %T", groupInterface), }) return } @@ -761,6 +778,63 @@ func (api *API) userOIDC(rw http.ResponseWriter, r *http.Request) { http.Redirect(rw, r, redirect, http.StatusTemporaryRedirect) } +// claimFields returns the sorted list of fields in the claims map. +func claimFields(claims map[string]interface{}) []string { + var fields []string + for field := range claims { + fields = append(fields, field) + } + sort.Strings(fields) + return fields +} + +// blankFields returns the list of fields in the claims map that are +// an empty string. +func blankFields(claims map[string]interface{}) []string { + fields := make([]string, 0) + for field, value := range claims { + if valueStr, ok := value.(string); ok && valueStr == "" { + fields = append(fields, field) + } + } + sort.Strings(fields) + return fields +} + +// requiredClaimsPresent returns false if any of the following claims are missing: +// - email (or the configured email field) +// - username (or the configured username field) +// - group (or the configured group field, unless GroupField is empty) +// - email_verified (unless IgnoreEmailVerified is true) +func requiredClaimsPresent(cfg *OIDCConfig, claims map[string]interface{}) bool { + if _, ok := claims[cfg.EmailField]; !ok { + return false + } + if _, ok := claims[cfg.UsernameField]; !ok { + return false + } + if _, hasGroupField := claims[cfg.GroupField]; !hasGroupField && cfg.GroupField != "" { + return false + } + if _, hasEmailVerifiedField := claims["email_verified"]; !hasEmailVerifiedField && !cfg.IgnoreEmailVerified { + return false + } + return true +} + +// mergeClaims merges the claims from a and b and returns the merged set. +// claims from b take precedence over claims from a. +func mergeClaims(a, b map[string]interface{}) map[string]interface{} { + c := make(map[string]interface{}) + for k, v := range a { + c[k] = v + } + for k, v := range b { + c[k] = v + } + return c +} + type oauthLoginParams struct { User database.User Link database.UserLink diff --git a/coderd/userauth_test.go b/coderd/userauth_test.go index 90c28cdaadc28..624fe3b27cc15 100644 --- a/coderd/userauth_test.go +++ b/coderd/userauth_test.go @@ -643,7 +643,23 @@ func TestUserOIDC(t *testing.T) { }, AllowSignups: true, StatusCode: http.StatusTemporaryRedirect, - }} { + }, { + Name: "UserInfoOverridesIDTokenClaims", + IDTokenClaims: jwt.MapClaims{ + "email": "internaluser@internal.domain", + "email_verified": false, + }, + UserInfoClaims: jwt.MapClaims{ + "email": "externaluser@external.domain", + "email_verified": true, + "preferred_username": "user", + }, + Username: "user", + AllowSignups: true, + IgnoreEmailVerified: false, + StatusCode: http.StatusTemporaryRedirect, + }, + } { tc := tc t.Run(tc.Name, func(t *testing.T) { t.Parallel() From 20991bba92b9ee71a94b01b97a92f4658970833a Mon Sep 17 00:00:00 2001 From: Cian Johnston Date: Fri, 31 Mar 2023 13:47:19 +0100 Subject: [PATCH 02/10] fix: codersdk: fix incorrectly named OIDC_GROUP_MAPPING -> CODER_OIDC_GROUP_MAPPING --- codersdk/deployment.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/codersdk/deployment.go b/codersdk/deployment.go index ebace3488709d..44f67c3895cbb 100644 --- a/codersdk/deployment.go +++ b/codersdk/deployment.go @@ -900,7 +900,7 @@ when required by your organization's security policy.`, Name: "OIDC Group Mapping", Description: "A map of OIDC group IDs and the group in Coder it should map to. This is useful for when OIDC providers only return group IDs.", Flag: "oidc-group-mapping", - Env: "OIDC_GROUP_MAPPING", + Env: "CODER_OIDC_GROUP_MAPPING", Default: "{}", Value: &c.OIDC.GroupMapping, Group: &deploymentGroupOIDC, From 38f1714188a70978b9504649bcae7575c9fa945c Mon Sep 17 00:00:00 2001 From: Cian Johnston Date: Fri, 31 Mar 2023 13:48:06 +0100 Subject: [PATCH 03/10] make gen fmt lint --- coderd/userauth_test.go | 315 ++++++++++++++++++++-------------------- docs/cli/server.md | 2 +- 2 files changed, 159 insertions(+), 158 deletions(-) diff --git a/coderd/userauth_test.go b/coderd/userauth_test.go index 624fe3b27cc15..ddda2791a433f 100644 --- a/coderd/userauth_test.go +++ b/coderd/userauth_test.go @@ -501,164 +501,165 @@ func TestUserOIDC(t *testing.T) { AvatarURL string StatusCode int IgnoreEmailVerified bool - }{{ - Name: "EmailOnly", - IDTokenClaims: jwt.MapClaims{ - "email": "kyle@kwc.io", - }, - AllowSignups: true, - StatusCode: http.StatusTemporaryRedirect, - Username: "kyle", - }, { - Name: "EmailNotVerified", - IDTokenClaims: jwt.MapClaims{ - "email": "kyle@kwc.io", - "email_verified": false, - }, - AllowSignups: true, - StatusCode: http.StatusForbidden, - }, { - Name: "EmailNotAString", - IDTokenClaims: jwt.MapClaims{ - "email": 3.14159, - "email_verified": false, - }, - AllowSignups: true, - StatusCode: http.StatusBadRequest, - }, { - Name: "EmailNotVerifiedIgnored", - IDTokenClaims: jwt.MapClaims{ - "email": "kyle@kwc.io", - "email_verified": false, - }, - AllowSignups: true, - StatusCode: http.StatusTemporaryRedirect, - Username: "kyle", - IgnoreEmailVerified: true, - }, { - Name: "NotInRequiredEmailDomain", - IDTokenClaims: jwt.MapClaims{ - "email": "kyle@kwc.io", - "email_verified": true, - }, - AllowSignups: true, - EmailDomain: []string{ - "coder.com", - }, - StatusCode: http.StatusForbidden, - }, { - Name: "EmailDomainCaseInsensitive", - IDTokenClaims: jwt.MapClaims{ - "email": "kyle@KWC.io", - "email_verified": true, - }, - AllowSignups: true, - EmailDomain: []string{ - "kwc.io", - }, - StatusCode: http.StatusTemporaryRedirect, - }, { - Name: "EmptyClaims", - IDTokenClaims: jwt.MapClaims{}, - AllowSignups: true, - StatusCode: http.StatusBadRequest, - }, { - Name: "NoSignups", - IDTokenClaims: jwt.MapClaims{ - "email": "kyle@kwc.io", - "email_verified": true, - }, - StatusCode: http.StatusForbidden, - }, { - Name: "UsernameFromEmail", - IDTokenClaims: jwt.MapClaims{ - "email": "kyle@kwc.io", - "email_verified": true, - }, - Username: "kyle", - AllowSignups: true, - StatusCode: http.StatusTemporaryRedirect, - }, { - Name: "UsernameFromClaims", - IDTokenClaims: jwt.MapClaims{ - "email": "kyle@kwc.io", - "email_verified": true, - "preferred_username": "hotdog", - }, - Username: "hotdog", - AllowSignups: true, - StatusCode: http.StatusTemporaryRedirect, - }, { - // Services like Okta return the email as the username: - // https://developer.okta.com/docs/reference/api/oidc/#base-claims-always-present - Name: "UsernameAsEmail", - IDTokenClaims: jwt.MapClaims{ - "email": "kyle@kwc.io", - "email_verified": true, - "preferred_username": "kyle@kwc.io", - }, - Username: "kyle", - AllowSignups: true, - StatusCode: http.StatusTemporaryRedirect, - }, { - // See: https://github.com/coder/coder/issues/4472 - Name: "UsernameIsEmail", - IDTokenClaims: jwt.MapClaims{ - "preferred_username": "kyle@kwc.io", - }, - Username: "kyle", - AllowSignups: true, - StatusCode: http.StatusTemporaryRedirect, - }, { - Name: "WithPicture", - IDTokenClaims: jwt.MapClaims{ - "email": "kyle@kwc.io", - "email_verified": true, - "preferred_username": "kyle", - "picture": "/example.png", - }, - Username: "kyle", - AllowSignups: true, - AvatarURL: "/example.png", - StatusCode: http.StatusTemporaryRedirect, - }, { - Name: "WithUserInfoClaims", - IDTokenClaims: jwt.MapClaims{ - "email": "kyle@kwc.io", - "email_verified": true, - }, - UserInfoClaims: jwt.MapClaims{ - "preferred_username": "potato", - "picture": "/example.png", - }, - Username: "potato", - AllowSignups: true, - AvatarURL: "/example.png", - StatusCode: http.StatusTemporaryRedirect, - }, { - Name: "GroupsDoesNothing", - IDTokenClaims: jwt.MapClaims{ - "email": "coolin@coder.com", - "groups": []string{"pingpong"}, - }, - AllowSignups: true, - StatusCode: http.StatusTemporaryRedirect, - }, { - Name: "UserInfoOverridesIDTokenClaims", - IDTokenClaims: jwt.MapClaims{ - "email": "internaluser@internal.domain", - "email_verified": false, - }, - UserInfoClaims: jwt.MapClaims{ - "email": "externaluser@external.domain", - "email_verified": true, - "preferred_username": "user", + }{ + { + Name: "EmailOnly", + IDTokenClaims: jwt.MapClaims{ + "email": "kyle@kwc.io", + }, + AllowSignups: true, + StatusCode: http.StatusTemporaryRedirect, + Username: "kyle", + }, { + Name: "EmailNotVerified", + IDTokenClaims: jwt.MapClaims{ + "email": "kyle@kwc.io", + "email_verified": false, + }, + AllowSignups: true, + StatusCode: http.StatusForbidden, + }, { + Name: "EmailNotAString", + IDTokenClaims: jwt.MapClaims{ + "email": 3.14159, + "email_verified": false, + }, + AllowSignups: true, + StatusCode: http.StatusBadRequest, + }, { + Name: "EmailNotVerifiedIgnored", + IDTokenClaims: jwt.MapClaims{ + "email": "kyle@kwc.io", + "email_verified": false, + }, + AllowSignups: true, + StatusCode: http.StatusTemporaryRedirect, + Username: "kyle", + IgnoreEmailVerified: true, + }, { + Name: "NotInRequiredEmailDomain", + IDTokenClaims: jwt.MapClaims{ + "email": "kyle@kwc.io", + "email_verified": true, + }, + AllowSignups: true, + EmailDomain: []string{ + "coder.com", + }, + StatusCode: http.StatusForbidden, + }, { + Name: "EmailDomainCaseInsensitive", + IDTokenClaims: jwt.MapClaims{ + "email": "kyle@KWC.io", + "email_verified": true, + }, + AllowSignups: true, + EmailDomain: []string{ + "kwc.io", + }, + StatusCode: http.StatusTemporaryRedirect, + }, { + Name: "EmptyClaims", + IDTokenClaims: jwt.MapClaims{}, + AllowSignups: true, + StatusCode: http.StatusBadRequest, + }, { + Name: "NoSignups", + IDTokenClaims: jwt.MapClaims{ + "email": "kyle@kwc.io", + "email_verified": true, + }, + StatusCode: http.StatusForbidden, + }, { + Name: "UsernameFromEmail", + IDTokenClaims: jwt.MapClaims{ + "email": "kyle@kwc.io", + "email_verified": true, + }, + Username: "kyle", + AllowSignups: true, + StatusCode: http.StatusTemporaryRedirect, + }, { + Name: "UsernameFromClaims", + IDTokenClaims: jwt.MapClaims{ + "email": "kyle@kwc.io", + "email_verified": true, + "preferred_username": "hotdog", + }, + Username: "hotdog", + AllowSignups: true, + StatusCode: http.StatusTemporaryRedirect, + }, { + // Services like Okta return the email as the username: + // https://developer.okta.com/docs/reference/api/oidc/#base-claims-always-present + Name: "UsernameAsEmail", + IDTokenClaims: jwt.MapClaims{ + "email": "kyle@kwc.io", + "email_verified": true, + "preferred_username": "kyle@kwc.io", + }, + Username: "kyle", + AllowSignups: true, + StatusCode: http.StatusTemporaryRedirect, + }, { + // See: https://github.com/coder/coder/issues/4472 + Name: "UsernameIsEmail", + IDTokenClaims: jwt.MapClaims{ + "preferred_username": "kyle@kwc.io", + }, + Username: "kyle", + AllowSignups: true, + StatusCode: http.StatusTemporaryRedirect, + }, { + Name: "WithPicture", + IDTokenClaims: jwt.MapClaims{ + "email": "kyle@kwc.io", + "email_verified": true, + "preferred_username": "kyle", + "picture": "/example.png", + }, + Username: "kyle", + AllowSignups: true, + AvatarURL: "/example.png", + StatusCode: http.StatusTemporaryRedirect, + }, { + Name: "WithUserInfoClaims", + IDTokenClaims: jwt.MapClaims{ + "email": "kyle@kwc.io", + "email_verified": true, + }, + UserInfoClaims: jwt.MapClaims{ + "preferred_username": "potato", + "picture": "/example.png", + }, + Username: "potato", + AllowSignups: true, + AvatarURL: "/example.png", + StatusCode: http.StatusTemporaryRedirect, + }, { + Name: "GroupsDoesNothing", + IDTokenClaims: jwt.MapClaims{ + "email": "coolin@coder.com", + "groups": []string{"pingpong"}, + }, + AllowSignups: true, + StatusCode: http.StatusTemporaryRedirect, + }, { + Name: "UserInfoOverridesIDTokenClaims", + IDTokenClaims: jwt.MapClaims{ + "email": "internaluser@internal.domain", + "email_verified": false, + }, + UserInfoClaims: jwt.MapClaims{ + "email": "externaluser@external.domain", + "email_verified": true, + "preferred_username": "user", + }, + Username: "user", + AllowSignups: true, + IgnoreEmailVerified: false, + StatusCode: http.StatusTemporaryRedirect, }, - Username: "user", - AllowSignups: true, - IgnoreEmailVerified: false, - StatusCode: http.StatusTemporaryRedirect, - }, } { tc := tc t.Run(tc.Name, func(t *testing.T) { diff --git a/docs/cli/server.md b/docs/cli/server.md index e9c9e73bb68d7..accbcd7bb05d6 100644 --- a/docs/cli/server.md +++ b/docs/cli/server.md @@ -371,7 +371,7 @@ Change the OIDC default 'groups' claim field. By default, will be 'groups' if pr | | | | ----------- | -------------------------------------- | | Type | struct[map[string]string] | -| Environment | $OIDC_GROUP_MAPPING | +| Environment | $CODER_OIDC_GROUP_MAPPING | | Default | {} | A map of OIDC group IDs and the group in Coder it should map to. This is useful for when OIDC providers only return group IDs. From 780f516389efa8f7416188f68deebd52b06a48e7 Mon Sep 17 00:00:00 2001 From: Cian Johnston Date: Fri, 31 Mar 2023 16:24:58 +0100 Subject: [PATCH 04/10] fixup! make gen fmt lint --- cli/testdata/coder_server_--help.golden | 2 +- coderd/userauth_test.go | 316 ++++++++++++------------ 2 files changed, 158 insertions(+), 160 deletions(-) diff --git a/cli/testdata/coder_server_--help.golden b/cli/testdata/coder_server_--help.golden index 56edb2c58de04..73f759d20fe7b 100644 --- a/cli/testdata/coder_server_--help.golden +++ b/cli/testdata/coder_server_--help.golden @@ -280,7 +280,7 @@ can safely ignore these settings. Change the OIDC default 'groups' claim field. By default, will be 'groups' if present in the oidc scopes argument. - --oidc-group-mapping struct[map[string]string], $OIDC_GROUP_MAPPING (default: {}) + --oidc-group-mapping struct[map[string]string], $CODER_OIDC_GROUP_MAPPING (default: {}) A map of OIDC group IDs and the group in Coder it should map to. This is useful for when OIDC providers only return group IDs. diff --git a/coderd/userauth_test.go b/coderd/userauth_test.go index ddda2791a433f..97ef7b957d6ac 100644 --- a/coderd/userauth_test.go +++ b/coderd/userauth_test.go @@ -501,166 +501,164 @@ func TestUserOIDC(t *testing.T) { AvatarURL string StatusCode int IgnoreEmailVerified bool - }{ - { - Name: "EmailOnly", - IDTokenClaims: jwt.MapClaims{ - "email": "kyle@kwc.io", - }, - AllowSignups: true, - StatusCode: http.StatusTemporaryRedirect, - Username: "kyle", - }, { - Name: "EmailNotVerified", - IDTokenClaims: jwt.MapClaims{ - "email": "kyle@kwc.io", - "email_verified": false, - }, - AllowSignups: true, - StatusCode: http.StatusForbidden, - }, { - Name: "EmailNotAString", - IDTokenClaims: jwt.MapClaims{ - "email": 3.14159, - "email_verified": false, - }, - AllowSignups: true, - StatusCode: http.StatusBadRequest, - }, { - Name: "EmailNotVerifiedIgnored", - IDTokenClaims: jwt.MapClaims{ - "email": "kyle@kwc.io", - "email_verified": false, - }, - AllowSignups: true, - StatusCode: http.StatusTemporaryRedirect, - Username: "kyle", - IgnoreEmailVerified: true, - }, { - Name: "NotInRequiredEmailDomain", - IDTokenClaims: jwt.MapClaims{ - "email": "kyle@kwc.io", - "email_verified": true, - }, - AllowSignups: true, - EmailDomain: []string{ - "coder.com", - }, - StatusCode: http.StatusForbidden, - }, { - Name: "EmailDomainCaseInsensitive", - IDTokenClaims: jwt.MapClaims{ - "email": "kyle@KWC.io", - "email_verified": true, - }, - AllowSignups: true, - EmailDomain: []string{ - "kwc.io", - }, - StatusCode: http.StatusTemporaryRedirect, - }, { - Name: "EmptyClaims", - IDTokenClaims: jwt.MapClaims{}, - AllowSignups: true, - StatusCode: http.StatusBadRequest, - }, { - Name: "NoSignups", - IDTokenClaims: jwt.MapClaims{ - "email": "kyle@kwc.io", - "email_verified": true, - }, - StatusCode: http.StatusForbidden, - }, { - Name: "UsernameFromEmail", - IDTokenClaims: jwt.MapClaims{ - "email": "kyle@kwc.io", - "email_verified": true, - }, - Username: "kyle", - AllowSignups: true, - StatusCode: http.StatusTemporaryRedirect, - }, { - Name: "UsernameFromClaims", - IDTokenClaims: jwt.MapClaims{ - "email": "kyle@kwc.io", - "email_verified": true, - "preferred_username": "hotdog", - }, - Username: "hotdog", - AllowSignups: true, - StatusCode: http.StatusTemporaryRedirect, - }, { - // Services like Okta return the email as the username: - // https://developer.okta.com/docs/reference/api/oidc/#base-claims-always-present - Name: "UsernameAsEmail", - IDTokenClaims: jwt.MapClaims{ - "email": "kyle@kwc.io", - "email_verified": true, - "preferred_username": "kyle@kwc.io", - }, - Username: "kyle", - AllowSignups: true, - StatusCode: http.StatusTemporaryRedirect, - }, { - // See: https://github.com/coder/coder/issues/4472 - Name: "UsernameIsEmail", - IDTokenClaims: jwt.MapClaims{ - "preferred_username": "kyle@kwc.io", - }, - Username: "kyle", - AllowSignups: true, - StatusCode: http.StatusTemporaryRedirect, - }, { - Name: "WithPicture", - IDTokenClaims: jwt.MapClaims{ - "email": "kyle@kwc.io", - "email_verified": true, - "preferred_username": "kyle", - "picture": "/example.png", - }, - Username: "kyle", - AllowSignups: true, - AvatarURL: "/example.png", - StatusCode: http.StatusTemporaryRedirect, - }, { - Name: "WithUserInfoClaims", - IDTokenClaims: jwt.MapClaims{ - "email": "kyle@kwc.io", - "email_verified": true, - }, - UserInfoClaims: jwt.MapClaims{ - "preferred_username": "potato", - "picture": "/example.png", - }, - Username: "potato", - AllowSignups: true, - AvatarURL: "/example.png", - StatusCode: http.StatusTemporaryRedirect, - }, { - Name: "GroupsDoesNothing", - IDTokenClaims: jwt.MapClaims{ - "email": "coolin@coder.com", - "groups": []string{"pingpong"}, - }, - AllowSignups: true, - StatusCode: http.StatusTemporaryRedirect, - }, { - Name: "UserInfoOverridesIDTokenClaims", - IDTokenClaims: jwt.MapClaims{ - "email": "internaluser@internal.domain", - "email_verified": false, - }, - UserInfoClaims: jwt.MapClaims{ - "email": "externaluser@external.domain", - "email_verified": true, - "preferred_username": "user", - }, - Username: "user", - AllowSignups: true, - IgnoreEmailVerified: false, - StatusCode: http.StatusTemporaryRedirect, + }{{ + Name: "EmailOnly", + IDTokenClaims: jwt.MapClaims{ + "email": "kyle@kwc.io", + }, + AllowSignups: true, + StatusCode: http.StatusTemporaryRedirect, + Username: "kyle", + }, { + Name: "EmailNotVerified", + IDTokenClaims: jwt.MapClaims{ + "email": "kyle@kwc.io", + "email_verified": false, + }, + AllowSignups: true, + StatusCode: http.StatusForbidden, + }, { + Name: "EmailNotAString", + IDTokenClaims: jwt.MapClaims{ + "email": 3.14159, + "email_verified": false, + }, + AllowSignups: true, + StatusCode: http.StatusBadRequest, + }, { + Name: "EmailNotVerifiedIgnored", + IDTokenClaims: jwt.MapClaims{ + "email": "kyle@kwc.io", + "email_verified": false, + }, + AllowSignups: true, + StatusCode: http.StatusTemporaryRedirect, + Username: "kyle", + IgnoreEmailVerified: true, + }, { + Name: "NotInRequiredEmailDomain", + IDTokenClaims: jwt.MapClaims{ + "email": "kyle@kwc.io", + "email_verified": true, + }, + AllowSignups: true, + EmailDomain: []string{ + "coder.com", + }, + StatusCode: http.StatusForbidden, + }, { + Name: "EmailDomainCaseInsensitive", + IDTokenClaims: jwt.MapClaims{ + "email": "kyle@KWC.io", + "email_verified": true, + }, + AllowSignups: true, + EmailDomain: []string{ + "kwc.io", + }, + StatusCode: http.StatusTemporaryRedirect, + }, { + Name: "EmptyClaims", + IDTokenClaims: jwt.MapClaims{}, + AllowSignups: true, + StatusCode: http.StatusBadRequest, + }, { + Name: "NoSignups", + IDTokenClaims: jwt.MapClaims{ + "email": "kyle@kwc.io", + "email_verified": true, + }, + StatusCode: http.StatusForbidden, + }, { + Name: "UsernameFromEmail", + IDTokenClaims: jwt.MapClaims{ + "email": "kyle@kwc.io", + "email_verified": true, + }, + Username: "kyle", + AllowSignups: true, + StatusCode: http.StatusTemporaryRedirect, + }, { + Name: "UsernameFromClaims", + IDTokenClaims: jwt.MapClaims{ + "email": "kyle@kwc.io", + "email_verified": true, + "preferred_username": "hotdog", + }, + Username: "hotdog", + AllowSignups: true, + StatusCode: http.StatusTemporaryRedirect, + }, { + // Services like Okta return the email as the username: + // https://developer.okta.com/docs/reference/api/oidc/#base-claims-always-present + Name: "UsernameAsEmail", + IDTokenClaims: jwt.MapClaims{ + "email": "kyle@kwc.io", + "email_verified": true, + "preferred_username": "kyle@kwc.io", + }, + Username: "kyle", + AllowSignups: true, + StatusCode: http.StatusTemporaryRedirect, + }, { + // See: https://github.com/coder/coder/issues/4472 + Name: "UsernameIsEmail", + IDTokenClaims: jwt.MapClaims{ + "preferred_username": "kyle@kwc.io", + }, + Username: "kyle", + AllowSignups: true, + StatusCode: http.StatusTemporaryRedirect, + }, { + Name: "WithPicture", + IDTokenClaims: jwt.MapClaims{ + "email": "kyle@kwc.io", + "email_verified": true, + "preferred_username": "kyle", + "picture": "/example.png", + }, + Username: "kyle", + AllowSignups: true, + AvatarURL: "/example.png", + StatusCode: http.StatusTemporaryRedirect, + }, { + Name: "WithUserInfoClaims", + IDTokenClaims: jwt.MapClaims{ + "email": "kyle@kwc.io", + "email_verified": true, + }, + UserInfoClaims: jwt.MapClaims{ + "preferred_username": "potato", + "picture": "/example.png", + }, + Username: "potato", + AllowSignups: true, + AvatarURL: "/example.png", + StatusCode: http.StatusTemporaryRedirect, + }, { + Name: "GroupsDoesNothing", + IDTokenClaims: jwt.MapClaims{ + "email": "coolin@coder.com", + "groups": []string{"pingpong"}, + }, + AllowSignups: true, + StatusCode: http.StatusTemporaryRedirect, + }, { + Name: "UserInfoOverridesIDTokenClaims", + IDTokenClaims: jwt.MapClaims{ + "email": "internaluser@internal.domain", + "email_verified": false, + }, + UserInfoClaims: jwt.MapClaims{ + "email": "externaluser@external.domain", + "email_verified": true, + "preferred_username": "user", }, - } { + Username: "user", + AllowSignups: true, + IgnoreEmailVerified: false, + StatusCode: http.StatusTemporaryRedirect, + }} { tc := tc t.Run(tc.Name, func(t *testing.T) { t.Parallel() From 8e8a7a2457df52afa1323b87c752884a38da33d2 Mon Sep 17 00:00:00 2001 From: Cian Johnston Date: Fri, 31 Mar 2023 17:43:36 +0100 Subject: [PATCH 05/10] chore: update docs for ADFS --- docs/admin/auth.md | 27 +++++++++++++++++++++------ 1 file changed, 21 insertions(+), 6 deletions(-) diff --git a/docs/admin/auth.md b/docs/admin/auth.md index fe4dc9ea2016b..c855ca0eda272 100644 --- a/docs/admin/auth.md +++ b/docs/admin/auth.md @@ -262,8 +262,8 @@ Below are some details specific to individual OIDC providers. steps as described [here.](https://learn.microsoft.com/en-us/windows-server/identity/ad-fs/development/msal/adfs-msal-web-app-web-api#app-registration-in-ad-fs) - **Server Application**: Note the Client ID. - **Configure Application Credentials**: Note the Client Secret. - - **Configure Web API**: Ensure the Client ID is set as the relying party identifier. - - **Application Permissions**: Allow access to the claims `openid`, `email`, and `profile`. + - **Configure Web API**: Set the Client ID as the relying party identifier. + - **Application Permissions**: Allow access to the claims `openid`, `email`, `profile`, and `allatclaims`. 1. Visit your ADFS server's `/.well-known/openid-configuration` URL and note the value for `issuer`. > **Note:** This is usually of the form `https://adfs.corp/adfs/.well-known/openid-configuration` @@ -272,7 +272,22 @@ Below are some details specific to individual OIDC providers. - `CODER_OIDC_ISSUER_URL`: the `issuer` value from the previous step. - `CODER_OIDC_CLIENT_ID`: the Client ID from step 1. - `CODER_OIDC_CLIENT_SECRET`: the Client Secret from step 1. - - `CODER_OIDC_AUTH_URL_PARAMS`: set to `{"resource":"urn:microsoft:userinfo"}` ([see here](https://learn.microsoft.com/en-us/windows-server/identity/ad-fs/overview/ad-fs-openid-connect-oauth-flows-scenarios#:~:text=scope%E2%80%AFopenid.-,resource,-optional)). OIDC logins will fail if this is not set. -1. Ensure that Coder has the required OIDC claims by performing either of the below: - - Configure your federation server to reuturn both the `email` and `preferred_username` fields by [creating a custom claim rule](https://learn.microsoft.com/en-us/windows-server/identity/ad-fs/operations/create-a-rule-to-send-ldap-attributes-as-claims), or - - Set `CODER_OIDC_EMAIL_FIELD="upn"`. This will use the User Principal Name as the user email, which is [guaranteed to be unique in an Active Directory Forest](https://learn.microsoft.com/en-us/windows/win32/ad/naming-properties#upn-format). + - `CODER_OIDC_AUTH_URL_PARAMS`: set to + + ```console + {"resource":"$CLIENT_ID"} + ``` + + where `$CLIENT_ID` is the Client ID from step 1 ([see here](https://learn.microsoft.com/en-us/windows-server/identity/ad-fs/overview/ad-fs-openid-connect-oauth-flows-scenarios#:~:text=scope%E2%80%AFopenid.-,resource,-optional)). + This is required for the upstream OIDC provider to return the requested claims. +1. Configure [Issuance Transform Rules](https://learn.microsoft.com/en-us/windows-server/identity/ad-fs/operations/create-a-rule-to-send-ldap-attributes-as-claims) + on your federation server to send the following claims: + - `preferred_username`: You can use e.g. "Display Name" as required. + - `email`: You can use e.g. the LDAP attribute "E-Mail-Addresses" as required. + - `email_verified`: Create a custom claim rule: + + ```console + => issue(Type = "email_verified", Value = "true") + ``` + + - (Optional) If using Group Sync, send the required groups in the configured groups claim field. See [here](https://stackoverflow.com/a/55570286) for an example. From dfde7c7ff596143d5ff8334f4aa31fb2baf8c557 Mon Sep 17 00:00:00 2001 From: Cian Johnston Date: Fri, 31 Mar 2023 19:47:03 +0100 Subject: [PATCH 06/10] fixup! chore: update docs for ADFS --- docs/admin/auth.md | 27 +++++++++++++++------------ 1 file changed, 15 insertions(+), 12 deletions(-) diff --git a/docs/admin/auth.md b/docs/admin/auth.md index c855ca0eda272..6d99fdddac22c 100644 --- a/docs/admin/auth.md +++ b/docs/admin/auth.md @@ -269,25 +269,28 @@ Below are some details specific to individual OIDC providers. > **Note:** This is usually of the form `https://adfs.corp/adfs/.well-known/openid-configuration` 1. In Coder's configuration file (or Helm values as appropriate), set the following environment variables or their corresponding CLI arguments: + - `CODER_OIDC_ISSUER_URL`: the `issuer` value from the previous step. - `CODER_OIDC_CLIENT_ID`: the Client ID from step 1. - `CODER_OIDC_CLIENT_SECRET`: the Client Secret from step 1. - `CODER_OIDC_AUTH_URL_PARAMS`: set to - ```console - {"resource":"$CLIENT_ID"} - ``` + ```console + {"resource":"$CLIENT_ID"} + ``` + + where `$CLIENT_ID` is the Client ID from step 1 ([see here](https://learn.microsoft.com/en-us/windows-server/identity/ad-fs/overview/ad-fs-openid-connect-oauth-flows-scenarios#:~:text=scope%E2%80%AFopenid.-,resource,-optional)). + This is required for the upstream OIDC provider to return the requested claims. - where `$CLIENT_ID` is the Client ID from step 1 ([see here](https://learn.microsoft.com/en-us/windows-server/identity/ad-fs/overview/ad-fs-openid-connect-oauth-flows-scenarios#:~:text=scope%E2%80%AFopenid.-,resource,-optional)). - This is required for the upstream OIDC provider to return the requested claims. 1. Configure [Issuance Transform Rules](https://learn.microsoft.com/en-us/windows-server/identity/ad-fs/operations/create-a-rule-to-send-ldap-attributes-as-claims) on your federation server to send the following claims: - - `preferred_username`: You can use e.g. "Display Name" as required. - - `email`: You can use e.g. the LDAP attribute "E-Mail-Addresses" as required. - - `email_verified`: Create a custom claim rule: - ```console - => issue(Type = "email_verified", Value = "true") - ``` + - `preferred_username`: You can use e.g. "Display Name" as required. + - `email`: You can use e.g. the LDAP attribute "E-Mail-Addresses" as required. + - `email_verified`: Create a custom claim rule: + + ```console + => issue(Type = "email_verified", Value = "true") + ``` - - (Optional) If using Group Sync, send the required groups in the configured groups claim field. See [here](https://stackoverflow.com/a/55570286) for an example. + - (Optional) If using Group Sync, send the required groups in the configured groups claim field. See [here](https://stackoverflow.com/a/55570286) for an example. From efee87da3974635ddfbc823699c69bbd37a2c361 Mon Sep 17 00:00:00 2001 From: Cian Johnston Date: Tue, 4 Apr 2023 13:18:17 +0100 Subject: [PATCH 07/10] address PR comments --- coderd/userauth.go | 2 +- coderd/userauth_test.go | 12 ++++++++++++ 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/coderd/userauth.go b/coderd/userauth.go index 15a38295a7851..691f8a1789da0 100644 --- a/coderd/userauth.go +++ b/coderd/userauth.go @@ -780,7 +780,7 @@ func (api *API) userOIDC(rw http.ResponseWriter, r *http.Request) { // claimFields returns the sorted list of fields in the claims map. func claimFields(claims map[string]interface{}) []string { - var fields []string + fields := []string{} for field := range claims { fields = append(fields, field) } diff --git a/coderd/userauth_test.go b/coderd/userauth_test.go index 97ef7b957d6ac..4e7d10c51a43e 100644 --- a/coderd/userauth_test.go +++ b/coderd/userauth_test.go @@ -658,6 +658,18 @@ func TestUserOIDC(t *testing.T) { AllowSignups: true, IgnoreEmailVerified: false, StatusCode: http.StatusTemporaryRedirect, + }, { + Name: "InvalidUserInfo", + IDTokenClaims: jwt.MapClaims{ + "email": "internaluser@internal.domain", + "email_verified": false, + }, + UserInfoClaims: jwt.MapClaims{ + "email": 1, + }, + AllowSignups: true, + IgnoreEmailVerified: false, + StatusCode: http.StatusInternalServerError, }} { tc := tc t.Run(tc.Name, func(t *testing.T) { From 50df5d00a213d260070ba94569d92600bf12ae3a Mon Sep 17 00:00:00 2001 From: Cian Johnston Date: Tue, 4 Apr 2023 14:13:47 +0100 Subject: [PATCH 08/10] add CODER_OIDC_IGNORE_USERINFO option --- cli/server.go | 1 + cli/server_test.go | 3 +++ cli/testdata/coder_server_--help.golden | 4 +++ coderd/apidoc/docs.go | 3 +++ coderd/apidoc/swagger.json | 3 +++ coderd/userauth.go | 36 +++++++------------------ coderd/userauth_test.go | 17 ++++++++++++ codersdk/deployment.go | 11 ++++++++ docs/api/general.md | 1 + docs/api/schemas.md | 4 +++ docs/cli/server.md | 10 +++++++ site/src/api/typesGenerated.ts | 1 + 12 files changed, 68 insertions(+), 26 deletions(-) diff --git a/cli/server.go b/cli/server.go index b6fa7c31b647c..723d14dcaddf2 100644 --- a/cli/server.go +++ b/cli/server.go @@ -728,6 +728,7 @@ func (r *RootCmd) Server(newAPI func(context.Context, *coderd.Options) (*coderd. UsernameField: cfg.OIDC.UsernameField.String(), EmailField: cfg.OIDC.EmailField.String(), AuthURLParams: cfg.OIDC.AuthURLParams.Value, + IgnoreUserInfo: cfg.OIDC.IgnoreUserInfo.Value(), GroupField: cfg.OIDC.GroupField.String(), GroupMapping: cfg.OIDC.GroupMapping.Value, SignInText: cfg.OIDC.SignInText.String(), diff --git a/cli/server_test.go b/cli/server_test.go index 3512601a1c31e..603f635209ae5 100644 --- a/cli/server_test.go +++ b/cli/server_test.go @@ -1086,6 +1086,7 @@ func TestServer(t *testing.T) { require.Equal(t, "preferred_username", deploymentConfig.Values.OIDC.UsernameField.Value()) require.Equal(t, "email", deploymentConfig.Values.OIDC.EmailField.Value()) require.Equal(t, map[string]string{"access_type": "offline"}, deploymentConfig.Values.OIDC.AuthURLParams.Value) + require.False(t, deploymentConfig.Values.OIDC.IgnoreUserInfo.Value()) require.Empty(t, deploymentConfig.Values.OIDC.GroupField.Value()) require.Empty(t, deploymentConfig.Values.OIDC.GroupMapping.Value) require.Equal(t, "OpenID Connect", deploymentConfig.Values.OIDC.SignInText.Value()) @@ -1125,6 +1126,7 @@ func TestServer(t *testing.T) { "--oidc-username-field", "not_preferred_username", "--oidc-email-field", "not_email", "--oidc-auth-url-params", `{"prompt":"consent"}`, + "--oidc-ignore-userinfo", "--oidc-group-field", "serious_business_unit", "--oidc-group-mapping", `{"serious_business_unit": "serious_business_unit"}`, "--oidc-sign-in-text", "Sign In With Coder", @@ -1170,6 +1172,7 @@ func TestServer(t *testing.T) { require.Equal(t, "not_preferred_username", deploymentConfig.Values.OIDC.UsernameField.Value()) require.Equal(t, "not_email", deploymentConfig.Values.OIDC.EmailField.Value()) require.Equal(t, map[string]string{"access_type": "offline", "prompt": "consent"}, deploymentConfig.Values.OIDC.AuthURLParams.Value) + require.True(t, deploymentConfig.Values.OIDC.IgnoreUserInfo.Value()) require.Equal(t, "serious_business_unit", deploymentConfig.Values.OIDC.GroupField.Value()) require.Equal(t, map[string]string{"serious_business_unit": "serious_business_unit"}, deploymentConfig.Values.OIDC.GroupMapping.Value) require.Equal(t, "Sign In With Coder", deploymentConfig.Values.OIDC.SignInText.Value()) diff --git a/cli/testdata/coder_server_--help.golden b/cli/testdata/coder_server_--help.golden index 73f759d20fe7b..344e7585a9bab 100644 --- a/cli/testdata/coder_server_--help.golden +++ b/cli/testdata/coder_server_--help.golden @@ -287,6 +287,10 @@ can safely ignore these settings. --oidc-ignore-email-verified bool, $CODER_OIDC_IGNORE_EMAIL_VERIFIED Ignore the email_verified claim from the upstream provider. + --oidc-ignore-userinfo bool, $CODER_OIDC_IGNORE_USERINFO (default: false) + Ignore the userinfo endpoint and only use the ID token for user + information. + --oidc-issuer-url string, $CODER_OIDC_ISSUER_URL Issuer URL to use for Login with OIDC. diff --git a/coderd/apidoc/docs.go b/coderd/apidoc/docs.go index 9561e0b511a6d..b497d2457f34d 100644 --- a/coderd/apidoc/docs.go +++ b/coderd/apidoc/docs.go @@ -7335,6 +7335,9 @@ const docTemplate = `{ "ignore_email_verified": { "type": "boolean" }, + "ignore_user_info": { + "type": "boolean" + }, "issuer_url": { "type": "string" }, diff --git a/coderd/apidoc/swagger.json b/coderd/apidoc/swagger.json index fd9c8a7e8a3ff..862ebbe827e98 100644 --- a/coderd/apidoc/swagger.json +++ b/coderd/apidoc/swagger.json @@ -6571,6 +6571,9 @@ "ignore_email_verified": { "type": "boolean" }, + "ignore_user_info": { + "type": "boolean" + }, "issuer_url": { "type": "string" }, diff --git a/coderd/userauth.go b/coderd/userauth.go index 691f8a1789da0..b3a18b1c52ac2 100644 --- a/coderd/userauth.go +++ b/coderd/userauth.go @@ -484,6 +484,11 @@ type OIDCConfig struct { // AuthURLParams are additional parameters to be passed to the OIDC provider // when requesting an access token. AuthURLParams map[string]string + // IgnoreUserInfo causes Coder to only use claims from the ID token to + // process OIDC logins. This is useful if the OIDC provider does not + // support the userinfo endpoint, or if the userinfo endpoint causes + // undesirable behavior. + IgnoreUserInfo bool // GroupField selects the claim field to be used as the created user's // groups. If the group field is the empty string, then no group updates // will ever come from the OIDC provider. @@ -565,10 +570,10 @@ func (api *API) userOIDC(rw http.ResponseWriter, r *http.Request) { // user info if required and merge the two claim sets to be sure we have // all of the correct data. // - // However, if we already have the required claims, we can just skip - // the UserInfo call. - needUserInfo := !requiredClaimsPresent(api.OIDCConfig, claims) - if needUserInfo { + // Some providers (e.g. ADFS) do not support custom OIDC claims in the + // UserInfo endpoint, so we allow users to disable it and only rely on the + // ID token. + if !api.OIDCConfig.IgnoreUserInfo { userInfo, err := api.OIDCConfig.Provider.UserInfo(ctx, oauth2.StaticTokenSource(state.Token)) if err == nil { userInfoClaims := map[string]interface{}{} @@ -599,7 +604,7 @@ func (api *API) userOIDC(rw http.ResponseWriter, r *http.Request) { } else if !strings.Contains(err.Error(), "user info endpoint is not supported by this provider") { httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ Message: "Failed to obtain user information claims.", - Detail: "The OIDC provider returned no claims as part of the `id_token`. The attempt to fetch claims via the UserInfo endpoint failed: " + err.Error(), + Detail: "The attempt to fetch claims via the UserInfo endpoint failed: " + err.Error(), }) return } else { @@ -801,27 +806,6 @@ func blankFields(claims map[string]interface{}) []string { return fields } -// requiredClaimsPresent returns false if any of the following claims are missing: -// - email (or the configured email field) -// - username (or the configured username field) -// - group (or the configured group field, unless GroupField is empty) -// - email_verified (unless IgnoreEmailVerified is true) -func requiredClaimsPresent(cfg *OIDCConfig, claims map[string]interface{}) bool { - if _, ok := claims[cfg.EmailField]; !ok { - return false - } - if _, ok := claims[cfg.UsernameField]; !ok { - return false - } - if _, hasGroupField := claims[cfg.GroupField]; !hasGroupField && cfg.GroupField != "" { - return false - } - if _, hasEmailVerifiedField := claims["email_verified"]; !hasEmailVerifiedField && !cfg.IgnoreEmailVerified { - return false - } - return true -} - // mergeClaims merges the claims from a and b and returns the merged set. // claims from b take precedence over claims from a. func mergeClaims(a, b map[string]interface{}) map[string]interface{} { diff --git a/coderd/userauth_test.go b/coderd/userauth_test.go index 4e7d10c51a43e..54c99b933c714 100644 --- a/coderd/userauth_test.go +++ b/coderd/userauth_test.go @@ -501,6 +501,7 @@ func TestUserOIDC(t *testing.T) { AvatarURL string StatusCode int IgnoreEmailVerified bool + IgnoreUserInfo bool }{{ Name: "EmailOnly", IDTokenClaims: jwt.MapClaims{ @@ -670,6 +671,21 @@ func TestUserOIDC(t *testing.T) { AllowSignups: true, IgnoreEmailVerified: false, StatusCode: http.StatusInternalServerError, + }, { + Name: "IgnoreUserInfo", + IDTokenClaims: jwt.MapClaims{ + "email": "user@internal.domain", + "email_verified": true, + "preferred_username": "user", + }, + UserInfoClaims: jwt.MapClaims{ + "email": "user.mcname@external.domain", + "preferred_username": "Mr. User McName", + }, + Username: "user", + IgnoreUserInfo: true, + AllowSignups: true, + StatusCode: http.StatusTemporaryRedirect, }} { tc := tc t.Run(tc.Name, func(t *testing.T) { @@ -681,6 +697,7 @@ func TestUserOIDC(t *testing.T) { config.AllowSignups = tc.AllowSignups config.EmailDomain = tc.EmailDomain config.IgnoreEmailVerified = tc.IgnoreEmailVerified + config.IgnoreUserInfo = tc.IgnoreUserInfo client := coderdtest.New(t, &coderdtest.Options{ Auditor: auditor, diff --git a/codersdk/deployment.go b/codersdk/deployment.go index 44f67c3895cbb..bb57697bbfef7 100644 --- a/codersdk/deployment.go +++ b/codersdk/deployment.go @@ -257,6 +257,7 @@ type OIDCConfig struct { UsernameField clibase.String `json:"username_field" typescript:",notnull"` EmailField clibase.String `json:"email_field" typescript:",notnull"` AuthURLParams clibase.Struct[map[string]string] `json:"auth_url_params" typescript:",notnull"` + IgnoreUserInfo clibase.Bool `json:"ignore_user_info" typescript:",notnull"` GroupField clibase.String `json:"groups_field" typescript:",notnull"` GroupMapping clibase.Struct[map[string]string] `json:"group_mapping" typescript:",notnull"` SignInText clibase.String `json:"sign_in_text" typescript:",notnull"` @@ -881,6 +882,16 @@ when required by your organization's security policy.`, Group: &deploymentGroupOIDC, YAML: "authURLParams", }, + { + Name: "OIDC Ignore UserInfo", + Description: "Ignore the userinfo endpoint and only use the ID token for user information.", + Flag: "oidc-ignore-userinfo", + Env: "CODER_OIDC_IGNORE_USERINFO", + Default: "false", + Value: &c.OIDC.IgnoreUserInfo, + Group: &deploymentGroupOIDC, + YAML: "ignoreUserInfo", + }, { Name: "OIDC Group Field", Description: "Change the OIDC default 'groups' claim field. By default, will be 'groups' if present in the oidc scopes argument.", diff --git a/docs/api/general.md b/docs/api/general.md index 43dafb41e5d51..31830b41e01f0 100644 --- a/docs/api/general.md +++ b/docs/api/general.md @@ -252,6 +252,7 @@ curl -X GET http://coder-server:8080/api/v2/deployment/config \ "user": {} }, "ignore_email_verified": true, + "ignore_user_info": true, "issuer_url": "string", "scopes": ["string"], "sign_in_text": "string", diff --git a/docs/api/schemas.md b/docs/api/schemas.md index 2f76aef88bbb9..6df0a4070124c 100644 --- a/docs/api/schemas.md +++ b/docs/api/schemas.md @@ -1822,6 +1822,7 @@ CreateParameterRequest is a structure used to create a new parameter value for a "user": {} }, "ignore_email_verified": true, + "ignore_user_info": true, "issuer_url": "string", "scopes": ["string"], "sign_in_text": "string", @@ -2170,6 +2171,7 @@ CreateParameterRequest is a structure used to create a new parameter value for a "user": {} }, "ignore_email_verified": true, + "ignore_user_info": true, "issuer_url": "string", "scopes": ["string"], "sign_in_text": "string", @@ -2836,6 +2838,7 @@ CreateParameterRequest is a structure used to create a new parameter value for a "user": {} }, "ignore_email_verified": true, + "ignore_user_info": true, "issuer_url": "string", "scopes": ["string"], "sign_in_text": "string", @@ -2857,6 +2860,7 @@ CreateParameterRequest is a structure used to create a new parameter value for a | `groups_field` | string | false | | | | `icon_url` | [clibase.URL](#clibaseurl) | false | | | | `ignore_email_verified` | boolean | false | | | +| `ignore_user_info` | boolean | false | | | | `issuer_url` | string | false | | | | `scopes` | array of string | false | | | | `sign_in_text` | string | false | | | diff --git a/docs/cli/server.md b/docs/cli/server.md index accbcd7bb05d6..739c69e4373c1 100644 --- a/docs/cli/server.md +++ b/docs/cli/server.md @@ -394,6 +394,16 @@ URL pointing to the icon to use on the OepnID Connect login button. Ignore the email_verified claim from the upstream provider. +### --oidc-ignore-userinfo + +| | | +| ----------- | ---------------------------------------- | +| Type | bool | +| Environment | $CODER_OIDC_IGNORE_USERINFO | +| Default | false | + +Ignore the userinfo endpoint and only use the ID token for user information. + ### --oidc-issuer-url | | | diff --git a/site/src/api/typesGenerated.ts b/site/src/api/typesGenerated.ts index f1b40dddc60cd..be6d169210612 100644 --- a/site/src/api/typesGenerated.ts +++ b/site/src/api/typesGenerated.ts @@ -521,6 +521,7 @@ export interface OIDCConfig { // Named type "github.com/coder/coder/cli/clibase.Struct[map[string]string]" unknown, using "any" // eslint-disable-next-line @typescript-eslint/no-explicit-any -- External type readonly auth_url_params: any + readonly ignore_user_info: boolean readonly groups_field: string // Named type "github.com/coder/coder/cli/clibase.Struct[map[string]string]" unknown, using "any" // eslint-disable-next-line @typescript-eslint/no-explicit-any -- External type From d0b832fbbe10b97041f32a352bf34ac30ecb54dc Mon Sep 17 00:00:00 2001 From: Cian Johnston Date: Tue, 4 Apr 2023 14:44:45 +0100 Subject: [PATCH 09/10] update docs for CODER_OIDC_IGNORE_USERINFO --- docs/admin/auth.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/docs/admin/auth.md b/docs/admin/auth.md index 6d99fdddac22c..784b494df70b8 100644 --- a/docs/admin/auth.md +++ b/docs/admin/auth.md @@ -144,6 +144,10 @@ while signing in via OIDC as a new user. Coder will log the claim fields returned by the upstream identity provider in a message containing the string `got oidc claims`, as well as the user info returned. +> **Note:** If you need to ensure that Coder only uses information from +> the ID token and does not hit the UserInfo endpoint, you can set the +> configuration option `CODER_OIDC_IGNORE_USERINFO=true`. + ### Email Addresses By default, Coder will look for the OIDC claim named `email` and use that @@ -281,6 +285,7 @@ Below are some details specific to individual OIDC providers. where `$CLIENT_ID` is the Client ID from step 1 ([see here](https://learn.microsoft.com/en-us/windows-server/identity/ad-fs/overview/ad-fs-openid-connect-oauth-flows-scenarios#:~:text=scope%E2%80%AFopenid.-,resource,-optional)). This is required for the upstream OIDC provider to return the requested claims. + - `CODER_OIDC_IGNORE_USERINFO`: Set to `true`. 1. Configure [Issuance Transform Rules](https://learn.microsoft.com/en-us/windows-server/identity/ad-fs/operations/create-a-rule-to-send-ldap-attributes-as-claims) on your federation server to send the following claims: From 690156327d60a21019dfa8b7fe98e98cbbd42f2e Mon Sep 17 00:00:00 2001 From: Cian Johnston Date: Tue, 4 Apr 2023 16:34:37 +0100 Subject: [PATCH 10/10] make fumpt --- docs/admin/auth.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/admin/auth.md b/docs/admin/auth.md index 784b494df70b8..4da9db7434970 100644 --- a/docs/admin/auth.md +++ b/docs/admin/auth.md @@ -285,6 +285,7 @@ Below are some details specific to individual OIDC providers. where `$CLIENT_ID` is the Client ID from step 1 ([see here](https://learn.microsoft.com/en-us/windows-server/identity/ad-fs/overview/ad-fs-openid-connect-oauth-flows-scenarios#:~:text=scope%E2%80%AFopenid.-,resource,-optional)). This is required for the upstream OIDC provider to return the requested claims. + - `CODER_OIDC_IGNORE_USERINFO`: Set to `true`. 1. Configure [Issuance Transform Rules](https://learn.microsoft.com/en-us/windows-server/identity/ad-fs/operations/create-a-rule-to-send-ldap-attributes-as-claims)