diff --git a/cli/server.go b/cli/server.go index 548f350bae9bc..c64af21ef5214 100644 --- a/cli/server.go +++ b/cli/server.go @@ -172,6 +172,17 @@ func createOIDCConfig(ctx context.Context, logger slog.Logger, vals *codersdk.De groupAllowList[group] = true } + secondaryClaimsSrc := coderd.MergedClaimsSourceUserInfo + if !vals.OIDC.IgnoreUserInfo && vals.OIDC.UserInfoFromAccessToken { + return nil, xerrors.Errorf("to use 'oidc-access-token-claims', 'oidc-ignore-userinfo' must be set to 'false'") + } + if vals.OIDC.IgnoreUserInfo { + secondaryClaimsSrc = coderd.MergedClaimsSourceNone + } + if vals.OIDC.UserInfoFromAccessToken { + secondaryClaimsSrc = coderd.MergedClaimsSourceAccessToken + } + return &coderd.OIDCConfig{ OAuth2Config: useCfg, Provider: oidcProvider, @@ -187,7 +198,7 @@ func createOIDCConfig(ctx context.Context, logger slog.Logger, vals *codersdk.De NameField: vals.OIDC.NameField.String(), EmailField: vals.OIDC.EmailField.String(), AuthURLParams: vals.OIDC.AuthURLParams.Value, - IgnoreUserInfo: vals.OIDC.IgnoreUserInfo.Value(), + SecondaryClaims: secondaryClaimsSrc, SignInText: vals.OIDC.SignInText.String(), SignupsDisabledText: vals.OIDC.SignupsDisabledText.String(), IconURL: vals.OIDC.IconURL.String(), diff --git a/cli/testdata/server-config.yaml.golden b/cli/testdata/server-config.yaml.golden index 96a03c5b1f05e..0d0be4a80d2a3 100644 --- a/cli/testdata/server-config.yaml.golden +++ b/cli/testdata/server-config.yaml.golden @@ -326,6 +326,12 @@ oidc: # Ignore the userinfo endpoint and only use the ID token for user information. # (default: false, type: bool) ignoreUserInfo: false + # Source supplemental user claims from the 'access_token'. This assumes the token + # is a jwt signed by the same issuer as the id_token. Using this requires setting + # 'oidc-ignore-userinfo' to true. This setting is not compliant with the OIDC + # specification and is not recommended. Use at your own risk. + # (default: false, type: bool) + accessTokenClaims: false # This field must be set if using the organization sync feature. Set to the claim # to be used for organizations. # (default: , type: string) diff --git a/coderd/apidoc/docs.go b/coderd/apidoc/docs.go index 5e4fcb001cc36..47eb8352cd51e 100644 --- a/coderd/apidoc/docs.go +++ b/coderd/apidoc/docs.go @@ -12518,6 +12518,7 @@ const docTemplate = `{ "type": "boolean" }, "ignore_user_info": { + "description": "IgnoreUserInfo \u0026 UserInfoFromAccessToken are mutually exclusive. Only 1\ncan be set to true. Ideally this would be an enum with 3 states, ['none',\n'userinfo', 'access_token']. However, for backward compatibility,\n` + "`" + `ignore_user_info` + "`" + ` must remain. And ` + "`" + `access_token` + "`" + ` is a niche, non-spec\ncompliant edge case. So it's use is rare, and should not be advised.", "type": "boolean" }, "issuer_url": { @@ -12550,6 +12551,10 @@ const docTemplate = `{ "skip_issuer_checks": { "type": "boolean" }, + "source_user_info_from_access_token": { + "description": "UserInfoFromAccessToken as mentioned above is an edge case. This allows\nsourcing the user_info from the access token itself instead of a user_info\nendpoint. This assumes the access token is a valid JWT with a set of claims to\nbe merged with the id_token.", + "type": "boolean" + }, "user_role_field": { "type": "string" }, diff --git a/coderd/apidoc/swagger.json b/coderd/apidoc/swagger.json index 29658d0a5e7b9..bc481416c8eb7 100644 --- a/coderd/apidoc/swagger.json +++ b/coderd/apidoc/swagger.json @@ -11264,6 +11264,7 @@ "type": "boolean" }, "ignore_user_info": { + "description": "IgnoreUserInfo \u0026 UserInfoFromAccessToken are mutually exclusive. Only 1\ncan be set to true. Ideally this would be an enum with 3 states, ['none',\n'userinfo', 'access_token']. However, for backward compatibility,\n`ignore_user_info` must remain. And `access_token` is a niche, non-spec\ncompliant edge case. So it's use is rare, and should not be advised.", "type": "boolean" }, "issuer_url": { @@ -11296,6 +11297,10 @@ "skip_issuer_checks": { "type": "boolean" }, + "source_user_info_from_access_token": { + "description": "UserInfoFromAccessToken as mentioned above is an edge case. This allows\nsourcing the user_info from the access token itself instead of a user_info\nendpoint. This assumes the access token is a valid JWT with a set of claims to\nbe merged with the id_token.", + "type": "boolean" + }, "user_role_field": { "type": "string" }, diff --git a/coderd/coderdtest/oidctest/idp.go b/coderd/coderdtest/oidctest/idp.go index d6c7e6259f760..e0fd1bb9b0be2 100644 --- a/coderd/coderdtest/oidctest/idp.go +++ b/coderd/coderdtest/oidctest/idp.go @@ -105,6 +105,7 @@ type FakeIDP struct { // "Authorized Redirect URLs". This can be used to emulate that. hookValidRedirectURL func(redirectURL string) error hookUserInfo func(email string) (jwt.MapClaims, error) + hookAccessTokenJWT func(email string, exp time.Time) jwt.MapClaims // defaultIDClaims is if a new client connects and we didn't preset // some claims. defaultIDClaims jwt.MapClaims @@ -154,6 +155,12 @@ func WithMiddlewares(mws ...func(http.Handler) http.Handler) func(*FakeIDP) { } } +func WithAccessTokenJWTHook(hook func(email string, exp time.Time) jwt.MapClaims) func(*FakeIDP) { + return func(f *FakeIDP) { + f.hookAccessTokenJWT = hook + } +} + func WithHookWellKnown(hook func(r *http.Request, j *ProviderJSON) error) func(*FakeIDP) { return func(f *FakeIDP) { f.hookWellKnown = hook @@ -316,8 +323,7 @@ const ( func NewFakeIDP(t testing.TB, opts ...FakeIDPOpt) *FakeIDP { t.Helper() - block, _ := pem.Decode([]byte(testRSAPrivateKey)) - pkey, err := x509.ParsePKCS1PrivateKey(block.Bytes) + pkey, err := FakeIDPKey() require.NoError(t, err) idp := &FakeIDP{ @@ -676,8 +682,13 @@ func (f *FakeIDP) newCode(state string) string { // newToken enforces the access token exchanged is actually a valid access token // created by the IDP. -func (f *FakeIDP) newToken(email string, expires time.Time) string { +func (f *FakeIDP) newToken(t testing.TB, email string, expires time.Time) string { accessToken := uuid.NewString() + if f.hookAccessTokenJWT != nil { + claims := f.hookAccessTokenJWT(email, expires) + accessToken = f.encodeClaims(t, claims) + } + f.accessTokens.Store(accessToken, token{ issued: time.Now(), email: email, @@ -963,7 +974,7 @@ func (f *FakeIDP) httpHandler(t testing.TB) http.Handler { email := getEmail(claims) refreshToken := f.newRefreshTokens(email) token := map[string]interface{}{ - "access_token": f.newToken(email, exp), + "access_token": f.newToken(t, email, exp), "refresh_token": refreshToken, "token_type": "Bearer", "expires_in": int64((f.defaultExpire).Seconds()), @@ -1465,9 +1476,10 @@ func (f *FakeIDP) internalOIDCConfig(ctx context.Context, t testing.TB, scopes [ Verifier: oidc.NewVerifier(f.provider.Issuer, &oidc.StaticKeySet{ PublicKeys: []crypto.PublicKey{f.key.Public()}, }, verifierConfig), - UsernameField: "preferred_username", - EmailField: "email", - AuthURLParams: map[string]string{"access_type": "offline"}, + UsernameField: "preferred_username", + EmailField: "email", + AuthURLParams: map[string]string{"access_type": "offline"}, + SecondaryClaims: coderd.MergedClaimsSourceUserInfo, } for _, opt := range opts { @@ -1552,3 +1564,8 @@ d8h4Ht09E+f3nhTEc87mODkl7WJZpHL6V2sORfeq/eIkds+H6CJ4hy5w/bSw8tjf sz9Di8sGIaUbLZI2rd0CQQCzlVwEtRtoNCyMJTTrkgUuNufLP19RZ5FpyXxBO5/u QastnN77KfUwdj3SJt44U/uh1jAIv4oSLBr8HYUkbnI8 -----END RSA PRIVATE KEY-----` + +func FakeIDPKey() (*rsa.PrivateKey, error) { + block, _ := pem.Decode([]byte(testRSAPrivateKey)) + return x509.ParsePKCS1PrivateKey(block.Bytes) +} diff --git a/coderd/userauth.go b/coderd/userauth.go index 15eea78b5bc8c..7f4319b3db470 100644 --- a/coderd/userauth.go +++ b/coderd/userauth.go @@ -46,6 +46,14 @@ import ( "github.com/coder/coder/v2/cryptorand" ) +type MergedClaimsSource string + +var ( + MergedClaimsSourceNone MergedClaimsSource = "none" + MergedClaimsSourceUserInfo MergedClaimsSource = "user_info" + MergedClaimsSourceAccessToken MergedClaimsSource = "access_token" +) + const ( userAuthLoggerName = "userauth" OAuthConvertCookieValue = "coder_oauth_convert_jwt" @@ -1042,11 +1050,13 @@ 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 + // SecondaryClaims indicates where to source additional claim information from. + // The standard is either 'MergedClaimsSourceNone' or 'MergedClaimsSourceUserInfo'. + // + // The OIDC compliant way is to use the userinfo endpoint. This option + // is useful when the userinfo endpoint does not exist or causes undesirable + // behavior. + SecondaryClaims MergedClaimsSource // SignInText is the text to display on the OIDC login button SignInText string // IconURL points to the URL of an icon to display on the OIDC login button @@ -1142,50 +1152,39 @@ func (api *API) userOIDC(rw http.ResponseWriter, r *http.Request) { // 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. - userInfoClaims := make(map[string]interface{}) + // // If user info is skipped, the idtokenClaims are the claims. mergedClaims := idtokenClaims - if !api.OIDCConfig.IgnoreUserInfo { - userInfo, err := api.OIDCConfig.Provider.UserInfo(ctx, oauth2.StaticTokenSource(state.Token)) - if err == nil { - err = userInfo.Claims(&userInfoClaims) - if err != nil { - logger.Error(ctx, "oauth2: unable to unmarshal user info claims", slog.Error(err)) - httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ - Message: "Failed to unmarshal user info claims.", - Detail: err.Error(), - }) - return - } - 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. - mergedClaims = mergeClaims(idtokenClaims, userInfoClaims) + supplementaryClaims := make(map[string]interface{}) + switch api.OIDCConfig.SecondaryClaims { + case MergedClaimsSourceUserInfo: + supplementaryClaims, ok = api.userInfoClaims(ctx, rw, state, logger) + if !ok { + return + } - // Log all of the field names after merging. - logger.Debug(ctx, "got oidc claims", - slog.F("source", "merged"), - slog.F("claim_fields", claimFields(mergedClaims)), - slog.F("blank", blankFields(mergedClaims)), - ) - } else if !strings.Contains(err.Error(), "user info endpoint is not supported by this provider") { - logger.Error(ctx, "oauth2: unable to obtain user information claims", slog.Error(err)) - httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ - Message: "Failed to obtain user information claims.", - Detail: "The attempt to fetch claims via the UserInfo endpoint failed: " + err.Error(), - }) + // The precedence ordering is userInfoClaims > idTokenClaims. + // Note: Unsure why exactly this is the case. idTokenClaims feels more + // important? + mergedClaims = mergeClaims(idtokenClaims, supplementaryClaims) + case MergedClaimsSourceAccessToken: + supplementaryClaims, ok = api.accessTokenClaims(ctx, rw, state, logger) + if !ok { 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. - logger.Warn(ctx, "OIDC provider does not support the user info endpoint, ensure that all required claims are present in the id_token") } + // idTokenClaims take priority over accessTokenClaims. The order should + // not matter. It is just safer to assume idTokenClaims is the truth, + // and accessTokenClaims are supplemental. + mergedClaims = mergeClaims(supplementaryClaims, idtokenClaims) + case MergedClaimsSourceNone: + // noop, keep the userInfoClaims empty + default: + // This should never happen and is a developer error + httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ + Message: "Invalid source for secondary user claims.", + Detail: fmt.Sprintf("invalid source: %q", api.OIDCConfig.SecondaryClaims), + }) + return // Invalid MergedClaimsSource } usernameRaw, ok := mergedClaims[api.OIDCConfig.UsernameField] @@ -1339,7 +1338,7 @@ func (api *API) userOIDC(rw http.ResponseWriter, r *http.Request) { RoleSync: roleSync, UserClaims: database.UserLinkClaims{ IDTokenClaims: idtokenClaims, - UserInfoClaims: userInfoClaims, + UserInfoClaims: supplementaryClaims, MergedClaims: mergedClaims, }, }).SetInitAuditRequest(func(params *audit.RequestParams) (*audit.Request[database.User], func()) { @@ -1373,6 +1372,68 @@ func (api *API) userOIDC(rw http.ResponseWriter, r *http.Request) { http.Redirect(rw, r, redirect, http.StatusTemporaryRedirect) } +func (api *API) accessTokenClaims(ctx context.Context, rw http.ResponseWriter, state httpmw.OAuth2State, logger slog.Logger) (accessTokenClaims map[string]interface{}, ok bool) { + // Assume the access token is a jwt, and signed by the provider. + accessToken, err := api.OIDCConfig.Verifier.Verify(ctx, state.Token.AccessToken) + if err != nil { + logger.Error(ctx, "oauth2: unable to verify access token as secondary claims source", slog.Error(err)) + httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{ + Message: "Failed to verify access token.", + Detail: fmt.Sprintf("sourcing secondary claims from access token: %s", err.Error()), + }) + return nil, false + } + + rawClaims := make(map[string]any) + err = accessToken.Claims(&rawClaims) + if err != nil { + logger.Error(ctx, "oauth2: unable to unmarshal access token claims", slog.Error(err)) + httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ + Message: "Failed to unmarshal access token claims.", + Detail: err.Error(), + }) + return nil, false + } + + return rawClaims, true +} + +func (api *API) userInfoClaims(ctx context.Context, rw http.ResponseWriter, state httpmw.OAuth2State, logger slog.Logger) (userInfoClaims map[string]interface{}, ok bool) { + userInfoClaims = make(map[string]interface{}) + userInfo, err := api.OIDCConfig.Provider.UserInfo(ctx, oauth2.StaticTokenSource(state.Token)) + if err == nil { + err = userInfo.Claims(&userInfoClaims) + if err != nil { + logger.Error(ctx, "oauth2: unable to unmarshal user info claims", slog.Error(err)) + httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ + Message: "Failed to unmarshal user info claims.", + Detail: err.Error(), + }) + return nil, false + } + logger.Debug(ctx, "got oidc claims", + slog.F("source", "userinfo"), + slog.F("claim_fields", claimFields(userInfoClaims)), + slog.F("blank", blankFields(userInfoClaims)), + ) + } else if !strings.Contains(err.Error(), "user info endpoint is not supported by this provider") { + logger.Error(ctx, "oauth2: unable to obtain user information claims", slog.Error(err)) + httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ + Message: "Failed to obtain user information claims.", + Detail: "The attempt to fetch claims via the UserInfo endpoint failed: " + err.Error(), + }) + return nil, false + } 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. + logger.Warn(ctx, "OIDC provider does not support the user info endpoint, ensure that all required claims are present in the id_token", + slog.Error(err), + ) + } + return userInfoClaims, true +} + // claimFields returns the sorted list of fields in the claims map. func claimFields(claims map[string]interface{}) []string { fields := []string{} diff --git a/coderd/userauth_test.go b/coderd/userauth_test.go index b0a4dd80efa03..4aba740d4ede0 100644 --- a/coderd/userauth_test.go +++ b/coderd/userauth_test.go @@ -60,7 +60,7 @@ func TestOIDCOauthLoginWithExisting(t *testing.T) { cfg := fake.OIDCConfig(t, nil, func(cfg *coderd.OIDCConfig) { cfg.AllowSignups = true - cfg.IgnoreUserInfo = true + cfg.SecondaryClaims = coderd.MergedClaimsSourceNone }) client, _, api := coderdtest.NewWithAPI(t, &coderdtest.Options{ @@ -892,6 +892,7 @@ func TestUserOIDC(t *testing.T) { Name string IDTokenClaims jwt.MapClaims UserInfoClaims jwt.MapClaims + AccessTokenClaims jwt.MapClaims AllowSignups bool EmailDomain []string AssertUser func(t testing.TB, u codersdk.User) @@ -899,6 +900,7 @@ func TestUserOIDC(t *testing.T) { AssertResponse func(t testing.TB, resp *http.Response) IgnoreEmailVerified bool IgnoreUserInfo bool + UseAccessToken bool }{ { Name: "NoSub", @@ -908,6 +910,32 @@ func TestUserOIDC(t *testing.T) { AllowSignups: true, StatusCode: http.StatusBadRequest, }, + { + Name: "AccessTokenMerge", + IDTokenClaims: jwt.MapClaims{ + "sub": uuid.NewString(), + }, + AccessTokenClaims: jwt.MapClaims{ + "email": "kyle@kwc.io", + }, + IgnoreUserInfo: true, + AllowSignups: true, + UseAccessToken: true, + StatusCode: http.StatusOK, + AssertUser: func(t testing.TB, u codersdk.User) { + assert.Equal(t, "kyle@kwc.io", u.Email) + }, + }, + { + Name: "AccessTokenMergeNotJWT", + IDTokenClaims: jwt.MapClaims{ + "sub": uuid.NewString(), + }, + IgnoreUserInfo: true, + AllowSignups: true, + UseAccessToken: true, + StatusCode: http.StatusBadRequest, + }, { Name: "EmailOnly", IDTokenClaims: jwt.MapClaims{ @@ -1290,18 +1318,32 @@ func TestUserOIDC(t *testing.T) { tc := tc t.Run(tc.Name, func(t *testing.T) { t.Parallel() - fake := oidctest.NewFakeIDP(t, + opts := []oidctest.FakeIDPOpt{ oidctest.WithRefresh(func(_ string) error { return xerrors.New("refreshing token should never occur") }), oidctest.WithServing(), oidctest.WithStaticUserInfo(tc.UserInfoClaims), - ) + } + + if tc.AccessTokenClaims != nil && len(tc.AccessTokenClaims) > 0 { + opts = append(opts, oidctest.WithAccessTokenJWTHook(func(email string, exp time.Time) jwt.MapClaims { + return tc.AccessTokenClaims + })) + } + + fake := oidctest.NewFakeIDP(t, opts...) cfg := fake.OIDCConfig(t, nil, func(cfg *coderd.OIDCConfig) { cfg.AllowSignups = tc.AllowSignups cfg.EmailDomain = tc.EmailDomain cfg.IgnoreEmailVerified = tc.IgnoreEmailVerified - cfg.IgnoreUserInfo = tc.IgnoreUserInfo + cfg.SecondaryClaims = coderd.MergedClaimsSourceUserInfo + if tc.IgnoreUserInfo { + cfg.SecondaryClaims = coderd.MergedClaimsSourceNone + } + if tc.UseAccessToken { + cfg.SecondaryClaims = coderd.MergedClaimsSourceAccessToken + } cfg.NameField = "name" }) diff --git a/codersdk/deployment.go b/codersdk/deployment.go index e1c0b977c00d2..d648dba1c0fea 100644 --- a/codersdk/deployment.go +++ b/codersdk/deployment.go @@ -517,17 +517,27 @@ type OIDCConfig struct { ClientID serpent.String `json:"client_id" typescript:",notnull"` ClientSecret serpent.String `json:"client_secret" typescript:",notnull"` // ClientKeyFile & ClientCertFile are used in place of ClientSecret for PKI auth. - ClientKeyFile serpent.String `json:"client_key_file" typescript:",notnull"` - ClientCertFile serpent.String `json:"client_cert_file" typescript:",notnull"` - EmailDomain serpent.StringArray `json:"email_domain" typescript:",notnull"` - IssuerURL serpent.String `json:"issuer_url" typescript:",notnull"` - Scopes serpent.StringArray `json:"scopes" typescript:",notnull"` - IgnoreEmailVerified serpent.Bool `json:"ignore_email_verified" typescript:",notnull"` - UsernameField serpent.String `json:"username_field" typescript:",notnull"` - NameField serpent.String `json:"name_field" typescript:",notnull"` - EmailField serpent.String `json:"email_field" typescript:",notnull"` - AuthURLParams serpent.Struct[map[string]string] `json:"auth_url_params" typescript:",notnull"` - IgnoreUserInfo serpent.Bool `json:"ignore_user_info" typescript:",notnull"` + ClientKeyFile serpent.String `json:"client_key_file" typescript:",notnull"` + ClientCertFile serpent.String `json:"client_cert_file" typescript:",notnull"` + EmailDomain serpent.StringArray `json:"email_domain" typescript:",notnull"` + IssuerURL serpent.String `json:"issuer_url" typescript:",notnull"` + Scopes serpent.StringArray `json:"scopes" typescript:",notnull"` + IgnoreEmailVerified serpent.Bool `json:"ignore_email_verified" typescript:",notnull"` + UsernameField serpent.String `json:"username_field" typescript:",notnull"` + NameField serpent.String `json:"name_field" typescript:",notnull"` + EmailField serpent.String `json:"email_field" typescript:",notnull"` + AuthURLParams serpent.Struct[map[string]string] `json:"auth_url_params" typescript:",notnull"` + // IgnoreUserInfo & UserInfoFromAccessToken are mutually exclusive. Only 1 + // can be set to true. Ideally this would be an enum with 3 states, ['none', + // 'userinfo', 'access_token']. However, for backward compatibility, + // `ignore_user_info` must remain. And `access_token` is a niche, non-spec + // compliant edge case. So it's use is rare, and should not be advised. + IgnoreUserInfo serpent.Bool `json:"ignore_user_info" typescript:",notnull"` + // UserInfoFromAccessToken as mentioned above is an edge case. This allows + // sourcing the user_info from the access token itself instead of a user_info + // endpoint. This assumes the access token is a valid JWT with a set of claims to + // be merged with the id_token. + UserInfoFromAccessToken serpent.Bool `json:"source_user_info_from_access_token" typescript:",notnull"` OrganizationField serpent.String `json:"organization_field" typescript:",notnull"` OrganizationMapping serpent.Struct[map[string][]uuid.UUID] `json:"organization_mapping" typescript:",notnull"` OrganizationAssignDefault serpent.Bool `json:"organization_assign_default" typescript:",notnull"` @@ -1753,6 +1763,23 @@ func (c *DeploymentValues) Options() serpent.OptionSet { Group: &deploymentGroupOIDC, YAML: "ignoreUserInfo", }, + { + Name: "OIDC Access Token Claims", + // This is a niche edge case that should not be advertised. Alternatives should + // be investigated before turning this on. A properly configured IdP should + // always have a userinfo endpoint which is preferred. + Hidden: true, + Description: "Source supplemental user claims from the 'access_token'. This assumes the " + + "token is a jwt signed by the same issuer as the id_token. Using this requires setting " + + "'oidc-ignore-userinfo' to true. This setting is not compliant with the OIDC specification " + + "and is not recommended. Use at your own risk.", + Flag: "oidc-access-token-claims", + Env: "CODER_OIDC_ACCESS_TOKEN_CLAIMS", + Default: "false", + Value: &c.OIDC.UserInfoFromAccessToken, + Group: &deploymentGroupOIDC, + YAML: "accessTokenClaims", + }, { Name: "OIDC Organization Field", Description: "This field must be set if using the organization sync feature." + diff --git a/docs/reference/api/general.md b/docs/reference/api/general.md index 66e85f3f6978a..4c7f0ba3254cf 100644 --- a/docs/reference/api/general.md +++ b/docs/reference/api/general.md @@ -375,6 +375,7 @@ curl -X GET http://coder-server:8080/api/v2/deployment/config \ "sign_in_text": "string", "signups_disabled_text": "string", "skip_issuer_checks": true, + "source_user_info_from_access_token": true, "user_role_field": "string", "user_role_mapping": {}, "user_roles_default": [ diff --git a/docs/reference/api/schemas.md b/docs/reference/api/schemas.md index ebccd362c9c96..bbc74d603309f 100644 --- a/docs/reference/api/schemas.md +++ b/docs/reference/api/schemas.md @@ -2018,6 +2018,7 @@ CreateWorkspaceRequest provides options for creating a new workspace. Only one o "sign_in_text": "string", "signups_disabled_text": "string", "skip_issuer_checks": true, + "source_user_info_from_access_token": true, "user_role_field": "string", "user_role_mapping": {}, "user_roles_default": [ @@ -2488,6 +2489,7 @@ CreateWorkspaceRequest provides options for creating a new workspace. Only one o "sign_in_text": "string", "signups_disabled_text": "string", "skip_issuer_checks": true, + "source_user_info_from_access_token": true, "user_role_field": "string", "user_role_mapping": {}, "user_roles_default": [ @@ -3983,6 +3985,7 @@ Git clone makes use of this by parsing the URL from: 'Username for "https://gith "sign_in_text": "string", "signups_disabled_text": "string", "skip_issuer_checks": true, + "source_user_info_from_access_token": true, "user_role_field": "string", "user_role_mapping": {}, "user_roles_default": [ @@ -3994,37 +3997,38 @@ Git clone makes use of this by parsing the URL from: 'Username for "https://gith ### Properties -| Name | Type | Required | Restrictions | Description | -|-------------------------------|----------------------------------|----------|--------------|----------------------------------------------------------------------------------| -| `allow_signups` | boolean | false | | | -| `auth_url_params` | object | false | | | -| `client_cert_file` | string | false | | | -| `client_id` | string | false | | | -| `client_key_file` | string | false | | Client key file & ClientCertFile are used in place of ClientSecret for PKI auth. | -| `client_secret` | string | false | | | -| `email_domain` | array of string | false | | | -| `email_field` | string | false | | | -| `group_allow_list` | array of string | false | | | -| `group_auto_create` | boolean | false | | | -| `group_mapping` | object | false | | | -| `group_regex_filter` | [serpent.Regexp](#serpentregexp) | false | | | -| `groups_field` | string | false | | | -| `icon_url` | [serpent.URL](#serpenturl) | false | | | -| `ignore_email_verified` | boolean | false | | | -| `ignore_user_info` | boolean | false | | | -| `issuer_url` | string | false | | | -| `name_field` | string | false | | | -| `organization_assign_default` | boolean | false | | | -| `organization_field` | string | false | | | -| `organization_mapping` | object | false | | | -| `scopes` | array of string | false | | | -| `sign_in_text` | string | false | | | -| `signups_disabled_text` | string | false | | | -| `skip_issuer_checks` | boolean | false | | | -| `user_role_field` | string | false | | | -| `user_role_mapping` | object | false | | | -| `user_roles_default` | array of string | false | | | -| `username_field` | string | false | | | +| Name | Type | Required | Restrictions | Description | +|--------------------------------------|----------------------------------|----------|--------------|--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| `allow_signups` | boolean | false | | | +| `auth_url_params` | object | false | | | +| `client_cert_file` | string | false | | | +| `client_id` | string | false | | | +| `client_key_file` | string | false | | Client key file & ClientCertFile are used in place of ClientSecret for PKI auth. | +| `client_secret` | string | false | | | +| `email_domain` | array of string | false | | | +| `email_field` | string | false | | | +| `group_allow_list` | array of string | false | | | +| `group_auto_create` | boolean | false | | | +| `group_mapping` | object | false | | | +| `group_regex_filter` | [serpent.Regexp](#serpentregexp) | false | | | +| `groups_field` | string | false | | | +| `icon_url` | [serpent.URL](#serpenturl) | false | | | +| `ignore_email_verified` | boolean | false | | | +| `ignore_user_info` | boolean | false | | Ignore user info & UserInfoFromAccessToken are mutually exclusive. Only 1 can be set to true. Ideally this would be an enum with 3 states, ['none', 'userinfo', 'access_token']. However, for backward compatibility, `ignore_user_info` must remain. And `access_token` is a niche, non-spec compliant edge case. So it's use is rare, and should not be advised. | +| `issuer_url` | string | false | | | +| `name_field` | string | false | | | +| `organization_assign_default` | boolean | false | | | +| `organization_field` | string | false | | | +| `organization_mapping` | object | false | | | +| `scopes` | array of string | false | | | +| `sign_in_text` | string | false | | | +| `signups_disabled_text` | string | false | | | +| `skip_issuer_checks` | boolean | false | | | +| `source_user_info_from_access_token` | boolean | false | | Source user info from access token as mentioned above is an edge case. This allows sourcing the user_info from the access token itself instead of a user_info endpoint. This assumes the access token is a valid JWT with a set of claims to be merged with the id_token. | +| `user_role_field` | string | false | | | +| `user_role_mapping` | object | false | | | +| `user_roles_default` | array of string | false | | | +| `username_field` | string | false | | | ## codersdk.Organization diff --git a/scripts/testidp/main.go b/scripts/testidp/main.go index e1b7a17f347e2..52b10ab94e975 100644 --- a/scripts/testidp/main.go +++ b/scripts/testidp/main.go @@ -11,6 +11,7 @@ import ( "time" "github.com/golang-jwt/jwt/v4" + "github.com/google/uuid" "github.com/stretchr/testify/require" "cdr.dev/slog" @@ -88,6 +89,7 @@ func RunIDP() func(t *testing.T) { // This is a static set of auth fields. Might be beneficial to make flags // to allow different values here. This is only required for using the // testIDP as primary auth. External auth does not ever fetch these fields. + "sub": uuid.MustParse("26c6a19c-b9b8-493b-a991-88a4c3310314"), "email": "oidc_member@coder.com", "preferred_username": "oidc_member", "email_verified": true, diff --git a/site/src/api/typesGenerated.ts b/site/src/api/typesGenerated.ts index 5ad807af38b6e..d5d10c6427c66 100644 --- a/site/src/api/typesGenerated.ts +++ b/site/src/api/typesGenerated.ts @@ -1392,6 +1392,7 @@ export interface OIDCConfig { readonly email_field: string; readonly auth_url_params: SerpentStruct>; readonly ignore_user_info: boolean; + readonly source_user_info_from_access_token: boolean; readonly organization_field: string; readonly organization_mapping: SerpentStruct>; readonly organization_assign_default: boolean;