From 92df43ba4268ec734f58254755349296948dc275 Mon Sep 17 00:00:00 2001 From: guabu <135956181+guabu@users.noreply.github.com> Date: Fri, 20 Dec 2024 06:44:43 +0100 Subject: [PATCH] 4.0.0-beta.13 (#1850) --- README.md | 21 +++-- package.json | 2 +- src/server/auth-client.test.ts | 157 +++++++++++++++++---------------- src/server/auth-client.ts | 38 ++++---- src/server/client.ts | 77 ++++++++++++---- 5 files changed, 173 insertions(+), 122 deletions(-) diff --git a/README.md b/README.md index 4b13f9bf..453338dc 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.12 +npm i @auth0/nextjs-auth0@beta ``` ### 2. Add the environment variables @@ -115,8 +115,8 @@ You can customize the client by using the options below: | clientId | `string` | The Auth0 client ID. If it's not specified, it will be loaded from the `AUTH0_CLIENT_ID` environment variable. | | clientSecret | `string` | The Auth0 client secret. If it's not specified, it will be loaded from the `AUTH0_CLIENT_SECRET` environment variable. | | authorizationParameters | `AuthorizationParameters` | The authorization parameters to pass to the `/authorize` endpoint. See [Passing authorization parameters](#passing-authorization-parameters) for more details. | -| clientAssertionSigningKey | `string` or `CryptoKey` | Private key for use with `private_key_jwt` clients. | -| clientAssertionSigningAlg | `string` | The algorithm used to sign the client assertion JWT. | +| clientAssertionSigningKey | `string` or `CryptoKey` | Private key for use with `private_key_jwt` clients. This can also be specified via the `AUTH0_CLIENT_ASSERTION_SIGNING_KEY` environment variable. | +| clientAssertionSigningAlg | `string` | The algorithm used to sign the client assertion JWT. This can also be provided via the `AUTH0_CLIENT_ASSERTION_SIGNING_ALG` environment variable. | | 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 `/`. | @@ -351,7 +351,12 @@ export default function Component() { ### On the server (App Router) -On the server, the `getAccessToken()` helper can be used in Server Components, Server Routes, Server Actions, and middleware to get an access token to call external APIs, like so: +On the server, the `getAccessToken()` helper can be used in Server Routes, Server Actions, Server Components, and middleware to get an access token to call external APIs. + +> [!IMPORTANT] +> Server Components cannot set cookies. Calling `getAccessToken()` in a Server Component will cause the access token to be refreshed, if it is expired, and the updated token set will not to be persisted. + +For example: ```tsx import { NextResponse } from "next/server" @@ -374,7 +379,7 @@ export async function GET() { ### On the server (Pages Router) -On the server, the `getAccessToken(req)` helper can be used in `getServerSideProps`, API routes, and middleware to get an access token to call external APIs, like so: +On the server, the `getAccessToken(req, res)` helper can be used in `getServerSideProps`, API routes, and middleware to get an access token to call external APIs, like so: ```tsx import type { NextApiRequest, NextApiResponse } from "next" @@ -386,7 +391,7 @@ export default async function handler( res: NextApiResponse<{ message: string }> ) { try { - const token = await auth0.getAccessToken(req) + const token = await auth0.getAccessToken(req, res) // call external API with token... } catch (err) { // err will be an instance of AccessTokenError if an access token could not be obtained @@ -434,11 +439,11 @@ The SDK exposes hooks to enable you to provide custom logic that would be run at The `beforeSessionSaved` hook is run right before the session is persisted. It provides a mechanism to modify the session claims before persisting them. -The hook recieves a `SessionData` object and must return a Promise that resolves to a `SessionData` object: `(session: SessionData) => Promise`. For example: +The hook recieves a `SessionData` object and an ID token. The function must return a Promise that resolves to a `SessionData` object: `(session: SessionData) => Promise`. For example: ```ts export const auth0 = new Auth0Client({ - async beforeSessionSaved(session) { + async beforeSessionSaved(session, idToken) { return { ...session, user: { diff --git a/package.json b/package.json index b396bd52..e1791360 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@auth0/nextjs-auth0", - "version": "4.0.0-beta.12", + "version": "4.0.0-beta.13", "description": "Auth0 Next.js SDK", "scripts": { "build": "tsc", diff --git a/src/server/auth-client.test.ts b/src/server/auth-client.test.ts index b56f4c0d..d6f80b4d 100644 --- a/src/server/auth-client.test.ts +++ b/src/server/auth-client.test.ts @@ -435,18 +435,7 @@ ca/T0LLtgmbMmxSv/MmzIg== } ) - const expiresAt = Math.floor(Date.now() / 1000) - 10 * 24 * 60 * 60 // expired 10 days ago - const updatedTokenSet = { - accessToken: "at_456", - refreshToken: "rt_456", - expiresAt, - } - authClient.getTokenSet = vi - .fn() - .mockResolvedValue([null, updatedTokenSet]) - const response = await authClient.handler(request) - expect(authClient.getTokenSet).toHaveBeenCalled() // assert session has been updated const updatedSessionCookie = response.cookies.get("__session") @@ -460,8 +449,8 @@ ca/T0LLtgmbMmxSv/MmzIg== sub: DEFAULT.sub, }, tokenSet: { - accessToken: "at_456", - refreshToken: "rt_456", + accessToken: "at_123", + refreshToken: "rt_123", expiresAt: expect.any(Number), }, internal: { @@ -516,70 +505,6 @@ ca/T0LLtgmbMmxSv/MmzIg== const updatedSessionCookie = response.cookies.get("__session") expect(updatedSessionCookie).toBeUndefined() }) - - it("should pass the request through if there was an error fetching the updated token set", async () => { - const secret = await generateSecret(32) - const transactionStore = new TransactionStore({ - secret, - }) - const sessionStore = new StatelessSessionStore({ - secret, - - rolling: true, - absoluteDuration: 3600, - inactivityDuration: 1800, - }) - const authClient = new AuthClient({ - transactionStore, - sessionStore, - - domain: DEFAULT.domain, - clientId: DEFAULT.clientId, - clientSecret: DEFAULT.clientSecret, - - secret, - appBaseUrl: DEFAULT.appBaseUrl, - - fetch: getMockAuthorizationServer(), - }) - - const session: SessionData = { - user: { sub: DEFAULT.sub }, - tokenSet: { - accessToken: DEFAULT.accessToken, - refreshToken: DEFAULT.refreshToken, - expiresAt: 123456, - }, - internal: { - sid: DEFAULT.sid, - createdAt: Math.floor(Date.now() / 1000), - }, - } - const sessionCookie = await encrypt(session, secret) - const headers = new Headers() - headers.append("cookie", `__session=${sessionCookie}`) - const request = new NextRequest( - "https://example.com/dashboard/projects", - { - method: "GET", - headers, - } - ) - - authClient.getTokenSet = vi - .fn() - .mockResolvedValue([ - new Error("error fetching updated token set"), - null, - ]) - - const response = await authClient.handler(request) - expect(authClient.getTokenSet).toHaveBeenCalled() - - // assert session has not been updated - const updatedSessionCookie = response.cookies.get("__session") - expect(updatedSessionCookie).toBeUndefined() - }) }) describe("with custom routes", async () => { @@ -2839,6 +2764,84 @@ ca/T0LLtgmbMmxSv/MmzIg== }) describe("beforeSessionSaved hook", async () => { + it("should be called with the correct arguments", async () => { + const state = "transaction-state" + const code = "auth-code" + + const secret = await generateSecret(32) + const transactionStore = new TransactionStore({ + secret, + }) + const sessionStore = new StatelessSessionStore({ + secret, + }) + const mockBeforeSessionSaved = vi.fn().mockResolvedValue({ + user: { + sub: DEFAULT.sub, + }, + internal: { + sid: DEFAULT.sid, + expiresAt: expect.any(Number), + }, + }) + const authClient = new AuthClient({ + transactionStore, + sessionStore, + + domain: DEFAULT.domain, + clientId: DEFAULT.clientId, + clientSecret: DEFAULT.clientSecret, + + secret, + appBaseUrl: DEFAULT.appBaseUrl, + + fetch: getMockAuthorizationServer(), + + beforeSessionSaved: mockBeforeSessionSaved, + }) + + const url = new URL("/auth/callback", DEFAULT.appBaseUrl) + url.searchParams.set("code", code) + url.searchParams.set("state", state) + + const headers = new Headers() + const transactionState: TransactionState = { + nonce: "nonce-value", + maxAge: 3600, + codeVerifier: "code-verifier", + responseType: "code", + state: state, + returnTo: "/dashboard", + } + headers.set( + "cookie", + `__txn_${state}=${await encrypt(transactionState, secret)}` + ) + const request = new NextRequest(url, { + method: "GET", + headers, + }) + + await authClient.handleCallback(request) + expect(mockBeforeSessionSaved).toHaveBeenCalledWith( + { + user: expect.objectContaining({ + sub: DEFAULT.sub, + }), + tokenSet: { + accessToken: DEFAULT.accessToken, + refreshToken: DEFAULT.refreshToken, + expiresAt: expect.any(Number), + }, + internal: { + sid: expect.any(String), + createdAt: expect.any(Number), + }, + }, + expect.any(String) + ) + }) + it("should use the return value of the hook as the session data", async () => { const state = "transaction-state" const code = "auth-code" diff --git a/src/server/auth-client.ts b/src/server/auth-client.ts index 7f311204..bca8165e 100644 --- a/src/server/auth-client.ts +++ b/src/server/auth-client.ts @@ -23,7 +23,8 @@ import { TransactionState, TransactionStore } from "./transaction-store" import { filterClaims } from "./user" export type BeforeSessionSavedHook = ( - session: SessionData + session: SessionData, + idToken: string | null ) => Promise type OnCallbackContext = { @@ -114,7 +115,7 @@ export class AuthClient { private clientSecret?: string private clientAssertionSigningKey?: string | CryptoKey private clientAssertionSigningAlg: string - private issuer: string + private domain: string private authorizationParameters: AuthorizationParameters private pushedAuthorizationRequests: boolean @@ -147,11 +148,7 @@ export class AuthClient { this.sessionStore = options.sessionStore // authorization server - this.issuer = - options.domain.startsWith("http://") || - options.domain.startsWith("https://") - ? options.domain - : `https://${options.domain}` + this.domain = options.domain this.clientMetadata = { client_id: options.clientId } this.clientSecret = options.clientSecret this.authorizationParameters = options.authorizationParameters || { @@ -224,20 +221,10 @@ export class AuthClient { const session = await this.sessionStore.get(req.cookies) if (session) { - // refresh the access token, if necessary - const [error, updatedTokenSet] = await this.getTokenSet( - session.tokenSet - ) - - if (error) { - return res - } - // we pass the existing session (containing an `createdAt` timestamp) to the set method // which will update the cookie's `maxAge` property based on the `createdAt` time await this.sessionStore.set(req.cookies, res.cookies, { ...session, - tokenSet: updatedTokenSet, }) } @@ -452,8 +439,14 @@ export class AuthClient { const res = await this.onCallback(null, onCallbackCtx, session) if (this.beforeSessionSaved) { - const { user } = await this.beforeSessionSaved(session) - session.user = user || {} + const updatedSession = await this.beforeSessionSaved( + session, + oidcRes.id_token ?? null + ) + session = { + ...updatedSession, + internal: session.internal, + } } else { session.user = filterClaims(idTokenClaims) } @@ -878,4 +871,11 @@ export class AuthClient { ? oauth.PrivateKeyJwt(clientPrivateKey) : oauth.ClientSecretPost(this.clientSecret!) } + + private get issuer(): string { + return this.domain.startsWith("http://") || + this.domain.startsWith("https://") + ? this.domain + : `https://${this.domain}` + } } diff --git a/src/server/client.ts b/src/server/client.ts index ac957eb5..8a5c5e4b 100644 --- a/src/server/client.ts +++ b/src/server/client.ts @@ -144,6 +144,13 @@ export class Auth0Client { process.env.APP_BASE_URL) as string const secret = (options.secret || process.env.AUTH0_SECRET) as string + const clientAssertionSigningKey = + options.clientAssertionSigningKey || + process.env.AUTH0_CLIENT_ASSERTION_SIGNING_KEY + const clientAssertionSigningAlg = + options.clientAssertionSigningAlg || + process.env.AUTH0_CLIENT_ASSERTION_SIGNING_ALG + const cookieOptions = { secure: false, } @@ -180,10 +187,10 @@ export class Auth0Client { domain, clientId, clientSecret, + clientAssertionSigningKey, + clientAssertionSigningAlg, authorizationParameters: options.authorizationParameters, pushedAuthorizationRequests: options.pushedAuthorizationRequests, - clientAssertionSigningKey: options.clientAssertionSigningKey, - clientAssertionSigningAlg: options.clientAssertionSigningAlg, appBaseUrl, secret, @@ -234,6 +241,8 @@ export class Auth0Client { * getAccessToken returns the access token. * * This method can be used in Server Components, Server Actions, Route Handlers, and middleware in the **App Router**. + * + * NOTE: Server Components cannot set cookies. Calling `getAccessToken()` in a Server Component will cause the access token to be refreshed, if it is expired, and the updated token set will not to be persisted. */ async getAccessToken(): Promise<{ token: string; expiresAt: number }> @@ -243,14 +252,18 @@ export class Auth0Client { * This method can be used in `getServerSideProps`, API routes, and middleware in the **Pages Router**. */ async getAccessToken( - req: PagesRouterRequest + req: PagesRouterRequest, + res: PagesRouterResponse ): Promise<{ token: string; expiresAt: number }> /** * getAccessToken returns the access token. + * + * NOTE: Server Components cannot set cookies. Calling `getAccessToken()` in a Server Component will cause the access token to be refreshed, if it is expired, and the updated token set will not to be persisted. */ async getAccessToken( - req?: PagesRouterRequest + req?: PagesRouterRequest, + res?: PagesRouterResponse ): Promise<{ token: string; expiresAt: number }> { let session: SessionData | null = null @@ -267,24 +280,54 @@ export class Auth0Client { ) } - // if access token has expired, throw an error - if (session.tokenSet.expiresAt <= Date.now() / 1000) { - if (!session.tokenSet.refreshToken) { - throw new AccessTokenError( - AccessTokenErrorCode.MISSING_REFRESH_TOKEN, - "The access token has expired and a refresh token was not provided. The user needs to re-authenticate." + const [error, tokenSet] = await this.authClient.getTokenSet( + session.tokenSet + ) + if (error) { + throw error + } + + // update the session with the new token set, if necessary + if ( + tokenSet.accessToken !== session.tokenSet.accessToken || + tokenSet.expiresAt !== session.tokenSet.expiresAt || + tokenSet.refreshToken !== session.tokenSet.refreshToken + ) { + if (req && res) { + const resHeaders = new Headers() + const resCookies = new ResponseCookies(resHeaders) + + await this.sessionStore.set( + this.createRequestCookies(req), + resCookies, + { + ...session, + tokenSet, + } ) - } - throw new AccessTokenError( - AccessTokenErrorCode.FAILED_TO_REFRESH_TOKEN, - "The access token has expired and there was an error while trying to refresh it. Check the server logs for more information." - ) + for (const [key, value] of resHeaders.entries()) { + res.setHeader(key, value) + } + } else { + try { + await this.sessionStore.set(await cookies(), await cookies(), { + ...session, + tokenSet, + }) + } catch (e) { + if (process.env.NODE_ENV === "development") { + console.warn( + "Failed to persist the updated token set. `getAccessToken()` was likely called from a Server Component which cannot set cookies." + ) + } + } + } } return { - token: session.tokenSet.accessToken, - expiresAt: session.tokenSet.expiresAt, + token: tokenSet.accessToken, + expiresAt: tokenSet.expiresAt, } }