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/**/*"], },