Prompt to login to link existing account to new OAuth identity
What does this MR do and why?
#393840 // https://gitlab.com/gitlab-com/partners/strategy-product-partnerships/product-partnerships/-/work_items/90
When auto_link_user is disabled (the default) and a user signs in with an OmniAuth
provider whose email matches an existing GitLab account, sign-in currently fails with a
generic 422 error page ("Sign-in using <provider> auth failed"). The build of a new
account collides with the existing email, the validation fails, and the user is left with a
dead end and no clear path to recover.
This MR replaces that dead end with a guided linking flow, behind a feature flag. When the collision is detected:
- Instead of the 422, the user is redirected back to the sign-in page with a notice:
"An account already exists with the email address for your
<provider>account. Sign in with your existing credentials to connect your<provider>account." - After the user authenticates with an existing method (for example, username + password), they land on the existing "Authorize identity provider link" page.
- One click on Authorize links the OmniAuth identity to their account.
The change deliberately reuses the existing identity-link flow (redirect_authorize_identity_link
session keys, UserSettings::IdentitiesController#new/#create, and its view). The only new
logic is detecting the email collision and routing into that existing, CSRF-protected flow.
Because the identity is only linked after the user proves ownership of the existing account
by logging in, this is safer than auto_link_user (which links purely on email trust).
Implementation
Gitlab::Auth::OAuth::User#existing_user_for_email_linkreturns the colliding user, but only when the sign-in would otherwise create a brand-new account (new?),auto_link_useris off, and the auth hash carries an email. It usesUser.find_by_any_email, so it matches both the primary email and confirmed secondary emails — i.e. every collision thatUser#unique_emailwould otherwise reject with a 422. It is a read-only lookup and does not changefind_userbehaviour.- Detection lives in
OmniauthCallbacksController#fail_login. It runs afterlog_failed_login(preserving the EE failed-login audit trail), then routes into the linking flow viaprompt_login_to_link_identitywhen the collision is detected and the feature flag is enabled; otherwise it falls back to the originalfail_loginbehaviour (the 422 error page). Putting detection infail_loginrather than a dedicated method keeps the EE Group SAMLfail_loginoverride in precedence and preserves the audit event. prompt_login_to_link_identitystashes the pending identity in the session (identity_link_state,identity_link_provider,identity_link_extern_uid) and stores the:userreturn location soafter_sign_in_path_forlands the user on the authorize page after they sign in.
Security: binding the pending link to the detected account
The pending link is written into an anonymous session, and warden carries that session
data across the subsequent sign-in (renew: true rotates the session id but preserves the
data). Without a binding, the stashed identity would attach to whoever signs in next — on a
shared browser, user A hits the collision prompt and walks away, user B signs in normally and
lands on the authorize page, and if B clicks Authorize, A's provider identity would be linked
to B's account.
To close this, prompt_login_to_link_identity also stashes identity_link_user_id (the id of
the account the collision was detected for), and UserSettings::IdentitiesController adds a
verify_intended_user before-action that renders 403 unless the authenticated user matches
it. The check is guarded on key presence, so the existing already-authenticated linking flow
(which never sets the key) is untouched.
Feature flag
Guarded by the link_omniauth_to_existing_user_on_login feature flag (gitlab_com_derisk,
disabled by default). When the flag is off, behaviour is unchanged (422). The flag is checked
with the colliding persisted user as the actor, so it supports a percentage-of-actors
rollout rather than a boolean switch.
References
- Feature flag:
link_omniauth_to_existing_user_on_login
Screenshots or screen recordings
How to set up and validate locally
These steps use GitHub as the OmniAuth provider, but any provider works.
- Enable the feature flag in the Rails console:
Feature.enable(:link_omniauth_to_existing_user_on_login) - Register a GitHub OAuth app at https://github.com/settings/developers ("New OAuth App"),
with the authorization callback URL set to
http://<your-gdk-host>:3000/users/auth/github/callback. Copy the Client ID and Client Secret. - Configure an OmniAuth provider (for example,
github) inconfig/gitlab.ymlunderomniauth:. Ensure the provider is inallow_single_sign_onandauto_link_userisfalse— this is the condition being tested:Then restart Rails (omniauth: allow_single_sign_on: ['github'] auto_link_user: false providers: - { name: 'github', app_id: 'aaa', app_secret: 'bbb', args: { scope: 'user:email' } }gdk restart rails-web). - Through the UI, create (or use an existing) local GitLab account whose email matches your provider account's primary email. (A confirmed secondary email on the account works too.)
- In an incognito window, go to the sign-in page and sign in with the provider.
- Expected: instead of a 422 page, you are redirected to the sign-in page with a notice prompting you to sign in with your existing credentials to connect your provider account.
- Sign in with the local account's username and password.
- Expected: you land on the "Authorize identity provider link" page. Click Authorize.
- Expected: you are redirected to Account settings with "Authentication method updated", and the provider now shows as a connected account.
- To confirm the flag-off behaviour, disable the flag and repeat — you should get the original
422 error page:
Feature.disable(:link_omniauth_to_existing_user_on_login)
MR acceptance checklist
Evaluate this MR against the MR acceptance checklist. It helps you analyze changes to reduce risks in quality, performance, reliability, security, and maintainability.