feat: 2fa

This commit is contained in:
Bereket Engida
2024-08-24 11:55:43 +03:00
parent 8913ca29be
commit 7fd1a59eee
34 changed files with 1245 additions and 62 deletions

View File

@@ -26,6 +26,7 @@
"class-variance-authority": "^0.7.0",
"clsx": "^2.1.1",
"input-otp": "^1.2.4",
"lucide-react": "^0.435.0",
"next": "14.2.5",
"next-themes": "^0.3.0",
"react": "^18",

Binary file not shown.

View File

@@ -12,7 +12,6 @@ import {
} from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { GitHubLogoIcon } from "@radix-ui/react-icons";
import { useState } from "react";
import { authClient } from "@/lib/client";

View File

@@ -0,0 +1,87 @@
"use client"
import { useState } from "react"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from "@/components/ui/card"
import { AlertCircle, CheckCircle2 } from "lucide-react"
import { authClient } from "@/lib/client"
export default function Component() {
const [totpCode, setTotpCode] = useState("")
const [error, setError] = useState("")
const [success, setSuccess] = useState(false)
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault()
if (totpCode.length !== 6 || !/^\d+$/.test(totpCode)) {
setError("TOTP code must be 6 digits")
return
}
authClient.verifyTotp({
body: {
code: totpCode,
callbackURL: "/"
}
}).then((res) => {
console.log(res)
if (res.data?.status) {
setSuccess(true)
setError("")
} else {
setError("Invalid TOTP code")
}
})
}
return (
<main className="flex flex-col items-center justify-center min-h-screen">
<Card className="w-[350px]">
<CardHeader>
<CardTitle>TOTP Verification</CardTitle>
<CardDescription>
Enter your 6-digit TOTP code to authenticate
</CardDescription>
</CardHeader>
<CardContent>
{!success ? (
<form onSubmit={handleSubmit}>
<div className="space-y-2">
<Label htmlFor="totp">TOTP Code</Label>
<Input
id="totp"
type="text"
inputMode="numeric"
pattern="\d{6}"
maxLength={6}
value={totpCode}
onChange={(e) => setTotpCode(e.target.value)}
placeholder="Enter 6-digit code"
required
/>
</div>
{error && (
<div className="flex items-center mt-2 text-red-500">
<AlertCircle className="w-4 h-4 mr-2" />
<span className="text-sm">{error}</span>
</div>
)}
<Button type="submit" className="w-full mt-4">
Verify
</Button>
</form>
) : (
<div className="flex flex-col items-center justify-center space-y-2">
<CheckCircle2 className="w-12 h-12 text-green-500" />
<p className="text-lg font-semibold">Verification Successful</p>
</div>
)}
</CardContent>
<CardFooter className="text-sm text-muted-foreground">
Protect your account with TOTP-based authentication
</CardFooter>
</Card>
</main>
)
}

View File

@@ -2,13 +2,35 @@
import { authClient } from "@/lib/client";
import { useAuthStore } from "better-auth/react"
import { Button } from "./ui/button";
export function Client() {
const session = useAuthStore(authClient.$session)
type S = NonNullable<typeof session>
const a: S['user'] = {
id: "1",
name: "test",
email: "test@test.com",
emailVerified: true,
createdAt: new Date(),
updatedAt: new Date(),
}
return (
<div>
{JSON.stringify(session)}
{
session ? <div>
<Button onClick={async () => {
if (session.user.twoFactorEnabled) {
await authClient.disableTotp()
} else {
await authClient.enableTotp()
}
}}>
{
session.user.twoFactorEnabled ? "Disable" : "Enable"
}
</Button>
</div> : null
}
</div>
)
}

View File

