feat: changes in api

This commit is contained in:
Bereket Engida
2024-08-31 20:53:56 +03:00
parent 3edc37c589
commit be75efaf47
21 changed files with 249 additions and 140 deletions

Binary file not shown.

View File

@@ -21,6 +21,9 @@ function App() {
<p>
{session.user.name}
</p>
<p>
{session.user.username}
</p>
<p>
{session.user.email}
</p>
@@ -58,6 +61,7 @@ function App() {
}}>
Continue with github
</button>
<SignIn />
<SignUp />
</div>
)
@@ -73,6 +77,7 @@ export default App;
function SignUp() {
const [email, setEmail] = useState("")
const [name, setName] = useState("")
const [username, setUsername] = useState("")
const [password, setPassword] = useState("")
return (
<div style={{
@@ -96,6 +101,12 @@ function SignUp() {
value={name}
onChange={(e) => setName(e.target.value)}
/>
<input type="text" id="username" placeholder="username" style={{
width: "100%"
}}
value={username}
onChange={(e) => setUsername(e.target.value)}
/>
<input type="password" id="password" placeholder="Password" style={{
width: "100%"
}}
@@ -103,15 +114,66 @@ function SignUp() {
onChange={(e) => setPassword(e.target.value)}
/>
<button onClick={async () => {
const res = await auth.signUp.credential({
await auth.signUp.username({
email,
password,
name
name,
username
})
console.log(res)
}}>
Sign Up
</button>
</div>
)
}
function SignIn() {
const [email, setEmail] = useState("")
const [password, setPassword] = useState("")
return (
<div style={{
display: "flex",
flexDirection: "column",
gap: "10px",
borderRadius: "10px",
border: "1px solid #4B453F",
padding: "20px",
marginTop: "10px"
}}>
<input type="email" id="email" placeholder="Email" style={{
width: "100%",
}}
value={email}
onChange={(e) => setEmail(e.target.value)}
/>
<input type="password" id="password" placeholder="Password" style={{
width: "100%"
}}
value={password}
onChange={(e) => setPassword(e.target.value)}
/>
<button onClick={async () => {
await auth.signIn.username({
username: email,
password,
options: {
onSuccess(context) {
console.log({
context
})
if (context.data.twoFactorRedirect) {
alert("two factor required")
}
},
}
})
}}>
Sign In
</button>
</div>
)
}

View File

@@ -1,7 +1,12 @@
import { createAuthClient } from "better-auth/react";
import { twoFactorClient } from "better-auth/client";
import { twoFactorClient, usernameClient } from "better-auth/client";
export const auth = createAuthClient({
baseURL: "http://localhost:3000/api/auth",
authPlugins: [twoFactorClient],
authPlugins: [
twoFactorClient({
twoFactorPage: "/two-factor",
}),
usernameClient,
],
});

View File

@@ -10,14 +10,14 @@ import {
getSession,
resetPassword,
sendVerificationEmail,
signInCredential,
signInEmail,
signInOAuth,
signOut,
verifyEmail,
} from "./routes";
import { getCSRFToken } from "./routes/csrf";
import { ok, welcome } from "./routes/ok";
import { signUpCredential } from "./routes/sign-up";
import { signUpEmail } from "./routes/sign-up";
import { error } from "./routes/error";
import type { z, ZodAny, ZodObject, ZodOptional, ZodString } from "zod";
@@ -85,8 +85,8 @@ export const router = <C extends AuthContext, Option extends BetterAuthOptions>(
getCSRFToken,
getSession: typedSession,
signOut,
signUpCredential,
signInCredential,
signUpEmail,
signInEmail,
forgetPassword,
resetPassword,
verifyEmail,
@@ -136,10 +136,10 @@ export const router = <C extends AuthContext, Option extends BetterAuthOptions>(
for (const hook of plugin.hooks.after) {
const match = hook.matcher(context);
if (match) {
const hookRes = await hook.handler({
...context,
const obj = Object.assign(context, {
returned: endpointRes,
});
const hookRes = await hook.handler(obj);
if (hookRes && "response" in hookRes) {
response = hookRes.response as any;
}

View File

@@ -39,7 +39,6 @@ export const signInOAuth = createAuthEndpoint(
if (!provider) {
throw new APIError("NOT_FOUND");
}
const cookie = c.context.authCookies;
const currentURL = c.query?.currentURL
? new URL(c.query?.currentURL)
@@ -78,8 +77,8 @@ export const signInOAuth = createAuthEndpoint(
},
);
export const signInCredential = createAuthEndpoint(
"/sign-in/email-password",
export const signInEmail = createAuthEndpoint(
"/sign-in/email",
{
method: "POST",
body: z.object({

View File

@@ -3,8 +3,8 @@ import { Argon2id } from "oslo/password";
import { z } from "zod";
import { createAuthEndpoint } from "../call";
export const signUpCredential = createAuthEndpoint(
"/sign-up/credential",
export const signUpEmail = createAuthEndpoint(
"/sign-up/email",
{
method: "POST",
body: z.object({

View File

@@ -28,15 +28,25 @@ export const createAuthClient = <O extends ClientOptions = ClientOptions>(
: {}) &
Auth["api"]
: Auth["api"];
/**
* used for plugins only
*/
const $baseFetch = createFetch({
...options,
baseURL: getBaseURL(options?.baseURL).withPath,
});
const $fetch = createFetch({
method: "GET",
...options,
baseURL: getBaseURL(options?.baseURL).withPath,
plugins: [
...(options?.plugins || []),
...(options?.authPlugins
?.flatMap((plugin) => plugin($baseFetch).fetchPlugins)
.filter((plugin) => plugin !== undefined) || []),
...(options?.csrfPlugin !== false ? [csrfPlugin] : []),
redirectPlugin,
addCurrentURL,
...(options?.csrfPlugin !== false ? [csrfPlugin] : []),
...(options?.plugins || []),
],
});
@@ -121,11 +131,10 @@ export const createAuthClient = <O extends ClientOptions = ClientOptions>(
atom: "$activeOrgSignal",
},
{
matcher: (path) => path === "/sign-out",
atom: "$sessionSignal",
},
{
matcher: (path) => path.startsWith("/sign-up"),
matcher: (path) =>
path === "/sign-out" ||
path.startsWith("/sign-up") ||
path.startsWith("/sign-in"),
atom: "$sessionSignal",
},
...pluginProxySignals,

View File

@@ -30,7 +30,13 @@ describe("client path to object", async () => {
client.$atoms.$session;
const client2 = createReactClient({
authPlugins: [organization, twoFactorClient, usernameClient],
authPlugins: [
organization,
twoFactorClient({
twoFactorPage: "/two-factor",
}),
usernameClient,
],
});
});
});

View File

@@ -1,4 +1,4 @@
import type { BetterFetch } from "@better-fetch/fetch";
import type { BetterFetch, BetterFetchPlugin } from "@better-fetch/fetch";
import type { Endpoint } from "better-call";
import type { AuthProxySignal } from "./proxy";
import type { Atom, PreinitializedWritableAtom } from "nanostores";
@@ -25,6 +25,7 @@ export const createClientPlugin = <E extends BetterAuthPlugin = never>() => {
atoms?: Record<string, Atom<any>>;
integrations?: Integrations;
pathMethods?: Record<string, "POST" | "GET">;
fetchPlugins?: BetterFetchPlugin[];
},
) => {
return ($fetch: BetterFetch) => {

View File

@@ -7,7 +7,6 @@ export const redirectPlugin = {
hooks: {
onSuccess(context) {
if (context.data?.url && context.data?.redirect) {
console.log("redirecting to", context.data.url);
window.location.href = context.data.url;
}
},

View File

@@ -64,12 +64,18 @@ export function createDynamicPathProxy<T extends Record<string, any>>(
body: method === "GET" ? undefined : body,
query: query,
method,
onSuccess() {
async onSuccess(context) {
const signal = $signal?.find((s) => s.matcher(routePath));
if (!signal) return;
const signalAtom = $signals?.[signal.atom];
if (!signalAtom) return;
signalAtom.set(!signalAtom.get());
/**
* call if options.onSuccess
* is passed since we are
* overriding onSuccess
*/
await options?.onSuccess?.(context);
},
});
},

View File

@@ -1,4 +1,8 @@
import type { BetterFetch, BetterFetchOption } from "@better-fetch/fetch";
import type {
BetterFetch,
BetterFetchOption,
BetterFetchPlugin,
} from "@better-fetch/fetch";
import type { Auth } from "../auth";
import type { UnionToIntersection } from "../types/helper";
import type { useAuthStore as reactStore } from "./react";
@@ -30,6 +34,7 @@ export type AuthPlugin = ($fetch: BetterFetch) => {
preact?: (useStore: typeof preactStore) => Record<string, any>;
};
pathMethods?: Record<string, "POST" | "GET">;
fetchPlugins?: BetterFetchPlugin[];
};
export interface ClientOptions extends BetterFetchOption {
/**

View File

@@ -19,7 +19,7 @@ export const bearer = () => {
?.startsWith("Bearer ") || false
);
},
handler: createAuthMiddleware(async (ctx) => {
handler: async (ctx) => {
const token = ctx.request?.headers
.get("authorization")
?.replace("Bearer ", "");
@@ -38,7 +38,7 @@ export const bearer = () => {
ctx.context.authCookies.sessionToken.name
}=${signedToken.replace("=", "")}`,
);
}),
},
},
],
},

View File

@@ -3,7 +3,7 @@ import { z } from "zod";
import { createAuthEndpoint } from "../../../api/call";
import { sessionMiddleware } from "../../../api/middlewares/session";
import { symmetricDecrypt, symmetricEncrypt } from "../../../crypto";
import { verifyTwoFactorMiddleware } from "../two-fa-middleware";
import { verifyTwoFactorMiddleware } from "../verify-middleware";
import type { TwoFactorProvider, UserWithTwoFactor } from "../types";
export interface BackupCodeOptions {

View File

@@ -1,8 +1,21 @@
import { createClientPlugin } from "../../client/create-client-plugin";
import type { twoFactor as twoFa } from "../../plugins/two-factor";
export const twoFactorClient = createClientPlugin<ReturnType<typeof twoFa>>()(
($fetch) => {
export const twoFactorClient = (
options: {
twoFactorPage: string;
/**
* Redirect to the two factor page. If twoFactorPage
* is not set this will redirect to the root path.
* @default true
*/
redirect?: boolean;
} = {
redirect: true,
twoFactorPage: "/",
},
) => {
return createClientPlugin<ReturnType<typeof twoFa>>()(($fetch) => {
return {
id: "two-factor",
authProxySignal: [
@@ -18,6 +31,21 @@ export const twoFactorClient = createClientPlugin<ReturnType<typeof twoFa>>()(
"/two-factor/enable": "POST",
"/two-factor/send-otp": "POST",
},
fetchPlugins: [
{
id: "two-factor",
name: "two-factor",
hooks: {
async onSuccess(context) {
if (context.data?.twoFactorRedirect) {
if (options.redirect) {
window.location.href = options.twoFactorPage;
}
}
},
},
},
],
};
},
);
});
};

View File

@@ -1,17 +1,15 @@
import { alphabet, generateRandomString } from "oslo/crypto";
import { z } from "zod";
import { createAuthEndpoint } from "../../api/call";
import { createAuthEndpoint, createAuthMiddleware } from "../../api/call";
import { sessionMiddleware } from "../../api/middlewares/session";
import { symmetricEncrypt } from "../../crypto";
import { hs256, symmetricEncrypt } from "../../crypto";
import type { BetterAuthPlugin } from "../../types/plugins";
import { backupCode2fa, generateBackupCodes } from "./backup-codes";
import { otp2fa } from "./otp";
import { totp2fa } from "./totp";
import {
twoFactorMiddleware,
verifyTwoFactorMiddleware,
} from "./two-fa-middleware";
import type { TwoFactorOptions, UserWithTwoFactor } from "./types";
import type { Session } from "../../adapters/schema";
export const twoFactor = <O extends TwoFactorOptions>(options: O) => {
const totp = totp2fa({
@@ -86,12 +84,65 @@ export const twoFactor = <O extends TwoFactorOptions>(options: O) => {
),
},
options: options,
middlewares: [
{
path: "/sign-in/credential",
middleware: twoFactorMiddleware(options),
},
],
hooks: {
after: [
{
matcher(context) {
return (
context.path === "/sign-in/email" ||
context.path === "/sign-in/username"
);
},
handler: createAuthMiddleware(async (ctx) => {
const returned = (await (ctx as any).returned) as Response;
if (returned?.status !== 200) {
return;
}
const response = (await returned.json()) as {
user: UserWithTwoFactor;
session: Session;
};
if (!response.user.twoFactorEnabled) {
return;
}
/**
* 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, response.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",
`${response.session.userId}!${hash}`,
ctx.context.secret,
ctx.context.authCookies.sessionToken.options,
);
const res = new Response(
JSON.stringify({
twoFactorRedirect: true,
}),
{
headers: ctx.responseHeader,
},
);
return {
response: res,
};
}),
},
],
},
schema: {
user: {
fields: {

View File

@@ -3,9 +3,8 @@ import { generateRandomInteger } from "oslo/crypto";
import { generateHOTP } from "oslo/otp";
import { z } from "zod";
import { createAuthEndpoint } from "../../../api/call";
import { sessionMiddleware } from "../../../api/middlewares/session";
import { OTP_RANDOM_NUMBER_COOKIE_NAME } from "../constant";
import { verifyTwoFactorMiddleware } from "../two-fa-middleware";
import { verifyTwoFactorMiddleware } from "../verify-middleware";
import type { TwoFactorProvider, UserWithTwoFactor } from "../types";
export interface OTPOptions {

View File

@@ -6,7 +6,7 @@ import { createAuthEndpoint } from "../../../api/call";
import { sessionMiddleware } from "../../../api/middlewares/session";
import { symmetricDecrypt } from "../../../crypto";
import type { BackupCodeOptions } from "../backup-codes";
import { verifyTwoFactorMiddleware } from "../two-fa-middleware";
import { verifyTwoFactorMiddleware } from "../verify-middleware";
import type { TwoFactorProvider, UserWithTwoFactor } from "../types";
export type TOTPOptions = {

View File

@@ -1,11 +1,9 @@
import { APIError } from "better-call";
import { z } from "zod";
import type { Session } from "../../adapters/schema";
import { createAuthMiddleware } from "../../api/call";
import { signInCredential } from "../../api/routes";
import { hs256 } from "../../crypto";
import { TWO_FACTOR_COOKIE_NAME } from "./constant";
import type { TwoFactorOptions, UserWithTwoFactor } from "./types";
import type { UserWithTwoFactor } from "./types";
export const verifyTwoFactorMiddleware = createAuthMiddleware(async (ctx) => {
const cookie = await ctx.getSignedCookie(
@@ -107,69 +105,3 @@ export const verifyTwoFactorMiddleware = createAuthMiddleware(async (ctx) => {
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

@@ -4,14 +4,14 @@ import type { BetterAuthPlugin } from "../../types/plugins";
import { Argon2id } from "oslo/password";
import { APIError } from "better-call";
import type { Account, User } from "../../adapters/schema";
import { signUpCredential } from "../../api/routes/sign-up";
import { signUpEmail } from "../../api/routes/sign-up";
export const username = () => {
return {
id: "username",
endpoints: {
signInUsername: createAuthEndpoint(
"/sign-in/username-password",
"/sign-in/username",
{
method: "POST",
body: z.object({
@@ -102,7 +102,7 @@ export const username = () => {
{
method: "POST",
body: z.object({
username: z.string().min(3).max(20).optional(),
username: z.string().min(3).max(20),
name: z.string(),
email: z.string().email(),
password: z.string(),
@@ -111,7 +111,11 @@ export const username = () => {
}),
},
async (ctx) => {
const res = await signUpCredential(ctx);
const res = await signUpEmail({
...ctx,
//@ts-expect-error
_flag: undefined,
});
if (!res) {
return ctx.json(null, {
status: 400,
@@ -121,9 +125,14 @@ export const username = () => {
},
});
}
await ctx.context.internalAdapter.updateUserByEmail(res.user.email, {
username: ctx.body.username,
});
const updatedUser =
await ctx.context.internalAdapter.updateUserByEmail(
res.user.email,
{
username: ctx.body.username,
},
);
console.log(updatedUser);
return ctx.json(res);
},
),
@@ -135,6 +144,8 @@ export const username = () => {
username: {
type: "string",
required: false,
unique: true,
returned: true,
},
},
},

View File

@@ -26,23 +26,19 @@ export type BetterAuthPlugin = {
hooks?: {
before?: {
matcher: (context: GenericEndpointContext) => boolean;
handler: Endpoint<
(context: GenericEndpointContext) => Promise<void | {
context: Partial<GenericEndpointContext>;
}>
>;
handler: (context: GenericEndpointContext) => Promise<void | {
context: Partial<GenericEndpointContext>;
}>;
}[];
after?: {
matcher: (context: GenericEndpointContext) => boolean;
handler: Endpoint<
(
context: GenericEndpointContext & {
returned: EndpointResponse;
},
) => Promise<void | {
response: EndpointResponse;
}>
>;
handler: (
context: GenericEndpointContext & {
returned: EndpointResponse;
},
) => Promise<void | {
response: EndpointResponse;
}>;
}[];
};
/**