Skip to content

Commit

Permalink
Create PKCE session for verification with email (#907)
Browse files Browse the repository at this point in the history
The current email verification flow always assumes that it ends in a PKCE code exchange. However, currently, if you send just the email, we do not start a PKCE flow, so it just ends in a redirect or 204 No Content response.
  • Loading branch information
scotttrinh committed Apr 8, 2024
1 parent 5dd3fa9 commit 94d02ae
Show file tree
Hide file tree
Showing 7 changed files with 186 additions and 68 deletions.
3 changes: 3 additions & 0 deletions packages/auth-core/src/core.ts
Expand Up @@ -214,11 +214,14 @@ export class Auth {
}

async resendVerificationEmailForEmail(email: string, verifyUrl: string) {
const { verifier, challenge } = await pkce.createVerifierChallengePair();
await this._post("resend-verification-email", {
provider: emailPasswordProviderName,
email,
verify_url: verifyUrl,
challenge,
});
return { verifier };
}

async sendPasswordResetEmail(email: string, resetUrl: string) {
Expand Down
58 changes: 40 additions & 18 deletions packages/auth-express/src/index.ts
Expand Up @@ -567,24 +567,46 @@ export class ExpressAuth {
next(err);
}
},
resendVerificationEmail: async (
req: AuthRequest,
res: ExpressResponse,
next: NextFunction
) => {
try {
const [verificationToken] = _extractParams(
req.body,
["verification_token"],
"verification_token missing from request body"
);
(await this.core).resendVerificationEmail(verificationToken);
res.status(204);
next();
} catch (err) {
next(err);
}
},
resendVerificationEmail:
(verifyUrl?: string) =>
async (req: AuthRequest, res: ExpressResponse, next: NextFunction) => {
try {
if ("verification_token" in req.body) {
const verificationToken = req.body.verification_token;
if (typeof verificationToken !== "string") {
throw new InvalidDataError(
"expected 'verification_token' to be a string"
);
}
await (await this.core).resendVerificationEmail(verificationToken);
} else if ("email" in req.body) {
const email = req.body.email;
if (typeof email !== "string") {
throw new InvalidDataError("expected 'email' to be a string");
}
if (!verifyUrl) {
throw new InvalidDataError(
"verifyUrl is required when email is provided"
);
}
const { verifier } = await (
await this.core
).resendVerificationEmailForEmail(email, verifyUrl);
res.cookie(this.options.pkceVerifierCookieName, verifier, {
httpOnly: true,
sameSite: "strict",
});
} else {
throw new InvalidDataError(
"verification_token or email missing from request body"
);
}
res.status(204);
next();
} catch (err) {
next(err);
}
},
};

