diff --git a/README.md b/README.md index 689d54db..45c5f608 100644 --- a/README.md +++ b/README.md @@ -5,7 +5,7 @@ ### 1. Install the SDK ```shell -npm i @auth0/nextjs-auth0@4.0.0-beta.10 +npm i @auth0/nextjs-auth0@4.0.0-beta.11 ``` ### 2. Add the environment variables @@ -109,21 +109,24 @@ export default async function Home() { You can customize the client by using the options below: -| Option | Type | Description | -| --------------------------- | ------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| domain | `string` | The Auth0 domain for the tenant (e.g.: `example.us.auth0.com`). If it's not specified, it will be loaded from the `AUTH0_DOMAIN` environment variable. | -| clientId | `string` | The Auth0 client ID. If it's not specified, it will be loaded from the `AUTH0_CLIENT_ID` environment variable. | -| clientSecret | `string` | The Auth0 client secret. If it's not specified, it will be loaded from the `AUTH0_CLIENT_SECRET` environment variable. | -| authorizationParameters | `AuthorizationParameters` | The authorization parameters to pass to the `/authorize` endpoint. See [Passing authorization parameters](#passing-authorization-parameters) for more details. | -| appBaseUrl | `string` | The URL of your application (e.g.: `http://localhost:3000`). If it's not specified, it will be loaded from the `APP_BASE_URL` environment variable. | -| secret | `string` | A 32-byte, hex-encoded secret used for encrypting cookies. If it's not specified, it will be loaded from the `AUTH0_SECRET` environment variable. | -| signInReturnToPath | `string` | The path to redirect the user to after successfully authenticating. Defaults to `/`. | -| session | `SessionConfiguration` | Configure the session timeouts and whether to use rolling sessions or not. See [Session configuration](#session-configuration) for additional details. | -| beforeSessionSaved | `BeforeSessionSavedHook` | A method to manipulate the session before persisting it. See [beforeSessionSaved](#beforesessionsaved) for additional details. | -| onCallback | `OnCallbackHook` | A method to handle errors or manage redirects after attempting to authenticate. See [onCallback](#oncallback) for additional details. | -| sessionStore | `SessionStore` | A custom session store implementation used to persist sessions to a data store. See [Database sessions](#database-sessions) for additional details. | -| pushedAuthorizationRequests | `boolean` | Configure the SDK to use the Pushed Authorization Requests (PAR) protocol when communicating with the authorization server. | -| routes | `Routes` | Configure the paths for the authentication routes. See [Custom routes](#custom-routes) for additional details. | +| Option | Type | Description | +| --------------------------- | ------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| domain | `string` | The Auth0 domain for the tenant (e.g.: `example.us.auth0.com` or `https://example.us.auth0.com`). If it's not specified, it will be loaded from the `AUTH0_DOMAIN` environment variable. | +| clientId | `string` | The Auth0 client ID. If it's not specified, it will be loaded from the `AUTH0_CLIENT_ID` environment variable. | +| clientSecret | `string` | The Auth0 client secret. If it's not specified, it will be loaded from the `AUTH0_CLIENT_SECRET` environment variable. | +| authorizationParameters | `AuthorizationParameters` | The authorization parameters to pass to the `/authorize` endpoint. See [Passing authorization parameters](#passing-authorization-parameters) for more details. | +| clientAssertionSigningKey | `string` or `CryptoKey` | Private key for use with `private_key_jwt` clients. | +| clientAssertionSigningAlg | `string` | The algorithm used to sign the client assertion JWT. | +| appBaseUrl | `string` | The URL of your application (e.g.: `http://localhost:3000`). If it's not specified, it will be loaded from the `APP_BASE_URL` environment variable. | +| secret | `string` | A 32-byte, hex-encoded secret used for encrypting cookies. If it's not specified, it will be loaded from the `AUTH0_SECRET` environment variable. | +| signInReturnToPath | `string` | The path to redirect the user to after successfully authenticating. Defaults to `/`. | +| session | `SessionConfiguration` | Configure the session timeouts and whether to use rolling sessions or not. See [Session configuration](#session-configuration) for additional details. | +| beforeSessionSaved | `BeforeSessionSavedHook` | A method to manipulate the session before persisting it. See [beforeSessionSaved](#beforesessionsaved) for additional details. | +| onCallback | `OnCallbackHook` | A method to handle errors or manage redirects after attempting to authenticate. See [onCallback](#oncallback) for additional details. | +| sessionStore | `SessionStore` | A custom session store implementation used to persist sessions to a data store. See [Database sessions](#database-sessions) for additional details. | +| pushedAuthorizationRequests | `boolean` | Configure the SDK to use the Pushed Authorization Requests (PAR) protocol when communicating with the authorization server. | +| routes | `Routes` | Configure the paths for the authentication routes. See [Custom routes](#custom-routes) for additional details. | +| allowInsecureRequests | `boolean` | Allow insecure requests to be made to the authorization server. This can be useful when testing with a mock OIDC provider that does not support TLS, locally. This option can only be used when `NODE_ENV` is not set to `production`. | ## Passing authorization parameters @@ -246,6 +249,75 @@ export default function Page({ } ``` +## Updating the session + +The `updateSession` method could be used to update the session of the currently authenticated user in both the App Router and Pages Router. If the user does not have a session, an error will be thrown. + +> [!NOTE] +> Any updates to the session will be overwritten when the user re-authenticates and obtains a new session. + +### On the server (App Router) + +On the server, the `updateSession()` helper can be used in Server Routes, Server Actions, and middleware to update the session of the currently authenticated user, like so: + +```tsx +import { NextResponse } from "next/server" + +import { auth0 } from "@/lib/auth0" + +export async function GET() { + const session = await auth0.getSession() + + if (!session) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }) + } + + await auth0.updateSession({ + ...session, + updatedAt: Date.now(), + }) + + return NextResponse.json(null, { status: 200 }) +} +``` + +> [!NOTE] +> The `updateSession()` method is not usable in Server Components as it is not possible to write cookies. + +### On the server (Pages Router) + +On the server, the `updateSession(req, res, session)` helper can be used in `getServerSideProps`, API routes, and middleware to update the session of the currently authenticated user, like so: + +```tsx +import type { NextApiRequest, NextApiResponse } from "next" + +import { auth0 } from "@/lib/auth0" + +type ResponseData = + | {} + | { + error: string + } + +export default async function handler( + req: NextApiRequest, + res: NextApiResponse +) { + const session = await auth0.getSession(req) + + if (!session) { + return res.status(401).json({ error: "Unauthorized" }) + } + + await auth0.updateSession(req, res, { + ...session, + updatedAt: Date.now(), + }) + + res.status(200).json({}) +} +``` + ## Getting an access token The `getAccessToken()` helper can be used both in the browser and on the server to obtain the access token to call external APIs. If the access token has expired and a refresh token is available, it will automatically be refreshed and persisted. @@ -262,7 +334,7 @@ import { getAccessToken } from "@auth0/nextjs-auth0" export default function Component() { async function fetchData() { try { - const token = await auth0.getAccessToken() + const token = await getAccessToken() // call external API with token... } catch (err) { // err will be an instance of AccessTokenError if an access token could not be obtained diff --git a/V4_MIGRATION_GUIDE.md b/V4_MIGRATION_GUIDE.md index cdfca621..4e611d29 100644 --- a/V4_MIGRATION_GUIDE.md +++ b/V4_MIGRATION_GUIDE.md @@ -21,6 +21,9 @@ Of the required variables, the following have changed from v3: All other configuration must be specified via the `Auth0Client` constructor. +> [!NOTE] +> In v3 the `audience` parameter could be specified via the `AUTH0_AUDIENCE` environment variable. In v4, the `audience` parameter must be specified as a query parameter or via the `authorizationParamaters` configuration option. For more information on how to pass custom parameters in v4, please see [Passing custom authorization parameters](#passing-custom-authorization-parameters). + ## Routes Previously, it was required to set up a dynamic Route Handler to mount the authentication endpoints to handle requests. @@ -237,5 +240,4 @@ If you'd like to customize the `user` object to include additional custom claims - To store large session data, please use a [custom data store](https://github.com/auth0/nextjs-auth0/tree/v4?tab=readme-ov-file#database-sessions) with a SessionStore implementation - All cookies set by the SDK default to `SameSite=Lax` - `touchSession` method was removed. The middleware enables rolling sessions by default and can be configured via the [session configuration](https://github.com/auth0/nextjs-auth0/tree/v4?tab=readme-ov-file#session-configuration). -- `updateSession` method was removed. - `getAccessToken` can now be called in React Server Components. diff --git a/e2e/app-router.spec.ts b/e2e/app-router.spec.ts index d9ee53eb..49b158d4 100644 --- a/e2e/app-router.spec.ts +++ b/e2e/app-router.spec.ts @@ -65,7 +65,7 @@ test("getAccessToken()", async ({ page }) => { expect(tokenRequest).toHaveProperty("expires_at") }) -test("protected server route", async ({ page, request, context }) => { +test("protected server route", async ({ page, context }) => { // before establishing a session, we should receive a 401 const unauthedRes = await context.request.fetch("/app-router/api/test") expect(unauthedRes.status()).toBe(401) @@ -101,3 +101,33 @@ test("protected server action", async ({ page }) => { await page.getByText("Call server action").click() await expect(page.locator("#status")).toHaveValue("authenticated") }) + +test("updateSession()", async ({ page, context }) => { + const now = Date.now() + + await page.goto("/auth/login?returnTo=/app-router/server") + + // fill out Auth0 form + await page.fill('input[id="username"]', "test@example.com") + await page.fill('input[id="password"]', process.env.TEST_USER_PASSWORD!) + await page.getByText("Continue", { exact: true }).click() + + // check that the page says "Welcome, test@example.com!" + expect(await page.getByRole("heading", { level: 1 }).textContent()).toBe( + "Welcome, test@example.com!" + ) + + // the session should not have an `updatedAt` field initially + let getSessionRes = await context.request.fetch("/app-router/api/get-session") + let getSessionJson = await getSessionRes.json() + expect(getSessionJson.updatedAt).toBeUndefined() + + // now update the session and check that the `updatedAt` field is updated + const updateSessionRes = await context.request.fetch( + "/app-router/api/update-session" + ) + expect(updateSessionRes.status()).toBe(200) + getSessionRes = await context.request.fetch("/app-router/api/get-session") + getSessionJson = await getSessionRes.json() + expect(getSessionJson.updatedAt).toBeGreaterThan(now) +}) diff --git a/e2e/pages-router.spec.ts b/e2e/pages-router.spec.ts index 3deaef2f..c2cc6f9d 100644 --- a/e2e/pages-router.spec.ts +++ b/e2e/pages-router.spec.ts @@ -82,3 +82,35 @@ test("protected API route", async ({ page, request, context }) => { expect(authedRes.status()).toBe(200) expect(await authedRes.json()).toEqual({ email: "test@example.com" }) }) + +test("updateSession()", async ({ page, context }) => { + const now = Date.now() + + await page.goto("/auth/login?returnTo=/pages-router/server") + + // fill out Auth0 form + await page.fill('input[id="username"]', "test@example.com") + await page.fill('input[id="password"]', process.env.TEST_USER_PASSWORD!) + await page.getByText("Continue", { exact: true }).click() + + // check that the page says "Welcome, test@example.com!" + expect(await page.getByRole("heading", { level: 1 }).textContent()).toBe( + "Welcome, test@example.com!" + ) + + // the session should not have an `updatedAt` field initially + let getSessionRes = await context.request.fetch( + "/api/pages-router/get-session" + ) + let getSessionJson = await getSessionRes.json() + expect(getSessionJson.updatedAt).toBeUndefined() + + // now update the session and check that the `updatedAt` field is updated + const updateSessionRes = await context.request.fetch( + "/api/pages-router/update-session" + ) + expect(updateSessionRes.status()).toBe(200) + getSessionRes = await context.request.fetch("/api/pages-router/get-session") + getSessionJson = await getSessionRes.json() + expect(getSessionJson.updatedAt).toBeGreaterThan(now) +}) diff --git a/e2e/test-app/app/app-router/api/get-session/route.ts b/e2e/test-app/app/app-router/api/get-session/route.ts new file mode 100644 index 00000000..0fb506bd --- /dev/null +++ b/e2e/test-app/app/app-router/api/get-session/route.ts @@ -0,0 +1,13 @@ +import { NextResponse } from "next/server" + +import { auth0 } from "@/lib/auth0" + +export async function GET() { + const session = await auth0.getSession() + + if (!session) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }) + } + + return NextResponse.json(session, { status: 200 }) +} diff --git a/e2e/test-app/app/app-router/api/update-session/route.ts b/e2e/test-app/app/app-router/api/update-session/route.ts new file mode 100644 index 00000000..4db6e811 --- /dev/null +++ b/e2e/test-app/app/app-router/api/update-session/route.ts @@ -0,0 +1,18 @@ +import { NextResponse } from "next/server" + +import { auth0 } from "@/lib/auth0" + +export async function GET() { + const session = await auth0.getSession() + + if (!session) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }) + } + + await auth0.updateSession({ + ...session, + updatedAt: Date.now(), + }) + + return NextResponse.json(null, { status: 200 }) +} diff --git a/e2e/test-app/app/app-router/server/page.tsx b/e2e/test-app/app/app-router/server/page.tsx index feae9c57..29dc975e 100644 --- a/e2e/test-app/app/app-router/server/page.tsx +++ b/e2e/test-app/app/app-router/server/page.tsx @@ -15,6 +15,7 @@ export default async function Home() { return (

