diff --git a/cli/server.go b/cli/server.go index 7d4261a2e2a7f..d7c9586339d5b 100644 --- a/cli/server.go +++ b/cli/server.go @@ -731,6 +731,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 b4f1901a993fa..c2c93ebb7a736 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", @@ -1169,6 +1171,7 @@ func TestServer(t *testing.T) { require.True(t, deploymentConfig.Values.OIDC.IgnoreEmailVerified.Value()) require.Equal(t, "not_preferred_username", deploymentConfig.Values.OIDC.UsernameField.Value()) require.Equal(t, "not_email", deploymentConfig.Values.OIDC.EmailField.Value()) + require.True(t, deploymentConfig.Values.OIDC.IgnoreUserInfo.Value()) require.Equal(t, map[string]string{"prompt": "consent"}, deploymentConfig.Values.OIDC.AuthURLParams.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) diff --git a/cli/testdata/coder_server_--help.golden b/cli/testdata/coder_server_--help.golden index 56edb2c58de04..344e7585a9bab 100644 --- a/cli/testdata/coder_server_--help.golden +++ b/cli/testdata/coder_server_--help.golden @@ -280,13 +280,17 @@ 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. --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 c6ef971f8879c..878e5e64e89a4 100644 --- a/coderd/apidoc/docs.go +++ b/coderd/apidoc/docs.go @@ -7469,6 +7469,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 750563cc9c44c..abc7fb57aa4c0 100644 --- a/coderd/apidoc/swagger.json +++ b/coderd/apidoc/swagger.json @@ -6695,6 +6695,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 558a44aa32c74..b3a18b1c52ac2 100644 --- a/coderd/userauth.go +++ b/coderd/userauth.go @@ -7,6 +7,7 @@ import ( "fmt" "net/http" "net/mail" + "sort" "strconv" "strings" @@ -483,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. @@ -551,46 +557,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. + // + // 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{}{} + 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 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 +679,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 +783,42 @@ 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 { + 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 +} + +// 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..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{ @@ -643,6 +644,48 @@ 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, + }, { + Name: "InvalidUserInfo", + IDTokenClaims: jwt.MapClaims{ + "email": "internaluser@internal.domain", + "email_verified": false, + }, + UserInfoClaims: jwt.MapClaims{ + "email": 1, + }, + 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) { @@ -654,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 ebace3488709d..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.", @@ -900,7 +911,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, diff --git a/docs/admin/auth.md b/docs/admin/auth.md index fe4dc9ea2016b..4da9db7434970 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 @@ -262,17 +266,37 @@ 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` 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 `{"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. + + - `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: + + - `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. 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 54facac87eab3..c72a64197a557 100644 --- a/docs/api/schemas.md +++ b/docs/api/schemas.md @@ -1856,6 +1856,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", @@ -2204,6 +2205,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", @@ -2870,6 +2872,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", @@ -2891,6 +2894,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 e9c9e73bb68d7..739c69e4373c1 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. @@ -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 41bd312808cce..56603eff785aa 100644 --- a/site/src/api/typesGenerated.ts +++ b/site/src/api/typesGenerated.ts @@ -523,6 +523,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