magicLink = {
Expand Down
52 changes: 28 additions & 24 deletions packages/auth-nextjs/src/app/index.ts
Expand Up @@ -145,37 +145,41 @@ export class NextAppAuth extends NextAuth {
emailPasswordResendVerificationEmail: async (
data: FormData | { verification_token: string } | { email: string }
) => {
let email;
let verificationToken;
try {
[verificationToken] = _extractParams(
data,
["verification_token"],
"verification_token missing"
);
} catch (tokenError) {
try {
[email] = _extractParams(data, ["email"], "email missing");
} catch (emailError) {
const bothParamsMissing = [tokenError, emailError]
.map((err) => (err as Error).message)
.join(" and ");

throw new InvalidDataError(
`${bothParamsMissing}. Either one is required.`
);
}
}
const verificationToken =
data instanceof FormData
? data.get("verification_token")
: "verification_token" in data
? data.verification_token
: null;
const email =
data instanceof FormData
? data.get("email")
: "email" in data
? data.email
: null;

if (verificationToken) {
await (await this.core).resendVerificationEmail(verificationToken);
} else if (email) {
await (
await this.core
).resendVerificationEmail(verificationToken.toString());
} else if (email) {
const { verifier } = await (
await this.core
).resendVerificationEmailForEmail(
email,
email.toString(),
`${this._authRoute}/emailpassword/verify`
);

cookies().set({
name: this.options.pkceVerifierCookieName,
value: verifier,
httpOnly: true,
sameSite: "strict",
});
} else {
throw new InvalidDataError(
"either verification_token or email must be provided"
);
}
},
magicLinkSignUp: async (data: FormData | { email: string }) => {
Expand Down
5 changes: 5 additions & 0 deletions packages/auth-nextjs/src/pages/client.ts
Expand Up @@ -54,6 +54,11 @@ export class NextPagesClientAuth extends NextAuthHelpers {
| {
verification_token: string;
}
| {
email: string;
verify_url: string;
challenge: string;
}
| FormData
) {
return await apiRequest(
Expand Down
51 changes: 41 additions & 10 deletions packages/auth-nextjs/src/shared.ts
Expand Up @@ -727,15 +727,44 @@ export abstract class NextAuth extends NextAuthHelpers {
case "emailpassword/resend-verification-email": {
const data = await _getReqBody(req);
const isAction = _isAction(data);
const [verificationToken] = _extractParams(
data,
["verification_token"],
"verification_token missing from request body"
);
(await this.core).resendVerificationEmail(verificationToken);
return isAction
? Response.json({ _data: null })
: new Response(null, { status: 204 });
const verificationToken =
data instanceof FormData
? data.get("verification_token")?.toString()
: data.verification_token;
const email =
data instanceof FormData
? data.get("email")?.toString()
: data.email;

if (verificationToken) {
await (
await this.core
).resendVerificationEmail(verificationToken.toString());
return isAction
? Response.json({ _data: null })
: new Response(null, { status: 204 });
} else if (email) {
const { verifier } = await (
await this.core
).resendVerificationEmailForEmail(
email.toString(),
`${this._authRoute}/emailpassword/verify`
);
cookies().set({
name: this.options.pkceVerifierCookieName,
value: verifier,
httpOnly: true,
sameSite: "strict",
path: "/",
});
return isAction
? Response.json({ _data: null })
: new Response(null, { status: 204 });
} else {
throw new InvalidDataError(
"verification_token or email missing from request body"
);
}
}
case "webauthn/signup": {
if (!onWebAuthnSignUp) {
Expand Down Expand Up @@ -920,7 +949,9 @@ export class NextAuthSession {
}
}

function _getReqBody(req: NextRequest) {
function _getReqBody(
req: NextRequest
): Promise<FormData | Record<string, unknown>> {
return req.headers.get("Content-Type") === "application/json"
? req.json()
: req.formData();
Expand Down
43 changes: 34 additions & 9 deletions packages/auth-remix/src/server.ts
Expand Up @@ -650,21 +650,22 @@ export class RemixServerAuth extends RemixClientAuth {

async emailPasswordResendVerificationEmail(
req: Request,
data?: { verification_token: string }
data?: { verification_token: string } | { email: string }
): Promise<{ headers: Headers }>;
async emailPasswordResendVerificationEmail<Res>(
req: Request,
cb: (error?: Error) => Res | Promise<Res>
): Promise<Res extends Response ? Res : TypedResponse<Res>>;
async emailPasswordResendVerificationEmail<Res>(
req: Request,
data: { verification_token: string },
data: { verification_token: string } | { email: string },
cb: (error?: Error) => Res | Promise<Res>
): Promise<Res extends Response ? Res : TypedResponse<Res>>;
async emailPasswordResendVerificationEmail<Res>(
req: Request,
dataOrCb?:
| { verification_token: string }
| { email: string }
| ((error?: Error) => Res | Promise<Res>),
cb?: (error?: Error) => Res | Promise<Res>
): Promise<
Expand All @@ -674,14 +675,38 @@ export class RemixServerAuth extends RemixClientAuth {
| (Res extends Response ? Res : TypedResponse<Res>)
> {
return handleAction(
async (data) => {
const [verificationToken] = _extractParams(
data,
["verification_token"],
"verification_token missing"
);
async (data, headers) => {
const verificationToken =
data instanceof FormData
? data.get("verification_token")
: data.verification_token;
const email = data instanceof FormData ? data.get("email") : data.email;

if (verificationToken) {
await (
await this.core
).resendVerificationEmail(verificationToken.toString());
} else if (email) {
const { verifier } = await (
await this.core
).resendVerificationEmailForEmail(
email.toString(),
`${this._authRoute}/emailpassword/verify`
);

await (await this.core).resendVerificationEmail(verificationToken);
headers.append(
"Set-Cookie",
cookie.serialize(this.options.pkceVerifierCookieName, verifier, {
httpOnly: true,
sameSite: "strict",
path: "/",
})
);
} else {
throw new InvalidDataError(
"verification_token or email missing. Either one is required."
);
}
},
req,
dataOrCb,
Expand Down
42 changes: 35 additions & 7 deletions packages/auth-sveltekit/src/server.ts
Expand Up @@ -175,15 +175,43 @@ export class ServerRequestAuth extends ClientAuth {
}

async emailPasswordResendVerificationEmail(
data: { verification_token: string } | FormData
data: { verification_token: string } | { email: string } | FormData
): Promise<void> {
const [verificationToken] = extractParams(
data,
["verification_token"],
"verification_token missing"
);
const verificationToken =
data instanceof FormData
? data.get("verification_token")
: "verification_token" in data
? data.verification_token
: null;
const email =
data instanceof FormData
? data.get("email")
: "email" in data
? data.email
: null;

if (verificationToken) {
return await (
await this.core
).resendVerificationEmail(verificationToken.toString());
} else if (email) {
const { verifier } = await (
await this.core
).resendVerificationEmailForEmail(
email.toString(),
`${this.config.authRoute}/emailpassword/verify`
);

await (await this.core).resendVerificationEmail(verificationToken);
this.cookies.set(this.config.pkceVerifierCookieName, verifier, {
httpOnly: true,
sameSite: "strict",
path: "/",
});
} else {
throw new InvalidDataError(
"expected 'verification_token' or 'email' in data"
);
}
}

async emailPasswordSignIn(
Expand Down

0 comments on commit 94d02ae

Please sign in to comment.