Welcome, {session.user.email}!

+

{session.user.updatedAt}

) } diff --git a/e2e/test-app/pages/api/pages-router/get-session/index.ts b/e2e/test-app/pages/api/pages-router/get-session/index.ts new file mode 100644 index 00000000..1956f5c2 --- /dev/null +++ b/e2e/test-app/pages/api/pages-router/get-session/index.ts @@ -0,0 +1,19 @@ +import type { NextApiRequest, NextApiResponse } from "next" +import { SessionData } from "@auth0/nextjs-auth0/types" + +import { auth0 } from "@/lib/auth0" + +type ResponseData = SessionData | { error: string } + +export default async function handler( + req: NextApiRequest, + res: NextApiResponse +) { + const session = await auth0.getSession(req) + + if (!session) { + return res.status(401).json({ error: "Unauthorized" }) + } + + res.status(200).json(session) +} diff --git a/e2e/test-app/pages/api/pages-router/update-session/index.ts b/e2e/test-app/pages/api/pages-router/update-session/index.ts new file mode 100644 index 00000000..4756ecfe --- /dev/null +++ b/e2e/test-app/pages/api/pages-router/update-session/index.ts @@ -0,0 +1,27 @@ +import type { NextApiRequest, NextApiResponse } from "next" + +import { auth0 } from "@/lib/auth0" + +type ResponseData = + | {} + | { + error: string + } + +export default async function handler( + req: NextApiRequest, + res: NextApiResponse +) { + const session = await auth0.getSession(req) + + if (!session) { + return res.status(401).json({ error: "Unauthorized" }) + } + + await auth0.updateSession(req, res, { + ...session, + updatedAt: Date.now(), + }) + + res.status(200).json({}) +} diff --git a/e2e/test-app/pnpm-lock.yaml b/e2e/test-app/pnpm-lock.yaml index fbe9fa66..97eef87d 100644 --- a/e2e/test-app/pnpm-lock.yaml +++ b/e2e/test-app/pnpm-lock.yaml @@ -56,8 +56,8 @@ packages: resolution: {directory: ../.., type: directory} peerDependencies: next: ^14.0.0 || ^15.0.0 - react: ^18 || ^19.0.0-0 - react-dom: ^18 || ^19.0.0-0 + react: ^18.0.0 || ^19.0.0 || ^19.0.0-0 + react-dom: ^18.0.0 || ^19.0.0 || ^19.0.0-0 '@edge-runtime/cookies@5.0.2': resolution: {integrity: sha512-Sd8LcWpZk/SWEeKGE8LT6gMm5MGfX/wm+GPnh1eBEtCpya3vYqn37wYknwAHw92ONoyyREl1hJwxV/Qx2DWNOg==} @@ -2366,8 +2366,8 @@ snapshots: '@typescript-eslint/parser': 8.14.0(eslint@8.57.1)(typescript@5.6.3) eslint: 8.57.1 eslint-import-resolver-node: 0.3.9 - eslint-import-resolver-typescript: 3.6.3(@typescript-eslint/parser@8.14.0(eslint@8.57.1)(typescript@5.6.3))(eslint-import-resolver-node@0.3.9)(eslint-plugin-import@2.31.0(@typescript-eslint/parser@8.14.0(eslint@8.57.1)(typescript@5.6.3))(eslint@8.57.1))(eslint@8.57.1) - eslint-plugin-import: 2.31.0(@typescript-eslint/parser@8.14.0(eslint@8.57.1)(typescript@5.6.3))(eslint-import-resolver-typescript@3.6.3(@typescript-eslint/parser@8.14.0(eslint@8.57.1)(typescript@5.6.3))(eslint-import-resolver-node@0.3.9)(eslint-plugin-import@2.31.0(@typescript-eslint/parser@8.14.0(eslint@8.57.1)(typescript@5.6.3))(eslint@8.57.1))(eslint@8.57.1))(eslint@8.57.1) + eslint-import-resolver-typescript: 3.6.3(@typescript-eslint/parser@8.14.0(eslint@8.57.1)(typescript@5.6.3))(eslint-import-resolver-node@0.3.9)(eslint-plugin-import@2.31.0)(eslint@8.57.1) + eslint-plugin-import: 2.31.0(@typescript-eslint/parser@8.14.0(eslint@8.57.1)(typescript@5.6.3))(eslint-import-resolver-typescript@3.6.3)(eslint@8.57.1) eslint-plugin-jsx-a11y: 6.10.2(eslint@8.57.1) eslint-plugin-react: 7.37.2(eslint@8.57.1) eslint-plugin-react-hooks: 5.0.0(eslint@8.57.1) @@ -2386,37 +2386,37 @@ snapshots: transitivePeerDependencies: - supports-color - eslint-import-resolver-typescript@3.6.3(@typescript-eslint/parser@8.14.0(eslint@8.57.1)(typescript@5.6.3))(eslint-import-resolver-node@0.3.9)(eslint-plugin-import@2.31.0(@typescript-eslint/parser@8.14.0(eslint@8.57.1)(typescript@5.6.3))(eslint@8.57.1))(eslint@8.57.1): + eslint-import-resolver-typescript@3.6.3(@typescript-eslint/parser@8.14.0(eslint@8.57.1)(typescript@5.6.3))(eslint-import-resolver-node@0.3.9)(eslint-plugin-import@2.31.0)(eslint@8.57.1): dependencies: '@nolyfill/is-core-module': 1.0.39 debug: 4.3.7 enhanced-resolve: 5.17.1 eslint: 8.57.1 - eslint-module-utils: 2.12.0(@typescript-eslint/parser@8.14.0(eslint@8.57.1)(typescript@5.6.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.3(@typescript-eslint/parser@8.14.0(eslint@8.57.1)(typescript@5.6.3))(eslint-import-resolver-node@0.3.9)(eslint-plugin-import@2.31.0(@typescript-eslint/parser@8.14.0(eslint@8.57.1)(typescript@5.6.3))(eslint@8.57.1))(eslint@8.57.1))(eslint@8.57.1) + eslint-module-utils: 2.12.0(@typescript-eslint/parser@8.14.0(eslint@8.57.1)(typescript@5.6.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.3)(eslint@8.57.1) fast-glob: 3.3.2 get-tsconfig: 4.8.1 is-bun-module: 1.2.1 is-glob: 4.0.3 optionalDependencies: - eslint-plugin-import: 2.31.0(@typescript-eslint/parser@8.14.0(eslint@8.57.1)(typescript@5.6.3))(eslint-import-resolver-typescript@3.6.3(@typescript-eslint/parser@8.14.0(eslint@8.57.1)(typescript@5.6.3))(eslint-import-resolver-node@0.3.9)(eslint-plugin-import@2.31.0(@typescript-eslint/parser@8.14.0(eslint@8.57.1)(typescript@5.6.3))(eslint@8.57.1))(eslint@8.57.1))(eslint@8.57.1) + eslint-plugin-import: 2.31.0(@typescript-eslint/parser@8.14.0(eslint@8.57.1)(typescript@5.6.3))(eslint-import-resolver-typescript@3.6.3)(eslint@8.57.1) transitivePeerDependencies: - '@typescript-eslint/parser' - eslint-import-resolver-node - eslint-import-resolver-webpack - supports-color - eslint-module-utils@2.12.0(@typescript-eslint/parser@8.14.0(eslint@8.57.1)(typescript@5.6.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.3(@typescript-eslint/parser@8.14.0(eslint@8.57.1)(typescript@5.6.3))(eslint-import-resolver-node@0.3.9)(eslint-plugin-import@2.31.0(@typescript-eslint/parser@8.14.0(eslint@8.57.1)(typescript@5.6.3))(eslint@8.57.1))(eslint@8.57.1))(eslint@8.57.1): + eslint-module-utils@2.12.0(@typescript-eslint/parser@8.14.0(eslint@8.57.1)(typescript@5.6.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.3)(eslint@8.57.1): dependencies: debug: 3.2.7 optionalDependencies: '@typescript-eslint/parser': 8.14.0(eslint@8.57.1)(typescript@5.6.3) eslint: 8.57.1 eslint-import-resolver-node: 0.3.9 - eslint-import-resolver-typescript: 3.6.3(@typescript-eslint/parser@8.14.0(eslint@8.57.1)(typescript@5.6.3))(eslint-import-resolver-node@0.3.9)(eslint-plugin-import@2.31.0(@typescript-eslint/parser@8.14.0(eslint@8.57.1)(typescript@5.6.3))(eslint@8.57.1))(eslint@8.57.1) + eslint-import-resolver-typescript: 3.6.3(@typescript-eslint/parser@8.14.0(eslint@8.57.1)(typescript@5.6.3))(eslint-import-resolver-node@0.3.9)(eslint-plugin-import@2.31.0)(eslint@8.57.1) transitivePeerDependencies: - supports-color - eslint-plugin-import@2.31.0(@typescript-eslint/parser@8.14.0(eslint@8.57.1)(typescript@5.6.3))(eslint-import-resolver-typescript@3.6.3(@typescript-eslint/parser@8.14.0(eslint@8.57.1)(typescript@5.6.3))(eslint-import-resolver-node@0.3.9)(eslint-plugin-import@2.31.0(@typescript-eslint/parser@8.14.0(eslint@8.57.1)(typescript@5.6.3))(eslint@8.57.1))(eslint@8.57.1))(eslint@8.57.1): + eslint-plugin-import@2.31.0(@typescript-eslint/parser@8.14.0(eslint@8.57.1)(typescript@5.6.3))(eslint-import-resolver-typescript@3.6.3)(eslint@8.57.1): dependencies: '@rtsao/scc': 1.1.0 array-includes: 3.1.8 @@ -2427,7 +2427,7 @@ snapshots: doctrine: 2.1.0 eslint: 8.57.1 eslint-import-resolver-node: 0.3.9 - eslint-module-utils: 2.12.0(@typescript-eslint/parser@8.14.0(eslint@8.57.1)(typescript@5.6.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.3(@typescript-eslint/parser@8.14.0(eslint@8.57.1)(typescript@5.6.3))(eslint-import-resolver-node@0.3.9)(eslint-plugin-import@2.31.0(@typescript-eslint/parser@8.14.0(eslint@8.57.1)(typescript@5.6.3))(eslint@8.57.1))(eslint@8.57.1))(eslint@8.57.1) + eslint-module-utils: 2.12.0(@typescript-eslint/parser@8.14.0(eslint@8.57.1)(typescript@5.6.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.3)(eslint@8.57.1) hasown: 2.0.2 is-core-module: 2.15.1 is-glob: 4.0.3 diff --git a/package.json b/package.json index b9f089b3..f992db3a 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@auth0/nextjs-auth0", - "version": "4.0.0-beta.10", + "version": "4.0.0-beta.11", "description": "Auth0 Next.js SDK", "scripts": { "build": "tsc", @@ -42,8 +42,8 @@ }, "peerDependencies": { "next": "^14.0.0 || ^15.0.0", - "react": "^18 || ^19.0.0-0", - "react-dom": "^18 || ^19.0.0-0" + "react": "^18.0.0 || ^19.0.0 || ^19.0.0-0", + "react-dom": "^18.0.0 || ^19.0.0 || ^19.0.0-0" }, "exports": { ".": { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 5645d902..dfec7bda 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -21,10 +21,10 @@ importers: specifier: ^3.1.2 version: 3.1.2 react: - specifier: ^18 || ^19.0.0-0 + specifier: ^18.0.0 || ^19.0.0 || ^19.0.0-0 version: 18.3.1 react-dom: - specifier: ^18 || ^19.0.0-0 + specifier: ^18.0.0 || ^19.0.0 || ^19.0.0-0 version: 18.3.1(react@18.3.1) swr: specifier: ^2.2.5 diff --git a/src/server/auth-client.test.ts b/src/server/auth-client.test.ts index 07af2aeb..0db250f6 100644 --- a/src/server/auth-client.test.ts +++ b/src/server/auth-client.test.ts @@ -23,6 +23,34 @@ describe("Authentication Client", async () => { sub: "user_123", alg: "RS256", keyPair: await jose.generateKeyPair("RS256"), + clientAssertionSigningKey: `-----BEGIN PRIVATE KEY----- +MIIEvAIBADANBgkqhkiG9w0BAQEFAASCBKYwggSiAgEAAoIBAQDbTKOQLtaZ6U1k +3fcYCMVoy8poieNPPcbj15TCLOm4Bbox73/UUxIArqczVcjtUGnL+jn5982V5EiB +y8W51m5K9mIBgEFLYdLkXk+OW5UTE/AdMPtfsIjConGrrs3mxN4WSH9kvh9Yr41r +hWUUSwqFyMOssbGE8K46Cv0WYvS7RXH9MzcyTcMSFp/60yUXH4rdHYZElF7XCdiE +63WxebxI1Qza4xkjTlbp5EWfWBQB1Ms10JO8NjrtkCXrDI57Bij5YanPAVhctcO9 +z5/y9i5xEzcer8ZLO8VDiXSdEsuP/fe+UKDyYHUITD8u51p3O2JwCKvdTHduemej +3Kd1RlHrAgMBAAECggEATWdzpASkQpcSdjPSb21JIIAt5VAmJ2YKuYjyPMdVh1qe +Kdn7KJpZlFwRMBFrZjgn35Nmu1A4BFwbK5UdKUcCjvsABL+cTFsu8ORI+Fpi9+Tl +r6gGUfQhkXF85bhBfN6n9P2J2akxrz/njrf6wXrrL+V5C498tQuus1YFls0+zIpD +N+GngNOPHlGeY3gW4K/HjGuHwuJOvWNmE4KNQhBijdd50Am824Y4NV/SmsIo7z+s +8CLjp/qtihwnE4rkUHnR6M4u5lpzXOnodzkDTG8euOJds0T8DwLNTx1b+ETim35i +D/hOCVwl8QFoj2aatjuJ5LXZtZUEpGpBF2TQecB+gQKBgQDvaZ1jG/FNPnKdayYv +z5yTOhKM6JTB+WjB0GSx8rebtbFppiHGgVhOd1bLIzli9uMOPdCNuXh7CKzIgSA6 +Q76Wxfuaw8F6CBIdlG9bZNL6x8wp6zF8tGz/BgW7fFKBwFYSWzTcStGr2QGtwr6F +9p1gYPSGfdERGOQc7RmhoNNHcQKBgQDqfkhpPfJlP/SdFnF7DDUvuMnaswzUsM6D +ZPhvfzdMBV8jGc0WjCW2Vd3pvsdPgWXZqAKjN7+A5HiT/8qv5ruoqOJSR9ZFZI/B +8v+8gS9Af7K56mCuCFKZmOXUmaL+3J2FKtzAyOlSLjEYyLuCgmhEA9Zo+duGR5xX +AIjx7N/ZGwKBgCZAYqQeJ8ymqJtcLkq/Sg3/3kzjMDlZxxIIYL5JwGpBemod4BGe +QuSujpCAPUABoD97QuIR+xz1Qt36O5LzlfTzBwMwOa5ssbBGMhCRKGBnIcikylBZ +Z3zLkojlES2n9FiUd/qmfZ+OWYVQsy4mO/jVJNyEJ64qou+4NjsrvfYRAoGAORki +3K1+1nSqRY3vd/zS/pnKXPx4RVoADzKI4+1gM5yjO9LOg40AqdNiw8X2lj9143fr +nH64nNQFIFSKsCZIz5q/8TUY0bDY6GsZJnd2YAg4JtkRTY8tPcVjQU9fxxtFJ+X1 +9uN1HNOulNBcCD1k0hr1HH6qm5nYUb8JmY8KOr0CgYB85pvPhBqqfcWi6qaVQtK1 +ukIdiJtMNPwePfsT/2KqrbnftQnAKNnhsgcYGo8NAvntX4FokOAEdunyYmm85mLp +BGKYgVXJqnm6+TJyCRac1ro3noG898P/LZ8MOBoaYQtWeWRpDc46jPrA0FqUJy+i +ca/T0LLtgmbMmxSv/MmzIg== +-----END PRIVATE KEY-----`, requestUri: "urn:ietf:params:oauth:request_uri:6esc_11ACC5bwc014ltc14eY22c", } @@ -2062,6 +2090,114 @@ describe("Authentication Client", async () => { ) }) + it("must use private_key_jwt when a clientAssertionSigningKey is specified", async () => { + function pemToArrayBuffer(pem: string) { + const b64 = pem + .replace("\n", "") + .replace("-----BEGIN PRIVATE KEY-----", "") + .replace("-----END PRIVATE KEY-----", "") + + const byteString = atob(b64) + const byteArray = new Uint8Array(byteString.length) + for (let i = 0; i < byteString.length; i++) { + byteArray[i] = byteString.charCodeAt(i) + } + return byteArray + } + + const clientAssertionSigningKey = await crypto.subtle.importKey( + "pkcs8", + pemToArrayBuffer(DEFAULT.clientAssertionSigningKey), + { + name: "RSASSA-PKCS1-v1_5", + hash: { name: "SHA-256" }, // or SHA-512 + }, + true, + ["sign"] + ) + + const state = "transaction-state" + const code = "auth-code" + + const secret = await generateSecret(32) + const transactionStore = new TransactionStore({ + secret, + }) + const sessionStore = new StatelessSessionStore({ + secret, + }) + const authClient = new AuthClient({ + transactionStore, + sessionStore, + + domain: DEFAULT.domain, + clientId: DEFAULT.clientId, + clientAssertionSigningKey: clientAssertionSigningKey, + clientAssertionSigningAlg: "RS256", + + secret, + appBaseUrl: DEFAULT.appBaseUrl, + + fetch: getMockAuthorizationServer(), + }) + + const url = new URL("/auth/callback", DEFAULT.appBaseUrl) + url.searchParams.set("code", code) + url.searchParams.set("state", state) + + const headers = new Headers() + const transactionState: TransactionState = { + nonce: "nonce-value", + maxAge: 3600, + codeVerifier: "code-verifier", + responseType: "code", + state: state, + returnTo: "/dashboard", + } + headers.set( + "cookie", + `__txn_${state}=${await encrypt(transactionState, secret)}` + ) + const request = new NextRequest(url, { + method: "GET", + headers, + }) + + const response = await authClient.handleCallback(request) + expect(response.status).toEqual(307) + expect(response.headers.get("Location")).not.toBeNull() + + const redirectUrl = new URL(response.headers.get("Location")!) + expect(redirectUrl.pathname).toEqual("/dashboard") + + // validate the session cookie + const sessionCookie = response.cookies.get("__session") + expect(sessionCookie).toBeDefined() + const session = await decrypt(sessionCookie!.value, secret) + expect(session).toEqual({ + user: { + sub: DEFAULT.sub, + }, + tokenSet: { + accessToken: DEFAULT.accessToken, + refreshToken: DEFAULT.refreshToken, + expiresAt: expect.any(Number), + }, + internal: { + sid: expect.any(String), + createdAt: expect.any(Number), + }, + }) + + // validate the transaction cookie has been removed + const transactionCookie = response.cookies.get(`__txn_${state}`) + expect(transactionCookie).toBeDefined() + expect(transactionCookie!.value).toEqual("") + expect(transactionCookie!.expires).toEqual( + new Date("1970-01-01T00:00:00.000Z") + ) + }) + it("should return an error if the state parameter is missing", async () => { const code = "auth-code" @@ -3871,6 +4007,38 @@ describe("Authentication Client", async () => { }) }) }) + + describe("allowInsecureRequests", async () => { + it("should now allow setting allowInsecureRequests when NODE_ENV is set to `production`", async () => { + process.env.NODE_ENV = "production" + const secret = await generateSecret(32) + const transactionStore = new TransactionStore({ + secret, + }) + const sessionStore = new StatelessSessionStore({ + secret, + }) + expect( + () => + new AuthClient({ + transactionStore, + sessionStore, + + domain: DEFAULT.domain, + clientId: DEFAULT.clientId, + clientSecret: DEFAULT.clientSecret, + + secret, + appBaseUrl: DEFAULT.appBaseUrl, + + fetch: getMockAuthorizationServer(), + allowInsecureRequests: true, + }) + ).toThrowError( + "Insecure requests are not allowed in production environments." + ) + }) + }) }) const _authorizationServerMetadata = { diff --git a/src/server/auth-client.ts b/src/server/auth-client.ts index fa6a4448..cbb8dbdc 100644 --- a/src/server/auth-client.ts +++ b/src/server/auth-client.ts @@ -85,7 +85,9 @@ export interface AuthClientOptions { domain: string clientId: string - clientSecret: string + clientSecret?: string + clientAssertionSigningKey?: string | CryptoKey + clientAssertionSigningAlg?: string authorizationParameters?: AuthorizationParameters pushedAuthorizationRequests?: boolean @@ -101,6 +103,7 @@ export interface AuthClientOptions { // custom fetch implementation to allow for dependency injection fetch?: typeof fetch jwksCache?: jose.JWKSCacheInput + allowInsecureRequests?: boolean } export class AuthClient { @@ -108,7 +111,9 @@ export class AuthClient { private sessionStore: AbstractSessionStore private clientMetadata: oauth.Client - private clientSecret: string + private clientSecret?: string + private clientAssertionSigningKey?: string | CryptoKey + private clientAssertionSigningAlg: string private issuer: string private authorizationParameters: AuthorizationParameters private pushedAuthorizationRequests: boolean @@ -123,18 +128,30 @@ export class AuthClient { private fetch: typeof fetch private jwksCache: jose.JWKSCacheInput + private allowInsecureRequests: boolean constructor(options: AuthClientOptions) { // dependencies this.fetch = options.fetch || fetch this.jwksCache = options.jwksCache || {} + this.allowInsecureRequests = options.allowInsecureRequests ?? false + + if (this.allowInsecureRequests && process.env.NODE_ENV === "production") { + throw new Error( + "Insecure requests are not allowed in production environments." + ) + } // stores this.transactionStore = options.transactionStore this.sessionStore = options.sessionStore // authorization server - this.issuer = `https://${options.domain}` + this.issuer = + options.domain.startsWith("http://") || + options.domain.startsWith("https://") + ? options.domain + : `https://${options.domain}` this.clientMetadata = { client_id: options.clientId } this.clientSecret = options.clientSecret this.authorizationParameters = options.authorizationParameters || { @@ -142,6 +159,9 @@ export class AuthClient { } this.pushedAuthorizationRequests = options.pushedAuthorizationRequests ?? false + this.clientAssertionSigningKey = options.clientAssertionSigningKey + this.clientAssertionSigningAlg = + options.clientAssertionSigningAlg || "RS256" if (!this.authorizationParameters.scope) { this.authorizationParameters.scope = DEFAULT_SCOPES @@ -380,12 +400,13 @@ export class AuthClient { const codeGrantResponse = await oauth.authorizationCodeGrantRequest( authorizationServerMetadata, this.clientMetadata, - oauth.ClientSecretPost(this.clientSecret), + await this.getClientAuth(), codeGrantParams, redirectUri.toString(), transactionState.codeVerifier, { [oauth.customFetch]: this.fetch, + [oauth.allowInsecureRequests]: this.allowInsecureRequests, } ) @@ -571,10 +592,11 @@ export class AuthClient { const refreshTokenRes = await oauth.refreshTokenGrantRequest( authorizationServerMetadata, this.clientMetadata, - oauth.ClientSecretPost(this.clientSecret), + await this.getClientAuth(), tokenSet.refreshToken, { [oauth.customFetch]: this.fetch, + [oauth.allowInsecureRequests]: this.allowInsecureRequests, } ) @@ -628,6 +650,7 @@ export class AuthClient { const authorizationServerMetadata = await oauth .discoveryRequest(issuer, { [oauth.customFetch]: this.fetch, + [oauth.allowInsecureRequests]: this.allowInsecureRequests, }) .then((response) => oauth.processDiscoveryResponse(issuer, response)) @@ -789,10 +812,11 @@ export class AuthClient { const response = await oauth.pushedAuthorizationRequest( authorizationServerMetadata, this.clientMetadata, - oauth.ClientSecretPost(this.clientSecret), + await this.getClientAuth(), params, { [oauth.customFetch]: this.fetch, + [oauth.allowInsecureRequests]: this.allowInsecureRequests, } ) @@ -831,4 +855,27 @@ export class AuthClient { return [null, authorizationUrl] } + + private async getClientAuth(): Promise { + if (!this.clientSecret && !this.clientAssertionSigningKey) { + throw new Error( + "The client secret or client assertion signing key must be provided." + ) + } + + let clientPrivateKey = this.clientAssertionSigningKey as + | CryptoKey + | undefined + + if (clientPrivateKey && !(clientPrivateKey instanceof CryptoKey)) { + clientPrivateKey = await jose.importPKCS8( + clientPrivateKey, + this.clientAssertionSigningAlg + ) + } + + return clientPrivateKey + ? oauth.PrivateKeyJwt(clientPrivateKey) + : oauth.ClientSecretPost(this.clientSecret!) + } } diff --git a/src/server/client.ts b/src/server/client.ts index 689f2fe7..b7d32f4d 100644 --- a/src/server/client.ts +++ b/src/server/client.ts @@ -1,6 +1,7 @@ +import type { IncomingMessage, ServerResponse } from "node:http" import { cookies } from "next/headers" import { NextRequest, NextResponse } from "next/server" -import { NextApiRequest } from "next/types" +import { NextApiRequest, NextApiResponse } from "next/types" import { AccessTokenError, AccessTokenErrorCode } from "../errors" import { SessionData } from "../types" @@ -11,7 +12,7 @@ import { OnCallbackHook, RoutesOptions, } from "./auth-client" -import { RequestCookies } from "./cookies" +import { RequestCookies, ResponseCookies } from "./cookies" import { AbstractSessionStore, SessionConfiguration, @@ -49,6 +50,18 @@ interface Auth0ClientOptions { * If enabled, the SDK will use the Pushed Authorization Requests (PAR) protocol when communicating with the authorization server. */ pushedAuthorizationRequests?: boolean + /** + * Private key for use with `private_key_jwt` clients. + * This should be a string that is the contents of a PEM file or a CryptoKey. + */ + clientAssertionSigningKey?: string | CryptoKey + /** + * The algorithm used to sign the client assertion JWT. + * Uses one of `token_endpoint_auth_signing_alg_values_supported` if not specified. + * If the Authorization Server discovery document does not list `token_endpoint_auth_signing_alg_values_supported` + * this property will be required. + */ + clientAssertionSigningAlg?: string // application configuration /** @@ -104,9 +117,17 @@ interface Auth0ClientOptions { * See [Custom routes](https://github.com/auth0/nextjs-auth0#custom-routes) for additional details. */ routes?: RoutesOptions + + /** + * Allow insecure requests to be made to the authorization server. This can be useful when testing + * with a mock OIDC provider that does not support TLS, locally. + * This option can only be used when NODE_ENV is not set to `production`. + */ + allowInsecureRequests?: boolean } -type PagesRouterRequest = Pick +type PagesRouterRequest = IncomingMessage | NextApiRequest +type PagesRouterResponse = ServerResponse | NextApiResponse export class Auth0Client { private transactionStore: TransactionStore @@ -139,12 +160,6 @@ export class Auth0Client { } } - if (domain && domain.includes("://")) { - throw new Error( - "The Auth0 domain should not contain a protocol. Please ensure the AUTH0_DOMAIN environment variable or `domain` configuration option uses the correct format (`example.us.auth0.com`)." - ) - } - this.transactionStore = new TransactionStore({ ...options.session, secret, @@ -173,6 +188,8 @@ export class Auth0Client { clientSecret, authorizationParameters: options.authorizationParameters, pushedAuthorizationRequests: options.pushedAuthorizationRequests, + clientAssertionSigningKey: options.clientAssertionSigningKey, + clientAssertionSigningAlg: options.clientAssertionSigningAlg, appBaseUrl, secret, @@ -182,6 +199,8 @@ export class Auth0Client { onCallback: options.onCallback, routes: options.routes, + + allowInsecureRequests: options.allowInsecureRequests, }) } @@ -275,6 +294,73 @@ export class Auth0Client { } } + /** + * updateSession updates the session of the currently authenticated user. If the user does not have a session, an error is thrown. + * + * This method can be used in `getServerSideProps`, API routes, and middleware in the **Pages Router**. + */ + async updateSession( + req: PagesRouterRequest, + res: PagesRouterResponse, + session: SessionData + ): Promise + + /** + * updateSession updates the session of the currently authenticated user. If the user does not have a session, an error is thrown. + * + * This method can be used in Server Actions, Route Handlers, and middleware in the **App Router**. + */ + async updateSession(session: SessionData): Promise + + /** + * updateSession updates the session of the currently authenticated user. If the user does not have a session, an error is thrown. + */ + async updateSession( + reqOrSession: PagesRouterRequest | SessionData, + res?: PagesRouterResponse, + sessionData?: SessionData + ) { + if (!res) { + // app router + const existingSession = await this.getSession() + + if (!existingSession) { + throw new Error("The user is not authenticated.") + } + + const updatedSession = reqOrSession as SessionData + await this.sessionStore.set(await cookies(), await cookies(), { + ...updatedSession, + internal: { + ...existingSession.internal, + }, + }) + } else { + // pages router + const req = reqOrSession as NextApiRequest + const existingSession = await this.getSession(req) + + if (!existingSession) { + throw new Error("The user is not authenticated.") + } + + const resHeaders = new Headers() + const resCookies = new ResponseCookies(resHeaders) + const updatedSession = sessionData as SessionData + + await this.sessionStore.set(this.createRequestCookies(req), resCookies, { + ...updatedSession, + internal: { + ...existingSession.internal, + }, + }) + + for (const [key, value] of resHeaders.entries()) { + res.setHeader(key, value) + } + } + } + private createRequestCookies(req: PagesRouterRequest) { const headers = new Headers()