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:

  1. 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."
  2. After the user authenticates with an existing method (for example, username + password), they land on the existing "Authorize identity provider link" page.
  3. 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_link returns the colliding user, but only when the sign-in would otherwise create a brand-new account (new?), auto_link_user is off, and the auth hash carries an email. It uses User.find_by_any_email, so it matches both the primary email and confirmed secondary emails — i.e. every collision that User#unique_email would otherwise reject with a 422. It is a read-only lookup and does not change find_user behaviour.
  • Detection lives in OmniauthCallbacksController#fail_login. It runs after log_failed_login (preserving the EE failed-login audit trail), then routes into the linking flow via prompt_login_to_link_identity when the collision is detected and the feature flag is enabled; otherwise it falls back to the original fail_login behaviour (the 422 error page). Putting detection in fail_login rather than a dedicated method keeps the EE Group SAML fail_login override in precedence and preserves the audit event.
  • prompt_login_to_link_identity stashes the pending identity in the session (identity_link_state, identity_link_provider, identity_link_extern_uid) and stores the :user return location so after_sign_in_path_for lands the user on the authorize page after they sign in.

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.

  1. Enable the feature flag in the Rails console:
    Feature.enable(:link_omniauth_to_existing_user_on_login)
  2. 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.
  3. Configure an OmniAuth provider (for example, github) in config/gitlab.yml under omniauth:. Ensure the provider is in allow_single_sign_on and auto_link_user is false — this is the condition being tested:
    omniauth:
      allow_single_sign_on: ['github']
      auto_link_user: false
      providers:
        - { name: 'github',
            app_id: 'aaa',
            app_secret: 'bbb',
            args: { scope: 'user:email' } }
    Then restart Rails (gdk restart rails-web).
  4. 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.)
  5. In an incognito window, go to the sign-in page and sign in with the provider.
  6. 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.
  7. Sign in with the local account's username and password.
  8. Expected: you land on the "Authorize identity provider link" page. Click Authorize.
  9. Expected: you are redirected to Account settings with "Authentication method updated", and the provider now shows as a connected account.
  10. 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.

Edited by Lee Tickett

Merge request reports

Loading