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