mirror of
https://github.com/stack-auth/stack.git
synced 2026-06-04 21:04:37 +08:00
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:
parent
7bf554e7b2
commit
5d8b6b7eaf
@ -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({
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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({
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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"]>>,
|
||||
|
||||
Loading…
Reference in New Issue
Block a user