feat: add INSECURE oidc email fallback flag for IdP brokers (#26751)

<!-- Authored by Coder Agents on behalf of @Emyrk. -->

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.
This commit is contained in:
Steven Masley
2026-06-26 11:23:42 -05:00
committed by GitHub
parent 18efcb6c41
commit ad355aeaa9
10 changed files with 254 additions and 6 deletions
+34 -6
View File
@@ -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()
@@ -1929,7 +1943,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",
@@ -2180,7 +2197,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
@@ -2233,8 +2259,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
}