From b4275b3ad8bfba6d8667f5ae5b1ebb57b33d6365 Mon Sep 17 00:00:00 2001 From: Steven Masley Date: Mon, 10 Feb 2025 11:34:31 -0600 Subject: [PATCH 1/6] feat(oidc): add sourcing secondary claims from access_token Niche edge case use, assumes access_token is jwt --- cli/server.go | 13 ++- coderd/coderdtest/oidctest/idp.go | 7 +- coderd/userauth.go | 165 +++++++++++++++++++----------- coderd/userauth_test.go | 6 +- codersdk/deployment.go | 49 +++++++-- 5 files changed, 164 insertions(+), 76 deletions(-) diff --git a/cli/server.go b/cli/server.go index 548f350bae9bc..75d75be414761 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("cannot specify both 'oidc-access-token-claims' and 'oidc-ignore-userinfo'") + } + 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/coderd/coderdtest/oidctest/idp.go b/coderd/coderdtest/oidctest/idp.go index d6c7e6259f760..5eb1873202691 100644 --- a/coderd/coderdtest/oidctest/idp.go +++ b/coderd/coderdtest/oidctest/idp.go @@ -1465,9 +1465,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 { diff --git a/coderd/userauth.go b/coderd/userauth.go index 15eea78b5bc8c..0b5292cf12feb 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 @@ -1112,20 +1122,6 @@ func (api *API) userOIDC(rw http.ResponseWriter, r *http.Request) { return } - if idToken.Subject == "" { - logger.Error(ctx, "oauth2: missing 'sub' claim field in OIDC token", - slog.F("source", "id_token"), - slog.F("claim_fields", claimFields(idtokenClaims)), - slog.F("blank", blankFields(idtokenClaims)), - ) - httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{ - Message: "OIDC token missing 'sub' claim field or 'sub' claim field is empty.", - Detail: "'sub' claim field is required to be unique for all users by a given issue, " + - "an empty field is invalid and this authentication attempt is rejected.", - }) - return - } - logger.Debug(ctx, "got oidc claims", slog.F("source", "id_token"), slog.F("claim_fields", claimFields(idtokenClaims)), @@ -1142,50 +1138,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 +1324,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 +1358,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..304467d552458 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{ @@ -1301,7 +1301,9 @@ func TestUserOIDC(t *testing.T) { cfg.AllowSignups = tc.AllowSignups cfg.EmailDomain = tc.EmailDomain cfg.IgnoreEmailVerified = tc.IgnoreEmailVerified - cfg.IgnoreUserInfo = tc.IgnoreUserInfo + if tc.IgnoreUserInfo { + cfg.SecondaryClaims = coderd.MergedClaimsSourceUserInfo + } 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." + From 895bb00ec22ee4cf953777190a6e7b198343c203 Mon Sep 17 00:00:00 2001 From: Steven Masley Date: Mon, 10 Feb 2025 15:12:17 -0600 Subject: [PATCH 2/6] test(oidc): test sourcing secondary claims from access token --- coderd/coderdtest/oidctest/idp.go | 24 ++++++++++++++--- coderd/userauth_test.go | 43 +++++++++++++++++++++++++++++-- 2 files changed, 61 insertions(+), 6 deletions(-) diff --git a/coderd/coderdtest/oidctest/idp.go b/coderd/coderdtest/oidctest/idp.go index 5eb1873202691..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()), @@ -1553,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_test.go b/coderd/userauth_test.go index 304467d552458..8a95cee849add 100644 --- a/coderd/userauth_test.go +++ b/coderd/userauth_test.go @@ -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,13 +1318,21 @@ 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 @@ -1304,6 +1340,9 @@ func TestUserOIDC(t *testing.T) { if tc.IgnoreUserInfo { cfg.SecondaryClaims = coderd.MergedClaimsSourceUserInfo } + if tc.UseAccessToken { + cfg.SecondaryClaims = coderd.MergedClaimsSourceAccessToken + } cfg.NameField = "name" }) From eee85c04b42ef9e33ee047321f8514bcbb5430b9 Mon Sep 17 00:00:00 2001 From: Steven Masley Date: Mon, 10 Feb 2025 15:16:35 -0600 Subject: [PATCH 3/6] make gen --- coderd/apidoc/docs.go | 5 +++ coderd/apidoc/swagger.json | 5 +++ coderd/userauth_test.go | 3 +- docs/reference/api/general.md | 1 + docs/reference/api/schemas.md | 66 ++++++++++++++++++---------------- site/src/api/typesGenerated.ts | 1 + 6 files changed, 49 insertions(+), 32 deletions(-) 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/userauth_test.go b/coderd/userauth_test.go index 8a95cee849add..4aba740d4ede0 100644 --- a/coderd/userauth_test.go +++ b/coderd/userauth_test.go @@ -1337,8 +1337,9 @@ func TestUserOIDC(t *testing.T) { cfg.AllowSignups = tc.AllowSignups cfg.EmailDomain = tc.EmailDomain cfg.IgnoreEmailVerified = tc.IgnoreEmailVerified + cfg.SecondaryClaims = coderd.MergedClaimsSourceUserInfo if tc.IgnoreUserInfo { - cfg.SecondaryClaims = coderd.MergedClaimsSourceUserInfo + cfg.SecondaryClaims = coderd.MergedClaimsSourceNone } if tc.UseAccessToken { cfg.SecondaryClaims = coderd.MergedClaimsSourceAccessToken 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/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; From c2b386863bb8a61dcad31b3044c58cd640e6587b Mon Sep 17 00:00:00 2001 From: Steven Masley Date: Mon, 10 Feb 2025 15:24:50 -0600 Subject: [PATCH 4/6] update golden files --- cli/testdata/server-config.yaml.golden | 6 ++++++ 1 file changed, 6 insertions(+) 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) From b89084f70cf372c1d94ddcfbea870069543abf9b Mon Sep 17 00:00:00 2001 From: Steven Masley Date: Mon, 10 Feb 2025 15:26:52 -0600 Subject: [PATCH 5/6] revert change to sub check --- coderd/userauth.go | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/coderd/userauth.go b/coderd/userauth.go index 0b5292cf12feb..7f4319b3db470 100644 --- a/coderd/userauth.go +++ b/coderd/userauth.go @@ -1122,6 +1122,20 @@ func (api *API) userOIDC(rw http.ResponseWriter, r *http.Request) { return } + if idToken.Subject == "" { + logger.Error(ctx, "oauth2: missing 'sub' claim field in OIDC token", + slog.F("source", "id_token"), + slog.F("claim_fields", claimFields(idtokenClaims)), + slog.F("blank", blankFields(idtokenClaims)), + ) + httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{ + Message: "OIDC token missing 'sub' claim field or 'sub' claim field is empty.", + Detail: "'sub' claim field is required to be unique for all users by a given issue, " + + "an empty field is invalid and this authentication attempt is rejected.", + }) + return + } + logger.Debug(ctx, "got oidc claims", slog.F("source", "id_token"), slog.F("claim_fields", claimFields(idtokenClaims)), From 11f4bccfc33b3b7ca13d4d3a072892fafd8b976e Mon Sep 17 00:00:00 2001 From: Steven Masley Date: Mon, 10 Feb 2025 15:34:16 -0600 Subject: [PATCH 6/6] update error message --- cli/server.go | 2 +- scripts/testidp/main.go | 2 ++ 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/cli/server.go b/cli/server.go index 75d75be414761..c64af21ef5214 100644 --- a/cli/server.go +++ b/cli/server.go @@ -174,7 +174,7 @@ func createOIDCConfig(ctx context.Context, logger slog.Logger, vals *codersdk.De secondaryClaimsSrc := coderd.MergedClaimsSourceUserInfo if !vals.OIDC.IgnoreUserInfo && vals.OIDC.UserInfoFromAccessToken { - return nil, xerrors.Errorf("cannot specify both 'oidc-access-token-claims' and 'oidc-ignore-userinfo'") + 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 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, pFad - Phonifier reborn

Pfad - The Proxy pFad of © 2024 Garber Painting. All rights reserved.

Note: This service is not intended for secure transactions such as banking, social media, email, or purchasing. Use at your own risk. We assume no liability whatsoever for broken pages.


Alternative Proxies:

Alternative Proxy

pFad Proxy

pFad v3 Proxy

pFad v4 Proxy