Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions apps/nextjs/pages-router/src/pages/client/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ export default function AuthClientPage() {
}

const handleSignOut = async () => {
await signOut({ redirectTo: "/client" })
await signOut({ redirectTo: "/server" })
}

return (
Expand Down Expand Up @@ -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}
</Button>
Expand Down
6 changes: 5 additions & 1 deletion packages/next/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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"
},
Expand All @@ -91,4 +95,4 @@
"react-dom": ">=19.0.0"
},
"packageManager": "pnpm@10.15.0"
}
}
48 changes: 17 additions & 31 deletions packages/next/src/pages/handler.ts
Original file line number Diff line number Diff line change
@@ -1,28 +1,20 @@
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"
const host = request.headers["x-forwarded-host"] ?? request.headers.host
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
}

/**
Expand All @@ -49,29 +41,23 @@ export const toHandler = <DefaultUser extends User = User>(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<string, string>),
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)
Comment thread
halvaradop marked this conversation as resolved.
} catch {
return res.status(500).json({ error: "Internal Server Error" })
}
Expand Down
252 changes: 252 additions & 0 deletions packages/next/test/pages/handler.test.ts
Original file line number Diff line number Diff line change
@@ -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=/",
])
})
})
19 changes: 19 additions & 0 deletions packages/next/test/pages/preset.ts
Original file line number Diff line number Diff line change
@@ -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`,
}
},
},
})
Loading
Loading