From 0c4ba1cebd1bebd6f6192e6ddbe62b63de21d33b Mon Sep 17 00:00:00 2001 From: Hernan Alvarado Date: Thu, 28 May 2026 17:06:26 -0500 Subject: [PATCH] fix(nextjs): fix JSON parsing in Pages Router handler --- .../pages-router/src/pages/client/index.tsx | 4 +- packages/next/package.json | 6 +- packages/next/src/pages/handler.ts | 48 ++-- packages/next/test/pages/handler.test.ts | 252 ++++++++++++++++++ packages/next/test/pages/preset.ts | 19 ++ packages/next/tsconfig.json | 5 +- packages/next/vitest.config.ts | 31 +++ pnpm-lock.yaml | 37 +++ 8 files changed, 366 insertions(+), 36 deletions(-) create mode 100644 packages/next/test/pages/handler.test.ts create mode 100644 packages/next/test/pages/preset.ts create mode 100644 packages/next/vitest.config.ts diff --git a/apps/nextjs/pages-router/src/pages/client/index.tsx b/apps/nextjs/pages-router/src/pages/client/index.tsx index 52a8f66e..a50fb74a 100644 --- a/apps/nextjs/pages-router/src/pages/client/index.tsx +++ b/apps/nextjs/pages-router/src/pages/client/index.tsx @@ -39,7 +39,7 @@ export default function AuthClientPage() { } const handleSignOut = async () => { - await signOut({ redirectTo: "/client" }) + await signOut({ redirectTo: "/server" }) } return ( @@ -120,7 +120,7 @@ export default function AuthClientPage() { variant="outline" disabled={isPending} key={provider} - onClick={() => signIn(provider.toLowerCase())} + onClick={() => signIn(provider.toLowerCase(), { redirect: true })} > Sign In with {provider} diff --git a/packages/next/package.json b/packages/next/package.json index aef3be21..db4f88d3 100644 --- a/packages/next/package.json +++ b/packages/next/package.json @@ -11,6 +11,9 @@ "lint:fix": "oxlint --fix", "format": "oxfmt", "format:check": "oxfmt --check", + "test": "vitest --run", + "test:watch": "vitest", + "test:coverage": "vitest --run --coverage", "type-check": "tsc --noEmit", "clean": "rm -rf dist src/_core src/oauth", "clean:cts": "find dist -type f -name \"*.cts\" -delete", @@ -82,6 +85,7 @@ "@aura-stack/tsconfig": "workspace:*", "@aura-stack/tsdown-config": "workspace:*", "next": "catalog:next", + "node-mocks-http": "^1.17.2", "react": "catalog:react", "react-dom": "catalog:react" }, @@ -91,4 +95,4 @@ "react-dom": ">=19.0.0" }, "packageManager": "pnpm@10.15.0" -} +} \ No newline at end of file diff --git a/packages/next/src/pages/handler.ts b/packages/next/src/pages/handler.ts index 8d1f915c..6439e19d 100644 --- a/packages/next/src/pages/handler.ts +++ b/packages/next/src/pages/handler.ts @@ -1,5 +1,5 @@ +import type { NextApiRequest, NextApiResponse } from "next" import type { AuthInstance, User } from "@aura-stack/react" -import { NextApiRequest, NextApiResponse } from "next" const getBaseURL = (request: NextApiRequest) => { const protocol = request.headers["x-forwarded-proto"] ?? "http" @@ -7,22 +7,14 @@ const getBaseURL = (request: NextApiRequest) => { return `${protocol}://${host}` } -const setResponseHeaders = (res: NextApiResponse, headers: Headers) => { +export const setResponseHeaders = (res: NextApiResponse, headers: Headers) => { for (const [key, value] of headers.entries()) { - res.setHeader(key, value) - } -} - -const toWebHeaders = (headers: NextApiRequest["headers"]) => { - const webHeaders = new Headers() - for (const [key, value] of Object.entries(headers)) { - if (Array.isArray(value)) { - webHeaders.set(key, value.join(", ")) - } else if (typeof value === "string") { - webHeaders.set(key, value) + if (key.toLowerCase() === "set-cookie") { + res.setHeader("Set-Cookie", headers.getSetCookie()) + } else { + res.setHeader(key, value) } } - return webHeaders } /** @@ -49,29 +41,23 @@ export const toHandler = (handlers: AuthInstanc const url = new URL(req.url!, getBaseURL(req)) const webRequest = new Request(url, { method, - headers: toWebHeaders(req.headers), - body: method !== "GET" && method !== "HEAD" && req.body ? JSON.stringify(req.body) : undefined, + headers: new Headers(req.headers as Record), + body: method !== "GET" && method !== "HEAD" ? req.body : undefined, }) try { const response = await handler(webRequest) - setResponseHeaders(res, response.headers) if (response.status >= 300 && response.status < 400) { - const location = response.headers.get("location") - if (location) { - return res.redirect(response.status, location) + /** + * Next.js Pages Router can't manage redirections and json responses at the same time, + * so if the response is a redirection, we need to remove the Location header and manage the redirection manually. + */ + if (!req.url?.includes("/auth/signIn/") && !req.url?.includes("/auth/callback/")) { + response.headers.delete("Location") } } - - const contentType = response.headers.get("content-type") ?? "" - if (contentType.includes("application/json")) { - const data = await response.json() - return res.status(response.status).json(data) - } - if (response.status === 204 || response.status === 304) { - return res.status(response.status).end() - } - const text = await response.text() - return res.status(response.status).send(text) + setResponseHeaders(res, response.headers) + const data = await response.json() + return res.status(response.status).json(data) } catch { return res.status(500).json({ error: "Internal Server Error" }) } diff --git a/packages/next/test/pages/handler.test.ts b/packages/next/test/pages/handler.test.ts new file mode 100644 index 00000000..8630541f --- /dev/null +++ b/packages/next/test/pages/handler.test.ts @@ -0,0 +1,252 @@ +import { describe, test, expect } from "vitest" +import { createMocks, createResponse, RequestOptions, ResponseOptions } from "node-mocks-http" +import { auth } from "@test/pages/preset" +import { NextApiRequest, NextApiResponse } from "next" +import { createCSRF } from "@aura-stack/react/crypto" +import { setResponseHeaders } from "@/pages/handler" + +const createHandler = async (reqOptions?: RequestOptions, resOptions?: ResponseOptions) => { + if (reqOptions?.body) { + // @ts-ignore Next.js by default parses the body + reqOptions.body = JSON.stringify(reqOptions.body) + } + const { req, res } = createMocks(reqOptions, resOptions) + await auth.toHandler(req as unknown as NextApiRequest, res as unknown as NextApiResponse) + return { req, res } +} + +describe("toHandler", () => { + test("unsupported method", async () => { + const { res } = await createHandler({ + method: "DELETE", + }) + expect(res.statusCode).toBe(405) + expect(res._getJSONData()).toEqual({ error: "Method DELETE Not Allowed" }) + }) + + test("GET /auth/session - http connection", async () => { + const payload = { + sub: "1234567890", + name: "John Doe", + email: "john.doe@example.com", + image: "https://example.com/john-doe.jpg", + } + + const sessionToken = await auth.jose.encodeJWT(payload) + + const { res } = await createHandler({ + method: "GET", + url: "/auth/session", + headers: { + Cookie: `aura-auth.session_token=${sessionToken}`, + Host: "localhost:3000", + }, + }) + expect(res.statusCode).toBe(200) + expect(res._getJSONData()).toEqual({ + session: { + user: payload, + expires: expect.any(String), + }, + success: true, + }) + }) + + test("GET /auth/session - https connection", async () => { + const payload = { + sub: "1234567890", + name: "John Doe", + email: "john.doe@example.com", + image: "https://example.com/john-doe.jpg", + } + + const sessionToken = await auth.jose.encodeJWT(payload) + + const { res } = await createHandler({ + method: "GET", + url: "/auth/session", + headers: { + Cookie: `__Secure-aura-auth.session_token=${sessionToken}`, + Host: "example.com", + "X-Forwarded-Proto": "https", + }, + }) + expect(res.statusCode).toBe(200) + expect(res._getJSONData()).toEqual({ + session: { + user: payload, + expires: expect.any(String), + }, + success: true, + }) + }) + + test("POST /auth/signIn/credentials", async () => { + const { res } = await createHandler({ + method: "POST", + url: "/auth/signIn/credentials", + headers: { + Host: "localhost:3000", + "X-Forwarded-Proto": "http", + "Content-Type": "application/json", + }, + body: { + username: "john.doe", + password: "super-password-123", + }, + }) + expect(res.statusCode).toBe(200) + expect(res._getJSONData()).toEqual({ + success: true, + redirectURL: "/", + }) + }) + + test("POST /auth/signIn/credentials - invalid body", async () => { + const { res } = await createHandler({ + method: "POST", + url: "/auth/signIn/credentials", + headers: { + Host: "localhost:3000", + "X-Forwarded-Proto": "http", + "Content-Type": "application/json", + }, + body: { + name: "john.doe", + hash: "super-password-123", + }, + }) + expect(res.statusCode).toBe(422) + expect(res._getJSONData()).toEqual({ + type: "ROUTER_ERROR", + code: "INVALID_REQUEST", + message: { + password: { + code: "invalid_type", + message: "Invalid input: expected string, received undefined", + }, + username: { + code: "invalid_type", + message: "Invalid input: expected string, received undefined", + }, + }, + }) + }) + + test("PATCH /auth/session", async () => { + const payload = { + sub: "1234567890", + name: "John Doe", + email: "john.doe@example.com", + image: "https://example.com/john-doe.jpg", + } + + const csrfToken = await createCSRF(auth.jose) + const sessionToken = await auth.jose.encodeJWT(payload) + + const { res } = await createHandler({ + method: "PATCH", + url: "/auth/session", + headers: { + Host: "localhost:3000", + "X-Forwarded-Proto": "http", + "Content-Type": "application/json", + Cookie: `aura-auth.session_token=${sessionToken}; aura-auth.csrf_token=${csrfToken}`, + "X-CSRF-Token": csrfToken, + }, + body: { + user: { + name: "Alice", + image: "https://example.com/alice.jpg", + }, + }, + }) + expect(res.statusCode).toBe(200) + expect(res._getJSONData()).toEqual({ + success: true, + session: { + user: { + sub: "1234567890", + name: "Alice", + email: "john.doe@example.com", + image: "https://example.com/alice.jpg", + }, + expires: expect.any(String), + }, + redirectURL: null, + }) + }) + + test("POST /auth/signOut", async () => { + const payload = { + sub: "1234567890", + name: "John Doe", + email: "john.doe@example.com", + image: "https://example.com/john-doe.jpg", + } + + const csrfToken = await createCSRF(auth.jose) + const sessionToken = await auth.jose.encodeJWT(payload) + + const { res } = await createHandler({ + method: "POST", + url: "/auth/signOut?token_type_hint=session_token", + headers: { + Host: "localhost:3000", + "X-Forwarded-Proto": "http", + "Content-Type": "application/json", + Cookie: `aura-auth.session_token=${sessionToken}; aura-auth.csrf_token=${csrfToken}`, + "X-CSRF-Token": csrfToken, + }, + body: {}, + }) + expect(res.statusCode).toBe(202) + expect(res._getJSONData()).toEqual({ + success: true, + redirect: false, + redirectURL: "/", + }) + }) +}) + +describe("setResponseHeaders", () => { + test("sets headers on Next.js response", () => { + const response = createResponse() as unknown as NextApiResponse + const headers = new Headers({ + "Content-Type": "application/json", + Cookie: "session_token=session; Path=/; HttpOnly; Secure; Domain=/", + }) + setResponseHeaders(response, headers) + expect(response.getHeaders()).toEqual({ + "content-type": "application/json", + cookie: "session_token=session; Path=/; HttpOnly; Secure; Domain=/", + }) + }) + + test("overwrites existing headers", () => { + const response = createResponse() as unknown as NextApiResponse + response.setHeader("Content-Type", "text/plain") + const headers = new Headers({ + "Content-Type": "application/json", + }) + setResponseHeaders(response, headers) + expect(response.getHeader("Content-Type")).toBe("application/json") + }) + + test("handle set-cookie headers", () => { + const response = createResponse() as unknown as NextApiResponse + const headers = new Headers([ + ["Set-Cookie", "session_token=session; Path=/; HttpOnly; Secure; Domain=/"], + ["Set-Cookie", "csrf_token=csrf; Path=/; HttpOnly; Secure; Domain=/"], + ["Set-Cookie", "__Secure-session=session; Path=/; HttpOnly; Secure; Domain=/"], + ["Set-Cookie", "__Secure-csrf=csrf; Path=/; HttpOnly; Secure; Domain=/"], + ]) + setResponseHeaders(response, headers) + expect(response.getHeader("Set-Cookie")).toEqual([ + "session_token=session; Path=/; HttpOnly; Secure; Domain=/", + "csrf_token=csrf; Path=/; HttpOnly; Secure; Domain=/", + "__Secure-session=session; Path=/; HttpOnly; Secure; Domain=/", + "__Secure-csrf=csrf; Path=/; HttpOnly; Secure; Domain=/", + ]) + }) +}) diff --git a/packages/next/test/pages/preset.ts b/packages/next/test/pages/preset.ts new file mode 100644 index 00000000..044327f8 --- /dev/null +++ b/packages/next/test/pages/preset.ts @@ -0,0 +1,19 @@ +import { createAuth } from "@/pages/createAuth" +import { createSecretValue } from "@aura-stack/react/crypto" + +export const auth = createAuth({ + oauth: [], + logger: true, + credentials: { + authorize: (ctx) => { + const { username, password } = ctx.credentials + if (!username || !password) return null + const sub = createSecretValue(10) + return { + sub, + name: username, + email: `${username.toLowerCase()}@example.com`, + } + }, + }, +}) diff --git a/packages/next/tsconfig.json b/packages/next/tsconfig.json index 0eab5a83..da7a65ed 100644 --- a/packages/next/tsconfig.json +++ b/packages/next/tsconfig.json @@ -2,10 +2,11 @@ "extends": "@aura-stack/tsconfig/next.json", "compilerOptions": { "paths": { - "@/*": ["./src/*"] + "@/*": ["./src/*"], + "@test/*": ["./test/*"] }, "incremental": false }, - "include": ["src"], + "include": ["src", "test"], "exclude": ["dist", "node_modules"] } diff --git a/packages/next/vitest.config.ts b/packages/next/vitest.config.ts new file mode 100644 index 00000000..2d7849b1 --- /dev/null +++ b/packages/next/vitest.config.ts @@ -0,0 +1,31 @@ +import path from "path" +import { defineConfig } from "vitest/config" +import { createSecretValue } from "@aura-stack/react/crypto" + +const SECRET_KEY = createSecretValue(44) +const SALT_KEY = createSecretValue(44) + +export default defineConfig({ + test: { + include: ["test/**/*.test.ts"], + coverage: { + provider: "v8", + enabled: true, + }, + unstubEnvs: true, + env: { + AURA_AUTH_SECRET: SECRET_KEY, + AURA_AUTH_SALT: SALT_KEY, + }, + typecheck: { + include: ["test/**/*.test-d.ts"], + enabled: false, + }, + }, + resolve: { + alias: { + "@": path.resolve(__dirname, "./src"), + "@test": path.resolve(__dirname, "./test"), + }, + }, +}) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b91beefe..f882ae2f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -820,6 +820,9 @@ importers: next: specifier: catalog:next version: 16.1.7(@babel/core@7.29.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + node-mocks-http: + specifier: ^1.17.2 + version: 1.17.2(@types/express@4.17.25)(@types/node@24.12.0) react: specifier: catalog:react version: 19.2.4 @@ -5509,6 +5512,10 @@ packages: resolution: {integrity: sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw==} engines: {node: '>=0.10'} + depd@1.1.2: + resolution: {integrity: sha512-7emPTl6Dpo6JRXOXjLRxck+FlLRX5847cLKEn00PLAgc3g2hTZZgr+e4c2v6QpSmLeFP3n5yUo7ft6avBK/5jQ==} + engines: {node: '>= 0.6'} + depd@2.0.0: resolution: {integrity: sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==} engines: {node: '>= 0.8'} @@ -7261,6 +7268,18 @@ packages: node-mock-http@1.0.4: resolution: {integrity: sha512-8DY+kFsDkNXy1sJglUfuODx1/opAGJGyrTuFqEoN90oRc2Vk0ZbD4K2qmKXBBEhZQzdKHIVfEJpDU8Ak2NJEvQ==} + node-mocks-http@1.17.2: + resolution: {integrity: sha512-HVxSnjNzE9NzoWMx9T9z4MLqwMpLwVvA0oVZ+L+gXskYXEJ6tFn3Kx4LargoB6ie7ZlCLplv7QbWO6N+MysWGA==} + engines: {node: '>=14'} + peerDependencies: + '@types/express': ^4.17.21 || ^5.0.0 + '@types/node': '*' + peerDependenciesMeta: + '@types/express': + optional: true + '@types/node': + optional: true + node-releases@2.0.36: resolution: {integrity: sha512-TdC8FSgHz8Mwtw9g5L4gR/Sh9XhSP/0DEkQxfEFXOpiul5IiHgHan2VhYYb6agDSfp4KuvltmGApc8HMgUrIkA==} @@ -14193,6 +14212,8 @@ snapshots: denque@2.1.0: {} + depd@1.1.2: {} + depd@2.0.0: {} dequal@2.0.3: {} @@ -16462,6 +16483,22 @@ snapshots: node-mock-http@1.0.4: {} + node-mocks-http@1.17.2(@types/express@4.17.25)(@types/node@24.12.0): + dependencies: + accepts: 1.3.8 + content-disposition: 0.5.4 + depd: 1.1.2 + fresh: 0.5.2 + merge-descriptors: 1.0.3 + methods: 1.1.2 + mime: 1.6.0 + parseurl: 1.3.3 + range-parser: 1.2.1 + type-is: 1.6.18 + optionalDependencies: + '@types/express': 4.17.25 + '@types/node': 24.12.0 + node-releases@2.0.36: {} nopt@8.1.0: