From 29afedc1bd274c3a01e59222cd92f68a4d34e7c7 Mon Sep 17 00:00:00 2001 From: Bereket Engida Date: Thu, 21 Nov 2024 18:31:42 +0300 Subject: [PATCH 1/6] feat: handle internal context --- demo/nextjs/app/api/auth/[...all]/route.ts | 8 +- packages/better-auth/package.json | 2 +- packages/better-auth/src/api/api.test.ts | 80 ---- packages/better-auth/src/api/call.test.ts | 384 ++++++++++++++++++ packages/better-auth/src/api/call.ts | 1 + packages/better-auth/src/api/index.ts | 223 ++++++---- .../better-auth/src/api/routes/session.ts | 1 + packages/better-auth/src/db/utils.ts | 23 +- .../better-auth/src/integrations/next-js.ts | 11 +- .../better-auth/src/plugins/admin/index.ts | 4 +- .../src/plugins/anonymous/index.ts | 18 +- .../src/plugins/multi-session/index.ts | 28 +- .../src/plugins/oauth-proxy/index.ts | 24 +- .../src/plugins/two-factor/index.ts | 8 +- .../src/plugins/two-factor/totp/index.ts | 2 +- packages/better-auth/src/types/context.ts | 4 +- packages/better-auth/src/types/plugins.ts | 40 +- .../better-auth/src/utils/plugin-helper.ts | 27 -- pnpm-lock.yaml | 10 +- 19 files changed, 605 insertions(+), 293 deletions(-) delete mode 100644 packages/better-auth/src/api/api.test.ts create mode 100644 packages/better-auth/src/api/call.test.ts diff --git a/demo/nextjs/app/api/auth/[...all]/route.ts b/demo/nextjs/app/api/auth/[...all]/route.ts index 5b67b0644b..6cba29b7e5 100644 --- a/demo/nextjs/app/api/auth/[...all]/route.ts +++ b/demo/nextjs/app/api/auth/[...all]/route.ts @@ -1,4 +1,10 @@ import { auth } from "@/lib/auth"; import { toNextJsHandler } from "better-auth/next-js"; +import { NextRequest } from "next/server"; -export const { GET, POST } = toNextJsHandler(auth); +export const { GET } = toNextJsHandler(auth); + +export const POST = async (req: NextRequest) => { + const res = await auth.handler(req); + return res; +}; diff --git a/packages/better-auth/package.json b/packages/better-auth/package.json index af1c3e677d..d5d43761f3 100644 --- a/packages/better-auth/package.json +++ b/packages/better-auth/package.json @@ -405,7 +405,7 @@ "@noble/hashes": "^1.5.0", "@simplewebauthn/browser": "^10.0.0", "@simplewebauthn/server": "^10.0.1", - "better-call": "0.2.15-beta.6", + "better-call": "0.2.15-beta.7", "consola": "^3.2.3", "defu": "^6.1.4", "jose": "^5.9.4", diff --git a/packages/better-auth/src/api/api.test.ts b/packages/better-auth/src/api/api.test.ts deleted file mode 100644 index 635c2617df..0000000000 --- a/packages/better-auth/src/api/api.test.ts +++ /dev/null @@ -1,80 +0,0 @@ -import { describe, expect } from "vitest"; -import { getTestInstance } from "../test-utils/test-instance"; -import { APIError } from "better-call"; - -describe("api error", async (it) => { - const { client } = await getTestInstance(); - - it("should have application/json content type on validation error", async () => { - await client.signIn.email( - { - email: "incorrect-email", - password: "incorrect-password", - }, - { - onError(context) { - expect(context.response.headers.get("content-type")).toBe( - "application/json", - ); - }, - }, - ); - }); - - it("should have application/json content type on error", async () => { - await client.signIn.email( - { - email: "formatted-email@email.com", - password: "incorrect-password", - }, - { - onError(context) { - expect(context.response.headers.get("content-type")).toBe( - "application/json", - ); - }, - }, - ); - }); -}); - -describe("auth api", async (it) => { - const { client, auth } = await getTestInstance(); - - const testUser = { - email: "test-email@email.com", - password: "test-password", - name: "test-name", - }; - - it("should sign up", async () => { - const user = await auth.api.signUpEmail({ - body: testUser, - }); - expect(user).toMatchObject({ - user: { - id: expect.any(String), - name: testUser.name, - email: testUser.email, - emailVerified: false, - image: null, - createdAt: expect.any(Date), - updatedAt: expect.any(Date), - }, - session: { - id: expect.any(String), - expiresAt: expect.any(Date), - ipAddress: "", - userAgent: "", - userId: expect.any(String), - }, - }); - }); - it("should throw API Error", async () => { - expect( - auth.api.signUpEmail({ - body: testUser, - }), - ).rejects.toThrowError(APIError); - }); -}); diff --git a/packages/better-auth/src/api/call.test.ts b/packages/better-auth/src/api/call.test.ts new file mode 100644 index 0000000000..85d58d1d37 --- /dev/null +++ b/packages/better-auth/src/api/call.test.ts @@ -0,0 +1,384 @@ +import { describe, expect, it } from "vitest"; +import { APIError } from "better-call"; +import { + createAuthEndpoint, + createAuthMiddleware, + getEndpoints, + router, +} from "."; +import { init } from "../init"; +import type { BetterAuthOptions, BetterAuthPlugin } from "../types"; +import { z } from "zod"; +import { createAuthClient } from "../client"; +import { convertSetCookieToCookie } from "../test-utils/headers"; + +describe("call", async () => { + const q = z.optional( + z.object({ + testBeforeHook: z.string().optional(), + testAfterHook: z.string().optional(), + testContext: z.string().optional(), + message: z.string().optional(), + }), + ); + const testPlugin = { + id: "test", + endpoints: { + test: createAuthEndpoint( + "/test", + { + method: "GET", + query: q, + }, + async (ctx) => { + return ctx.json({ success: ctx.query?.message || "true" }); + }, + ), + testCookies: createAuthEndpoint( + "/test/cookies", + { + method: "POST", + query: q, + body: z.object({ + cookies: z.array(z.object({ name: z.string(), value: z.string() })), + }), + }, + async (ctx) => { + for (const cookie of ctx.body.cookies) { + ctx.setCookie(cookie.name, cookie.value); + } + return ctx.json({ success: true }); + }, + ), + testThrow: createAuthEndpoint( + "/test/throw", + { + method: "GET", + query: q, + }, + async (ctx) => { + if (ctx.query?.message === "throw-api-error") { + throw new APIError("BAD_REQUEST", { + message: "Test error", + }); + } + if (ctx.query?.message === "throw-error") { + throw new Error("Test error"); + } + if (ctx.query?.message === "throw redirect") { + throw ctx.redirect("/test"); + } + throw new APIError("BAD_REQUEST", { + message: ctx.query?.message, + }); + }, + ), + }, + } satisfies BetterAuthPlugin; + + const testPlugin2 = { + id: "test2", + hooks: { + before: [ + { + matcher(ctx) { + return ctx.path === "/test"; + }, + handler: createAuthMiddleware(async (ctx) => { + const query = ctx.query; + if (!query) { + return; + } + if (query.testBeforeHook) { + return ctx.json({ + before: "test", + }); + } + if (query.testContext) { + return { + context: { + // change context + query: { + message: query.testContext, + }, + }, + }; + } + }), + }, + ], + after: [ + { + matcher(ctx) { + return ctx.path === "/test"; + }, + handler: createAuthMiddleware(async (ctx) => { + const query = ctx.query?.testAfterHook; + if (!query) { + return; + } + + return ctx.json({ + after: "test", + }); + }), + }, + { + matcher(ctx) { + return ctx.path === "/test/cookies"; + }, + handler: createAuthMiddleware(async (ctx) => { + const query = ctx.query?.testAfterHook; + if (!query) { + return; + } + ctx.setCookie("after", "test"); + }), + }, + { + matcher(ctx) { + return ( + (ctx.path === "/test/throw" && + ctx.query?.message === "throw-after-hook") || + ctx.query?.message === "throw-chained-hook" + ); + }, + handler: createAuthMiddleware(async (ctx) => { + if (ctx.query?.message === "throw-chained-hook") { + throw new APIError("BAD_REQUEST", { + message: "from chained hook 1", + }); + } + if (ctx.context.returned instanceof APIError) { + throw new APIError("BAD_REQUEST", { + message: "from after hook", + }); + } + }), + }, + { + matcher(ctx) { + return ( + ctx.path === "/test/throw" && + ctx.query?.message === "throw-chained-hook" + ); + }, + handler: createAuthMiddleware(async (ctx) => { + if (ctx.context.returned instanceof APIError) { + const returned = ctx.context.returned; + const message = returned.message; + throw new APIError("BAD_REQUEST", { + message: message.replace("1", "2"), + }); + } + }), + }, + ], + }, + } satisfies BetterAuthPlugin; + const options = { + baseURL: "http://localhost:3000", + plugins: [testPlugin, testPlugin2], + emailAndPassword: { + enabled: true, + }, + } satisfies BetterAuthOptions; + const authContext = init(options); + const { api } = getEndpoints(authContext, options); + + const r = router(await authContext, options); + const client = createAuthClient({ + baseURL: "http://localhost:3000", + fetchOptions: { + customFetchImpl: async (url, init) => { + return r.handler(new Request(url, init)); + }, + }, + }); + + it("should call api", async () => { + const response = await api.test(); + expect(response).toMatchObject({ + success: "true", + }); + }); + + it("should set cookies", async () => { + await api.testCookies({ + body: { + cookies: [ + { + name: "test-cookie", + value: "test-value", + }, + ], + }, + }); + const setCookies = + testPlugin.endpoints.testCookies.headers.get("set-cookie"); + expect(setCookies).toContain("test-cookie=test-value"); + }); + + it("should intercept on before hook", async () => { + const response = await api.test({ + query: { + testBeforeHook: "true", + }, + }); + expect(response).toMatchObject({ + before: "test", + }); + }); + + it("should change context on before hook", async () => { + const response = await api.test({ + query: { + testContext: "context-changed", + }, + }); + expect(response).toMatchObject({ + success: "context-changed", + }); + }); + + it("should intercept on after hook", async () => { + const response = await api.test({ + query: { + testAfterHook: "true", + }, + }); + expect(response).toMatchObject({ + after: "test", + }); + }); + + it("should return Response object", async () => { + const response = await api.test({ + _flag: "router", + }); + expect(response).toBeInstanceOf(Response); + const response2 = await api.test({ + asResponse: true, + }); + expect(response2).toBeInstanceOf(Response); + }); + + it("should set cookies on after hook", async () => { + await api.testCookies({ + body: { + cookies: [ + { + name: "test-cookie", + value: "test-value", + }, + ], + }, + query: { + testAfterHook: "true", + }, + }); + const setCookies = + testPlugin.endpoints.testCookies.headers.get("set-cookie"); + expect(setCookies).toContain("after=test"); + expect(setCookies).toContain("test-cookie=test-value"); + }); + + it("should throw APIError", async () => { + await expect( + api.testThrow({ + query: { + message: "throw-api-error", + }, + }), + ).rejects.toThrowError(APIError); + }); + + it("should throw Error", async () => { + await expect( + api.testThrow({ + query: { + message: "throw-error", + }, + }), + ).rejects.toThrowError(Error); + }); + + it("should redirect", async () => { + await api + .testThrow({ + query: { + message: "throw redirect", + }, + }) + .catch((e) => { + expect(e).toBeInstanceOf(APIError); + expect(e.status).toBe("FOUND"); + expect(e.headers.get("Location")).toBe("/test"); + }); + }); + + it("should throw from after hook", async () => { + await api + .testThrow({ + query: { + message: "throw-after-hook", + }, + }) + .catch((e) => { + expect(e).toBeInstanceOf(APIError); + expect(e.status).toBe("BAD_REQUEST"); + expect(e.message).toContain("from after hook"); + }); + }); + + it("should throw from chained hook", async () => { + await api + .testThrow({ + query: { + message: "throw-chained-hook", + }, + }) + .catch((e) => { + expect(e).toBeInstanceOf(APIError); + expect(e.status).toBe("BAD_REQUEST"); + expect(e.message).toContain("from chained hook 2"); + }); + }); + + it("should fetch using a client", async () => { + const response = await client.$fetch("/test"); + expect(response.data).toMatchObject({ + success: "true", + }); + }); + + it("should fetch using a client with query", async () => { + const response = await client.$fetch("/test", { + query: { + message: "test", + }, + }); + expect(response.data).toMatchObject({ + success: "test", + }); + }); + + it("should set cookies using a client", async () => { + await client.$fetch("/test/cookies", { + method: "POST", + body: { + cookies: [ + { + name: "test-cookie", + value: "test-value", + }, + ], + }, + onResponse(context) { + expect(context.response.headers.get("set-cookie")).toContain( + "test-cookie=test-value", + ); + }, + }); + }); +}); diff --git a/packages/better-auth/src/api/call.ts b/packages/better-auth/src/api/call.ts index 2b9743e43f..54d3c4769b 100644 --- a/packages/better-auth/src/api/call.ts +++ b/packages/better-auth/src/api/call.ts @@ -26,6 +26,7 @@ export const createAuthMiddleware = createMiddlewareCreator({ createMiddleware(async () => { return {} as { returned?: unknown; + endpoint: Endpoint; }; }), ], diff --git a/packages/better-auth/src/api/index.ts b/packages/better-auth/src/api/index.ts index 10d928498c..393a344a5d 100644 --- a/packages/better-auth/src/api/index.ts +++ b/packages/better-auth/src/api/index.ts @@ -1,4 +1,14 @@ -import { APIError, type Endpoint, createRouter, statusCode } from "better-call"; +import { + APIError, + type CookieOptions, + type CookiePrefixOptions, + type Endpoint, + createRouter, + getCookie, + getSignedCookie, + setCookie, + setSignedCookie, +} from "better-call"; import type { AuthContext } from "../init"; import type { BetterAuthOptions } from "../types"; import type { UnionToIntersection } from "../types/helper"; @@ -115,45 +125,89 @@ export function getEndpoints< error, }; let api: Record = {}; - for (const [key, endpointOptions] of Object.entries(endpoints)) { + for (const [key, endpoint] of Object.entries(endpoints)) { api[key] = async (context = {} as any) => { + endpoint.headers = new Headers(); + let internalCtx = { + setHeader(key: string, value: string) { + endpoint.headers.set(key, value); + }, + setCookie(key: string, value: string, options?: CookieOptions) { + setCookie(endpoint.headers, key, value, options); + }, + getCookie(key: string, prefix?: CookiePrefixOptions) { + const header = context.headers; + const cookieH = header?.get("cookie"); + const cookie = getCookie(cookieH || "", key, prefix); + return cookie; + }, + getSignedCookie( + key: string, + secret: string, + prefix?: CookiePrefixOptions, + ) { + const header = context.headers; + if (!header) { + return null; + } + const cookie = getSignedCookie(header, secret, key, prefix); + return cookie; + }, + async setSignedCookie( + key: string, + value: string, + secret: string | BufferSource, + options?: CookieOptions, + ) { + await setSignedCookie(endpoint.headers, key, value, secret, options); + }, + redirect(url: string) { + endpoint.headers.set("Location", url); + return new APIError("FOUND"); + }, + responseHeader: endpoint.headers, + }; + let authCtx = await ctx; - // clear session so it doesn't persist between requests + + let internalContext = { + ...internalCtx, + ...context, + path: endpoint.path, + context: { + ...authCtx, + ...context.context, + endpoint, + }, + }; + authCtx.session = null; - for (const plugin of options.plugins || []) { - if (plugin.hooks?.before) { - for (const hook of plugin.hooks.before) { - const ctx = { - ...endpointOptions, - ...context, - context: { - ...authCtx, - ...context?.context, - }, + const plugins = options.plugins || []; + for (const plugin of plugins) { + const beforeHooks = plugin.hooks?.before ?? []; + for (const hook of beforeHooks) { + if (!hook.matcher(internalContext)) continue; + const hookRes = await hook.handler(internalContext); + if (hookRes && "context" in hookRes) { + // modify the context with the response from the hook + internalContext = { + ...internalContext, + ...hookRes.context, }; - const match = hook.matcher(ctx); - if (match) { - const hookRes = await hook.handler(ctx); - if (hookRes && "context" in hookRes) { - context = { - ...hookRes, - ...context, - }; - } - } + continue; + } + + if (hookRes) { + // return with the response from the hook + return hookRes; } } } + let endpointRes: any; try { //@ts-ignore - endpointRes = await endpointOptions({ - ...context, - context: { - ...authCtx, - ...context.context, - }, - }); + endpointRes = await endpoint(internalContext); } catch (e) { if (e instanceof APIError) { const afterPlugins = options.plugins @@ -165,90 +219,81 @@ export function getEndpoints< .filter((plugin) => plugin !== undefined) .flat(); + /** + * If there are no after plugins, we can directly throw the error + */ if (!afterPlugins?.length) { + e.headers = endpoint.headers; throw e; } - - let response = new Response(JSON.stringify(e.body), { - status: statusCode[e.status], - headers: e.headers, - }); - - let pluginResponse: Request | undefined = undefined; - + internalContext.context.returned = e; + internalContext.context.returned.headers = endpoint.headers; for (const hook of afterPlugins || []) { - const ctx = { - ...endpointOptions, - ...context, - context: { - ...authCtx, - ...context.context, - endpoint: endpointOptions, - returned: response, - }, - }; - - const match = hook.matcher(ctx); + const match = hook.matcher(internalContext); if (match) { - const hookRes = await hook.handler(ctx); - if (hookRes && "response" in hookRes) { - pluginResponse = hookRes.response as any; + try { + const hookRes = await hook.handler(internalContext); + if (hookRes && "response" in hookRes) { + internalContext.context.returned = hookRes.response; + } + } catch (e) { + if (e instanceof APIError) { + internalContext.context.returned = e; + continue; + } + throw e; } } } - if (pluginResponse instanceof Response) { - return pluginResponse; + if (internalContext.context.returned instanceof APIError) { + // set the headers from the endpoint + internalContext.context.returned.headers = endpoint.headers; + throw internalContext.context.returned; } - throw e; + + return internalContext.context.returned; } throw e; } - let response = endpointRes; + internalContext.context.returned = endpointRes; + internalContext.responseHeader = endpoint.headers; for (const plugin of options.plugins || []) { if (plugin.hooks?.after) { for (const hook of plugin.hooks.after) { - const ctx = { - ...endpointOptions, - ...context, - context: { - ...authCtx, - ...context.context, - endpoint: endpointOptions, - returned: response, - }, - }; - const match = hook.matcher(ctx); + const match = hook.matcher(internalContext); if (match) { - const hookRes = await hook.handler(ctx); - if (hookRes) { - if ("response" in hookRes) { - response = hookRes.response as any; + try { + const hookRes = await hook.handler(internalContext); + if (hookRes) { + internalContext.context.returned = hookRes; } - if ("responseHeader" in hookRes) { - if (response instanceof Response) { - response = new Response(response.body, { - status: response.status, - headers: { - ...response.headers, - ...hookRes.responseHeader, - }, - }); - } else { - api[key].headers = hookRes.responseHeader; - } + } catch (e) { + if (e instanceof APIError) { + internalContext.context.returned = e; + continue; } + throw e; } } } } } - + const response = internalContext.context.returned; + if (response instanceof Response) { + endpoint.headers.forEach((value, key) => { + if (key === "set-cookie") { + response.headers.append(key, value); + } else { + response.headers.set(key, value); + } + }); + } return response; }; - api[key].path = endpointOptions.path; - api[key].method = endpointOptions.method; - api[key].options = endpointOptions.options; - api[key].headers = endpointOptions.headers; + api[key].path = endpoint.path; + api[key].method = endpoint.method; + api[key].options = endpoint.options; + api[key].headers = endpoint.headers; } return { api: api as typeof endpoints & PluginEndpoint, diff --git a/packages/better-auth/src/api/routes/session.ts b/packages/better-auth/src/api/routes/session.ts index 5008fb7b5f..97e56340ed 100644 --- a/packages/better-auth/src/api/routes/session.ts +++ b/packages/better-auth/src/api/routes/session.ts @@ -37,6 +37,7 @@ export const getSession =