@@ -1,6 +1,6 @@
import { betterAuth } from "better-auth";
import { github, passkey } from "better-auth/provider";
import { organization } from "better-auth/plugins";
import { twoFactor } from "better-auth/plugins";
export const auth = betterAuth({
basePath: "/api/auth",
@@ -22,4 +22,10 @@ export const auth = betterAuth({
emailAndPassword: {
enabled: true,
},
plugins: [
twoFactor({
issuer: "BetterAuth",
twoFactorURL: "/two-factor",
}),
],
});

View File

@@ -46,11 +46,13 @@
"@nanostores/react": "^0.7.3",
"@nanostores/solid": "^0.4.2",
"@nanostores/vue": "^0.10.0",
"@noble/ciphers": "^0.6.0",
"@noble/hashes": "^1.4.0",
"@paralleldrive/cuid2": "^2.2.2",
"@simplewebauthn/browser": "^10.0.0",
"@simplewebauthn/server": "^10.0.1",
"arctic": "^1.9.2",
"better-call": "^0.1.20",
"better-call": "^0.1.28",
"chalk": "^5.3.0",
"commander": "^12.1.0",
"consola": "^3.2.3",

View File

@@ -1,4 +1,4 @@
import { createRouter } from "better-call";
import { createRouter, Endpoint } from "better-call";
import {
signInOAuth,
callbackOAuth,
@@ -35,6 +35,31 @@ export const router = <C extends AuthContext>(ctx: C) => {
{} as Record<string, any>,
);
const middlewares =
ctx.options.plugins
?.map((plugin) =>
plugin.middlewares?.map((m) => {
const middleware = (async (context: any) => {
return m.middleware({
...context,
context: {
...ctx,
...context.context,
},
});
}) as Endpoint;
middleware.path = m.path;
middleware.options = m.middleware.options;
middleware.headers = m.middleware.headers;
return {
path: m.path,
middleware,
};
}),
)
.filter((plugin) => plugin !== undefined)
.flat() || [];
const baseEndpoints = {
signInOAuth,
callbackOAuth,
@@ -74,6 +99,7 @@ export const router = <C extends AuthContext>(ctx: C) => {
path: "/**",
middleware: csrfMiddleware,
},
...middlewares,
],
onError(e) {
ctx.logger.error(e);

View File

@@ -20,7 +20,7 @@ export const csrfMiddleware = createAuthMiddleware(
const csrfToken = ctx.body?.csrfToken;
const csrfCookie = await ctx.getSignedCookie(
ctx.context.authCookies.csrfToken.name,
ctx.context.options.secret,
ctx.context.secret,
);
const [token, hash] = csrfCookie?.split("!") || [null, null];
if (
@@ -37,7 +37,7 @@ export const csrfMiddleware = createAuthMiddleware(
message: "Invalid CSRF Token",
});
}
const expectedHash = await hs256(ctx.context.options.secret, token);
const expectedHash = await hs256(ctx.context.secret, token);
if (hash !== expectedHash) {
ctx.setCookie(ctx.context.authCookies.csrfToken.name, "", {
maxAge: 0,

View File

@@ -98,7 +98,7 @@ export const callbackOAuth = createAuthEndpoint(
await c.setSignedCookie(
c.context.authCookies.sessionToken.name,
session.id,
c.context.options.secret,
c.context.secret,
c.context.authCookies.sessionToken.options,
);
} catch (e) {

View File

@@ -10,7 +10,7 @@ export const getCSRFToken = createAuthEndpoint(
async (ctx) => {
const csrfToken = await ctx.getSignedCookie(
ctx.context.authCookies.csrfToken.name,
ctx.context.options.secret,
ctx.context.secret,
);
if (csrfToken) {
return {
@@ -18,12 +18,12 @@ export const getCSRFToken = createAuthEndpoint(
};
}
const token = generateRandomString(32, alphabet("a-z", "0-9", "A-Z"));
const hash = await hs256(ctx.context.options.secret, token);
const hash = await hs256(ctx.context.secret, token);
const cookie = `${token}!${hash}`;
await ctx.setSignedCookie(
ctx.context.authCookies.csrfToken.name,
cookie,
ctx.context.options.secret,
ctx.context.secret,
ctx.context.authCookies.csrfToken.options,
);
return {

View File

@@ -10,7 +10,7 @@ export const getSession = createAuthEndpoint(
async (ctx) => {
const sessionCookieToken = await ctx.getSignedCookie(
ctx.context.authCookies.sessionToken.name,
ctx.context.options.secret,
ctx.context.secret,
);
if (!sessionCookieToken) {
return ctx.json(null, {

View File

@@ -44,14 +44,14 @@ export const signInOAuth = createAuthEndpoint(
await c.setSignedCookie(
cookie.state.name,
state.code,
c.context.options.secret,
c.context.secret,
cookie.state.options,
);
const codeVerifier = generateCodeVerifier();
await c.setSignedCookie(
cookie.pkCodeVerifier.name,
codeVerifier,
c.context.options.secret,
c.context.secret,
cookie.pkCodeVerifier.options,
);
const url = await provider.provider.createAuthorizationURL(
@@ -121,13 +121,14 @@ export const signInCredential = createAuthEndpoint(
body: { message: "Invalid email or password" },
});
}
const session = await ctx.context.internalAdapter.createSession(
user.user.id,
);
await ctx.setSignedCookie(
ctx.context.authCookies.sessionToken.name,
session.id,
ctx.context.options.secret,
ctx.context.secret,
ctx.context.authCookies.sessionToken.options,
);
return ctx.json(

View File

@@ -14,7 +14,7 @@ export const signOut = createAuthEndpoint(
async (ctx) => {
const sessionCookieToken = await ctx.getSignedCookie(
ctx.context.authCookies.sessionToken.name,
ctx.context.options.secret,
ctx.context.secret,
);
if (!sessionCookieToken) {
return ctx.json(null);

View File

@@ -73,7 +73,7 @@ export const signUpCredential = createAuthEndpoint(
await ctx.setSignedCookie(
ctx.context.authCookies.sessionToken.name,
session.id,
ctx.context.options.secret,
ctx.context.secret,
ctx.context.authCookies.sessionToken.options,
);
if (ctx.body.callbackUrl) {

View File

@@ -47,6 +47,7 @@ function handleEdgeCases(str: string) {
const knownPathMethods: Record<string, "POST" | "GET"> = {
"/sign-out": "POST",
"enable/totp": "POST",
};
function getMethod(path: string, args?: BetterFetchOption) {

View File

@@ -17,10 +17,46 @@ export function getSessionAtom<Auth extends BetterAuth>(client: BetterFetch) {
};
}
? Field extends Record<string, FieldAttribute>
? InferFieldOutput<Field>
? {
[key in keyof Field]: InferFieldOutput<Field[key]>;
}
: {}
: {}
: {};
type AdditionalUserFields = Auth["options"]["plugins"] extends Array<infer T>
? T extends {
schema: {
user: {
fields: infer Field;
};
};
}
? Field extends Record<infer Key, FieldAttribute>
? Prettify<
{
[key in Key as Field[key]["required"] extends false
? never
: Field[key]["defaultValue"] extends
| boolean
| string
| number
| Date
| Function
? key
: never]: InferFieldOutput<Field[key]>;
} & {
[key in Key as Field[key]["returned"] extends false
? never
: key]?: InferFieldOutput<Field[key]>;
}
>
: {}
: {}
: {};
type UserWithAdditionalFields = User & AdditionalUserFields;
type SessionWithAdditionalFields = Session & AdditionalSessionFields;
const $signal = atom<boolean>(false);
const $session = computed($signal, () =>
task(async () => {
@@ -29,8 +65,8 @@ export function getSessionAtom<Auth extends BetterAuth>(client: BetterFetch) {
method: "GET",
});
return session.data as {
user: User;
session: Prettify<Session & AdditionalSessionFields>;
user: Prettify<UserWithAdditionalFields>;
session: Prettify<SessionWithAdditionalFields>;
} | null;
}),
);

View File

@@ -1,3 +1,8 @@
import { sha256 } from "@noble/hashes/sha256";
import { managedNonce } from "@noble/ciphers/webcrypto";
import { xchacha20poly1305 } from "@noble/ciphers/chacha";
import { bytesToHex, hexToBytes, utf8ToBytes } from "@noble/ciphers/utils";
export async function hs256(secretKey: string, message: string) {
const enc = new TextEncoder();
const algorithm = { name: "HMAC", hash: "SHA-256" };
@@ -15,3 +20,27 @@ export async function hs256(secretKey: string, message: string) {
);
return btoa(String.fromCharCode(...new Uint8Array(signature)));
}
export type SymmetricEncryptOptions = {
key: string;
data: string;
};
export const symmetricEncrypt = ({ key, data }: SymmetricEncryptOptions) => {
const keyAsBytes = sha256(key);
const dataAsBytes = utf8ToBytes(data);
const chacha = managedNonce(xchacha20poly1305)(keyAsBytes);
return bytesToHex(chacha.encrypt(dataAsBytes));
};
export type SymmetricDecryptOptions = {
key: string;
data: string;
};
export const symmetricDecrypt = ({ key, data }: SymmetricDecryptOptions) => {
const keyAsBytes = sha256(key);
const dataAsBytes = hexToBytes(data);
const chacha = managedNonce(xchacha20poly1305)(keyAsBytes);
return chacha.decrypt(dataAsBytes);
};

View File

@@ -29,18 +29,12 @@ export type InferValueType<T extends FieldType> = T extends "string"
? Date
: never;
export type InferFieldOutput<
T extends Record<string, FieldAttribute>,
K extends keyof T = keyof T,
> = T[K]["returned"] extends false
? never
: T[K]["required"] extends false
? {
[key in K]?: InferValueType<T[K]["type"]>;
}
: {
[key in K]: InferValueType<T[K]["type"]>;
};
export type InferFieldOutput<T extends FieldAttribute> =
T["returned"] extends false
? never
: T["required"] extends false
? InferValueType<T["type"]> | undefined
: InferValueType<T["type"]>;
export type FieldAttributeConfig<T extends FieldType = FieldType> = {
/**

View File

@@ -2,7 +2,11 @@ import { createKyselyAdapter } from "./adapters/kysely";
import { getAdapter } from "./adapters/utils";
import { createInternalAdapter } from "./db";
import { BetterAuthOptions } from "./types";
import { BetterAuthCookies, getCookies } from "./utils/cookies";
import {
BetterAuthCookies,
createCookieGetter,
getCookies,
} from "./utils/cookies";
import { createLogger } from "./utils/logger";
export const init = (options: BetterAuthOptions) => {
@@ -10,6 +14,11 @@ export const init = (options: BetterAuthOptions) => {
const db = createKyselyAdapter(options);
return {
options,
secret:
options.secret ||
process.env.BETTER_AUTH_SECRET ||
process.env.AUTH_SECRET ||
"better-auth-secret-123456789",
authCookies: getCookies(options),
logger: createLogger({
disabled: options.disableLog,
@@ -17,6 +26,7 @@ export const init = (options: BetterAuthOptions) => {
db,
adapter: adapter,
internalAdapter: createInternalAdapter(adapter, options),
createAuthCookie: createCookieGetter(options),
};
};
@@ -27,4 +37,6 @@ export type AuthContext = {
db: ReturnType<typeof createKyselyAdapter>;
adapter: ReturnType<typeof getAdapter>;
internalAdapter: ReturnType<typeof createInternalAdapter>;
createAuthCookie: ReturnType<typeof createCookieGetter>;
secret: string;
};

View File

@@ -1 +1,2 @@
export * from "./organization";
export * from "./two-factor";

View File

@@ -0,0 +1,157 @@
import { alphabet, generateRandomString } from "oslo/crypto";
import { TwoFactorProvider, UserWithTwoFactor } from "../types";
import { symmetricDecrypt, symmetricEncrypt } from "../../../crypto";
import { z } from "zod";
import { createAuthEndpoint } from "../../../api/call";
import { verifyTwoFactorMiddleware } from "../verify-middleware";
import { sessionMiddleware } from "../../../api/middlewares/session";
export interface BackupCodeOptions {
/**
* The amount of backup codes to generate
*
* @default 10
*/
amount?: number;
/**
* The length of the backup codes
*
* @default 10
*/
length?: number;
customBackupCodesGenerate?: () => string[];
}
function generateBackupCodesFn(options?: BackupCodeOptions) {
return Array.from({ length: options?.amount ?? 10 })
.fill(null)
.map(() =>
generateRandomString(options?.length ?? 10, alphabet("a-z", "0-9")),
)
.map((code) => `${code.slice(0, 5)}-${code.slice(5)}`);
}
export async function generateBackupCodes(
secret: string,
options?: BackupCodeOptions,
) {
const key = secret;
const backupCodes = options?.customBackupCodesGenerate
? options.customBackupCodesGenerate()
: generateBackupCodesFn();
const encCodes = symmetricEncrypt({
data: JSON.stringify(backupCodes),
key: key,
});
return {
backupCodes,
encryptedBackupCodes: encCodes,
};
}
export function verifyBackupCode(
data: {
user: UserWithTwoFactor;
code: string;
},
key: string,
) {
const codes = getBackupCodes(data.user, key);
if (!codes) {
return false;
}
return codes.includes(data.code);
}
export function getBackupCodes(user: UserWithTwoFactor, key: string) {
const secret = Buffer.from(
symmetricDecrypt({ key, data: user.twoFactorBackupCodes }),
).toString("utf-8");
const data = JSON.parse(secret);
const result = z.array(z.string()).safeParse(data);
if (result.success) {
return result.data;
}
return null;
}
export const backupCode2fa = (options?: BackupCodeOptions) => {
return {
id: "backup_code",
verify: createAuthEndpoint(
"/verify/backup-code",
{
method: "POST",
body: z.object({
code: z.string(),
}),
use: [verifyTwoFactorMiddleware],
},
async (ctx) => {
const validate = verifyBackupCode(
{
user: ctx.context.session.user,
code: ctx.body.code,
},
ctx.context.secret,
);
if (!validate) {
return ctx.json(
{ status: false },
{
status: 401,
},
);
}
return ctx.json({ status: true });
},
),
customActions: {
generateBackupCodes: createAuthEndpoint(
"/generate/backup-codes",
{
method: "POST",
use: [sessionMiddleware],
},
async (ctx) => {
const backupCodes = await generateBackupCodes(
ctx.context.secret,
options,
);
await ctx.context.adapter.update({
model: "user",
update: {
twoFactorEnabled: true,
twoFactorBackupCodes: backupCodes.encryptedBackupCodes,
},
where: [
{
field: "id",
value: ctx.context.session.user.id,
},
],
});
return ctx.json({
status: true,
backupCodes: backupCodes.backupCodes,
});
},
),
viewBackupCodes: createAuthEndpoint(
"/view/backup-codes",
{
method: "GET",
use: [sessionMiddleware],
},
async (ctx) => {
const user = ctx.context.session.user as UserWithTwoFactor;
const backupCodes = getBackupCodes(user, ctx.context.secret);
return ctx.json({
status: true,
backupCodes: backupCodes,
});
},
),
},
} satisfies TwoFactorProvider;
};

View File

@@ -0,0 +1,2 @@
export const TWO_FACTOR_COOKIE_NAME = "better-auth.two-factor";
export const OTP_RANDOM_NUMBER_COOKIE_NAME = "otp.counter";

View File

@@ -1,8 +1,168 @@
import { z } from "zod";
import { createAuthEndpoint } from "../../api/call";
import { Plugin } from "../../types/plugins";
import { totp2fa } from "./totp";
import { TwoFactorOptions, UserWithTwoFactor } from "./types";
import {
twoFactorMiddleware,
verifyTwoFactorMiddleware,
} from "./verify-middleware";
import { sessionMiddleware } from "../../api/middlewares/session";
import { alphabet, generateRandomString } from "oslo/crypto";
import { backupCode2fa, generateBackupCodes } from "./backup-codes";
import { otp2fa } from "./otp";
import { symmetricEncrypt } from "../../crypto";
export const multiFactor = () => {
export const twoFactor = <O extends TwoFactorOptions>(options: O) => {
const totp = totp2fa({
issuer: options.issuer,
...options.totpOptions,
});
const backupCode = backupCode2fa(options.backupCodeOptions);
const otp = otp2fa(options.otpOptions);
const providers = [totp, backupCode, otp];
return {
id: "multi-factor",
endpoints: {},
id: "two-factor",
endpoints: {
...totp.customActions,
...otp.customActions,
...backupCode.customActions,
enableTwoFactor: createAuthEndpoint(
"/enable/two-factor",
{
method: "POST",
use: [sessionMiddleware],
},
async (ctx) => {
const user = ctx.context.session.user as UserWithTwoFactor;
const secret = generateRandomString(16, alphabet("a-z", "0-9", "-"));
const encryptedSecret = symmetricEncrypt({
key: ctx.context.secret,
data: secret,
});
const backupCodes = await generateBackupCodes(
ctx.context.secret,
options.backupCodeOptions,
);
await ctx.context.adapter.update({
model: "user",
update: {
twoFactorSecret: encryptedSecret,
twoFactorEnabled: true,
twoFactorBackupCodes: backupCodes.encryptedBackupCodes,
},
where: [
{
field: "id",
value: user.id,
},
],
});
return ctx.json({ status: true });
},
),
disableTwoFactor: createAuthEndpoint(
"/disable/two-factor",
{
method: "POST",
use: [sessionMiddleware],
},
async (ctx) => {
const user = ctx.context.session.user as UserWithTwoFactor;
await ctx.context.adapter.update({
model: "user",
update: {
twoFactorEnabled: false,
},
where: [
{
field: "id",
value: user.id,
},
],
});
return ctx.json({ status: true });
},
),
verifyTwoFactor: createAuthEndpoint(
"/verify/two-factor",
{
method: "POST",
body: z.object({
/**
* The code to validate
*/
code: z.string(),
with: z.enum(["totp", "otp", "backup_code"]),
callbackURL: z.string().optional(),
}),
use: [verifyTwoFactorMiddleware],
},
async (ctx) => {
const providerId = ctx.body.with;
const provider = providers.find((p) => p.id === providerId);
if (!provider) {
return ctx.json(
{ status: false },
{
status: 401,
},
);
}
const res = await provider.verify(ctx);
if (!res.status) {
return ctx.json(
{ status: false },
{
status: 401,
},
);
}
await ctx.context.createSession();
if (ctx.body.callbackURL) {
return ctx.json({
status: true,
callbackURL: ctx.body.callbackURL,
redirect: true,
});
}
return ctx.json({ status: true });
},
),
},
options: options,
middlewares: [
{
path: "/sign-in/credential",
middleware: twoFactorMiddleware(options),
},
],
schema: {
user: {
fields: {
twoFactorEnabled: {
type: "boolean",
required: false,
defaultValue: false,
},
twoFactorSecret: {
type: "string",
required: false,
},
backupCodes: {
type: "string",
required: false,
returned: false,
},
/**
* list of two factor providers id separated by comma
*/
twoFactorProviders: {
type: "string",
required: false,
},
},
},
},
} satisfies Plugin;
};

View File

@@ -0,0 +1,119 @@
import { APIError } from "better-call";
import { createAuthEndpoint } from "../../../api/call";
import { sessionMiddleware } from "../../../api/middlewares/session";
import { TwoFactorProvider, UserWithTwoFactor } from "../types";
import { generateHOTP } from "oslo/otp";
import { generateRandomInteger } from "oslo/crypto";
import { OTP_RANDOM_NUMBER_COOKIE_NAME } from "../constant";
import { z } from "zod";
import { verifyTwoFactorMiddleware } from "../verify-middleware";
export interface OTPOptions {
/**
* How long the opt will be valid for
*
* @default "5 mins"
*/
period?: number;
sendOTP: (user: UserWithTwoFactor, otp: string) => Promise<void>;
}
/**
* The otp adapter is created from the totp adapter.
*/
export const otp2fa = (options?: OTPOptions) => {
/**
* Generate OTP and send it to the user.
*/
const generateOTP = createAuthEndpoint(
"/generate/otp",
{
method: "POST",
use: [sessionMiddleware],
},
async (ctx) => {
if (!options || !options.sendOTP) {
ctx.context.logger.error(
"otp isn't configured. please pass otp option on two factor plugin to enable otp",
);
throw new APIError("BAD_REQUEST", {
message: "otp isn't configured",
});
}
const randomNumber = generateRandomInteger(100000);
const otp = await generateHOTP(
Buffer.from(ctx.context.secret),
randomNumber,
);
await options.sendOTP(ctx.context.session.user as UserWithTwoFactor, otp);
const cookie = ctx.context.createAuthCookie(
OTP_RANDOM_NUMBER_COOKIE_NAME,
{
maxAge: options.period,
},
);
await ctx.setSignedCookie(
cookie.name,
randomNumber.toString(),
ctx.context.secret,
cookie.options,
);
return ctx.json({ status: true });
},
);
const verifyOTP = createAuthEndpoint(
"/verify/otp",
{
method: "POST",
body: z.object({
code: z.string(),
}),
use: [verifyTwoFactorMiddleware],
},
async (ctx) => {
const user = ctx.context.session.user;
if (!user.twoFactorEnabled) {
throw new APIError("BAD_REQUEST", {
message: "two factor isn't enabled",
});
}
const cookie = ctx.context.createAuthCookie(
OTP_RANDOM_NUMBER_COOKIE_NAME,
);
const randomNumber = await ctx.getSignedCookie(
cookie.name,
ctx.context.secret,
);
if (!randomNumber) {
throw new APIError("BAD_REQUEST", {
message: "counter cookie not found",
});
}
const toCheckOtp = await generateHOTP(
Buffer.from(ctx.context.secret),
parseInt(randomNumber),
);
if (toCheckOtp !== ctx.body.code) {
await ctx.context.createSession();
return ctx.json({ status: true });
} else {
return ctx.json(
{ status: false },
{
status: 401,
},
);
}
},
);
return {
id: "otp",
verify: verifyOTP,
customActions: {
generateOTP: generateOTP,
},
} satisfies TwoFactorProvider;
};

View File

@@ -0,0 +1,226 @@
import { TimeSpan } from "oslo";
import { alphabet, generateRandomString } from "oslo/crypto";
import { TOTPController, createTOTPKeyURI } from "oslo/otp";
import { z } from "zod";
import { createAuthEndpoint } from "../../../api/call";
import { sessionMiddleware } from "../../../api/middlewares/session";
import { APIError } from "better-call";
import { TwoFactorProvider, UserWithTwoFactor } from "../types";
import { verifyTwoFactorMiddleware } from "../verify-middleware";
import { BackupCodeOptions, generateBackupCodes } from "../backup-codes";
import { symmetricDecrypt } from "../../../crypto";
export type TOTPOptions = {
/**
* Issuer
*/
issuer: string;
/**
* How many digits the otp to be
*
* @default 6
*/
digits?: 6 | 8;
/**
* Period for otp in seconds.
* @default 30
*/
period?: number;
/**
* Backup codes configuration
*/
backupCodes?: BackupCodeOptions;
};
export const totp2fa = (options: TOTPOptions) => {
const opts = {
digits: 6,
period: new TimeSpan(options?.period || 30, "s"),
secret: {
field: "twoFactorSecret",
},
};
const enableTOTP = createAuthEndpoint(
"/enable/totp",
{
method: "POST",
use: [sessionMiddleware],
},
async (ctx) => {
if (!options) {
ctx.context.logger.error(
"totp isn't configured. please pass totp option on two factor plugin to enable totp",
);
throw new APIError("BAD_REQUEST", {
message: "totp isn't configured",
});
}
const secret = generateRandomString(16, alphabet("a-z", "0-9", "-"));
const user = ctx.context.session.user as UserWithTwoFactor;
const uri = createTOTPKeyURI(
options.issuer || "BetterAuth",
user.name,
Buffer.from(secret),
opts,
);
const backupCodes = await generateBackupCodes(
secret,
options.backupCodes,
);
await ctx.context.adapter.update({
model: "user",
update: {
twoFactorSecret: secret,
twoFactorEnabled: true,
backupCodes: backupCodes.encryptedBackupCodes,
},
where: [
{
field: "id",
value: user.id,
},
],
});
return ctx.json({ uri, backupCodes: backupCodes.backupCodes });
},
);
async function enable(user: UserWithTwoFactor) {
const secret = generateRandomString(16, alphabet("a-z", "0-9", "-"));
const uri = createTOTPKeyURI(
options.issuer,
user.name,
Buffer.from(secret),
opts,
);
const backupCodes = await generateBackupCodes(secret, options.backupCodes);
return {
uri,
backupCodes: backupCodes.backupCodes,
};
}
const disableTOTP = createAuthEndpoint(
"/disable/totp",
{
method: "POST",
use: [sessionMiddleware],
},
async (ctx) => {
if (!options) {
ctx.context.logger.error(
"totp isn't configured. please pass totp option on two factor plugin to enable totp",
);
throw new APIError("BAD_REQUEST", {
message: "totp isn't configured",
});
}
const user = ctx.context.session.user as UserWithTwoFactor;
await ctx.context.adapter.update({
model: "user",
update: {
twoFactorEnabled: false,
},
where: [
{
field: "id",
value: user.id,
},
],
});
return ctx.json({ status: true });
},
);
const generateTOTP = createAuthEndpoint(
"/generate/totp",
{
method: "POST",
use: [sessionMiddleware],
},
async (ctx) => {
if (!options) {
ctx.context.logger.error(
"totp isn't configured. please pass totp option on two factor plugin to enable totp",
);
throw new APIError("BAD_REQUEST", {
message: "totp isn't configured",
});
}
const session = ctx.context.session;
const totp = new TOTPController(opts);
const secret = (session.user as any).secret;
const code = await totp.generate(secret);
return { code };
},
);
const getTOTPURI = createAuthEndpoint(
"/get/totp/uri",
{
method: "GET",
use: [sessionMiddleware],
},
async (ctx) => {
if (!options) {
ctx.context.logger.error(
"totp isn't configured. please pass totp option on two factor plugin to enable totp",
);
throw new APIError("BAD_REQUEST", {
message: "totp isn't configured",
});
}
const user = ctx.context.session.user as UserWithTwoFactor;
return {
totpURI: createTOTPKeyURI(
options?.issuer || "BetterAuth",
user.name,
Buffer.from(user.twoFactorSecret),
opts,
),
};
},
);
const verifyTOTP = createAuthEndpoint(
"/verify/totp",
{
method: "POST",
body: z.object({
code: z.string(),
callbackURL: z.string().optional(),
}),
use: [verifyTwoFactorMiddleware],
},
async (ctx) => {
if (!options) {
ctx.context.logger.error(
"totp isn't configured. please pass totp option on two factor plugin to enable totp",
);
throw new APIError("BAD_REQUEST", {
message: "totp isn't configured",
});
}
const totp = new TOTPController(opts);
const secret = Buffer.from(
symmetricDecrypt({
key: ctx.context.secret,
data: ctx.context.session.user.twoFactorSecret,
}),
);
const status = await totp.verify(ctx.body.code, secret);
return {
status,
};
},
);
return {
id: "totp",
verify: verifyTOTP,
customActions: {
generateTOTP: generateTOTP,
viewTOTPURI: getTOTPURI,
},
} satisfies TwoFactorProvider;
};

View File

@@ -0,0 +1,56 @@
import { ZodObject, ZodSchema } from "zod";
import { User } from "../../adapters/schema";
import { AuthEndpoint } from "../../api/call";
import { LiteralString } from "../../types/helper";
import { BackupCodeOptions } from "./backup-codes";
import { TOTPOptions } from "./totp";
import { Endpoint } from "better-call";
import { OTPOptions } from "./otp";
export interface TwoFactorOptions {
issuer: string;
totpOptions?: Omit<TOTPOptions, "issuer">;
otpOptions?: OTPOptions;
backupCodeOptions?: BackupCodeOptions;
requireOn?: {
signIn: () => boolean;
};
/**
* The url to redirect to after the user has
* signed in to validate the two factor. If not
* provided, the callbackURL will be used. If
* callbackURL is not provided, the user will be
* redirected to the root path.
*
* @default "/"
*/
twoFactorURL?: string;
}
export interface UserWithTwoFactor extends User {
/**
* If the user has enabled two factor authentication.
*/
twoFactorEnabled: boolean;
/**
* The secret used to generate the TOTP or OTP.
*/
twoFactorSecret: string;
/**
* List of backup codes separated by a
* comma
*/
twoFactorBackupCodes: string;
}
export interface TwoFactorProvider {
id: LiteralString;
enable?: (user: UserWithTwoFactor) => Promise<void>;
disable?: () => Promise<void>;
verify: Endpoint<
(ctx: any) => Promise<{
status: boolean;
}>
>;
customActions?: Record<string, AuthEndpoint>;
}

View File

@@ -0,0 +1,154 @@
import { APIError } from "better-call";
import { Session } from "../../adapters/schema";
import { createAuthMiddleware } from "../../api/call";
import { hs256 } from "../../crypto";
import { TWO_FACTOR_COOKIE_NAME } from "./constant";
import { TwoFactorOptions, UserWithTwoFactor } from "./types";
import { z } from "zod";
export const verifyTwoFactorMiddleware = createAuthMiddleware(async (ctx) => {
const cookie = await ctx.getSignedCookie(
TWO_FACTOR_COOKIE_NAME,
ctx.context.secret,
);
if (!cookie) {
throw new APIError("UNAUTHORIZED", {
message: "two factor isn't enabled",
});
}
const [userId, hash] = cookie.split("!");
if (!userId || !hash) {
throw new APIError("UNAUTHORIZED", {
message: "invalid two factor cookie",
});
}
const sessions = await ctx.context.adapter.findMany<Session>({
model: "session",
where: [
{
field: "userId",
value: userId,
},
],
});
if (!sessions.length) {
throw new APIError("UNAUTHORIZED", {
message: "invalid session",
});
}
const activeSessions = sessions.filter(
(session) => session.expiresAt > new Date(),
);
if (!activeSessions) {
throw new APIError("UNAUTHORIZED", {
message: "invalid session",
});
}
for (const session of activeSessions) {
const hashToMatch = await hs256(ctx.context.secret, session.id);
const user = await ctx.context.adapter.findOne<UserWithTwoFactor>({
model: "user",
where: [
{
field: "id",
value: session.userId,
},
],
});
if (!user) {
throw new APIError("UNAUTHORIZED", {
message: "invalid session",
});
}
if (hashToMatch === hash) {
return {
createSession: async () => {
/**
* Set the session cookie
*/
await ctx.setSignedCookie(
ctx.context.authCookies.sessionToken.name,
session.id,
ctx.context.secret,
ctx.context.authCookies.sessionToken.options,
);
},
session: {
id: session.id,
userId: session.userId,
expiresAt: session.expiresAt,
user,
},
};
}
}
throw new APIError("UNAUTHORIZED", {
message: "invalid two factor authentication",
});
});
export const twoFactorMiddleware = (options: TwoFactorOptions) =>
createAuthMiddleware(
{
body: z.object({
email: z.string().email(),
password: z.string(),
/**
* Callback URL to
* redirect to after
* the user has signed in.
*/
callbackURL: z.string().optional(),
}),
},
async (ctx) => {
//@ts-ignore
const signIn = await signInCredential({
...ctx,
body: ctx.body,
});
if (!signIn?.user) {
return new Response(null, {
status: 401,
});
}
const user = signIn.user as UserWithTwoFactor;
if (!user.twoFactorEnabled) {
return new Response(JSON.stringify(signIn), {
headers: ctx.responseHeader,
});
}
/**
* remove the session cookie. It's set by the sign in credential
*/
ctx.setCookie(ctx.context.authCookies.sessionToken.name, "", {
path: "/",
sameSite: "lax",
httpOnly: true,
secure: false,
maxAge: 0,
});
const hash = await hs256(ctx.context.secret, signIn.session.id);
/**
* We set the user id and the session
* id as a hash. Later will fetch for
* sessions with the user id compare
* the hash and set that as session.
*/
await ctx.setSignedCookie(
"better-auth.two-factor",
`${signIn.session.userId}!${hash}`,
ctx.context.secret,
ctx.context.authCookies.sessionToken.options,
);
return new Response(
JSON.stringify({
url: options.twoFactorURL || ctx.body.callbackURL || "/",
redirect: true,
}),
{
headers: ctx.responseHeader,
},
);
},
);

View File

@@ -130,7 +130,7 @@ export const passkey = (options: PasskeyOptions) => {
await ctx.setSignedCookie(
opts.advanced.webAuthnChallengeCookie,
JSON.stringify(data),
ctx.context.options.secret,
ctx.context.secret,
{
secure: true,
httpOnly: true,
@@ -189,7 +189,7 @@ export const passkey = (options: PasskeyOptions) => {
await ctx.setSignedCookie(
opts.advanced.webAuthnChallengeCookie,
JSON.stringify(data),
ctx.context.options.secret,
ctx.context.secret,
{
secure: true,
httpOnly: true,
@@ -224,7 +224,7 @@ export const passkey = (options: PasskeyOptions) => {
const challengeString = await ctx.getSignedCookie(
opts.advanced.webAuthnChallengeCookie,
ctx.context.options.secret,
ctx.context.secret,
);
if (!challengeString) {
return ctx.json(null, {
@@ -329,7 +329,7 @@ export const passkey = (options: PasskeyOptions) => {
await ctx.setSignedCookie(
ctx.context.authCookies.sessionToken.name,
s.id,
ctx.context.options.secret,
ctx.context.secret,
ctx.context.authCookies.sessionToken.options,
);
const user = await ctx.context.internalAdapter.findUserById(

View File

@@ -25,12 +25,29 @@ export interface BetterAuthOptions {
*/
basePath?: string;
/**
* The secret used to sign the session token. This is required for the session to work.
* to generate a good secret you can use the following command:
* The secret to use for encryption,
* signing and hashing.
*
* @example openssl rand -base64 32
* By default better auth will look for
* the following environment variables:
* process.env.BETTER_AUTH_SECRET,
* process.env.AUTH_SECRET
* If none of these environment
* variables are set,
* it will default to
* "better-auth-secret-123456789".
*
* on production if it's not set
* it will throw an error.
*
* you can generate a good secret
* using the following command:
* @example
* ```bash
* openssl rand -base64 32
* ```
*/
secret: string;
secret?: string;
/**
* list of oauth providers
*/
@@ -129,5 +146,11 @@ export interface BetterAuthOptions {
* @default 32
*/
minPasswordLength?: number;
/**
* Two factor configuration
*/
twoFactor?: {
enabled: boolean;
};
};
}

View File

@@ -1,7 +1,8 @@
import { Migration } from "kysely";
import { AuthEndpoint } from "../api/call";
import { AuthEndpoint, AuthMiddleware } from "../api/call";
import { FieldAttribute } from "../db/field";
import { LiteralString } from "./helper";
import { Endpoint } from "better-call";
export type PluginSchema = {
[table: string]: {
@@ -17,6 +18,10 @@ export type Plugin = {
endpoints: {
[key: string]: AuthEndpoint;
};
middlewares?: {
path: string;
middleware: Endpoint;
}[];
/**
* Schema the plugin needs
*
@@ -53,4 +58,8 @@ export type Plugin = {
* the tables.
*/
migrations?: Record<string, Migration>;
/**
* The options of the plugin
*/
options?: Record<string, any>;
};

View File

@@ -9,6 +9,7 @@ import { User } from "../adapters/schema";
import { FieldAttribute } from "../db";
import { Migration } from "kysely";
import { AuthEndpoint } from "../api/call";
import { Context, Endpoint } from "better-call";
export interface BaseProvider {
id: LiteralString;
@@ -43,6 +44,17 @@ export interface OAuthProvider extends BaseProvider {
userInfo: OAuthUserInfo;
}
export interface TwoFactorProvider extends BaseProvider {
type: "two-factor";
endpoints: {
[key: string]: AuthEndpoint;
};
verify?: (ctx: any) => Promise<{
status: boolean;
}>;
isEnabled: (user: User) => Promise<boolean>;
}
export interface CustomProvider extends BaseProvider {
type: "custom";
endpoints: {
@@ -50,6 +62,6 @@ export interface CustomProvider extends BaseProvider {
};
}
export type Provider = OAuthProvider | CustomProvider;
export type Provider = OAuthProvider | CustomProvider | TwoFactorProvider;
export type OAuthProviderList = typeof oAuthProviderList;

View File

@@ -3,7 +3,9 @@ import { BetterAuthOptions } from "../types/options";
import { TimeSpan } from "oslo";
export function getCookies(options: BetterAuthOptions) {
const secure = !!options.advanced?.useSecureCookies;
const secure =
!!options.advanced?.useSecureCookies ||
process.env.NODE_ENV === "production";
const secureCookiePrefix = secure ? "__Secure-" : "";
const cookiePrefix = "better-auth";
const sessionMaxAge = new TimeSpan(7, "d").seconds();
@@ -61,4 +63,27 @@ export function getCookies(options: BetterAuthOptions) {
};
}
export function createCookieGetter(options: BetterAuthOptions) {
const secure =
!!options.advanced?.useSecureCookies ||
process.env.NODE_ENV === "production";
const secureCookiePrefix = secure ? "__Secure-" : "";
const cookiePrefix = "better-auth";
function getCookie(cookieName: string, options?: CookieOptions) {
return {
name:
process.env.NODE_ENV === "production"
? `${secureCookiePrefix}${cookiePrefix}.${cookieName}`
: `${cookiePrefix}${cookieName}`,
options: {
secure,
sameSite: "lax",
path: "/",
maxAge: 60 * 15, // 15 minutes in seconds
...options,
} as CookieOptions,
};
}
return getCookie;
}
export type BetterAuthCookies = ReturnType<typeof getCookies>;

49
pnpm-lock.yaml generated
View File

@@ -51,7 +51,7 @@ importers:
devDependencies:
'@types/bun':
specifier: latest
version: 1.1.6
version: 1.1.7
vite:
specifier: ^5.3.5
version: 5.3.5(@types/node@20.14.12)
@@ -109,6 +109,9 @@ importers:
input-otp:
specifier: ^1.2.4
version: 1.2.4(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
lucide-react:
specifier: ^0.435.0
version: 0.435.0(react@18.3.1)
next:
specifier: 14.2.5
version: 14.2.5(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
@@ -176,6 +179,12 @@ importers:
'@nanostores/vue':
specifier: ^0.10.0
version: 0.10.0(nanostores@0.11.2)(vue@3.4.38(typescript@5.5.4))
'@noble/ciphers':
specifier: ^0.6.0
version: 0.6.0
'@noble/hashes':
specifier: ^1.4.0
version: 1.4.0
'@paralleldrive/cuid2':
specifier: ^2.2.2
version: 2.2.2
@@ -189,8 +198,8 @@ importers:
specifier: ^1.9.2
version: 1.9.2
better-call:
specifier: ^0.1.20
version: 0.1.20(typescript@5.5.4)
specifier: ^0.1.28
version: 0.1.28(typescript@5.5.4)
chalk:
specifier: ^5.3.0
version: 5.3.0
@@ -805,6 +814,9 @@ packages:
cpu: [x64]
os: [win32]
'@noble/ciphers@0.6.0':
resolution: {integrity: sha512-mIbq/R9QXk5/cTfESb1OKtyFnk7oc1Om/8onA1158K9/OZUQFDEVy55jVTato+xmp3XX6F6Qh0zz0Nc1AxAlRQ==}
'@noble/hashes@1.4.0':
resolution: {integrity: sha512-V1JJ1WTRUqHHrOSh597hURcMqVKVGL/ea3kv0gSnEdsEZ0/+VyPghM1lMNGc00z7CIQorSvbKpuJkxvuHbvdbg==}
engines: {node: '>= 16'}
@@ -1569,8 +1581,8 @@ packages:
'@types/better-sqlite3@7.6.11':
resolution: {integrity: sha512-i8KcD3PgGtGBLl3+mMYA8PdKkButvPyARxA7IQAd6qeslht13qxb1zzO8dRCtE7U3IoJS782zDBAeoKiM695kg==}
'@types/bun@1.1.6':
resolution: {integrity: sha512-uJgKjTdX0GkWEHZzQzFsJkWp5+43ZS7HC8sZPFnOwnSo1AsNl2q9o2bFeS23disNDqbggEgyFkKCHl/w8iZsMA==}
'@types/bun@1.1.7':
resolution: {integrity: sha512-iIIn26SOX8qI5E8Juh+0rUgBmFHvll1akscwerhp9O/fHZGdQBWNLJkkRg/3z2Mh6a3ZgWUIkXViLZZYg47TXw==}
'@types/estree@1.0.5':
resolution: {integrity: sha512-/kYRxGDLWzHOB7q+wtSUQlFrtcdUccpfy+X+9iMBpHK8QLLhx2wIPYuS5DYtR9Wa/YlZAbIovy7qVdB1Aq6Lyw==}
@@ -1721,8 +1733,8 @@ packages:
peerDependencies:
typescript: ^5.0.0
better-call@0.1.20:
resolution: {integrity: sha512-IEw2rU5ViT/yaZKY51tEl6QGeB3JTUhpAQKoTKbkNYqweFh9xmfNFYOja7v25G+OqrV6j6GPYyD5FqUBqtTHlg==}
better-call@0.1.28:
resolution: {integrity: sha512-9jl3q7Mb+3lRGeJUqkHAzAHph8BKcGHsj84h1gkfcMy2skyrCUbpFuhxMNAKBDtg2ppa66wTWDv0mGLOshxtEA==}
peerDependencies:
typescript: ^5.0.0
@@ -1757,8 +1769,8 @@ packages:
bun-html-live-reload@0.1.3:
resolution: {integrity: sha512-PW1sp9ZmBAqiAa0aUhHpFc6sEQmC6FgRNKVAvcjSDUMqASzgq7xYpNkEt2Z6VjuiPXKtOx/49b6sLLmjyojrOw==}
bun-types@1.1.17:
resolution: {integrity: sha512-Z4+OplcSd/YZq7ZsrfD00DKJeCwuNY96a1IDJyR73+cTBaFIS7SC6LhpY/W3AMEXO9iYq5NJ58WAwnwL1p5vKg==}
bun-types@1.1.25:
resolution: {integrity: sha512-WpRb8/N3S5IE8UYdIn39+0Is1XzxsC78+MCe5cIdaer0lfFs6+DREtQH9TM6KJNKTxBYDvbx81RwbvxS5+CkVQ==}
bundle-require@5.0.0:
resolution: {integrity: sha512-GuziW3fSSmopcx4KRymQEJVbZUfqlCqcq7dvs6TYwKRZiegK/2buMxQTPs6MGlNv50wms1699qYO54R8XfRX4w==}
@@ -2297,6 +2309,11 @@ packages:
resolution: {integrity: sha512-MhWWlVnuab1RG5/zMRRcVGXZLCXrZTgfwMikgzCegsPnG62yDQo5JnqKkrK4jO5iKqDAZGItAqN5CtKBCBWRUA==}
engines: {node: '>=16.14'}
lucide-react@0.435.0:
resolution: {integrity: sha512-we5GKfzjMDw9m9SsyZJvWim9qaT+Ya5kaRS+OGFqgLqXUrPM1h+7CiMw5pKdEIoaBqfXz2pyv9TASAdpIAJs0Q==}
peerDependencies:
react: ^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0-rc
magic-string@0.30.10:
resolution: {integrity: sha512-iIRwTIf0QKV3UAnYK4PU8uiEc4SRh5jX0mwpIwETPpHdhVM4f53RSwS/vXvN1JhGX+Cs7B8qIq3d6AH49O5fAQ==}
@@ -3613,6 +3630,8 @@ snapshots:
'@next/swc-win32-x64-msvc@14.2.5':
optional: true
'@noble/ciphers@0.6.0': {}
'@noble/hashes@1.4.0': {}
'@node-rs/argon2-android-arm-eabi@1.7.0':
@@ -4282,9 +4301,9 @@ snapshots:
dependencies:
'@types/node': 20.14.12
'@types/bun@1.1.6':
'@types/bun@1.1.7':
dependencies:
bun-types: 1.1.17
bun-types: 1.1.25
'@types/estree@1.0.5': {}
@@ -4468,7 +4487,7 @@ snapshots:
rou3: 0.5.1
typescript: 5.5.4
better-call@0.1.20(typescript@5.5.4):
better-call@0.1.28(typescript@5.5.4):
dependencies:
'@better-fetch/fetch': 1.1.4
'@types/set-cookie-parser': 2.4.10
@@ -4522,7 +4541,7 @@ snapshots:
bun-html-live-reload@0.1.3: {}
bun-types@1.1.17:
bun-types@1.1.25:
dependencies:
'@types/node': 20.12.14
'@types/ws': 8.5.11
@@ -5065,6 +5084,10 @@ snapshots:
lru-cache@8.0.5: {}
lucide-react@0.435.0(react@18.3.1):
dependencies:
react: 18.3.1
magic-string@0.30.10:
dependencies:
'@jridgewell/sourcemap-codec': 1.5.0