From fed3bf46c02e7be426a4f2f1eac8214216224b18 Mon Sep 17 00:00:00 2001
From: guabu <135956181+guabu@users.noreply.github.com>
Date: Tue, 10 Dec 2024 12:57:36 +0100
Subject: [PATCH] 4.0.0-beta.10 (#1833)
---
README.md | 60 +-
V4_MIGRATION_GUIDE.md | 241 ++++++++
package.json | 25 +-
src/client/helpers/get-access-token.ts | 4 +-
src/client/hooks/use-user.ts | 2 +-
src/server/auth-client.test.ts | 574 ++++++++++++++++--
src/server/auth-client.ts | 182 ++++--
src/server/client.ts | 44 +-
src/server/session/abstract-session-store.ts | 19 +-
.../session/stateful-session-store.test.ts | 55 ++
src/server/session/stateful-session-store.ts | 4 +
.../session/stateless-session-store.test.ts | 43 ++
src/server/session/stateless-session-store.ts | 4 +
src/server/transaction-store.test.ts | 39 ++
src/server/transaction-store.ts | 5 +-
vitest.config.mts | 2 +-
16 files changed, 1199 insertions(+), 104 deletions(-)
create mode 100644 V4_MIGRATION_GUIDE.md
diff --git a/README.md b/README.md
index 5fcca121..689d54db 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.9
+npm i @auth0/nextjs-auth0@4.0.0-beta.10
```
### 2. Add the environment variables
@@ -109,19 +109,21 @@ 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. |
+| 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. |
## Passing authorization parameters
@@ -519,3 +521,33 @@ The SDK mounts 6 routes:
4. `/auth/profile`: the route to check the user's session and return their attributes
5. `/auth/access-token`: the route to check the user's session and return an access token (which will be automatically refreshed if a refresh token is available)
6. `/auth/backchannel-logout`: the route that will receive a `logout_token` when a configured Back-Channel Logout initiator occurs
+
+### Custom routes
+
+The default paths can be set using the `routes` configuration option. For example, when instantiating the client:
+
+```ts
+import { Auth0Client } from "@auth0/nextjs-auth0/server"
+
+export const auth0 = new Auth0Client({
+ routes: {
+ login: "/login",
+ logout: "/logout",
+ callback: "/callback",
+ backChannelLogout: "/backchannel-logout",
+ },
+})
+```
+
+To configure the profile and access token routes, you must use the `NEXT_PUBLIC_PROFILE_ROUTE` and `NEXT_PUBLIC_ACCESS_TOKEN_ROUTE`, respectively. For example:
+
+```
+# .env.local
+# required environment variables...
+
+NEXT_PUBLIC_PROFILE_ROUTE=/api/me
+NEXT_PUBLIC_ACCESS_TOKEN_ROUTE=/api/auth/token
+```
+
+> [!IMPORTANT]
+> Updating the route paths will also require updating the **Allowed Callback URLs** and **Allowed Logout URLs** configured in the [Auth0 Dashboard](https://manage.auth0.com) for your client.
diff --git a/V4_MIGRATION_GUIDE.md b/V4_MIGRATION_GUIDE.md
new file mode 100644
index 00000000..cdfca621
--- /dev/null
+++ b/V4_MIGRATION_GUIDE.md
@@ -0,0 +1,241 @@
+# V4 Migration Guide
+
+Guide to migrating from `3.x` to `4.x`.
+
+## Environment variables
+
+The following environment variables are required in v4:
+
+```
+AUTH0_DOMAIN
+AUTH0_CLIENT_ID
+AUTH0_CLIENT_SECRET
+AUTH0_SECRET
+APP_BASE_URL
+```
+
+Of the required variables, the following have changed from v3:
+
+- `AUTH0_BASE_URL` has been renamed to `APP_BASE_URL` (e.g.: `http://localhost:3000`)
+- `AUTH0_ISSUER_BASE_URL` has been renamed to `AUTH0_DOMAIN` and does **not** accept a scheme (e.g.: `example.us.auth0.com`)
+
+All other configuration must be specified via the `Auth0Client` constructor.
+
+## Routes
+
+Previously, it was required to set up a dynamic Route Handler to mount the authentication endpoints to handle requests.
+
+For example, in v3 when using the App Router, you were required to create a Route Handler, under `/app/api/auth/[auth0]/route.ts`, with the following contents:
+
+```ts
+import { handleAuth } from "@auth0/nextjs-auth0"
+
+export const GET = handleAuth()
+```
+
+In v4, the routes are now mounted automatically by the middleware:
+
+```ts
+import type { NextRequest } from "next/server"
+
+import { auth0 } from "./lib/auth0"
+
+export async function middleware(request: NextRequest) {
+ return await auth0.middleware(request)
+}
+```
+
+For a complete example, see [the Getting Started section](https://github.com/auth0/nextjs-auth0/tree/v4?tab=readme-ov-file#getting-started).
+
+Additionally, in v4, the mounted routes drop the `/api` prefix. For example, the default login route is now `/auth/login` instead of `/api/auth/login`. To link to the login route, it would now be: `Log in`.
+
+> [!NOTE]
+> If you are using an existing client, you will need to update your **Allowed Callback URLs** accordingly.
+
+The complete list of routes mounted by the SDK can be found [here](https://github.com/auth0/nextjs-auth0/tree/v4?tab=readme-ov-file#routes).
+
+## Auth0 middleware
+
+In v4, the Auth0 middleware is a central component of the SDK. It serves a number of core functions such as registering the required authentication endpoints, providing rolling sessions functionality, keeping access tokens fresh, etc.
+
+When configuring your application to use v4 of the SDK, it is now **required** to mount the middleware:
+
+```ts
+// middleware.ts
+
+import type { NextRequest } from "next/server"
+
+import { auth0 } from "./lib/auth0"
+
+export async function middleware(request: NextRequest) {
+ return await auth0.middleware(request)
+}
+
+export const config = {
+ matcher: [
+ /*
+ * Match all request paths except for the ones starting with:
+ * - _next/static (static files)
+ * - _next/image (image optimization files)
+ * - favicon.ico, sitemap.xml, robots.txt (metadata files)
+ */
+ "/((?!_next/static|_next/image|favicon.ico|sitemap.xml|robots.txt).*)",
+ ],
+}
+```
+
+See [the Getting Started section](https://github.com/auth0/nextjs-auth0/tree/v4?tab=readme-ov-file#getting-started) for details on how to configure the middleware.
+
+### Protecting routes
+
+By default, **the middleware does not protect any routes**. To protect a page, you can use the `getSession()` handler in the middleware, like so:
+
+```ts
+export async function middleware(request: NextRequest) {
+ const authRes = await auth0.middleware(request)
+
+ // authentication routes — let the middleware handle it
+ if (request.nextUrl.pathname.startsWith("/auth")) {
+ return authRes
+ }
+
+ const { origin } = new URL(request.url)
+ const session = await auth0.getSession()
+
+ // user does not have a session — redirect to login
+ if (!session) {
+ return NextResponse.redirect(`${origin}/auth/login`)
+ }
+
+ return authRes
+}
+```
+
+> [!NOTE]
+> We recommend keeping the security checks as close as possible to the data source you're accessing. This is also in-line with [the recommendations from the Next.js team](https://nextjs.org/docs/app/building-your-application/authentication#optimistic-checks-with-middleware-optional).
+
+## ``
+
+The `` has been renamed to ``.
+
+Previously, when setting up your application to use v3 of the SDK, it was required to wrap your layout in the ``. **This is no longer required by default.**
+
+If you would like to pass an initial user during server rendering to be available to the `useUser()` hook, you can wrap your components with the new `` ([see example](https://github.com/auth0/nextjs-auth0/tree/v4?tab=readme-ov-file#auth0provider-)).
+
+## Rolling sessions
+
+In v4, rolling sessions are enabled by default and are handled automatically by the middleware with no additional configuration required.
+
+See the [session configuration section](https://github.com/auth0/nextjs-auth0/tree/v4?tab=readme-ov-file#session-configuration) for additional details on how to configure it.
+
+## `withPageAuthRequired` and `withApiAuthRequired`
+
+`withPageAuthRequired` and `withApiAuthRequired` have been removed from v4 of the SDK. Instead, we recommend adding a `getSession()` check or relying on `useUser()` hook where you would have previously used the helpers.
+
+On the server-side, the `getSession()` method can be used to check if the user is authenticated:
+
+```tsx
+function Page() {
+ const session = await getSession()
+
+ if (!session) {
+ // the user will be redirected to authenticate and then taken to the
+ // /dashboard route after successfully being authenticated
+ return redirect('/auth/login?returnTo=/dashboard')
+ }
+
+ return
Hello, {session.user.name}
+}
+```
+
+The `getSession()` method can be used in the App Router in Server Components, Server Routes (APIs), Server Actions, and middleware.
+
+In the Pages Router, the `getSession(req)` method takes a request object and can be used in `getServerSideProps`, API routes, and middleware.
+
+Read more about [accessing the authenticated user here](https://github.com/guabu/nextjs-auth0/tree/v4?tab=readme-ov-file#accessing-the-authenticated-user).
+
+In the browser, you can rely on the `useUser()` hook to check if the user is authenticated. For example:
+
+```tsx
+"use client"
+
+import { useUser } from "@auth0/nextjs-auth0"
+
+export default function Profile() {
+ const { user, isLoading, error } = useUser()
+
+ if (isLoading) return Loading...
+ if (!user) return Not authenticated!
+
+ return (
+
+ Profile
+
+
{JSON.stringify(user, null, 2)}
+
+
+ )
+}
+```
+
+## Passing custom authorization parameters
+
+In v3, custom authorization parameters required specifying a custom handler, like so:
+
+```ts
+import { handleAuth, handleLogin } from "@auth0/nextjs-auth0"
+
+export default handleAuth({
+ login: handleLogin({
+ authorizationParams: { audience: "urn:my-api" },
+ }),
+})
+```
+
+In v4, you can simply append the authorization parameters to the query parameter of the login endpoint and they will be automatically fowarded to the `/authorize` endpoint, like so:
+
+```html
+Login
+```
+
+Or alternatively, it can be statically configured when initializing the SDK, like so:
+
+```ts
+export const auth0 = new Auth0Client({
+ authorizationParameters: {
+ scope: "openid profile email",
+ audience: "urn:custom:api",
+ },
+})
+```
+
+Read more about [passing authorization parameters](https://github.com/auth0/nextjs-auth0/tree/v4?tab=readme-ov-file#passing-authorization-parameters).
+
+## ID token claims
+
+In v3, any claims added to the ID token were automatically propagated to the `user` object in the session. This resulted in the large cookies that exceeded browser limits.
+
+In v4, by default, the only claims that are persisted in the `user` object of session are:
+
+- `sub`
+- `name`
+- `nickname`
+- `given_name`
+- `family_name`
+- `picture`
+- `email`
+- `email_verified`
+- `org_id`
+
+If you'd like to customize the `user` object to include additional custom claims from the ID token, you can use the `beforeSessionSaved` hook (see [beforeSessionSaved hook](https://github.com/guabu/nextjs-auth0/tree/v4?tab=readme-ov-file#beforesessionsaved))
+
+## Additional changes
+
+- By default, v4 is edge-compatible and as such there is no longer a `@auth0/nextjs-auth0/edge` export.
+- Cookie chunking has been removed
+ - If the cookie size exceeds the browser limit of 4096 bytes, a warning will be logged
+ - 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/package.json b/package.json
index cccf2eb3..b9f089b3 100644
--- a/package.json
+++ b/package.json
@@ -1,8 +1,7 @@
{
"name": "@auth0/nextjs-auth0",
- "version": "4.0.0-beta.9",
+ "version": "4.0.0-beta.10",
"description": "Auth0 Next.js SDK",
- "main": "dist/index.js",
"scripts": {
"build": "tsc",
"build:watch": "tsc -w",
@@ -69,5 +68,25 @@
},
"publishConfig": {
"access": "public"
- }
+ },
+ "typesVersions": {
+ "*": {
+ "types": [
+ "./dist/types/index.d.ts"
+ ],
+ "server": [
+ "./dist/server/index.d.ts"
+ ],
+ "errors": [
+ "./dist/errors/index.d.ts"
+ ],
+ "*": [
+ "./dist/client/*",
+ "./dist/client/index.d.ts"
+ ]
+ }
+ },
+ "files": [
+ "dist"
+ ]
}
diff --git a/src/client/helpers/get-access-token.ts b/src/client/helpers/get-access-token.ts
index 93736793..98e618db 100644
--- a/src/client/helpers/get-access-token.ts
+++ b/src/client/helpers/get-access-token.ts
@@ -1,7 +1,9 @@
import { AccessTokenError } from "../../errors"
export async function getAccessToken() {
- const tokenRes = await fetch("/auth/access-token")
+ const tokenRes = await fetch(
+ process.env.NEXT_PUBLIC_ACCESS_TOKEN_ROUTE || "/auth/access-token"
+ )
if (!tokenRes.ok) {
// try to parse it as JSON and throw the error from the API
diff --git a/src/client/hooks/use-user.ts b/src/client/hooks/use-user.ts
index 681c4461..4dfcb3cd 100644
--- a/src/client/hooks/use-user.ts
+++ b/src/client/hooks/use-user.ts
@@ -6,7 +6,7 @@ import type { User } from "../../types"
export function useUser() {
const { data, error, isLoading } = useSWR(
- "/auth/profile",
+ process.env.NEXT_PUBLIC_PROFILE_ROUTE || "/auth/profile",
(...args) =>
fetch(...args).then((res) => {
if (!res.ok) {
diff --git a/src/server/auth-client.test.ts b/src/server/auth-client.test.ts
index 614a4d9b..07af2aeb 100644
--- a/src/server/auth-client.test.ts
+++ b/src/server/auth-client.test.ts
@@ -23,6 +23,7 @@ describe("Authentication Client", async () => {
sub: "user_123",
alg: "RS256",
keyPair: await jose.generateKeyPair("RS256"),
+ requestUri: "urn:ietf:params:oauth:request_uri:6esc_11ACC5bwc014ltc14eY22c",
}
function getMockAuthorizationServer({
@@ -31,55 +32,76 @@ describe("Authentication Client", async () => {
audience,
nonce,
keyPair = DEFAULT.keyPair,
+ onParRequest,
}: {
tokenEndpointResponse?: oauth.TokenEndpointResponse | oauth.OAuth2Error
discoveryResponse?: Response
audience?: string
nonce?: string
keyPair?: jose.GenerateKeyPairResult
+ onParRequest?: (request: Request) => Promise
} = {}) {
// this function acts as a mock authorization server
- return vi.fn(async (input: RequestInfo | URL): Promise => {
- let url: URL
- if (input instanceof Request) {
- url = new URL(input.url)
- } else {
- url = new URL(input)
- }
+ return vi.fn(
+ async (
+ input: RequestInfo | URL,
+ init?: RequestInit
+ ): Promise => {
+ let url: URL
+ if (input instanceof Request) {
+ url = new URL(input.url)
+ } else {
+ url = new URL(input)
+ }
- if (url.pathname === "/oauth/token") {
- const jwt = await new jose.SignJWT({
- sid: DEFAULT.sid,
- auth_time: Date.now(),
- nonce: nonce ?? "nonce-value",
- "https://example.com/custom_claim": "value",
- })
- .setProtectedHeader({ alg: DEFAULT.alg })
- .setSubject(DEFAULT.sub)
- .setIssuedAt()
- .setIssuer(_authorizationServerMetadata.issuer)
- .setAudience(audience ?? DEFAULT.clientId)
- .setExpirationTime("2h")
- .sign(keyPair.privateKey)
-
- return Response.json(
- tokenEndpointResponse ?? {
- token_type: "Bearer",
- access_token: DEFAULT.accessToken,
- refresh_token: DEFAULT.refreshToken,
- id_token: jwt,
- expires_in: 86400, // expires in 10 days
+ if (url.pathname === "/oauth/token") {
+ const jwt = await new jose.SignJWT({
+ sid: DEFAULT.sid,
+ auth_time: Date.now(),
+ nonce: nonce ?? "nonce-value",
+ "https://example.com/custom_claim": "value",
+ })
+ .setProtectedHeader({ alg: DEFAULT.alg })
+ .setSubject(DEFAULT.sub)
+ .setIssuedAt()
+ .setIssuer(_authorizationServerMetadata.issuer)
+ .setAudience(audience ?? DEFAULT.clientId)
+ .setExpirationTime("2h")
+ .sign(keyPair.privateKey)
+ return Response.json(
+ tokenEndpointResponse ?? {
+ token_type: "Bearer",
+ access_token: DEFAULT.accessToken,
+ refresh_token: DEFAULT.refreshToken,
+ id_token: jwt,
+ expires_in: 86400, // expires in 10 days
+ }
+ )
+ }
+ // discovery URL
+ if (url.pathname === "/.well-known/openid-configuration") {
+ return (
+ discoveryResponse ?? Response.json(_authorizationServerMetadata)
+ )
+ }
+ // PAR endpoint
+ if (url.pathname === "/oauth/par") {
+ if (onParRequest) {
+ // TODO: for some reason the input here is a URL and not a request
+ await onParRequest(new Request(input, init))
}
- )
- }
- // discovery URL
- if (url.pathname === "/.well-known/openid-configuration") {
- return discoveryResponse ?? Response.json(_authorizationServerMetadata)
- }
+ return Response.json(
+ { request_uri: DEFAULT.requestUri, expires_in: 30 },
+ {
+ status: 201,
+ }
+ )
+ }
- return new Response(null, { status: 404 })
- })
+ return new Response(null, { status: 404 })
+ }
+ )
}
async function generateLogoutToken({
@@ -531,6 +553,230 @@ describe("Authentication Client", async () => {
expect(updatedSessionCookie).toBeUndefined()
})
})
+
+ describe("with custom routes", async () => {
+ it("should call the login handler when the configured route is called", async () => {
+ 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,
+ clientSecret: DEFAULT.clientSecret,
+
+ secret,
+ appBaseUrl: DEFAULT.appBaseUrl,
+
+ fetch: getMockAuthorizationServer(),
+
+ routes: {
+ login: "/custom-login",
+ },
+ })
+ const request = new NextRequest(
+ new URL("/custom-login", DEFAULT.appBaseUrl),
+ {
+ method: "GET",
+ }
+ )
+
+ authClient.handleLogin = vi.fn()
+ await authClient.handler(request)
+ expect(authClient.handleLogin).toHaveBeenCalled()
+ })
+
+ it("should call the logout handler when the configured route is called", async () => {
+ 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,
+ clientSecret: DEFAULT.clientSecret,
+
+ secret,
+ appBaseUrl: DEFAULT.appBaseUrl,
+
+ fetch: getMockAuthorizationServer(),
+
+ routes: {
+ logout: "/custom-logout",
+ },
+ })
+ const request = new NextRequest(
+ new URL("/custom-logout", DEFAULT.appBaseUrl),
+ {
+ method: "GET",
+ }
+ )
+
+ authClient.handleLogout = vi.fn()
+ await authClient.handler(request)
+ expect(authClient.handleLogout).toHaveBeenCalled()
+ })
+
+ it("should call the callback handler when the configured route is called", async () => {
+ 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,
+ clientSecret: DEFAULT.clientSecret,
+
+ secret,
+ appBaseUrl: DEFAULT.appBaseUrl,
+
+ fetch: getMockAuthorizationServer(),
+
+ routes: {
+ callback: "/custom-callback",
+ },
+ })
+ const request = new NextRequest(
+ new URL("/custom-callback", DEFAULT.appBaseUrl),
+ {
+ method: "GET",
+ }
+ )
+
+ authClient.handleCallback = vi.fn()
+ await authClient.handler(request)
+ expect(authClient.handleCallback).toHaveBeenCalled()
+ })
+
+ it("should call the backChannelLogout handler when the configured route is called", async () => {
+ 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,
+ clientSecret: DEFAULT.clientSecret,
+
+ secret,
+ appBaseUrl: DEFAULT.appBaseUrl,
+
+ fetch: getMockAuthorizationServer(),
+
+ routes: {
+ backChannelLogout: "/custom-backchannel-logout",
+ },
+ })
+ const request = new NextRequest(
+ new URL("/custom-backchannel-logout", DEFAULT.appBaseUrl),
+ {
+ method: "POST",
+ }
+ )
+
+ authClient.handleBackChannelLogout = vi.fn()
+ await authClient.handler(request)
+ expect(authClient.handleBackChannelLogout).toHaveBeenCalled()
+ })
+
+ it("should call the profile handler when the configured route is called", async () => {
+ process.env.NEXT_PUBLIC_PROFILE_ROUTE = "/custom-profile"
+
+ 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,
+ clientSecret: DEFAULT.clientSecret,
+
+ secret,
+ appBaseUrl: DEFAULT.appBaseUrl,
+
+ fetch: getMockAuthorizationServer(),
+ })
+ const request = new NextRequest(
+ new URL("/custom-profile", DEFAULT.appBaseUrl),
+ {
+ method: "GET",
+ }
+ )
+
+ authClient.handleProfile = vi.fn()
+ await authClient.handler(request)
+ expect(authClient.handleProfile).toHaveBeenCalled()
+
+ delete process.env.NEXT_PUBLIC_PROFILE_ROUTE
+ })
+
+ it("should call the access-token handler when the configured route is called", async () => {
+ process.env.NEXT_PUBLIC_ACCESS_TOKEN_ROUTE = "/custom-access-token"
+
+ 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,
+ clientSecret: DEFAULT.clientSecret,
+
+ secret,
+ appBaseUrl: DEFAULT.appBaseUrl,
+
+ fetch: getMockAuthorizationServer(),
+ })
+ const request = new NextRequest(
+ new URL("/custom-access-token", DEFAULT.appBaseUrl),
+ {
+ method: "GET",
+ }
+ )
+
+ authClient.handleAccessToken = vi.fn()
+ await authClient.handler(request)
+ expect(authClient.handleAccessToken).toHaveBeenCalled()
+
+ delete process.env.NEXT_PUBLIC_ACCESS_TOKEN_ROUTE
+ })
+ })
})
describe("handleLogin", async () => {
@@ -1093,6 +1339,259 @@ describe("Authentication Client", async () => {
returnTo: "/dashboard",
})
})
+
+ describe("with pushed authorization requests", async () => {
+ it("should return an error if the authorization server does not support PAR", async () => {
+ 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,
+ clientSecret: DEFAULT.clientSecret,
+ pushedAuthorizationRequests: true,
+ secret,
+ appBaseUrl: DEFAULT.appBaseUrl,
+ fetch: getMockAuthorizationServer({
+ discoveryResponse: Response.json(
+ {
+ ..._authorizationServerMetadata,
+ pushed_authorization_request_endpoint: null,
+ },
+ {
+ status: 200,
+ headers: {
+ "content-type": "application/json",
+ },
+ }
+ ),
+ }),
+ })
+
+ const request = new NextRequest(
+ new URL("/auth/login", DEFAULT.appBaseUrl),
+ {
+ method: "GET",
+ }
+ )
+ const response = await authClient.handleLogin(request)
+
+ expect(response.status).toEqual(500)
+ expect(await response.text()).toEqual(
+ "An error occured while trying to initiate the login request."
+ )
+ })
+
+ it("should redirect to the authorization server with the request_uri and store the transaction state", async () => {
+ 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,
+ clientSecret: DEFAULT.clientSecret,
+ pushedAuthorizationRequests: true,
+ secret,
+ appBaseUrl: DEFAULT.appBaseUrl,
+ fetch: getMockAuthorizationServer({
+ onParRequest: async (request) => {
+ const params = new URLSearchParams(await request.text())
+ expect(params.get("client_id")).toEqual(DEFAULT.clientId)
+ expect(params.get("redirect_uri")).toEqual(
+ `${DEFAULT.appBaseUrl}/auth/callback`
+ )
+ expect(params.get("response_type")).toEqual("code")
+ expect(params.get("code_challenge")).toEqual(expect.any(String))
+ expect(params.get("code_challenge_method")).toEqual("S256")
+ expect(params.get("state")).toEqual(expect.any(String))
+ expect(params.get("nonce")).toEqual(expect.any(String))
+ expect(params.get("scope")).toEqual(
+ "openid profile email offline_access"
+ )
+ },
+ }),
+ })
+
+ const request = new NextRequest(
+ new URL("/auth/login", DEFAULT.appBaseUrl),
+ {
+ method: "GET",
+ }
+ )
+
+ const response = await authClient.handleLogin(request)
+ expect(response.status).toEqual(307)
+ expect(response.headers.get("Location")).not.toBeNull()
+
+ const authorizationUrl = new URL(response.headers.get("Location")!)
+ expect(authorizationUrl.origin).toEqual(`https://${DEFAULT.domain}`)
+ // query parameters should only include the `request_uri` and not the standard auth params
+ expect(authorizationUrl.searchParams.get("request_uri")).toEqual(
+ DEFAULT.requestUri
+ )
+ expect(authorizationUrl.searchParams.get("client_id")).toEqual(
+ DEFAULT.clientId
+ )
+ expect(authorizationUrl.searchParams.get("redirect_uri")).toBeNull()
+ expect(authorizationUrl.searchParams.get("response_type")).toBeNull()
+ expect(authorizationUrl.searchParams.get("code_challenge")).toBeNull()
+ expect(
+ authorizationUrl.searchParams.get("code_challenge_method")
+ ).toBeNull()
+ expect(authorizationUrl.searchParams.get("state")).toBeNull()
+ expect(authorizationUrl.searchParams.get("nonce")).toBeNull()
+ expect(authorizationUrl.searchParams.get("scope")).toBeNull()
+
+ // transaction state
+ const transactionCookies = response.cookies
+ .getAll()
+ .filter((c) => c.name.startsWith("__txn_"))
+ expect(transactionCookies.length).toEqual(1)
+ const transactionCookie = transactionCookies[0]
+ const state = transactionCookie.name.replace("__txn_", "")
+ expect(transactionCookie).toBeDefined()
+ expect(await decrypt(transactionCookie!.value, secret)).toEqual({
+ nonce: expect.any(String),
+ codeVerifier: expect.any(String),
+ responseType: "code",
+ state,
+ returnTo: "/",
+ })
+ })
+
+ it("should forward any custom parameters to the authorization server in the PAR request", async () => {
+ const secret = await generateSecret(32)
+ const transactionStore = new TransactionStore({
+ secret,
+ })
+ const sessionStore = new StatelessSessionStore({
+ secret,
+ })
+
+ // set custom parameters in the login URL which should be forwarded to the authorization server (in PAR request)
+ const loginUrl = new URL("/auth/login", DEFAULT.appBaseUrl)
+ loginUrl.searchParams.set("ext-custom_param", "custom_value")
+ loginUrl.searchParams.set("audience", "urn:mystore:api")
+ const request = new NextRequest(loginUrl, {
+ method: "GET",
+ })
+
+ const authClient = new AuthClient({
+ transactionStore,
+ sessionStore,
+ domain: DEFAULT.domain,
+ clientId: DEFAULT.clientId,
+ clientSecret: DEFAULT.clientSecret,
+ pushedAuthorizationRequests: true,
+ secret,
+ appBaseUrl: DEFAULT.appBaseUrl,
+ fetch: getMockAuthorizationServer({
+ onParRequest: async (request) => {
+ const params = new URLSearchParams(await request.text())
+ expect(params.get("ext-custom_param")).toEqual("custom_value")
+ expect(params.get("audience")).toEqual("urn:mystore:api")
+ },
+ }),
+ })
+
+ const response = await authClient.handleLogin(request)
+ expect(response.status).toEqual(307)
+ expect(response.headers.get("Location")).not.toBeNull()
+ const authorizationUrl = new URL(response.headers.get("Location")!)
+ expect(authorizationUrl.origin).toEqual(`https://${DEFAULT.domain}`)
+ // query parameters should only include the `request_uri` and not the standard auth params
+ expect(authorizationUrl.searchParams.get("request_uri")).toEqual(
+ DEFAULT.requestUri
+ )
+ expect(authorizationUrl.searchParams.get("client_id")).toEqual(
+ DEFAULT.clientId
+ )
+ expect(authorizationUrl.searchParams.get("redirect_uri")).toBeNull()
+ expect(authorizationUrl.searchParams.get("response_type")).toBeNull()
+ expect(authorizationUrl.searchParams.get("code_challenge")).toBeNull()
+ expect(
+ authorizationUrl.searchParams.get("code_challenge_method")
+ ).toBeNull()
+ expect(authorizationUrl.searchParams.get("state")).toBeNull()
+ expect(authorizationUrl.searchParams.get("nonce")).toBeNull()
+ expect(authorizationUrl.searchParams.get("scope")).toBeNull()
+
+ // transaction state
+ const transactionCookies = response.cookies
+ .getAll()
+ .filter((c) => c.name.startsWith("__txn_"))
+ expect(transactionCookies.length).toEqual(1)
+ const transactionCookie = transactionCookies[0]
+ const state = transactionCookie.name.replace("__txn_", "")
+ expect(transactionCookie).toBeDefined()
+ expect(await decrypt(transactionCookie!.value, secret)).toEqual({
+ nonce: expect.any(String),
+ codeVerifier: expect.any(String),
+ responseType: "code",
+ state,
+ returnTo: "/",
+ })
+ })
+ })
+
+ describe("with custom callback route", async () => {
+ it("should redirect to the custom callback route after login", async () => {
+ 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,
+ clientSecret: DEFAULT.clientSecret,
+
+ secret,
+ appBaseUrl: DEFAULT.appBaseUrl,
+
+ fetch: getMockAuthorizationServer(),
+
+ routes: {
+ callback: "/custom-callback",
+ },
+ })
+ const request = new NextRequest(
+ new URL("/auth/login", DEFAULT.appBaseUrl),
+ {
+ method: "GET",
+ }
+ )
+
+ const response = await authClient.handleLogin(request)
+ expect(response.status).toEqual(307)
+ expect(response.headers.get("Location")).not.toBeNull()
+
+ const authorizationUrl = new URL(response.headers.get("Location")!)
+ expect(authorizationUrl.origin).toEqual(`https://${DEFAULT.domain}`)
+
+ // query parameters
+ expect(authorizationUrl.searchParams.get("redirect_uri")).toEqual(
+ `${DEFAULT.appBaseUrl}/custom-callback`
+ )
+ })
+ })
})
describe("handleLogout", async () => {
@@ -3442,4 +3941,5 @@ const _authorizationServerMetadata = {
backchannel_logout_supported: true,
backchannel_logout_session_supported: true,
end_session_endpoint: "https://guabu.us.auth0.com/oidc/logout",
+ pushed_authorization_request_endpoint: "https://guabu.us.auth0.com/oauth/par",
}
diff --git a/src/server/auth-client.ts b/src/server/auth-client.ts
index f298e8c8..fa6a4448 100644
--- a/src/server/auth-client.ts
+++ b/src/server/auth-client.ts
@@ -67,6 +67,18 @@ export interface AuthorizationParameters {
[key: string]: unknown
}
+interface Routes {
+ login: string
+ logout: string
+ callback: string
+ profile: string
+ accessToken: string
+ backChannelLogout: string
+}
+export type RoutesOptions = Partial<
+ Pick
+>
+
export interface AuthClientOptions {
transactionStore: TransactionStore
sessionStore: AbstractSessionStore
@@ -75,6 +87,7 @@ export interface AuthClientOptions {
clientId: string
clientSecret: string
authorizationParameters?: AuthorizationParameters
+ pushedAuthorizationRequests?: boolean
secret: string
appBaseUrl: string
@@ -83,6 +96,8 @@ export interface AuthClientOptions {
beforeSessionSaved?: BeforeSessionSavedHook
onCallback?: OnCallbackHook
+ routes?: RoutesOptions
+
// custom fetch implementation to allow for dependency injection
fetch?: typeof fetch
jwksCache?: jose.JWKSCacheInput
@@ -96,6 +111,7 @@ export class AuthClient {
private clientSecret: string
private issuer: string
private authorizationParameters: AuthorizationParameters
+ private pushedAuthorizationRequests: boolean
private appBaseUrl: string
private signInReturnToPath: string
@@ -103,6 +119,8 @@ export class AuthClient {
private beforeSessionSaved?: BeforeSessionSavedHook
private onCallback: OnCallbackHook
+ private routes: Routes
+
private fetch: typeof fetch
private jwksCache: jose.JWKSCacheInput
@@ -122,6 +140,8 @@ export class AuthClient {
this.authorizationParameters = options.authorizationParameters || {
scope: DEFAULT_SCOPES,
}
+ this.pushedAuthorizationRequests =
+ options.pushedAuthorizationRequests ?? false
if (!this.authorizationParameters.scope) {
this.authorizationParameters.scope = DEFAULT_SCOPES
@@ -143,23 +163,38 @@ export class AuthClient {
// hooks
this.beforeSessionSaved = options.beforeSessionSaved
this.onCallback = options.onCallback || this.defaultOnCallback
+
+ // routes
+ this.routes = {
+ login: "/auth/login",
+ logout: "/auth/logout",
+ callback: "/auth/callback",
+ backChannelLogout: "/auth/backchannel-logout",
+ profile: process.env.NEXT_PUBLIC_PROFILE_ROUTE || "/auth/profile",
+ accessToken:
+ process.env.NEXT_PUBLIC_ACCESS_TOKEN_ROUTE || "/auth/access-token",
+ ...options.routes,
+ }
}
async handler(req: NextRequest): Promise {
const { pathname } = req.nextUrl
const method = req.method
- if (method === "GET" && pathname === "/auth/login") {
+ if (method === "GET" && pathname === this.routes.login) {
return this.handleLogin(req)
- } else if (method === "GET" && pathname === "/auth/logout") {
+ } else if (method === "GET" && pathname === this.routes.logout) {
return this.handleLogout(req)
- } else if (method === "GET" && pathname === "/auth/callback") {
+ } else if (method === "GET" && pathname === this.routes.callback) {
return this.handleCallback(req)
- } else if (method === "GET" && pathname === "/auth/profile") {
+ } else if (method === "GET" && pathname === this.routes.profile) {
return this.handleProfile(req)
- } else if (method === "GET" && pathname === "/auth/access-token") {
+ } else if (method === "GET" && pathname === this.routes.accessToken) {
return this.handleAccessToken(req)
- } else if (method === "POST" && pathname === "/auth/backchannel-logout") {
+ } else if (
+ method === "POST" &&
+ pathname === this.routes.backChannelLogout
+ ) {
return this.handleBackChannelLogout(req)
} else {
// no auth handler found, simply touch the sessions
@@ -191,19 +226,7 @@ export class AuthClient {
}
async handleLogin(req: NextRequest): Promise {
- const [discoveryError, authorizationServerMetadata] =
- await this.discoverAuthorizationServerMetadata()
-
- if (discoveryError) {
- return new NextResponse(
- "An error occured while trying to initiate the login request.",
- {
- status: 500,
- }
- )
- }
-
- const redirectUri = new URL("/auth/callback", this.appBaseUrl) // must be registed with the authorization server
+ const redirectUri = new URL(this.routes.callback, this.appBaseUrl) // must be registed with the authorization server
const returnTo =
req.nextUrl.searchParams.get("returnTo") || this.signInReturnToPath
@@ -213,22 +236,14 @@ export class AuthClient {
const state = oauth.generateRandomState()
const nonce = oauth.generateRandomNonce()
- const authorizationUrl = new URL(
- authorizationServerMetadata.authorization_endpoint!
- )
- authorizationUrl.searchParams.set(
- "client_id",
- this.clientMetadata.client_id
- )
- authorizationUrl.searchParams.set("redirect_uri", redirectUri.toString())
- authorizationUrl.searchParams.set("response_type", "code")
- authorizationUrl.searchParams.set("code_challenge", codeChallenge)
- authorizationUrl.searchParams.set(
- "code_challenge_method",
- codeChallengeMethod
- )
- authorizationUrl.searchParams.set("state", state)
- authorizationUrl.searchParams.set("nonce", nonce)
+ const authorizationParams = new URLSearchParams()
+ authorizationParams.set("client_id", this.clientMetadata.client_id)
+ authorizationParams.set("redirect_uri", redirectUri.toString())
+ authorizationParams.set("response_type", "code")
+ authorizationParams.set("code_challenge", codeChallenge)
+ authorizationParams.set("code_challenge_method", codeChallengeMethod)
+ authorizationParams.set("state", state)
+ authorizationParams.set("nonce", nonce)
// any custom params to forward to /authorize defined as configuration
Object.entries(this.authorizationParameters).forEach(([key, val]) => {
@@ -237,14 +252,14 @@ export class AuthClient {
return
}
- authorizationUrl.searchParams.set(key, String(val))
+ authorizationParams.set(key, String(val))
}
})
// any custom params to forward to /authorize passed as query parameters
req.nextUrl.searchParams.forEach((val, key) => {
if (!INTERNAL_AUTHORIZE_PARAMS.includes(key)) {
- authorizationUrl.searchParams.set(key, val)
+ authorizationParams.set(key, val)
}
})
@@ -257,6 +272,17 @@ export class AuthClient {
returnTo,
}
+ const [error, authorizationUrl] =
+ await this.authorizationUrl(authorizationParams)
+ if (error) {
+ return new NextResponse(
+ "An error occured while trying to initiate the login request.",
+ {
+ status: 500,
+ }
+ )
+ }
+
const res = NextResponse.redirect(authorizationUrl.toString())
await this.transactionStore.save(res.cookies, transactionState)
@@ -350,7 +376,7 @@ export class AuthClient {
)
}
- const redirectUri = new URL("/auth/callback", this.appBaseUrl) // must be registed with the authorization server
+ const redirectUri = new URL(this.routes.callback, this.appBaseUrl) // must be registed with the authorization server
const codeGrantResponse = await oauth.authorizationCodeGrantRequest(
authorizationServerMetadata,
this.clientMetadata,
@@ -607,6 +633,10 @@ export class AuthClient {
return [null, authorizationServerMetadata]
} catch (e) {
+ console.error(
+ `An error occured while performing the discovery request. Please make sure the AUTH0_DOMAIN environment variable is correctly configured — the format must be 'example.us.auth0.com'. issuer=${issuer.toString()}, error:`,
+ e
+ )
return [
new DiscoveryError(
"Discovery failed for the OpenID Connect configuration."
@@ -725,4 +755,80 @@ export class AuthClient {
},
]
}
+
+ private async authorizationUrl(
+ params: URLSearchParams
+ ): Promise<[null, URL] | [Error, null]> {
+ const [discoveryError, authorizationServerMetadata] =
+ await this.discoverAuthorizationServerMetadata()
+ if (discoveryError) {
+ return [discoveryError, null]
+ }
+
+ if (
+ this.pushedAuthorizationRequests &&
+ !authorizationServerMetadata.pushed_authorization_request_endpoint
+ ) {
+ console.error(
+ "The Auth0 tenant does not have pushed authorization requests enabled. Learn how to enable it here: https://auth0.com/docs/get-started/applications/configure-par"
+ )
+ return [
+ new Error(
+ "The authorization server does not support pushed authorization requests."
+ ),
+ null,
+ ]
+ }
+
+ const authorizationUrl = new URL(
+ authorizationServerMetadata.authorization_endpoint!
+ )
+
+ if (this.pushedAuthorizationRequests) {
+ // push the request params to the authorization server
+ const response = await oauth.pushedAuthorizationRequest(
+ authorizationServerMetadata,
+ this.clientMetadata,
+ oauth.ClientSecretPost(this.clientSecret),
+ params,
+ {
+ [oauth.customFetch]: this.fetch,
+ }
+ )
+
+ let parRes: oauth.PushedAuthorizationResponse
+ try {
+ parRes = await oauth.processPushedAuthorizationResponse(
+ authorizationServerMetadata,
+ this.clientMetadata,
+ response
+ )
+ } catch (e: any) {
+ return [
+ new AuthorizationError({
+ cause: new OAuth2Error({
+ code: e.error,
+ message: e.error_description,
+ }),
+ message:
+ "An error occured while pushing the authorization request.",
+ }),
+ null,
+ ]
+ }
+
+ authorizationUrl.searchParams.set("request_uri", parRes.request_uri)
+ authorizationUrl.searchParams.set(
+ "client_id",
+ this.clientMetadata.client_id
+ )
+
+ return [null, authorizationUrl]
+ }
+
+ // append the query parameters to the authorization URL for the normal flow
+ authorizationUrl.search = params.toString()
+
+ return [null, authorizationUrl]
+ }
}
diff --git a/src/server/client.ts b/src/server/client.ts
index e5b9c529..689f2fe7 100644
--- a/src/server/client.ts
+++ b/src/server/client.ts
@@ -9,6 +9,7 @@ import {
AuthorizationParameters,
BeforeSessionSavedHook,
OnCallbackHook,
+ RoutesOptions,
} from "./auth-client"
import { RequestCookies } from "./cookies"
import {
@@ -40,8 +41,14 @@ interface Auth0ClientOptions {
* If it's not specified, it will be loaded from the `AUTH0_CLIENT_SECRET` environment variable.
*/
clientSecret?: string
-
+ /**
+ * Additional parameters to send to the `/authorize` endpoint.
+ */
authorizationParameters?: AuthorizationParameters
+ /**
+ * If enabled, the SDK will use the Pushed Authorization Requests (PAR) protocol when communicating with the authorization server.
+ */
+ pushedAuthorizationRequests?: boolean
// application configuration
/**
@@ -90,6 +97,13 @@ interface Auth0ClientOptions {
* See [Database sessions](https://github.com/auth0/nextjs-auth0#database-sessions) for additional details.
*/
sessionStore?: SessionDataStore
+
+ /**
+ * Configure the paths for the authentication routes.
+ *
+ * See [Custom routes](https://github.com/auth0/nextjs-auth0#custom-routes) for additional details.
+ */
+ routes?: RoutesOptions
}
type PagesRouterRequest = Pick
@@ -109,9 +123,32 @@ export class Auth0Client {
process.env.APP_BASE_URL) as string
const secret = (options.secret || process.env.AUTH0_SECRET) as string
+ const cookieOptions = {
+ secure: false,
+ }
+ if (appBaseUrl) {
+ const { protocol } = new URL(appBaseUrl)
+ if (protocol === "https:") {
+ cookieOptions.secure = true
+ }
+
+ if (process.env.NODE_ENV === "production" && !cookieOptions.secure) {
+ console.warn(
+ `The application's base URL (${appBaseUrl}) is not set to HTTPS. This is not recommended for production environments.`
+ )
+ }
+ }
+
+ 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,
+ cookieOptions,
})
this.sessionStore = options.sessionStore
@@ -119,10 +156,12 @@ export class Auth0Client {
...options.session,
secret,
store: options.sessionStore,
+ cookieOptions,
})
: new StatelessSessionStore({
...options.session,
secret,
+ cookieOptions,
})
this.authClient = new AuthClient({
@@ -133,6 +172,7 @@ export class Auth0Client {
clientId,
clientSecret,
authorizationParameters: options.authorizationParameters,
+ pushedAuthorizationRequests: options.pushedAuthorizationRequests,
appBaseUrl,
secret,
@@ -140,6 +180,8 @@ export class Auth0Client {
beforeSessionSaved: options.beforeSessionSaved,
onCallback: options.onCallback,
+
+ routes: options.routes,
})
}
diff --git a/src/server/session/abstract-session-store.ts b/src/server/session/abstract-session-store.ts
index e33975d5..72098081 100644
--- a/src/server/session/abstract-session-store.ts
+++ b/src/server/session/abstract-session-store.ts
@@ -1,5 +1,6 @@
import type { SessionData } from "../../types"
import {
+ CookieOptions,
ReadonlyRequestCookies,
RequestCookies,
ResponseCookies,
@@ -60,6 +61,8 @@ export interface SessionConfiguration {
interface SessionStoreOptions extends SessionConfiguration {
secret: string
store?: SessionDataStore
+
+ cookieOptions?: Partial>
}
export abstract class AbstractSessionStore {
@@ -72,12 +75,7 @@ export abstract class AbstractSessionStore {
public store?: SessionDataStore
- public cookieConfig = {
- httpOnly: true,
- sameSite: "lax",
- secure: process.env.NODE_ENV === "production",
- path: "/",
- } as const
+ public cookieConfig: CookieOptions
constructor({
secret,
@@ -86,6 +84,8 @@ export abstract class AbstractSessionStore {
absoluteDuration = 60 * 60 * 24 * 30, // 30 days in seconds
inactivityDuration = 60 * 60 * 24 * 7, // 7 days in seconds
store,
+
+ cookieOptions,
}: SessionStoreOptions) {
this.secret = secret
@@ -93,6 +93,13 @@ export abstract class AbstractSessionStore {
this.absoluteDuration = absoluteDuration
this.inactivityDuration = inactivityDuration
this.store = store
+
+ this.cookieConfig = {
+ httpOnly: true,
+ sameSite: "lax",
+ secure: cookieOptions?.secure ?? false,
+ path: "/",
+ }
}
abstract get(
diff --git a/src/server/session/stateful-session-store.test.ts b/src/server/session/stateful-session-store.test.ts
index ef203208..8db78911 100644
--- a/src/server/session/stateful-session-store.test.ts
+++ b/src/server/session/stateful-session-store.test.ts
@@ -168,6 +168,7 @@ describe("Stateful Session Store", async () => {
expect(cookie?.httpOnly).toEqual(true)
expect(cookie?.sameSite).toEqual("lax")
expect(cookie?.maxAge).toEqual(1800)
+ expect(cookie?.secure).toEqual(false)
expect(store.set).toHaveBeenCalledOnce()
expect(store.set).toHaveBeenCalledWith(cookieValue.id, session)
@@ -221,6 +222,7 @@ describe("Stateful Session Store", async () => {
expect(cookie?.httpOnly).toEqual(true)
expect(cookie?.sameSite).toEqual("lax")
expect(cookie?.maxAge).toEqual(0) // cookie should expire immedcreatedAtely
+ expect(cookie?.secure).toEqual(false)
expect(store.set).toHaveBeenCalledOnce()
expect(store.set).toHaveBeenCalledWith(cookieValue.id, session)
@@ -268,6 +270,7 @@ describe("Stateful Session Store", async () => {
expect(cookie?.httpOnly).toEqual(true)
expect(cookie?.sameSite).toEqual("lax")
expect(cookie?.maxAge).toEqual(3600)
+ expect(cookie?.secure).toEqual(false)
expect(store.set).toHaveBeenCalledOnce()
expect(store.set).toHaveBeenCalledWith(cookieValue.id, session)
@@ -324,6 +327,58 @@ describe("Stateful Session Store", async () => {
expect(store.set).toHaveBeenCalledWith(cookieValue.id, session) // a new session ID should be generated
})
})
+
+ describe("with cookieOptions", async () => {
+ it("should apply the secure attribute to the cookie", async () => {
+ const currentTime = Date.now()
+ const createdAt = Math.floor(currentTime / 1000)
+ const secret = await generateSecret(32)
+ const session: SessionData = {
+ user: { sub: "user_123" },
+ tokenSet: {
+ accessToken: "at_123",
+ refreshToken: "rt_123",
+ expiresAt: 123456,
+ },
+ internal: {
+ sid: "auth0-sid",
+ createdAt,
+ },
+ }
+ const store = {
+ get: vi.fn().mockResolvedValue(session),
+ set: vi.fn(),
+ delete: vi.fn(),
+ }
+
+ const requestCookies = new RequestCookies(new Headers())
+ const responseCookies = new ResponseCookies(new Headers())
+
+ const sessionStore = new StatefulSessionStore({
+ secret,
+ store,
+ rolling: true,
+ absoluteDuration: 3600,
+ inactivityDuration: 1800,
+
+ cookieOptions: {
+ secure: true,
+ },
+ })
+ await sessionStore.set(requestCookies, responseCookies, session)
+
+ const cookie = responseCookies.get("__session")
+ const cookieValue = await decrypt(cookie!.value, secret)
+
+ expect(cookie).toBeDefined()
+ expect(cookieValue).toHaveProperty("id")
+ expect(cookie?.path).toEqual("/")
+ expect(cookie?.httpOnly).toEqual(true)
+ expect(cookie?.sameSite).toEqual("lax")
+ expect(cookie?.maxAge).toEqual(1800)
+ expect(cookie?.secure).toEqual(true)
+ })
+ })
})
describe("delete", async () => {
diff --git a/src/server/session/stateful-session-store.ts b/src/server/session/stateful-session-store.ts
index f4b57283..674e9b46 100644
--- a/src/server/session/stateful-session-store.ts
+++ b/src/server/session/stateful-session-store.ts
@@ -19,6 +19,8 @@ interface StatefulSessionStoreOptions {
inactivityDuration?: number // defaults to 7 days
store: SessionDataStore
+
+ cookieOptions?: Partial>
}
const generateId = () => {
@@ -38,12 +40,14 @@ export class StatefulSessionStore extends AbstractSessionStore {
rolling,
absoluteDuration,
inactivityDuration,
+ cookieOptions,
}: StatefulSessionStoreOptions) {
super({
secret,
rolling,
absoluteDuration,
inactivityDuration,
+ cookieOptions,
})
this.store = store
diff --git a/src/server/session/stateless-session-store.test.ts b/src/server/session/stateless-session-store.test.ts
index 8e1cf2e6..775ecc35 100644
--- a/src/server/session/stateless-session-store.test.ts
+++ b/src/server/session/stateless-session-store.test.ts
@@ -96,6 +96,7 @@ describe("Stateless Session Store", async () => {
expect(cookie?.httpOnly).toEqual(true)
expect(cookie?.sameSite).toEqual("lax")
expect(cookie?.maxAge).toEqual(1800) // should be extended by inactivity duration
+ expect(cookie?.secure).toEqual(false)
})
it("should not exceed the absolute timeout duration", async () => {
@@ -137,6 +138,7 @@ describe("Stateless Session Store", async () => {
expect(cookie?.httpOnly).toEqual(true)
expect(cookie?.sameSite).toEqual("lax")
expect(cookie?.maxAge).toEqual(0) // cookie should expire immediately
+ expect(cookie?.secure).toEqual(false)
})
})
@@ -173,6 +175,47 @@ describe("Stateless Session Store", async () => {
expect(cookie?.httpOnly).toEqual(true)
expect(cookie?.sameSite).toEqual("lax")
expect(cookie?.maxAge).toEqual(3600)
+ expect(cookie?.secure).toEqual(false)
+ })
+ })
+
+ describe("with cookieOptions", async () => {
+ it("should apply the secure attribute to the cookie", async () => {
+ const secret = await generateSecret(32)
+ const session: SessionData = {
+ user: { sub: "user_123" },
+ tokenSet: {
+ accessToken: "at_123",
+ refreshToken: "rt_123",
+ expiresAt: 123456,
+ },
+ internal: {
+ sid: "auth0-sid",
+ createdAt: Math.floor(Date.now() / 1000),
+ },
+ }
+ const requestCookies = new RequestCookies(new Headers())
+ const responseCookies = new ResponseCookies(new Headers())
+
+ const sessionStore = new StatelessSessionStore({
+ secret,
+ rolling: false,
+ absoluteDuration: 3600,
+ cookieOptions: {
+ secure: true,
+ },
+ })
+ await sessionStore.set(requestCookies, responseCookies, session)
+
+ const cookie = responseCookies.get("__session")
+
+ expect(cookie).toBeDefined()
+ expect(await decrypt(cookie!.value, secret)).toEqual(session)
+ expect(cookie?.path).toEqual("/")
+ expect(cookie?.httpOnly).toEqual(true)
+ expect(cookie?.sameSite).toEqual("lax")
+ expect(cookie?.maxAge).toEqual(3600)
+ expect(cookie?.secure).toEqual(true)
})
})
})
diff --git a/src/server/session/stateless-session-store.ts b/src/server/session/stateless-session-store.ts
index d1d4eb5f..ce4dd4ed 100644
--- a/src/server/session/stateless-session-store.ts
+++ b/src/server/session/stateless-session-store.ts
@@ -8,6 +8,8 @@ interface StatelessSessionStoreOptions {
rolling?: boolean // defaults to true
absoluteDuration?: number // defaults to 30 days
inactivityDuration?: number // defaults to 7 days
+
+ cookieOptions?: Partial>
}
export class StatelessSessionStore extends AbstractSessionStore {
@@ -16,12 +18,14 @@ export class StatelessSessionStore extends AbstractSessionStore {
rolling,
absoluteDuration,
inactivityDuration,
+ cookieOptions,
}: StatelessSessionStoreOptions) {
super({
secret,
rolling,
absoluteDuration,
inactivityDuration,
+ cookieOptions,
})
}
diff --git a/src/server/transaction-store.test.ts b/src/server/transaction-store.test.ts
index f616244e..b5bf4617 100644
--- a/src/server/transaction-store.test.ts
+++ b/src/server/transaction-store.test.ts
@@ -93,6 +93,7 @@ describe("Transaction Store", async () => {
expect(cookie?.httpOnly).toEqual(true)
expect(cookie?.sameSite).toEqual("lax")
expect(cookie?.maxAge).toEqual(3600)
+ expect(cookie?.secure).toEqual(false)
})
it("should throw an error if the transaction does not contain a state", async () => {
@@ -118,6 +119,44 @@ describe("Transaction Store", async () => {
transactionStore.save(responseCookies, transactionState)
).rejects.toThrowError()
})
+
+ describe("with cookieOptions", async () => {
+ it("should apply the secure attribute to the cookie", async () => {
+ const secret = await generateSecret(32)
+ const codeVerifier = oauth.generateRandomCodeVerifier()
+ const nonce = oauth.generateRandomNonce()
+ const state = oauth.generateRandomState()
+ const transactionState: TransactionState = {
+ nonce,
+ maxAge: 3600,
+ codeVerifier: codeVerifier,
+ responseType: "code",
+ state,
+ returnTo: "/dashboard",
+ }
+ const headers = new Headers()
+ const responseCookies = new ResponseCookies(headers)
+
+ const transactionStore = new TransactionStore({
+ secret,
+ cookieOptions: {
+ secure: true,
+ },
+ })
+ await transactionStore.save(responseCookies, transactionState)
+
+ const cookieName = `__txn_${state}`
+ const cookie = responseCookies.get(cookieName)
+
+ expect(cookie).toBeDefined()
+ expect(await decrypt(cookie!.value, secret)).toEqual(transactionState)
+ expect(cookie?.path).toEqual("/")
+ expect(cookie?.httpOnly).toEqual(true)
+ expect(cookie?.sameSite).toEqual("lax")
+ expect(cookie?.maxAge).toEqual(3600)
+ expect(cookie?.secure).toEqual(true)
+ })
+ })
})
describe("delete", async () => {
diff --git a/src/server/transaction-store.ts b/src/server/transaction-store.ts
index 5aa7b82d..ee9a2478 100644
--- a/src/server/transaction-store.ts
+++ b/src/server/transaction-store.ts
@@ -15,6 +15,7 @@ export interface TransactionState extends jose.JWTPayload {
interface TransactionStoreOptions {
secret: string
+ cookieOptions?: Partial>
}
/**
@@ -26,12 +27,12 @@ export class TransactionStore {
private secret: string
private cookieConfig: cookies.CookieOptions
- constructor({ secret }: TransactionStoreOptions) {
+ constructor({ secret, cookieOptions }: TransactionStoreOptions) {
this.secret = secret
this.cookieConfig = {
httpOnly: true,
sameSite: "lax", // required to allow the cookie to be sent on the callback request
- secure: process.env.NODE_ENV === "production",
+ secure: cookieOptions?.secure ?? false,
path: "/",
maxAge: 60 * 60, // 1 hour in seconds
}
diff --git a/vitest.config.mts b/vitest.config.mts
index fd28d74b..7de0b577 100644
--- a/vitest.config.mts
+++ b/vitest.config.mts
@@ -4,7 +4,7 @@ import { configDefaults } from "vitest/config"
export default defineConfig({
test: {
- exclude: [...configDefaults.exclude, "e2e/*"],
+ exclude: [...configDefaults.exclude, "e2e/*", "examples/*"],
coverage: {
include: ["src/**/*"],
},