unblock signup endpoint (#967)

<!--

Make sure you've read the CONTRIBUTING.md guidelines:
https://github.com/stack-auth/stack-auth/blob/dev/CONTRIBUTING.md

-->


<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

* **New Features**
* Sign-up accepts an optional verification callback URL and a new
opt-out flag to disable email verification; when opted-out or absent,
URL checks and verification emails are skipped.
* Client APIs and runtime validation updated to forbid providing a
callback URL when opting out. Sign-up now retries without a callback if
a redirect URL is not whitelisted.

* **Tests**
* End-to-end tests added for sign-up without verification and for
conflicting verification settings.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->

---------

Co-authored-by: Konsti Wohlwend <n2d4xc@gmail.com>
Co-authored-by: claude[bot] <209825114+claude[bot]@users.noreply.github.com>
Co-authored-by: Konsti Wohlwend <N2D4@users.noreply.github.com>
This commit is contained in:
BilalG1 2025-10-27 10:18:19 -07:00 committed by GitHub
parent 7bf554e7b2
commit 5d8b6b7eaf
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 117 additions and 21 deletions

View File

@ -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({

View File

@ -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;

View File

@ -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({

View File

@ -807,7 +807,7 @@ export class StackClientInterface {
async signUpWithCredential(
email: string,
password: string,
emailVerificationRedirectUrl: string,
emailVerificationRedirectUrl: string | undefined,
session: InternalSession,
): Promise<Result<{ accessToken: string, refreshToken: string }, KnownErrors["UserWithEmailAlreadyExists"] | KnownErrors["PasswordRequirementsNotMet"]>> {
const res = await this.sendClientRequestAndCatchKnownError(

View File

@ -1853,17 +1853,39 @@ export class _StackClientAppImplIncomplete<HasTokenStore extends boolean, Projec
email: string,
password: string,
noRedirect?: boolean,
noVerificationCallback?: boolean,
verificationCallbackUrl?: string,
}): Promise<Result<undefined, KnownErrors["UserWithEmailAlreadyExists"] | KnownErrors['PasswordRequirementsNotMet']>> {
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) {

View File

@ -44,7 +44,7 @@ export type StackClientApp<HasTokenStore extends boolean = boolean, ProjectId ex
signInWithOAuth(provider: string, options?: { returnTo?: string }): Promise<void>,
signInWithCredential(options: { email: string, password: string, noRedirect?: boolean }): Promise<Result<undefined, KnownErrors["EmailPasswordMismatch"] | KnownErrors["InvalidTotpCode"]>>,
signUpWithCredential(options: { email: string, password: string, noRedirect?: boolean, verificationCallbackUrl?: string }): Promise<Result<undefined, KnownErrors["UserWithEmailAlreadyExists"] | KnownErrors["PasswordRequirementsNotMet"]>>,
signUpWithCredential(options: { email: string, password: string, noRedirect?: boolean } & ({ noVerificationCallback: true } | { noVerificationCallback?: false, verificationCallbackUrl?: string })): Promise<Result<undefined, KnownErrors["UserWithEmailAlreadyExists"] | KnownErrors["PasswordRequirementsNotMet"]>>,
signInWithPasskey(): Promise<Result<undefined, KnownErrors["PasskeyAuthenticationFailed"] | KnownErrors["InvalidTotpCode"] | KnownErrors["PasskeyWebAuthnError"]>>,
callOAuthCallback(): Promise<boolean>,
promptCliLogin(options: { appUrl: string, expiresInMillis?: number }): Promise<Result<string, KnownErrors["CliAuthError"] | KnownErrors["CliAuthExpiredError"] | KnownErrors["CliAuthUsedError"]>>,