diff --git a/apps/backend/src/app/api/latest/auth/password/sign-up/route.tsx b/apps/backend/src/app/api/latest/auth/password/sign-up/route.tsx index 4cd77d481..bc9ad092a 100644 --- a/apps/backend/src/app/api/latest/auth/password/sign-up/route.tsx +++ b/apps/backend/src/app/api/latest/auth/password/sign-up/route.tsx @@ -24,7 +24,7 @@ export const POST = createSmartRouteHandler({ body: yupObject({ email: signInEmailSchema.defined(), password: passwordSchema.defined(), - verification_callback_url: emailVerificationCallbackUrlSchema.defined(), + verification_callback_url: emailVerificationCallbackUrlSchema.optional(), }).defined(), }), response: yupObject({ @@ -41,7 +41,7 @@ export const POST = createSmartRouteHandler({ throw new KnownErrors.PasswordAuthenticationNotEnabled(); } - if (!validateRedirectUrl(verificationCallbackUrl, tenancy)) { + if (verificationCallbackUrl && !validateRedirectUrl(verificationCallbackUrl, tenancy)) { throw new KnownErrors.RedirectUrlNotWhitelisted(); } @@ -66,20 +66,22 @@ export const POST = createSmartRouteHandler({ [KnownErrors.UserWithEmailAlreadyExists] ); - runAsynchronouslyAndWaitUntil((async () => { - await contactChannelVerificationCodeHandler.sendCode({ - tenancy, - data: { - user_id: createdUser.id, - }, - method: { - email, - }, - callbackUrl: verificationCallbackUrl, - }, { - user: createdUser, - }); - })()); + if (verificationCallbackUrl) { + runAsynchronouslyAndWaitUntil((async () => { + await contactChannelVerificationCodeHandler.sendCode({ + tenancy, + data: { + user_id: createdUser.id, + }, + method: { + email, + }, + callbackUrl: verificationCallbackUrl, + }, { + user: createdUser, + }); + })()); + } if (createdUser.requires_totp_mfa) { throw await createMfaRequiredError({ diff --git a/apps/e2e/tests/backend/endpoints/api/v1/auth/password/sign-up.test.ts b/apps/e2e/tests/backend/endpoints/api/v1/auth/password/sign-up.test.ts index 2b147c7d3..1c76ef452 100644 --- a/apps/e2e/tests/backend/endpoints/api/v1/auth/password/sign-up.test.ts +++ b/apps/e2e/tests/backend/endpoints/api/v1/auth/password/sign-up.test.ts @@ -1,7 +1,7 @@ import { generateSecureRandomString } from "@stackframe/stack-shared/dist/utils/crypto"; import { wait } from "@stackframe/stack-shared/dist/utils/promises"; import { it } from "../../../../../../helpers"; -import { Auth, Project, backendContext, niceBackendFetch } from "../../../../../backend-helpers"; +import { Auth, Project, backendContext, bumpEmailAddress, niceBackendFetch } from "../../../../../backend-helpers"; it("should sign up new users", async ({ expect }) => { const res = await Auth.Password.signUpWithEmail(); @@ -62,6 +62,35 @@ it("should sign up new users", async ({ expect }) => { `); }); +it("should sign up without verification callback and not send email", async ({ expect }) => { + await bumpEmailAddress(); + const mailbox = backendContext.value.mailbox; + const email = mailbox.emailAddress; + const password = generateSecureRandomString(); + + const response = await niceBackendFetch("/api/v1/auth/password/sign-up", { + method: "POST", + accessType: "client", + body: { + email, + password, + }, + }); + + expect(response).toMatchObject({ + status: 200, + body: { + access_token: expect.any(String), + refresh_token: expect.any(String), + user_id: expect.any(String), + }, + }); + + await wait(5000); + const messages = await mailbox.fetchMessages({ noBody: true }); + expect(messages).toMatchInlineSnapshot(`[]`); +}); + it("should not sign up new users if verification callback url is not valid", async ({ expect }) => { const mailbox = backendContext.value.mailbox; const email = mailbox.emailAddress; diff --git a/apps/e2e/tests/js/app.test.ts b/apps/e2e/tests/js/app.test.ts index edb0da3c8..3f25252dc 100644 --- a/apps/e2e/tests/js/app.test.ts +++ b/apps/e2e/tests/js/app.test.ts @@ -35,6 +35,49 @@ it("should sign up with credential", async ({ expect }) => { `); }); +it("should sign up without a verification callback when disabled", async ({ expect }) => { + const { clientApp } = await createApp(); + const signUpResult = await clientApp.signUpWithCredential({ + email: "no-verification@test.com", + password: "password", + noVerificationCallback: true, + }); + + expect(signUpResult).toMatchInlineSnapshot(` + { + "data": undefined, + "status": "ok", + } + `); + + const signInResult = await clientApp.signInWithCredential({ + email: "no-verification@test.com", + password: "password", + }); + + expect(signInResult).toMatchInlineSnapshot(` + { + "data": undefined, + "status": "ok", + } + `); +}); + +it("should throw when disabling verification with a callback url provided", async ({ expect }) => { + const { clientApp } = await createApp(); + + await expect(clientApp.signUpWithCredential({ + email: "no-verification-conflict@test.com", + password: "password", + noVerificationCallback: true, + // @ts-expect-error - testing the error case + verificationCallbackUrl: "http://localhost:3000", + })).rejects.toMatchObject({ + message: expect.stringContaining("verificationCallbackUrl is not allowed when noVerificationCallback is true"), + name: "StackAssertionError", + }); +}); + it("should create user on the server", async ({ expect }) => { const { serverApp } = await createApp(); const user = await serverApp.createUser({ diff --git a/packages/stack-shared/src/interface/client-interface.ts b/packages/stack-shared/src/interface/client-interface.ts index f5ec136df..6e26a1d97 100644 --- a/packages/stack-shared/src/interface/client-interface.ts +++ b/packages/stack-shared/src/interface/client-interface.ts @@ -807,7 +807,7 @@ export class StackClientInterface { async signUpWithCredential( email: string, password: string, - emailVerificationRedirectUrl: string, + emailVerificationRedirectUrl: string | undefined, session: InternalSession, ): Promise> { const res = await this.sendClientRequestAndCatchKnownError( diff --git a/packages/template/src/lib/stack-app/apps/implementations/client-app-impl.ts b/packages/template/src/lib/stack-app/apps/implementations/client-app-impl.ts index 8ba0d83ef..c9ec6f1a4 100644 --- a/packages/template/src/lib/stack-app/apps/implementations/client-app-impl.ts +++ b/packages/template/src/lib/stack-app/apps/implementations/client-app-impl.ts @@ -1853,17 +1853,39 @@ export class _StackClientAppImplIncomplete> { + if (options.noVerificationCallback && options.verificationCallbackUrl) { + throw new StackAssertionError("verificationCallbackUrl is not allowed when noVerificationCallback is true"); + } this._ensurePersistentTokenStore(); const session = await this._getSession(); - const emailVerificationRedirectUrl = options.verificationCallbackUrl ?? constructRedirectUrl(this.urls.emailVerification, "verificationCallbackUrl"); - const result = await this._interface.signUpWithCredential( + const emailVerificationRedirectUrl = options.noVerificationCallback ? undefined : options.verificationCallbackUrl ?? constructRedirectUrl(this.urls.emailVerification, "verificationCallbackUrl"); + + let result = await this._interface.signUpWithCredential( options.email, options.password, emailVerificationRedirectUrl, session ); + + // If the redirect URL is not whitelisted and we didn't explicitly opt out of verification, + // retry with undefined (no email verification) and log a warning + if (result.status === 'error' && + result.error instanceof KnownErrors.RedirectUrlNotWhitelisted && + !options.noVerificationCallback && + emailVerificationRedirectUrl !== undefined) { + console.error("Warning: The verification callback URL is not trusted. Proceeding with signup without email verification. Please add your domain to the trusted domains list in your Stack Auth dashboard.", { url: emailVerificationRedirectUrl }); + + result = await this._interface.signUpWithCredential( + options.email, + options.password, + undefined, // No email verification + session + ); + } + if (result.status === 'ok') { await this._signInToAccountWithTokens(result.data); if (!options.noRedirect) { diff --git a/packages/template/src/lib/stack-app/apps/interfaces/client-app.ts b/packages/template/src/lib/stack-app/apps/interfaces/client-app.ts index 39dde2d8c..4871330cb 100644 --- a/packages/template/src/lib/stack-app/apps/interfaces/client-app.ts +++ b/packages/template/src/lib/stack-app/apps/interfaces/client-app.ts @@ -44,7 +44,7 @@ export type StackClientApp, signInWithCredential(options: { email: string, password: string, noRedirect?: boolean }): Promise>, - signUpWithCredential(options: { email: string, password: string, noRedirect?: boolean, verificationCallbackUrl?: string }): Promise>, + signUpWithCredential(options: { email: string, password: string, noRedirect?: boolean } & ({ noVerificationCallback: true } | { noVerificationCallback?: false, verificationCallbackUrl?: string })): Promise>, signInWithPasskey(): Promise>, callOAuthCallback(): Promise, promptCliLogin(options: { appUrl: string, expiresInMillis?: number }): Promise>,