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()) +}