mirror of
https://github.com/LukeHagar/better-auth.git
synced 2025-12-10 12:27:44 +00:00
* docs: Add guide for Sign In with Apple * docs-feat: add apple JWT generator * fix-lint: ran lint:fix to fix CI test * chore: refactor to remove jose * update docs * chore: lock file * fix test --------- Co-authored-by: Bereket Engida <Bekacru@gmail.com>
395 lines
9.8 KiB
TypeScript
395 lines
9.8 KiB
TypeScript
import { APIError } from "better-call";
|
|
import { z } from "zod";
|
|
import { createAuthEndpoint } from "../../../api/call";
|
|
import { verifyTwoFactor } from "../verify-two-factor";
|
|
import type {
|
|
TwoFactorProvider,
|
|
TwoFactorTable,
|
|
UserWithTwoFactor,
|
|
} from "../types";
|
|
import { TWO_FACTOR_ERROR_CODES } from "../error-code";
|
|
import {
|
|
generateRandomString,
|
|
symmetricDecrypt,
|
|
symmetricEncrypt,
|
|
} from "../../../crypto";
|
|
import { setSessionCookie } from "../../../cookies";
|
|
import { BASE_ERROR_CODES } from "../../../error/codes";
|
|
import type { GenericEndpointContext } from "../../../types";
|
|
import { defaultKeyHasher } from "../utils";
|
|
|
|
export interface OTPOptions {
|
|
/**
|
|
* How long the opt will be valid for in
|
|
* minutes
|
|
*
|
|
* @default "3 mins"
|
|
*/
|
|
period?: number;
|
|
/**
|
|
* Number of digits for the OTP code
|
|
*
|
|
* @default 6
|
|
*/
|
|
digits?: number;
|
|
/**
|
|
* Send the otp to the user
|
|
*
|
|
* @param user - The user to send the otp to
|
|
* @param otp - The otp to send
|
|
* @param request - The request object
|
|
* @returns void | Promise<void>
|
|
*/
|
|
sendOTP?: (
|
|
/**
|
|
* The user to send the otp to
|
|
* @type UserWithTwoFactor
|
|
* @default UserWithTwoFactors
|
|
*/
|
|
data: {
|
|
user: UserWithTwoFactor;
|
|
otp: string;
|
|
},
|
|
/**
|
|
* The request object
|
|
*/
|
|
request?: Request,
|
|
) => Promise<void> | void;
|
|
/**
|
|
* The number of allowed attempts for the OTP
|
|
*
|
|
* @default 5
|
|
*/
|
|
allowedAttempts?: number;
|
|
storeOTP?:
|
|
| "plain"
|
|
| "encrypted"
|
|
| "hashed"
|
|
| { hash: (token: string) => Promise<string> }
|
|
| {
|
|
encrypt: (token: string) => Promise<string>;
|
|
decrypt: (token: string) => Promise<string>;
|
|
};
|
|
}
|
|
|
|
/**
|
|
* The otp adapter is created from the totp adapter.
|
|
*/
|
|
export const otp2fa = (options?: OTPOptions) => {
|
|
const opts = {
|
|
storeOTP: "plain",
|
|
digits: 6,
|
|
...options,
|
|
period: (options?.period || 3) * 60 * 1000,
|
|
};
|
|
const twoFactorTable = "twoFactor";
|
|
|
|
async function storeOTP(ctx: GenericEndpointContext, otp: string) {
|
|
if (opts.storeOTP === "hashed") {
|
|
return await defaultKeyHasher(otp);
|
|
}
|
|
if (typeof opts.storeOTP === "object" && "hash" in opts.storeOTP) {
|
|
return await opts.storeOTP.hash(otp);
|
|
}
|
|
if (typeof opts.storeOTP === "object" && "encrypt" in opts.storeOTP) {
|
|
return await opts.storeOTP.encrypt(otp);
|
|
}
|
|
if (opts.storeOTP === "encrypted") {
|
|
return await symmetricEncrypt({
|
|
key: ctx.context.secret,
|
|
data: otp,
|
|
});
|
|
}
|
|
return otp;
|
|
}
|
|
|
|
async function decryptOTP(ctx: GenericEndpointContext, otp: string) {
|
|
if (opts.storeOTP === "hashed") {
|
|
return await defaultKeyHasher(otp);
|
|
}
|
|
if (opts.storeOTP === "encrypted") {
|
|
return await symmetricDecrypt({
|
|
key: ctx.context.secret,
|
|
data: otp,
|
|
});
|
|
}
|
|
if (typeof opts.storeOTP === "object" && "encrypt" in opts.storeOTP) {
|
|
return await opts.storeOTP.decrypt(otp);
|
|
}
|
|
if (typeof opts.storeOTP === "object" && "hash" in opts.storeOTP) {
|
|
return await opts.storeOTP.hash(otp);
|
|
}
|
|
return otp;
|
|
}
|
|
|
|
/**
|
|
* Generate OTP and send it to the user.
|
|
*/
|
|
const send2FaOTP = createAuthEndpoint(
|
|
"/two-factor/send-otp",
|
|
{
|
|
method: "POST",
|
|
body: z
|
|
.object({
|
|
/**
|
|
* if true, the device will be trusted
|
|
* for 30 days. It'll be refreshed on
|
|
* every sign in request within this time.
|
|
*/
|
|
trustDevice: z.boolean().optional(),
|
|
})
|
|
.optional(),
|
|
metadata: {
|
|
openapi: {
|
|
summary: "Send two factor OTP",
|
|
description: "Send two factor OTP to the user",
|
|
responses: {
|
|
200: {
|
|
description: "Successful response",
|
|
content: {
|
|
"application/json": {
|
|
schema: {
|
|
type: "object",
|
|
properties: {
|
|
status: {
|
|
type: "boolean",
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
async (ctx) => {
|
|
if (!options || !options.sendOTP) {
|
|
ctx.context.logger.error(
|
|
"send otp isn't configured. Please configure the send otp function on otp options.",
|
|
);
|
|
throw new APIError("BAD_REQUEST", {
|
|
message: "otp isn't configured",
|
|
});
|
|
}
|
|
const { session, key } = await verifyTwoFactor(ctx);
|
|
const twoFactor = await ctx.context.adapter.findOne<TwoFactorTable>({
|
|
model: twoFactorTable,
|
|
where: [
|
|
{
|
|
field: "userId",
|
|
value: session.user.id,
|
|
},
|
|
],
|
|
});
|
|
if (!twoFactor) {
|
|
throw new APIError("BAD_REQUEST", {
|
|
message: TWO_FACTOR_ERROR_CODES.OTP_NOT_ENABLED,
|
|
});
|
|
}
|
|
const code = generateRandomString(opts.digits, "0-9");
|
|
const hashedCode = await storeOTP(ctx, code);
|
|
await ctx.context.internalAdapter.createVerificationValue(
|
|
{
|
|
value: `${hashedCode}:0`,
|
|
identifier: `2fa-otp-${key}`,
|
|
expiresAt: new Date(Date.now() + opts.period),
|
|
},
|
|
ctx,
|
|
);
|
|
await options.sendOTP(
|
|
{ user: session.user as UserWithTwoFactor, otp: code },
|
|
ctx.request,
|
|
);
|
|
return ctx.json({ status: true });
|
|
},
|
|
);
|
|
|
|
const verifyOTP = createAuthEndpoint(
|
|
"/two-factor/verify-otp",
|
|
{
|
|
method: "POST",
|
|
body: z.object({
|
|
code: z.string({
|
|
description: "The otp code to verify",
|
|
}),
|
|
/**
|
|
* if true, the device will be trusted
|
|
* for 30 days. It'll be refreshed on
|
|
* every sign in request within this time.
|
|
*/
|
|
trustDevice: z.boolean().optional(),
|
|
}),
|
|
metadata: {
|
|
openapi: {
|
|
summary: "Verify two factor OTP",
|
|
description: "Verify two factor OTP",
|
|
responses: {
|
|
"200": {
|
|
description: "Two-factor OTP verified successfully",
|
|
content: {
|
|
"application/json": {
|
|
schema: {
|
|
type: "object",
|
|
properties: {
|
|
token: {
|
|
type: "string",
|
|
description:
|
|
"Session token for the authenticated session",
|
|
},
|
|
user: {
|
|
type: "object",
|
|
properties: {
|
|
id: {
|
|
type: "string",
|
|
description: "Unique identifier of the user",
|
|
},
|
|
email: {
|
|
type: "string",
|
|
format: "email",
|
|
nullable: true,
|
|
description: "User's email address",
|
|
},
|
|
emailVerified: {
|
|
type: "boolean",
|
|
nullable: true,
|
|
description: "Whether the email is verified",
|
|
},
|
|
name: {
|
|
type: "string",
|
|
nullable: true,
|
|
description: "User's name",
|
|
},
|
|
image: {
|
|
type: "string",
|
|
format: "uri",
|
|
nullable: true,
|
|
description: "User's profile image URL",
|
|
},
|
|
createdAt: {
|
|
type: "string",
|
|
format: "date-time",
|
|
description: "Timestamp when the user was created",
|
|
},
|
|
updatedAt: {
|
|
type: "string",
|
|
format: "date-time",
|
|
description:
|
|
"Timestamp when the user was last updated",
|
|
},
|
|
},
|
|
required: ["id", "createdAt", "updatedAt"],
|
|
description: "The authenticated user object",
|
|
},
|
|
},
|
|
required: ["token", "user"],
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
async (ctx) => {
|
|
const { session, key, valid, invalid } = await verifyTwoFactor(ctx);
|
|
const twoFactor = await ctx.context.adapter.findOne<TwoFactorTable>({
|
|
model: twoFactorTable,
|
|
where: [
|
|
{
|
|
field: "userId",
|
|
value: session.user.id,
|
|
},
|
|
],
|
|
});
|
|
if (!twoFactor) {
|
|
throw new APIError("BAD_REQUEST", {
|
|
message: TWO_FACTOR_ERROR_CODES.OTP_NOT_ENABLED,
|
|
});
|
|
}
|
|
const toCheckOtp =
|
|
await ctx.context.internalAdapter.findVerificationValue(
|
|
`2fa-otp-${key}`,
|
|
);
|
|
const [otp, counter] = toCheckOtp?.value?.split(":") ?? [];
|
|
const decryptedOtp = await decryptOTP(ctx, otp);
|
|
if (!toCheckOtp || toCheckOtp.expiresAt < new Date()) {
|
|
if (toCheckOtp) {
|
|
await ctx.context.internalAdapter.deleteVerificationValue(
|
|
toCheckOtp.id,
|
|
);
|
|
}
|
|
throw new APIError("BAD_REQUEST", {
|
|
message: TWO_FACTOR_ERROR_CODES.OTP_HAS_EXPIRED,
|
|
});
|
|
}
|
|
const allowedAttempts = options?.allowedAttempts || 5;
|
|
if (parseInt(counter) >= allowedAttempts) {
|
|
await ctx.context.internalAdapter.deleteVerificationValue(
|
|
toCheckOtp.id,
|
|
);
|
|
throw new APIError("BAD_REQUEST", {
|
|
message: TWO_FACTOR_ERROR_CODES.TOO_MANY_ATTEMPTS_REQUEST_NEW_CODE,
|
|
});
|
|
}
|
|
if (decryptedOtp === ctx.body.code) {
|
|
if (!session.user.twoFactorEnabled) {
|
|
if (!session.session) {
|
|
throw new APIError("BAD_REQUEST", {
|
|
message: BASE_ERROR_CODES.FAILED_TO_CREATE_SESSION,
|
|
});
|
|
}
|
|
const updatedUser = await ctx.context.internalAdapter.updateUser(
|
|
session.user.id,
|
|
{
|
|
twoFactorEnabled: true,
|
|
},
|
|
);
|
|
const newSession = await ctx.context.internalAdapter.createSession(
|
|
session.user.id,
|
|
ctx,
|
|
false,
|
|
session.session,
|
|
);
|
|
await ctx.context.internalAdapter.deleteSession(
|
|
session.session.token,
|
|
);
|
|
await setSessionCookie(ctx, {
|
|
session: newSession,
|
|
user: updatedUser,
|
|
});
|
|
return ctx.json({
|
|
token: newSession.token,
|
|
user: {
|
|
id: updatedUser.id,
|
|
email: updatedUser.email,
|
|
emailVerified: updatedUser.emailVerified,
|
|
name: updatedUser.name,
|
|
image: updatedUser.image,
|
|
createdAt: updatedUser.createdAt,
|
|
updatedAt: updatedUser.updatedAt,
|
|
},
|
|
});
|
|
}
|
|
return valid(ctx);
|
|
} else {
|
|
await ctx.context.internalAdapter.updateVerificationValue(
|
|
toCheckOtp.id,
|
|
{
|
|
value: `${otp}:${(parseInt(counter, 10) || 0) + 1}`,
|
|
},
|
|
);
|
|
return invalid("INVALID_CODE");
|
|
}
|
|
},
|
|
);
|
|
|
|
return {
|
|
id: "otp",
|
|
endpoints: {
|
|
sendTwoFactorOTP: send2FaOTP,
|
|
verifyTwoFactorOTP: verifyOTP,
|
|
},
|
|
} satisfies TwoFactorProvider;
|
|
};
|