diff --git a/internal/api/ui/login/external_provider_handler.go b/internal/api/ui/login/external_provider_handler.go
index f2d1fb6b18..1b9ed3c9b0 100644
--- a/internal/api/ui/login/external_provider_handler.go
+++ b/internal/api/ui/login/external_provider_handler.go
@@ -73,6 +73,12 @@ type externalIDPCallbackData struct {
State string `schema:"state"`
Code string `schema:"code"`
+ // Error and ErrorDescription are the OAuth2/OIDC error response parameters the IDP may return on
+ // the callback (e.g. when the user cancels consent). They are used for logging/handling the failure;
+ // they are not IDP-authenticated and are not trusted to drive any security decision.
+ Error string `schema:"error"`
+ ErrorDescription string `schema:"error_description"`
+
RelayState string `schema:"RelayState"`
Method string `schema:"Method"`
@@ -294,6 +300,23 @@ func (l *Login) handleExternalLoginCallback(w http.ResponseWriter, r *http.Reque
l.externalAuthCallbackFailed(w, r, authReq, nil, nil, err)
return
}
+ // The IDP may return an OAuth2/OIDC error response (error/error_description) on the callback, e.g.
+ // when the user cancels consent. Handle it explicitly so it is logged with context instead of
+ // surfacing later as an opaque code-exchange failure. We then route it through the normal failure
+ // path, which - like any external-auth failure - falls back to local auth when the login policy
+ // allows it. (Note: this error signal is not IDP-authenticated, but treating it as a security
+ // boundary adds no protection: an attacker can force the same fallback with a garbage code, and the
+ // downgrade only ever affects their own auth request.)
+ if data.Error != "" {
+ logging.WithFields(
+ "instance", authz.GetInstance(r.Context()).InstanceID(),
+ "idpConfigID", authReq.SelectedIDPConfigID,
+ "error", data.Error,
+ "errorDescription", data.ErrorDescription,
+ ).Info("external authentication returned an error on callback")
+ l.externalAuthCallbackFailed(w, r, authReq, nil, nil, zerrors.ThrowInvalidArgument(nil, "LOGIN-Ni2gs", "Errors.User.ExternalIDP.LoginFailedSwitchLocal"))
+ return
+ }
identityProvider, err := l.getIDPByID(r, authReq.SelectedIDPConfigID)
if err != nil {
l.externalAuthCallbackFailed(w, r, authReq, nil, nil, err)
@@ -818,12 +841,22 @@ func (l *Login) handleExternalNotFoundOptionCheck(w http.ResponseWriter, r *http
l.renderExternalNotFoundOption(w, r, authReq, nil, nil, nil, zerrors.ThrowPreconditionFailed(nil, "LOGIN-dsfd3", "Errors.ExternalIDP.CreationNotAllowed"))
return
}
- linkingUser := mapExternalNotFoundOptionFormDataToLoginUser(data)
- // the form only carries the editable profile fields, so preserve the ZITADEL project
+ // This page is only ever reached after a genuine IDP callback populated authReq.LinkingUsers
+ // (via SetLinkingUser, gated by a real session.FetchUser). If it is empty, the request did not
+ // originate from a verified external authentication and must be rejected - otherwise the external
+ // identity would be taken solely from attacker-controlled POST fields (GHSA-738m-7888-jfv8).
+ // This mirrors the guard in autoCreateExternalUser.
+ if len(authReq.LinkingUsers) == 0 {
+ l.renderError(w, r, authReq, zerrors.ThrowPreconditionFailed(nil, "LOGIN-Dju3f", "Errors.ExternalIDP.NoExternalUserData"))
+ return
+ }
+ linkingUser := authReq.LinkingUsers[len(authReq.LinkingUsers)-1]
+ externalUser := mapExternalNotFoundOptionFormDataToLoginUser(data, linkingUser)
+ // the form only carries the editable profile fields, so preserve the ZITADEL project
// roles captured at authentication (stored on the linking user) to keep the
// support-user instance membership grant working on the manual creation path.
- preserveProjectRoles(linkingUser, authReq.LinkingUsers)
- l.registerExternalUser(w, r, idpTemplate, authReq, linkingUser)
+ preserveProjectRoles(externalUser, authReq.LinkingUsers)
+ l.registerExternalUser(w, r, authReq, externalUser)
}
// registerExternalUser creates an externalUser with the provided data
@@ -1538,12 +1571,20 @@ func mapExternalUserToLoginUser(externalUser *domain.ExternalUser, mustBeDomain
return human, externalIDP, externalUser.Metadatas
}
-func mapExternalNotFoundOptionFormDataToLoginUser(formData *externalNotFoundOptionFormData) *domain.ExternalUser {
- isEmailVerified := formData.ExternalEmailVerified && formData.Email == formData.ExternalEmail
- isPhoneVerified := formData.ExternalPhoneVerified && formData.Phone == formData.ExternalPhone
+// mapExternalNotFoundOptionFormDataToLoginUser builds the externalUser to be created from the
+// submitted "external account not found" page. The identity and verification of that user must
+// originate from the genuine, protocol-verified IDP callback (carried in linkingUser), never from
+// the request body: the external-* form fields are only echoed back for display and are fully
+// attacker-controlled. We therefore take IDPConfigID/ExternalUserID from linkingUser, and only keep
+// the email/phone "verified" flag when the (user-editable) form value still matches what the IDP
+// actually returned. The remaining fields (name, nickname, language, and the possibly-edited
+// email/phone) are legitimately editable on this page and are taken from the form.
+func mapExternalNotFoundOptionFormDataToLoginUser(formData *externalNotFoundOptionFormData, linkingUser *domain.ExternalUser) *domain.ExternalUser {
+ isEmailVerified := linkingUser.IsEmailVerified && formData.Email == linkingUser.Email
+ isPhoneVerified := linkingUser.IsPhoneVerified && formData.Phone == linkingUser.Phone
return &domain.ExternalUser{
- IDPConfigID: formData.ExternalIDPConfigID,
- ExternalUserID: formData.ExternalIDPExtUserID,
+ IDPConfigID: linkingUser.IDPConfigID,
+ ExternalUserID: linkingUser.ExternalUserID,
PreferredUsername: formData.Username,
DisplayName: string(formData.Email),
FirstName: formData.Firstname,
diff --git a/internal/api/ui/login/external_provider_handler_test.go b/internal/api/ui/login/external_provider_handler_test.go
index c425d588cb..dbdb456fcf 100644
--- a/internal/api/ui/login/external_provider_handler_test.go
+++ b/internal/api/ui/login/external_provider_handler_test.go
@@ -4,6 +4,7 @@ import (
"testing"
"github.com/stretchr/testify/assert"
+ "golang.org/x/text/language"
"github.com/zitadel/oidc/v3/pkg/oidc"
"github.com/zitadel/zitadel/internal/domain"
@@ -13,6 +14,184 @@ import (
provideridp "github.com/zitadel/zitadel/internal/repository/idp"
)
+// Test_mapExternalNotFoundOptionFormDataToLoginUser is a regression test for
+// GHSA-738m-7888-jfv8. The "external account not found" page lets a user edit
+// their *profile* (name, email, language, ...) before the account is created,
+// but the identity that account is bound to must come from the real, verified
+// IDP callback (carried in authReq.LinkingUsers), never from the raw POST body.
+//
+// Before the fix, this mapping trusted the request's own external-* fields, so an
+// attacker could forge an arbitrary (IDPConfigID, ExternalUserID) binding and a
+// self-asserted "verified" email. After the fix, the security-critical fields are
+// sourced from the trusted linkingUser, and the verified flags only survive when
+// the (editable) form value still matches what the IDP actually returned.
+func Test_mapExternalNotFoundOptionFormDataToLoginUser(t *testing.T) {
+ type args struct {
+ formData *externalNotFoundOptionFormData
+ linkingUser *domain.ExternalUser
+ }
+ tests := []struct {
+ name string
+ args args
+ want *domain.ExternalUser
+ }{
+ {
+ // The attacker sets the external identity fields in the POST body to a
+ // victim's identifier / a chosen IDP. The result must ignore them and use
+ // the trusted linkingUser instead.
+ "forged external identity in form is ignored, trusted linkingUser wins",
+ args{
+ formData: &externalNotFoundOptionFormData{
+ externalRegisterFormData: externalRegisterFormData{
+ ExternalIDPConfigID: "attacker-chosen-idp",
+ ExternalIDPExtUserID: "victim-github-id",
+ Email: domain.EmailAddress("attacker@evil.test"),
+ Username: "attacker",
+ Firstname: "At",
+ Lastname: "Tacker",
+ Nickname: "a",
+ Language: "en",
+ },
+ },
+ linkingUser: &domain.ExternalUser{
+ IDPConfigID: "real-idp",
+ ExternalUserID: "real-callback-user-id",
+ Email: domain.EmailAddress("real@idp.test"),
+ },
+ },
+ &domain.ExternalUser{
+ IDPConfigID: "real-idp", // from trusted linkingUser, NOT the form
+ ExternalUserID: "real-callback-user-id", // from trusted linkingUser, NOT the form
+ PreferredUsername: "attacker",
+ DisplayName: "attacker@evil.test",
+ FirstName: "At",
+ LastName: "Tacker",
+ NickName: "a",
+ Email: domain.EmailAddress("attacker@evil.test"),
+ IsEmailVerified: false, // linkingUser.IsEmailVerified is false
+ PreferredLanguage: language.English,
+ },
+ },
+ {
+ // The attacker sets external-email-verified=true and email==external-email
+ // in the POST body; before the fix that alone marked the email verified.
+ "forged verified flag in form cannot mark email verified",
+ args{
+ formData: &externalNotFoundOptionFormData{
+ externalRegisterFormData: externalRegisterFormData{
+ ExternalEmail: domain.EmailAddress("attacker@evil.test"),
+ ExternalEmailVerified: true,
+ Email: domain.EmailAddress("attacker@evil.test"),
+ Language: "en",
+ },
+ },
+ linkingUser: &domain.ExternalUser{
+ IDPConfigID: "real-idp",
+ ExternalUserID: "real-callback-user-id",
+ Email: domain.EmailAddress("real@idp.test"),
+ IsEmailVerified: false,
+ },
+ },
+ &domain.ExternalUser{
+ IDPConfigID: "real-idp",
+ ExternalUserID: "real-callback-user-id",
+ DisplayName: "attacker@evil.test",
+ Email: domain.EmailAddress("attacker@evil.test"),
+ IsEmailVerified: false, // trusted linkingUser was not verified
+ PreferredLanguage: language.English,
+ },
+ },
+ {
+ // Genuine flow: the IDP returned a verified email and the user did not
+ // change it on the page, so it stays verified.
+ "genuinely verified email preserved when user keeps it unchanged",
+ args{
+ formData: &externalNotFoundOptionFormData{
+ externalRegisterFormData: externalRegisterFormData{
+ Email: domain.EmailAddress("real@idp.test"),
+ Language: "en",
+ },
+ },
+ linkingUser: &domain.ExternalUser{
+ IDPConfigID: "real-idp",
+ ExternalUserID: "real-callback-user-id",
+ Email: domain.EmailAddress("real@idp.test"),
+ IsEmailVerified: true,
+ },
+ },
+ &domain.ExternalUser{
+ IDPConfigID: "real-idp",
+ ExternalUserID: "real-callback-user-id",
+ DisplayName: "real@idp.test",
+ Email: domain.EmailAddress("real@idp.test"),
+ IsEmailVerified: true, // verified by IDP and unchanged by the user
+ PreferredLanguage: language.English,
+ },
+ },
+ {
+ // Genuine flow but the user edits the email away from the IDP-verified one:
+ // the new address is unverified until proven, so the flag must drop.
+ "verified email drops when user edits it away from the IDP value",
+ args{
+ formData: &externalNotFoundOptionFormData{
+ externalRegisterFormData: externalRegisterFormData{
+ Email: domain.EmailAddress("changed@user.test"),
+ Language: "en",
+ },
+ },
+ linkingUser: &domain.ExternalUser{
+ IDPConfigID: "real-idp",
+ ExternalUserID: "real-callback-user-id",
+ Email: domain.EmailAddress("real@idp.test"),
+ IsEmailVerified: true,
+ },
+ },
+ &domain.ExternalUser{
+ IDPConfigID: "real-idp",
+ ExternalUserID: "real-callback-user-id",
+ DisplayName: "changed@user.test",
+ Email: domain.EmailAddress("changed@user.test"),
+ IsEmailVerified: false, // edited away from the verified value
+ PreferredLanguage: language.English,
+ },
+ },
+ {
+ // Same trust rule for the phone: verified only when the IDP verified it
+ // and the user kept the value; a forged form phone-verified is ignored.
+ "phone verified only when IDP-verified and unchanged",
+ args{
+ formData: &externalNotFoundOptionFormData{
+ externalRegisterFormData: externalRegisterFormData{
+ ExternalPhone: domain.PhoneNumber("+41791234567"),
+ ExternalPhoneVerified: true,
+ Phone: domain.PhoneNumber("+41791234567"),
+ Language: "en",
+ },
+ },
+ linkingUser: &domain.ExternalUser{
+ IDPConfigID: "real-idp",
+ ExternalUserID: "real-callback-user-id",
+ Phone: domain.PhoneNumber("+41791234567"),
+ IsPhoneVerified: true,
+ },
+ },
+ &domain.ExternalUser{
+ IDPConfigID: "real-idp",
+ ExternalUserID: "real-callback-user-id",
+ Phone: domain.PhoneNumber("+41791234567"),
+ IsPhoneVerified: true,
+ PreferredLanguage: language.English,
+ },
+ },
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ got := mapExternalNotFoundOptionFormDataToLoginUser(tt.args.formData, tt.args.linkingUser)
+ assert.Equal(t, tt.want, got)
+ })
+ }
+}
+
func Test_hasEmailChanged(t *testing.T) {
type args struct {
user *query.User
diff --git a/internal/api/ui/login/integration_test/external_login_test.go b/internal/api/ui/login/integration_test/external_login_test.go
new file mode 100644
index 0000000000..32bbd9fc0c
--- /dev/null
+++ b/internal/api/ui/login/integration_test/external_login_test.go
@@ -0,0 +1,261 @@
+//go:build integration
+
+// Package login_test contains an end-to-end reproduction of GHSA-738m-7888-jfv8, an
+// external-IDP account pre-hijack / takeover in the server-rendered Login V1 UI.
+//
+// The vulnerability: POST /ui/login/externaluser/option (handleExternalNotFoundOptionCheck)
+// is meant to be the *submit* of the "external account not found" page, which only ever appears
+// after a genuine, protocol-verified IDP callback has populated the server-side
+// authReq.LinkingUsers state. Before the fix, the handler ignored that state and built the new
+// user's external identity (IDPConfigID + ExternalUserID) and its "verified" email straight from
+// the raw POST body. An UNAUTHENTICATED attacker could therefore POST forged data directly to the
+// endpoint - without ever going through the real IDP - and have Zitadel create a real account
+// bound to an arbitrary (IDPConfigID, ExternalUserID). By setting ExternalUserID to a victim's
+// public external identifier (e.g. a GitHub numeric user id) the attacker pre-creates an account
+// that the victim's own, genuine future "Sign in with X" then silently logs into.
+//
+// This test drives the exact attack over raw HTTP against a running Zitadel instance:
+//
+// 1. authorize (create a V1 auth request) -> 2. load login page (grab CSRF)
+// -> 3. select the IDP (sets SelectedIDPConfigID) -> 4. forge the not-found-option POST
+//
+// It then asserts, over gRPC, that NO user was created for the attacker's forged identity. Against
+// the unpatched code this fails (the forged account is created); against the fixed code it passes
+// (the request is rejected with Errors.ExternalIDP.NoExternalUserData). That is the red -> green
+// signal for the fix.
+//
+// Note on setup: no real GitHub account or working OAuth app is needed. The exploit bypasses the
+// real OAuth round-trip entirely, so the IDP template only has to EXIST with creation allowed and
+// dummy credentials - its client id/secret and endpoints are never exercised on the attack path.
+//
+// Requires a running backend + database. Run with:
+//
+// go test -tags integration ./internal/api/ui/login/integration_test/...
+package login_test
+
+import (
+ "context"
+ "io"
+ "net/http"
+ "net/http/cookiejar"
+ "net/url"
+ "os"
+ "regexp"
+ "strings"
+ "testing"
+ "time"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+ "github.com/zitadel/oidc/v3/pkg/oidc"
+
+ "github.com/zitadel/zitadel/internal/api/ui/login"
+ "github.com/zitadel/zitadel/internal/integration"
+ "github.com/zitadel/zitadel/pkg/grpc/app"
+ feature "github.com/zitadel/zitadel/pkg/grpc/feature/v2"
+ idp_pb "github.com/zitadel/zitadel/pkg/grpc/idp"
+ user "github.com/zitadel/zitadel/pkg/grpc/user/v2"
+)
+
+var (
+ CTX context.Context
+ Instance *integration.Instance
+)
+
+func TestMain(m *testing.M) {
+ os.Exit(func() int {
+ ctx, cancel := context.WithTimeout(context.Background(), 15*time.Minute)
+ defer cancel()
+
+ Instance = integration.NewInstance(ctx)
+ CTX = Instance.WithAuthorization(ctx, integration.UserTypeIAMOwner)
+ return m.Run()
+ }())
+}
+
+// csrfTokenRegex extracts the gorilla/csrf hidden form field that the login templates embed via
+// {{ .CSRF }} (rendered as ). The token
+// must be echoed back on every POST; the paired cookie travels automatically in the cookie jar.
+var csrfTokenRegex = regexp.MustCompile(`name="gorilla\.csrf\.Token"[^>]*value="([^"]*)"`)
+
+// TestExternalNotFoundOption_ForgedRegistration_IsRejected reproduces the core exploit and asserts
+// the fix rejects it. It is the end-to-end, behavioral red -> green test for GHSA-738m-7888-jfv8.
+func TestExternalNotFoundOption_ForgedRegistration_IsRejected(t *testing.T) {
+ // --- Server setup (all via gRPC as the IAM owner) -------------------------------------------
+
+ // Force Login V1. Three things select V1 at auth-request creation (internal/api/oidc/auth_request.go):
+ // (a) the instance feature LoginV2.Required must be false, (b) the OIDC app's login version must be
+ // unspecified/V1, and (c) the authorize request must NOT carry the x-zitadel-login-client header.
+ // We pin (a) here explicitly so the test does not depend on the instance default.
+ integration.EnsureInstanceFeature(t, CTX, Instance,
+ &feature.SetInstanceFeaturesRequest{LoginV2: &feature.LoginV2{Required: false}},
+ func(tt *assert.CollectT, got *feature.GetInstanceFeaturesResponse) {
+ assert.False(tt, got.GetLoginV2().GetRequired())
+ },
+ )
+
+ // A project + OIDC app. loginVersion is left nil (unspecified => V1). The redirect URI only has to
+ // be registered; the attack never actually redeems a code there.
+ redirectURI := "http://localhost:9999/callback"
+ project := Instance.CreateProject(CTX, t, Instance.DefaultOrg.GetId(), integration.RandString(10), false, false)
+ oidcApp, err := Instance.CreateOIDCClient(CTX, redirectURI, "", project.GetId(),
+ app.OIDCAppType_OIDC_APP_TYPE_WEB, app.OIDCAuthMethodType_OIDC_AUTH_METHOD_TYPE_NONE, true)
+ require.NoError(t, err)
+ clientID := oidcApp.GetClientId()
+
+ // An external OAuth IDP with creation ALLOWED but auto-creation DISABLED - the configuration under
+ // which the genuine flow renders the not-found-option page. Its clientID/secret + example.com
+ // endpoints are dummy and are never contacted on the attack path. auto-linking is left unspecified
+ // so it plays no role here.
+ idpResp := Instance.AddGenericOAuthProviderWithOptions(CTX, integration.RandString(10),
+ true /*isLinkingAllowed*/, true /*isCreationAllowed*/, false /*isAutoCreation*/, idp_pb.AutoLinkingOption_AUTO_LINKING_OPTION_UNSPECIFIED)
+ idpID := idpResp.GetId()
+ // The IDP must be enabled on the login policy to be selectable in the login UI.
+ Instance.AddProviderToDefaultLoginPolicy(CTX, idpID)
+
+ // --- Attacker HTTP client -------------------------------------------------------------------
+
+ // One shared cookie jar for the WHOLE flow. This is mandatory: the server issues a user-agent
+ // cookie (zitadel.useragent) on the authorize response and binds the created auth request to that
+ // user-agent id; getAuthRequest later looks it up by (authRequestID, userAgentID). A different jar
+ // (or no jar) would make the auth request unresolvable. The csrf cookie also lives here.
+ jar, err := cookiejar.New(nil)
+ require.NoError(t, err)
+ httpClient := &http.Client{
+ Jar: jar,
+ // Never auto-follow redirects: we want to inspect each hop (and must NOT chase the IDP redirect
+ // out to the dummy example.com authorize URL in step 3).
+ CheckRedirect: func(req *http.Request, via []*http.Request) error {
+ return http.ErrUseLastResponse
+ },
+ }
+ issuer := Instance.OIDCIssuer() // http://:
+
+ // Step 1: create a Login V1 auth request by hitting the OIDC authorize endpoint WITHOUT the
+ // login-client header. The response is a 302 to /ui/login/login?authRequestID=; we extract id.
+ authURL := issuer + "/oauth/v2/authorize?" + url.Values{
+ "client_id": {clientID},
+ "redirect_uri": {redirectURI},
+ "response_type": {"code"},
+ "scope": {"openid"},
+ "state": {"attacker-state"},
+ "code_challenge": {oidc.NewSHACodeChallenge(integration.CodeVerifier)},
+ "code_challenge_method": {"S256"},
+ }.Encode()
+ loc := getLocation(t, httpClient, authURL)
+ authRequestID := loc.Query().Get(login.QueryAuthRequestID)
+ require.NotEmpty(t, authRequestID, "expected a V1 auth request id in the authorize redirect %q", loc.String())
+
+ // Step 2: load the login page. This renders the login form and lets us scrape the CSRF token
+ // (valid for any subsequent POST in this session, since it is bound to the csrf cookie in the jar).
+ loginPageURL := issuer + login.HandlerPrefix + login.EndpointLogin + "?" + login.QueryAuthRequestID + "=" + authRequestID
+ csrfToken := scrapeCSRF(t, httpClient, loginPageURL)
+
+ // Step 3: select the external IDP. handleExternalLogin -> handleIDP persists SelectedIDPConfigID on
+ // the auth request (SelectExternalIDP) and then 302-redirects toward the (dummy) IDP authorize URL,
+ // which we deliberately do not follow. This is the ONLY legitimate step the attacker performs; it
+ // requires no interaction with the real IDP.
+ selectIDPURL := issuer + login.HandlerPrefix + login.EndpointExternalLogin + "?" +
+ login.QueryAuthRequestID + "=" + authRequestID + "&idpConfigID=" + idpID
+ resp := doGet(t, httpClient, selectIDPURL)
+ resp.Body.Close()
+
+ // Step 4: the attack. POST forged "external account not found" data. Every external-* field below is
+ // attacker-controlled and, before the fix, was trusted verbatim to build the created user's identity.
+ // The fix must ignore them (authReq.LinkingUsers is empty here because no genuine callback happened)
+ // and reject the request.
+ forgedEmail := "attacker-" + integration.RandString(8) + "@evil.test"
+ forgedVictimID := "999999999" // e.g. a victim's public GitHub numeric user id
+ form := url.Values{
+ login.QueryAuthRequestID: {authRequestID},
+ "gorilla.csrf.Token": {csrfToken},
+ "external-idp-config-id": {idpID}, // attacker-controlled: which IDP the link is bound to
+ "external-idp-ext-user-id": {forgedVictimID}, // attacker-controlled: the identity being claimed
+ "external-email": {forgedEmail}, // attacker-controlled: echoed "IDP" email
+ "external-email-verified": {"true"}, // attacker-controlled: forged "verified" flag
+ "email": {forgedEmail}, // attacker-controlled
+ "username": {"attacker-" + integration.RandString(8)},
+ "firstname": {"At"},
+ "lastname": {"Tacker"},
+ "language": {"en"},
+ "terms-confirm": {"true"},
+ }
+ postURL := issuer + login.HandlerPrefix + login.EndpointExternalNotFoundOption + "?autoregisterbutton=true"
+ body := doPostForm(t, httpClient, postURL, form)
+
+ // The fixed handler rejects with the LinkingUsers guard (zerrors id LOGIN-Dju3f,
+ // Errors.ExternalIDP.NoExternalUserData). We match the error code, which the error page renders
+ // verbatim regardless of the negotiated UI locale (the translated message text is locale-dependent
+ // and may fall back to the raw i18n key). This is an immediate, projection-lag-free signal.
+ assert.Contains(t, body, "LOGIN-Dju3f",
+ "forged registration should be rejected by the LinkingUsers guard; got a page without that rejection (attack likely succeeded)")
+
+ // The load-bearing assertion: no account may exist for the attacker's forged identity. We use
+ // assert.Never (not Eventually) so that if the account IS created but its projection lags, the user
+ // still surfaces within the window and fails the test - which is exactly the unpatched behavior.
+ assert.Never(t, func() bool {
+ return userCountByEmail(t, forgedEmail) > 0
+ }, 5*time.Second, 250*time.Millisecond,
+ "a user was created for the attacker-forged external identity %q - account pre-hijack succeeded", forgedEmail)
+}
+
+// --- helpers ------------------------------------------------------------------------------------
+
+func doGet(t *testing.T, c *http.Client, rawURL string) *http.Response {
+ t.Helper()
+ req, err := http.NewRequestWithContext(CTX, http.MethodGet, rawURL, nil)
+ require.NoError(t, err)
+ resp, err := c.Do(req)
+ require.NoError(t, err)
+ return resp
+}
+
+// getLocation issues a GET and returns the parsed Location of the expected redirect response.
+func getLocation(t *testing.T, c *http.Client, rawURL string) *url.URL {
+ t.Helper()
+ resp := doGet(t, c, rawURL)
+ defer resp.Body.Close()
+ require.GreaterOrEqual(t, resp.StatusCode, 300)
+ require.Less(t, resp.StatusCode, 400, "expected a redirect from %q", rawURL)
+ loc, err := resp.Location()
+ require.NoError(t, err)
+ return loc
+}
+
+// scrapeCSRF GETs a rendered login page and returns its gorilla/csrf token.
+func scrapeCSRF(t *testing.T, c *http.Client, rawURL string) string {
+ t.Helper()
+ resp := doGet(t, c, rawURL)
+ defer resp.Body.Close()
+ b, err := io.ReadAll(resp.Body)
+ require.NoError(t, err)
+ m := csrfTokenRegex.FindStringSubmatch(string(b))
+ require.Len(t, m, 2, "could not find gorilla.csrf.Token on %q", rawURL)
+ return m[1]
+}
+
+// doPostForm submits a urlencoded form and returns the response body as a string.
+func doPostForm(t *testing.T, c *http.Client, rawURL string, form url.Values) string {
+ t.Helper()
+ req, err := http.NewRequestWithContext(CTX, http.MethodPost, rawURL, strings.NewReader(form.Encode()))
+ require.NoError(t, err)
+ req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
+ resp, err := c.Do(req)
+ require.NoError(t, err)
+ defer resp.Body.Close()
+ b, err := io.ReadAll(resp.Body)
+ require.NoError(t, err)
+ return string(b)
+}
+
+// userCountByEmail returns how many users match the given email address (via the User v2 API).
+func userCountByEmail(t *testing.T, email string) int {
+ t.Helper()
+ resp, err := Instance.Client.UserV2.ListUsers(CTX, &user.ListUsersRequest{
+ Queries: []*user.SearchQuery{
+ {Query: &user.SearchQuery_EmailQuery{EmailQuery: &user.EmailQuery{EmailAddress: email}}},
+ },
+ })
+ require.NoError(t, err)
+ return len(resp.GetResult())
+}