From d105afce0f0203003e5e5fdd5be41d9deef61f77 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Mon, 29 Jun 2026 15:37:52 +0000 Subject: [PATCH 1/2] Cherry-pick of #26751 requires manual resolution The automatic cherry-pick of ad355aeaa9a10d44c8b910236d6e293e321e9d2f to release/2.33 had conflicts. Please cherry-pick manually: git cherry-pick -x -m1 ad355aeaa9a10d44c8b910236d6e293e321e9d2f From dd1426fbacc3afaef7aeed086c70aeecf778b530 Mon Sep 17 00:00:00 2001 From: Steven Masley Date: Fri, 26 Jun 2026 11:23:42 -0500 Subject: [PATCH 2/2] feat: add INSECURE oidc email fallback flag for IdP brokers (#26751) Adds an opt-in `CODER_DANGEROUS_OIDC_EMAIL_FALLBACK` flag (alias `--dangerous-oidc-email-fallback`) for IdP brokers that do not issue a stable `sub` for the same user across connections. (cherry picked from commit ad355aeaa9a10d44c8b910236d6e293e321e9d2f) --- cli/server.go | 1 + cli/testdata/server-config.yaml.golden | 7 + coderd/apidoc/docs.go | 4 + coderd/apidoc/swagger.json | 4 + coderd/userauth.go | 40 +++++- coderd/userauth_test.go | 170 +++++++++++++++++++++++++ codersdk/deployment.go | 21 +++ docs/reference/api/general.md | 1 + docs/reference/api/schemas.md | 4 + site/src/api/typesGenerated.ts | 8 ++ 10 files changed, 254 insertions(+), 6 deletions(-) diff --git a/cli/server.go b/cli/server.go index 136d67fac04c8..9a94e94134dd0 100644 --- a/cli/server.go +++ b/cli/server.go @@ -278,6 +278,7 @@ func createOIDCConfig(ctx context.Context, logger slog.Logger, vals *codersdk.De IconURL: vals.OIDC.IconURL.String(), IgnoreEmailVerified: vals.OIDC.IgnoreEmailVerified.Value(), PKCEMethods: pkceSupport.CodeChallengeMethodsSupported, + EmailFallback: vals.OIDC.EmailFallback.Value(), }, nil } diff --git a/cli/testdata/server-config.yaml.golden b/cli/testdata/server-config.yaml.golden index 3f70ce8c274ef..392195beaf9f2 100644 --- a/cli/testdata/server-config.yaml.golden +++ b/cli/testdata/server-config.yaml.golden @@ -434,6 +434,13 @@ oidc: # next login. # (default: true, type: bool) oidc-repair-links: true + # INSECURE: Allow OIDC logins to fall back to email-based matching when the + # linked_id (issuer+subject) does not match an existing user link. Required for + # IdP brokers that do not issue a stable 'sub' for the same user across + # connections. The existing user_link's linked_id is preserved on fallback. Only + # enable if you understand and accept the risk. + # (default: , type: bool) + dangerousOidcEmailFallback: false # Telemetry is critical to our ability to improve Coder. We strip all personal # information before sending data to our servers. Please only disable telemetry # when required by your organization's security policy. diff --git a/coderd/apidoc/docs.go b/coderd/apidoc/docs.go index 8aa530b460ae6..80cf508c9d839 100644 --- a/coderd/apidoc/docs.go +++ b/coderd/apidoc/docs.go @@ -19637,6 +19637,10 @@ const docTemplate = `{ "type": "string" } }, + "email_fallback": { + "description": "EmailFallback allows OIDC logins to fall back to email-based matching\nwhen the ` + "`" + `linked_id` + "`" + ` (issuer+subject) does not match an existing user\nlink. INSECURE: weakens the linked_id check. It exists for IdP\nbrokers that do not issue a stable ` + "`" + `sub` + "`" + ` for the same user across\nconnections.", + "type": "boolean" + }, "email_field": { "type": "string" }, diff --git a/coderd/apidoc/swagger.json b/coderd/apidoc/swagger.json index 73151ef1c0909..23311f50d0622 100644 --- a/coderd/apidoc/swagger.json +++ b/coderd/apidoc/swagger.json @@ -17930,6 +17930,10 @@ "type": "string" } }, + "email_fallback": { + "description": "EmailFallback allows OIDC logins to fall back to email-based matching\nwhen the `linked_id` (issuer+subject) does not match an existing user\nlink. INSECURE: weakens the linked_id check. It exists for IdP\nbrokers that do not issue a stable `sub` for the same user across\nconnections.", + "type": "boolean" + }, "email_field": { "type": "string" }, diff --git a/coderd/userauth.go b/coderd/userauth.go index ec1907af479d8..bd96bafdf96f7 100644 --- a/coderd/userauth.go +++ b/coderd/userauth.go @@ -1037,7 +1037,7 @@ func (api *API) userOAuth2Github(rw http.ResponseWriter, r *http.Request) { }) return } - user, link, err := findLinkedUser(ctx, api.Database, githubLinkedID(ghUser), database.LoginTypeGithub, verifiedEmail.GetEmail()) + user, link, err := findLinkedUser(ctx, api.Database, githubLinkedID(ghUser), database.LoginTypeGithub, false, verifiedEmail.GetEmail()) if errors.Is(err, errLinkedIDAlreadyBound) { logger.Warn(ctx, "oauth2: blocked login, account already linked to different identity", slog.F("email", verifiedEmail.GetEmail()), @@ -1187,6 +1187,12 @@ type OIDCConfig struct { // SignupsDisabledText is the text do display on the static error page. SignupsDisabledText string PKCEMethods []promoauth.Oauth2PKCEChallengeMethod + // EmailFallback, when true, allows OIDC logins to fall back to + // email-based user matching when the linked_id (issuer+subject) does + // not match an existing user link. INSECURE: weakens the linked_id + // check. Used for IdP brokers that do not issue a stable `sub` for the + // same user across connections. + EmailFallback bool } // PKCESupported is to prevent nil pointer dereference. @@ -1458,7 +1464,7 @@ func (api *API) userOIDC(rw http.ResponseWriter, r *http.Request) { } ctx = slog.With(ctx, slog.F("email", email), slog.F("username", username), slog.F("name", name)) - user, link, err := findLinkedUser(ctx, api.Database, oidcLinkedID(idToken), database.LoginTypeOIDC, email) + user, link, err := findLinkedUser(ctx, api.Database, oidcLinkedID(idToken), database.LoginTypeOIDC, api.OIDCConfig.EmailFallback, email) if errors.Is(err, errLinkedIDAlreadyBound) { logger.Warn(ctx, "oauth2: blocked login, account already linked to different identity", slog.F("email", email), @@ -1526,6 +1532,7 @@ func (api *API) userOIDC(rw http.ResponseWriter, r *http.Request) { UserInfoClaims: supplementaryClaims, MergedClaims: mergedClaims, }, + AllowInsecureLinkedIDMismatch: api.OIDCConfig.EmailFallback, }).SetInitAuditRequest(func(params *audit.RequestParams) (*audit.Request[database.User], func()) { return audit.InitRequest[database.User](rw, params) }) @@ -1679,6 +1686,13 @@ type oauthLoginParams struct { // It is used to save the user's claims on login. UserClaims database.UserLinkClaims + // AllowInsecureLinkedIDMismatch, when true, allows the login to proceed + // when the existing user_link's linked_id differs from LinkedID. The + // existing linked_id is preserved (no overwrite). INSECURE: opt-in + // escape hatch for IdP brokers that emit different subjects for the + // same user across connections. + AllowInsecureLinkedIDMismatch bool + commitLock sync.Mutex initAuditRequest func(params *audit.RequestParams) *audit.Request[database.User] commits []func() @@ -1911,7 +1925,10 @@ func (api *API) oauthLogin(r *http.Request, params *oauthLoginParams) ([]*http.C // Defense-in-depth: if a concurrent transaction backfilled // linked_id between findLinkedUser and this point, reject the // login with a 403 instead of letting it bubble up as a 500. - if link.LinkedID != "" && link.LinkedID != params.LinkedID { + // The INSECURE AllowInsecureLinkedIDMismatch escape hatch + // preserves the existing linked_id and lets the login proceed; + // the warning was already emitted by the caller. + if link.LinkedID != "" && link.LinkedID != params.LinkedID && !params.AllowInsecureLinkedIDMismatch { return &idpsync.HTTPError{ Code: http.StatusForbidden, Msg: "Account already linked", @@ -2162,7 +2179,16 @@ var errLinkedIDAlreadyBound = xerrors.New("user account is already linked to a d // legacy links (empty linked_id) only. If the user found by email // already has a link with a different linked_id, errLinkedIDAlreadyBound // is returned to prevent account takeover via IdP email reuse. -func findLinkedUser(ctx context.Context, db database.Store, linkedID string, loginType database.LoginType, emails ...string) (database.User, database.UserLink, error) { +// +// When allowInsecureLinkedIDMismatch is true, the linked_id mismatch +// check is skipped and the email fallback resolves the login even when +// the existing link's linked_id differs from the current login's. The +// existing linked_id is left intact (no overwrite). This is an INSECURE +// opt-in for IdP brokers that do not issue a stable `sub` for the same +// user across connections. +// +//nolint:revive // allowInsecureLinkedIDMismatch is intentionally a control flag; it gates an INSECURE opt-in. +func findLinkedUser(ctx context.Context, db database.Store, linkedID string, loginType database.LoginType, allowInsecureLinkedIDMismatch bool, emails ...string) (database.User, database.UserLink, error) { var ( user database.User link database.UserLink @@ -2215,8 +2241,10 @@ func findLinkedUser(ctx context.Context, db database.Store, linkedID string, log // Block email fallback when an existing link has a different linked_id. // Prevents account takeover via IdP email reuse; first-time and legacy - // (empty linked_id) links pass through. - if err == nil && link.LinkedID != "" && link.LinkedID != linkedID { + // (empty linked_id) links pass through. The INSECURE + // allowInsecureLinkedIDMismatch escape hatch keeps the existing link + // (and its original linked_id) and lets the login proceed. + if err == nil && link.LinkedID != "" && link.LinkedID != linkedID && !allowInsecureLinkedIDMismatch { return database.User{}, database.UserLink{}, errLinkedIDAlreadyBound } diff --git a/coderd/userauth_test.go b/coderd/userauth_test.go index d9f5dabf49e4f..3833dc6423323 100644 --- a/coderd/userauth_test.go +++ b/coderd/userauth_test.go @@ -2002,6 +2002,176 @@ func TestUserOIDC(t *testing.T) { "linked_id must not be modified when the login is blocked") }) + // Tests the INSECURE OIDC email fallback escape hatch. When the + // deployment flag is set, an OIDC login whose subject differs from an + // existing user_link's linked_id but whose email matches must be + // allowed through. The original linked_id is preserved (no overwrite), + // so the user can keep logging in with either subject. + t.Run("OIDCInsecureEmailFallbackAllowed", func(t *testing.T) { + t.Parallel() + ctx := testutil.Context(t, testutil.WaitShort) + + fake := oidctest.NewFakeIDP(t, + oidctest.WithRefresh(func(_ string) error { + return xerrors.New("refreshing token should never occur") + }), + oidctest.WithServing(), + ) + cfg := fake.OIDCConfig(t, nil, func(cfg *coderd.OIDCConfig) { + cfg.AllowSignups = true + cfg.EmailFallback = true + }) + + logger := slogtest.Make(t, &slogtest.Options{IgnoreErrors: true}).Leveled(slog.LevelDebug) + owner, db := coderdtest.NewWithDatabase(t, &coderdtest.Options{ + OIDCConfig: cfg, + Logger: &logger, + }) + + // Seed a user whose link records the IdP's first connection. + user := dbgen.User(t, db, database.User{ + LoginType: database.LoginTypeOIDC, + }) + originalLinkedID := fake.IssuerURL().String() + "||" + "first-connection-sub" + dbgen.UserLink(t, db, database.UserLink{ + UserID: user.ID, + LoginType: database.LoginTypeOIDC, + LinkedID: originalLinkedID, + }) + + // Login with a different subject (the broker emitted a new `sub` + // for the same user) but the same email. With EmailFallback + // enabled the email match resolves the login despite the linked_id + // mismatch. + client, resp := fake.AttemptLogin(t, owner, jwt.MapClaims{ + "email": user.Email, + "sub": "second-connection-sub", + }) + require.Equal(t, http.StatusOK, resp.StatusCode, + "insecure email fallback must allow login with a mismatched subject") + + me, err := client.User(ctx, "me") + require.NoError(t, err) + require.Equal(t, user.ID, me.ID, + "should authenticate as the existing user") + + // The original linked_id must be preserved, not overwritten by the + // new subject. This keeps the next login from the original subject + // (which hits the primary linked_id path) working. + link, err := db.GetUserLinkByUserIDLoginType(dbauthz.AsSystemRestricted(context.Background()), database.GetUserLinkByUserIDLoginTypeParams{ + UserID: user.ID, + LoginType: database.LoginTypeOIDC, + }) + require.NoError(t, err) + require.Equal(t, originalLinkedID, link.LinkedID, + "linked_id must be preserved on insecure email fallback") + }) + + // Tests that with the INSECURE OIDC email fallback enabled, the + // original subject's login still resolves via the primary linked_id + // path after a fallback login from a different subject. The fallback + // path does not overwrite the link, so the original subject keeps + // matching directly. + t.Run("OIDCInsecureEmailFallbackPreservesOriginalLogin", func(t *testing.T) { + t.Parallel() + ctx := testutil.Context(t, testutil.WaitShort) + + fake := oidctest.NewFakeIDP(t, + oidctest.WithRefresh(func(_ string) error { + return xerrors.New("refreshing token should never occur") + }), + oidctest.WithServing(), + ) + cfg := fake.OIDCConfig(t, nil, func(cfg *coderd.OIDCConfig) { + cfg.AllowSignups = true + cfg.EmailFallback = true + }) + + logger := slogtest.Make(t, &slogtest.Options{IgnoreErrors: true}).Leveled(slog.LevelDebug) + owner, db := coderdtest.NewWithDatabase(t, &coderdtest.Options{ + OIDCConfig: cfg, + Logger: &logger, + }) + + user := dbgen.User(t, db, database.User{ + LoginType: database.LoginTypeOIDC, + }) + const originalSub = "first-connection-sub" + originalLinkedID := fake.IssuerURL().String() + "||" + originalSub + dbgen.UserLink(t, db, database.UserLink{ + UserID: user.ID, + LoginType: database.LoginTypeOIDC, + LinkedID: originalLinkedID, + }) + + // Fallback login with a different subject. + _, resp := fake.AttemptLogin(t, owner, jwt.MapClaims{ + "email": user.Email, + "sub": "second-connection-sub", + }) + require.Equal(t, http.StatusOK, resp.StatusCode) + + // Subsequent login with the original subject must hit the primary + // linked_id match and succeed. + client, resp := fake.AttemptLogin(t, owner, jwt.MapClaims{ + "email": user.Email, + "sub": originalSub, + }) + require.Equal(t, http.StatusOK, resp.StatusCode) + + me, err := client.User(ctx, "me") + require.NoError(t, err) + require.Equal(t, user.ID, me.ID) + + link, err := db.GetUserLinkByUserIDLoginType(dbauthz.AsSystemRestricted(context.Background()), database.GetUserLinkByUserIDLoginTypeParams{ + UserID: user.ID, + LoginType: database.LoginTypeOIDC, + }) + require.NoError(t, err) + require.Equal(t, originalLinkedID, link.LinkedID, + "linked_id must stay anchored to the original subject") + }) + + // Tests that the INSECURE OIDC email fallback does NOT extend to + // signups: an attacker logging in with a brand-new email (no existing + // user) still goes through the normal signup gate. The escape hatch is + // only about resolving subject-mismatch on existing accounts. + t.Run("OIDCInsecureEmailFallbackDoesNotCreateUsers", func(t *testing.T) { + t.Parallel() + + fake := oidctest.NewFakeIDP(t, + oidctest.WithRefresh(func(_ string) error { + return xerrors.New("refreshing token should never occur") + }), + oidctest.WithServing(), + ) + cfg := fake.OIDCConfig(t, nil, func(cfg *coderd.OIDCConfig) { + cfg.AllowSignups = false + cfg.EmailFallback = true + }) + + logger := slogtest.Make(t, &slogtest.Options{IgnoreErrors: true}).Leveled(slog.LevelDebug) + owner, db := coderdtest.NewWithDatabase(t, &coderdtest.Options{ + OIDCConfig: cfg, + Logger: &logger, + }) + + // Seed an existing user so the deployment's user count is > 0; + // otherwise the first signup is always allowed. + dbgen.User(t, db, database.User{ + LoginType: database.LoginTypeOIDC, + }) + + // New email, no existing user. Signups are disabled, so the + // fallback flag must not let the login through. + _, resp := fake.AttemptLogin(t, owner, jwt.MapClaims{ + "email": "stranger@example.com", + "sub": "stranger-subject", + }) + require.Equal(t, http.StatusForbidden, resp.StatusCode, + "insecure email fallback must not bypass the signup gate") + }) + t.Run("OIDCConvert", func(t *testing.T) { t.Parallel() diff --git a/codersdk/deployment.go b/codersdk/deployment.go index 894928ecefb0e..bdbe9f8fceee3 100644 --- a/codersdk/deployment.go +++ b/codersdk/deployment.go @@ -927,6 +927,13 @@ type OIDCConfig struct { RedirectURL serpent.URL `json:"redirect_url" typescript:",notnull"` AutoRepairLinks serpent.Bool `json:"auto_repair_links" typescript:",notnull"` + + // EmailFallback allows OIDC logins to fall back to email-based matching + // when the `linked_id` (issuer+subject) does not match an existing user + // link. INSECURE: weakens the linked_id check. It exists for IdP + // brokers that do not issue a stable `sub` for the same user across + // connections. + EmailFallback serpent.Bool `json:"email_fallback" typescript:",notnull"` } type TelemetryConfig struct { @@ -2592,6 +2599,20 @@ func (c *DeploymentValues) Options() serpent.OptionSet { // as a flag as an escape hatch for now. Hidden: true, }, + { + Name: "OIDC Insecure Email Fallback (DANGEROUS)", + Description: "INSECURE: Allow OIDC logins to fall back to email-based matching when " + + "the linked_id (issuer+subject) does not match an existing user link. " + + "Required for IdP brokers that do not issue a stable 'sub' for the same user across connections. " + + "The existing user_link's linked_id is preserved on fallback. " + + "Only enable if you understand and accept the risk.", + Flag: "dangerous-oidc-email-fallback", + Env: "CODER_DANGEROUS_OIDC_EMAIL_FALLBACK", + YAML: "dangerousOidcEmailFallback", + Value: &c.OIDC.EmailFallback, + Group: &deploymentGroupOIDC, + Hidden: true, + }, // Telemetry settings telemetryEnable, { diff --git a/docs/reference/api/general.md b/docs/reference/api/general.md index 2be5a290a47f3..06cc7a37cc21c 100644 --- a/docs/reference/api/general.md +++ b/docs/reference/api/general.md @@ -427,6 +427,7 @@ curl -X GET http://coder-server:8080/api/v2/deployment/config \ "email_domain": [ "string" ], + "email_fallback": true, "email_field": "string", "group_allow_list": [ "string" diff --git a/docs/reference/api/schemas.md b/docs/reference/api/schemas.md index ec98281a354a9..33cf41b724f57 100644 --- a/docs/reference/api/schemas.md +++ b/docs/reference/api/schemas.md @@ -5429,6 +5429,7 @@ CreateWorkspaceRequest provides options for creating a new workspace. Only one o "email_domain": [ "string" ], + "email_fallback": true, "email_field": "string", "group_allow_list": [ "string" @@ -6020,6 +6021,7 @@ CreateWorkspaceRequest provides options for creating a new workspace. Only one o "email_domain": [ "string" ], + "email_fallback": true, "email_field": "string", "group_allow_list": [ "string" @@ -8463,6 +8465,7 @@ Only certain features set these fields: - FeatureManagedAgentLimit| "email_domain": [ "string" ], + "email_fallback": true, "email_field": "string", "group_allow_list": [ "string" @@ -8532,6 +8535,7 @@ Only certain features set these fields: - FeatureManagedAgentLimit| | `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_fallback` | boolean | false | | Email fallback allows OIDC logins to fall back to email-based matching when the `linked_id` (issuer+subject) does not match an existing user link. INSECURE: weakens the linked_id check. It exists for IdP brokers that do not issue a stable `sub` for the same user across connections. | | `email_field` | string | false | | | | `group_allow_list` | array of string | false | | | | `group_auto_create` | boolean | false | | | diff --git a/site/src/api/typesGenerated.ts b/site/src/api/typesGenerated.ts index 0309d2b970b9c..19dce823b9f41 100644 --- a/site/src/api/typesGenerated.ts +++ b/site/src/api/typesGenerated.ts @@ -5306,6 +5306,14 @@ export interface OIDCConfig { */ readonly redirect_url: string; readonly auto_repair_links: boolean; + /** + * EmailFallback allows OIDC logins to fall back to email-based matching + * when the `linked_id` (issuer+subject) does not match an existing user + * link. INSECURE: weakens the linked_id check. It exists for IdP + * brokers that do not issue a stable `sub` for the same user across + * connections. + */ + readonly email_fallback: boolean; } // From codersdk/parameters.go