Skip to content

Commit

Permalink
Disallow invalid emails
Browse files Browse the repository at this point in the history
  • Loading branch information
N2D4 committed Nov 19, 2024
1 parent d0a596b commit 6a0ca75
Show file tree
Hide file tree
Showing 18 changed files with 90 additions and 34 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { createAuthTokens } from "@/lib/tokens";
import { createVerificationCodeHandler } from "@/route-handlers/verification-code-handler";
import { VerificationCodeType } from "@prisma/client";
import { KnownErrors } from "@stackframe/stack-shared";
import { signInResponseSchema, yupBoolean, yupNumber, yupObject, yupString } from "@stackframe/stack-shared/dist/schema-fields";
import { emailSchema, signInResponseSchema, yupBoolean, yupNumber, yupObject, yupString } from "@stackframe/stack-shared/dist/schema-fields";
import { StackAssertionError } from "@stackframe/stack-shared/dist/utils/errors";
import { usersCrudHandlers } from "../../../users/crud";
import { createMfaRequiredError } from "../../mfa/sign-in/verification-code-handler";
Expand All @@ -27,7 +27,7 @@ export const signInVerificationCodeHandler = createVerificationCodeHandler({
is_new_user: yupBoolean().defined(),
}),
method: yupObject({
email: yupString().email().defined(),
email: emailSchema.defined(),
type: yupString().oneOf(["legacy", "standard"]).defined(),
}),
response: yupObject({
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { VerificationCodeType } from "@prisma/client";
import { KnownErrors } from "@stackframe/stack-shared";
import { getPasswordError } from "@stackframe/stack-shared/dist/helpers/password";
import { UsersCrud } from "@stackframe/stack-shared/dist/interface/crud/users";
import { yupNumber, yupObject, yupString } from "@stackframe/stack-shared/dist/schema-fields";
import { emailSchema, yupNumber, yupObject, yupString } from "@stackframe/stack-shared/dist/schema-fields";
import { usersCrudHandlers } from "../../../users/crud";

export const resetPasswordVerificationCodeHandler = createVerificationCodeHandler({
Expand All @@ -25,7 +25,7 @@ export const resetPasswordVerificationCodeHandler = createVerificationCodeHandle
user_id: yupString().defined(),
}),
method: yupObject({
email: yupString().email().defined(),
email: emailSchema.defined(),
}),
requestBody: yupObject({
password: yupString().defined(),
Expand Down
6 changes: 3 additions & 3 deletions apps/backend/src/app/api/v1/auth/password/sign-in/route.tsx
Original file line number Diff line number Diff line change
@@ -1,12 +1,12 @@
import { getAuthContactChannel } from "@/lib/contact-channel";
import { createAuthTokens } from "@/lib/tokens";
import { prismaClient } from "@/prisma-client";
import { createSmartRouteHandler } from "@/route-handlers/smart-route-handler";
import { KnownErrors } from "@stackframe/stack-shared";
import { adaptSchema, clientOrHigherAuthTypeSchema, yupNumber, yupObject, yupString } from "@stackframe/stack-shared/dist/schema-fields";
import { adaptSchema, clientOrHigherAuthTypeSchema, emailSchema, yupNumber, yupObject, yupString } from "@stackframe/stack-shared/dist/schema-fields";
import { StackAssertionError } from "@stackframe/stack-shared/dist/utils/errors";
import { comparePassword } from "@stackframe/stack-shared/dist/utils/password";
import { createMfaRequiredError } from "../../mfa/sign-in/verification-code-handler";
import { getAuthContactChannel } from "@/lib/contact-channel";

export const POST = createSmartRouteHandler({
metadata: {
Expand All @@ -20,7 +20,7 @@ export const POST = createSmartRouteHandler({
project: adaptSchema,
}).defined(),
body: yupObject({
email: yupString().email().defined(),
email: emailSchema.defined(),
password: yupString().defined(),
}).defined(),
}),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { prismaClient } from "@/prisma-client";
import { createVerificationCodeHandler } from "@/route-handlers/verification-code-handler";
import { VerificationCodeType } from "@prisma/client";
import { UsersCrud } from "@stackframe/stack-shared/dist/interface/crud/users";
import { yupNumber, yupObject, yupString } from "@stackframe/stack-shared/dist/schema-fields";
import { emailSchema, yupNumber, yupObject, yupString } from "@stackframe/stack-shared/dist/schema-fields";

export const contactChannelVerificationCodeHandler = createVerificationCodeHandler({
metadata: {
Expand All @@ -23,7 +23,7 @@ export const contactChannelVerificationCodeHandler = createVerificationCodeHandl
user_id: yupString().defined(),
}).defined(),
method: yupObject({
email: yupString().email().defined(),
email: emailSchema.defined(),
}),
response: yupObject({
statusCode: yupNumber().oneOf([200]).defined(),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { prismaClient } from "@/prisma-client";
import { createVerificationCodeHandler } from "@/route-handlers/verification-code-handler";
import { VerificationCodeType } from "@prisma/client";
import { KnownErrors } from "@stackframe/stack-shared";
import { yupNumber, yupObject, yupString } from "@stackframe/stack-shared/dist/schema-fields";
import { emailSchema, yupNumber, yupObject, yupString } from "@stackframe/stack-shared/dist/schema-fields";
import { teamsCrudHandlers } from "../../teams/crud";

export const teamInvitationCodeHandler = createVerificationCodeHandler({
Expand All @@ -30,7 +30,7 @@ export const teamInvitationCodeHandler = createVerificationCodeHandler({
team_id: yupString().defined(),
}).defined(),
method: yupObject({
email: yupString().email().defined(),
email: emailSchema.defined(),
}),
response: yupObject({
statusCode: yupNumber().oneOf([200]).defined(),
Expand Down
4 changes: 2 additions & 2 deletions apps/backend/src/oauth/utils.tsx
Original file line number Diff line number Diff line change
@@ -1,12 +1,12 @@
import { yupBoolean, yupObject, yupString } from "@stackframe/stack-shared/dist/schema-fields";
import { emailSchema, yupBoolean, yupObject, yupString } from "@stackframe/stack-shared/dist/schema-fields";
import * as yup from 'yup';

export type OAuthUserInfo = yup.InferType<typeof OAuthUserInfoSchema>;

const OAuthUserInfoSchema = yupObject({
accountId: yupString().min(1).defined(),
displayName: yupString().nullable().default(null),
email: yupString().email().nullable().default(null),
email: emailSchema.nullable().default(null),
profileImageUrl: yupString().nullable().default(null),
emailVerified: yupBoolean().default(false),
});
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { AdminEmailConfig, AdminProject } from "@stackframe/stack";
import { Reader } from "@stackframe/stack-emails/dist/editor/email-builder/index";
import { EMAIL_TEMPLATES_METADATA, convertEmailSubjectVariables, convertEmailTemplateMetadataExampleValues, convertEmailTemplateVariables, validateEmailTemplateContent } from "@stackframe/stack-emails/dist/utils";
import { EmailTemplateType } from "@stackframe/stack-shared/dist/interface/crud/email-templates";
import { strictEmailSchema, emailSchema } from "@stackframe/stack-shared/dist/schema-fields";
import { ActionCell, ActionDialog, Button, Card, SimpleTooltip, Typography } from "@stackframe/stack-ui";
import { useMemo, useState } from "react";
import * as yup from "yup";
Expand Down Expand Up @@ -154,7 +155,7 @@ const emailServerSchema = yup.object({
port: definedWhenShared(yup.number(), "Port is required"),
username: definedWhenShared(yup.string(), "Username is required"),
password: definedWhenShared(yup.string(), "Password is required"),
senderEmail: definedWhenShared(yup.string().email("Sender email must be a valid email"), "Sender email is required"),
senderEmail: definedWhenShared(strictEmailSchema("Sender email must be a valid email"), "Sender email is required"),
senderName: definedWhenShared(yup.string(), "Email sender name is required"),
});

Expand Down
4 changes: 2 additions & 2 deletions apps/dashboard/src/components/feedback-dialog.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { useUser } from "@stackframe/stack";
import { emailSchema } from "@stackframe/stack-shared/dist/schema-fields";
import { useToast } from "@stackframe/stack-ui";
import * as yup from "yup";
import { SmartFormDialog } from "./form-dialog";
Expand All @@ -17,8 +18,7 @@ export function FeedbackDialog(props: {
.optional()
.label("Your name")
.default(user?.displayName),
email: yup.string()
.email("Invalid email")
email: emailSchema
.defined()
.nonEmpty("Email is required")
.label("Your email")
Expand Down
4 changes: 2 additions & 2 deletions apps/dashboard/src/components/user-dialog.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { useAdminApp } from "@/app/(main)/(protected)/projects/[projectId]/use-admin-app";
import { ServerUser } from "@stackframe/stack";
import { KnownErrors } from "@stackframe/stack-shared";
import { jsonStringOrEmptySchema } from "@stackframe/stack-shared/dist/schema-fields";
import { emailSchema, jsonStringOrEmptySchema } from "@stackframe/stack-shared/dist/schema-fields";
import { Accordion, AccordionContent, AccordionItem, AccordionTrigger, Typography, useToast } from "@stackframe/stack-ui";
import * as yup from "yup";
import { FormDialog } from "./form-dialog";
Expand Down Expand Up @@ -41,7 +41,7 @@ export function UserDialog(props: {
}

const formSchema = yup.object({
primaryEmail: yup.string().email("Primary Email must be a valid email address").defined().nonEmpty("Primary email is required"),
primaryEmail: emailSchema.label("Primary email").defined().nonEmpty(),
displayName: yup.string().optional(),
signedUpAt: yup.date().defined(),
clientMetadata: jsonStringOrEmptySchema.default("null"),
Expand Down
41 changes: 41 additions & 0 deletions apps/e2e/tests/backend/endpoints/api/v1/users.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -890,6 +890,47 @@ describe("with server access", () => {
`);
});

it("should be able to create a user with an email that doesn't match the strict email schema", async ({ expect }) => {
// This test is to ensure that we don't break existing users who have an email that doesn't match the strict email
// schema.
// The frontend no longer allows those emails, but some users may still have them in their accounts and we should
// continue to support them.
const response = await niceBackendFetch("/api/v1/users", {
accessType: "server",
method: "POST",
body: {
primary_email: "invalid_email@gmai"
},
});
expect(response).toMatchInlineSnapshot(`
NiceResponse {
"status": 201,
"body": {
"auth_with_email": false,
"client_metadata": null,
"client_read_only_metadata": null,
"display_name": null,
"has_password": false,
"id": "<stripped UUID>",
"last_active_at_millis": <stripped field 'last_active_at_millis'>,
"oauth_providers": [],
"otp_auth_enabled": false,
"passkey_auth_enabled": false,
"primary_email": "invalid_email@gmai",
"primary_email_auth_enabled": false,
"primary_email_verified": false,
"profile_image_url": null,
"requires_totp_mfa": false,
"selected_team": null,
"selected_team_id": null,
"server_metadata": null,
"signed_up_at_millis": <stripped field 'signed_up_at_millis'>,
},
"headers": Headers { <some fields may have been hidden> },
}
`);
});

it("should be able to create a user with a password and sign in with it", async ({ expect }) => {
const password = generateSecureRandomString();
const response = await niceBackendFetch("/api/v1/users", {
Expand Down
6 changes: 6 additions & 0 deletions eslint-configs/defaults.js
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,12 @@ module.exports = {
message:
`Use .defined(), .nonNullable(), or .nonEmpty() instead of .required(), as the latter has inconsistent/unexpected behavior on strings.`,
},
{
selector:
"CallExpression > MemberExpression:has(Identifier[name='yupString']) > Identifier[name='email']",
message:
`Use emailSchema instead of yupString().email().`,
},
{
selector:
"MemberExpression:has(Identifier[name='yup']):has(Identifier[name='string'], Identifier[name='number'], Identifier[name='boolean'], Identifier[name='array'], Identifier[name='object'], Identifier[name='tuple'], Identifier[name='date'], Identifier[name='mixed'])",
Expand Down
12 changes: 11 additions & 1 deletion packages/stack-shared/src/schema-fields.ts
Original file line number Diff line number Diff line change
Expand Up @@ -212,12 +212,22 @@ export const jsonStringOrEmptySchema = yupString().test("json", "Invalid JSON fo
return false;
}
});
export const emailSchema = yupString().email();
export const base64Schema = yupString().test("is-base64", "Invalid base64 format", (value) => {
if (value == null) return true;
return isBase64(value);
});

/**
* A stricter email schema that does some additional checks for UX input.
*
* Note that some users in the DB have an email that doesn't match this regex, so most of the time you should use
* `emailSchema` instead until we do the DB migration.
*/
// eslint-disable-next-line no-restricted-syntax
export const strictEmailSchema = (message: string | undefined) => yupString().email(message).matches(/^.*@.*\..*$/, message);
// eslint-disable-next-line no-restricted-syntax
export const emailSchema = yupString().email();

// Request auth
export const clientOrHigherAuthTypeSchema = yupString().oneOf(['client', 'server', 'admin']);
export const serverOrHigherAuthTypeSchema = yupString().oneOf(['server', 'admin']);
Expand Down
7 changes: 3 additions & 4 deletions packages/stack/src/components-page/account-settings.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
import { yupResolver } from "@hookform/resolvers/yup";
import { getPasswordError } from '@stackframe/stack-shared/dist/helpers/password';
import { useAsyncCallback } from '@stackframe/stack-shared/dist/hooks/use-async-callback';
import { yupObject, yupString } from '@stackframe/stack-shared/dist/schema-fields';
import { emailSchema, strictEmailSchema, yupObject, yupString } from '@stackframe/stack-shared/dist/schema-fields';
import { generateRandomValues } from '@stackframe/stack-shared/dist/utils/crypto';
import { throwErr } from '@stackframe/stack-shared/dist/utils/errors';
import { runAsynchronously, runAsynchronouslyWithAlert } from '@stackframe/stack-shared/dist/utils/promises';
Expand Down Expand Up @@ -190,8 +190,7 @@ function EmailsSection() {
}, [contactChannels, addedEmail]);

const emailSchema = yupObject({
email: yupString()
.email(t('Please enter a valid email address'))
email: strictEmailSchema(t('Please enter a valid email address'))
.notOneOf(contactChannels.map(x => x.value), t('Email already exists'))
.defined()
.nonEmpty(t('Email is required')),
Expand Down Expand Up @@ -911,7 +910,7 @@ function useMemberInvitationSection(props: { team: Team }) {
const { t } = useTranslation();

const invitationSchema = yupObject({
email: yupString().email().defined().nonEmpty(t('Please enter an email address')),
email: emailSchema.defined().nonEmpty(t('Please enter an email address')),
});

const user = useUser({ or: 'redirect' });
Expand Down
4 changes: 2 additions & 2 deletions packages/stack/src/components-page/forgot-password.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
'use client';

import { yupResolver } from "@hookform/resolvers/yup";
import { yupObject, yupString } from "@stackframe/stack-shared/dist/schema-fields";
import { strictEmailSchema, yupObject } from "@stackframe/stack-shared/dist/schema-fields";
import { runAsynchronouslyWithAlert } from "@stackframe/stack-shared/dist/utils/promises";
import { Button, Input, Label, StyledLink, Typography, cn } from "@stackframe/stack-ui";
import { useState } from "react";
Expand All @@ -17,7 +17,7 @@ export function ForgotPasswordForm({ onSent }: { onSent?: () => void }) {
const { t } = useTranslation();

const schema = yupObject({
email: yupString().email(t("Please enter a valid email")).defined().nonEmpty(t("Please enter your email"))
email: strictEmailSchema(t("Please enter a valid email")).defined().nonEmpty(t("Please enter your email"))
});

const { register, handleSubmit, formState: { errors }, clearErrors } = useForm({
Expand Down
4 changes: 2 additions & 2 deletions packages/stack/src/components/credential-sign-in.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
'use client';

import { yupResolver } from "@hookform/resolvers/yup";
import { yupObject, yupString } from "@stackframe/stack-shared/dist/schema-fields";
import { strictEmailSchema, yupObject, yupString } from "@stackframe/stack-shared/dist/schema-fields";
import { runAsynchronouslyWithAlert } from "@stackframe/stack-shared/dist/utils/promises";
import { Button, Input, Label, PasswordInput, StyledLink } from "@stackframe/stack-ui";
import { useState } from "react";
Expand All @@ -15,7 +15,7 @@ export function CredentialSignIn() {
const { t } = useTranslation();

const schema = yupObject({
email: yupString().email(t('Please enter a valid email')).defined().nonEmpty(t('Please enter your email')),
email: strictEmailSchema(t('Please enter a valid email')).defined().nonEmpty(t('Please enter your email')),
password: yupString().defined().nonEmpty(t('Please enter your password'))
});

Expand Down
4 changes: 2 additions & 2 deletions packages/stack/src/components/credential-sign-up.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

import { yupResolver } from "@hookform/resolvers/yup";
import { getPasswordError } from "@stackframe/stack-shared/dist/helpers/password";
import { yupObject, yupString } from "@stackframe/stack-shared/dist/schema-fields";
import { strictEmailSchema, yupObject, yupString } from "@stackframe/stack-shared/dist/schema-fields";
import { runAsynchronously, runAsynchronouslyWithAlert } from "@stackframe/stack-shared/dist/utils/promises";
import { Button, Input, Label, PasswordInput } from "@stackframe/stack-ui";
import { useState } from "react";
Expand All @@ -16,7 +16,7 @@ export function CredentialSignUp(props: { noPasswordRepeat?: boolean }) {
const { t } = useTranslation();

const schema = yupObject({
email: yupString().email(t('Please enter a valid email')).defined().nonEmpty(t('Please enter your email')),
email: strictEmailSchema(t('Please enter a valid email')).defined().nonEmpty(t('Please enter your email')),
password: yupString().defined().nonEmpty(t('Please enter your password')).test({
name: 'is-valid-password',
test: (value, ctx) => {
Expand Down
4 changes: 2 additions & 2 deletions packages/stack/src/components/magic-link-sign-in.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

import { yupResolver } from "@hookform/resolvers/yup";
import { KnownErrors } from "@stackframe/stack-shared";
import { yupObject, yupString } from "@stackframe/stack-shared/dist/schema-fields";
import { strictEmailSchema, yupObject } from "@stackframe/stack-shared/dist/schema-fields";
import { runAsynchronouslyWithAlert } from "@stackframe/stack-shared/dist/utils/promises";
import { Button, Input, InputOTP, InputOTPGroup, InputOTPSlot, Label, Typography } from "@stackframe/stack-ui";
import { useEffect, useState } from "react";
Expand Down Expand Up @@ -79,7 +79,7 @@ export function MagicLinkSignIn() {
const [nonce, setNonce] = useState<string | null>(null);

const schema = yupObject({
email: yupString().email(t('Please enter a valid email')).defined().nonEmpty(t('Please enter your email'))
email: strictEmailSchema(t('Please enter a valid email')).defined().nonEmpty(t('Please enter your email'))
});

const { register, handleSubmit, setError, formState: { errors } } = useForm({
Expand Down
5 changes: 2 additions & 3 deletions packages/stack/src/utils/email.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
import { yupString } from "@stackframe/stack-shared/dist/schema-fields";
import * as yup from "yup";
import { emailSchema } from "@stackframe/stack-shared/dist/schema-fields";

export function validateEmail(email: string): boolean {
if (typeof email !== "string") throw new Error("Email must be a string");
return yupString().email().isValidSync(email);
return emailSchema.isValidSync(email);
};

0 comments on commit 6a0ca75

Please sign in to comment.