From 7a8e677c1d8aed3ba0d30201011955a54c99d9c6 Mon Sep 17 00:00:00 2001 From: guabu <135956181+guabu@users.noreply.github.com> Date: Mon, 25 Nov 2024 09:27:11 +0100 Subject: [PATCH] 4.0.0-beta.8 (#1811) --- README.md | 50 +++++++++++---- package.json | 8 ++- src/client/helpers/get-access-token.ts | 26 +++++++- src/client/hooks/use-user.ts | 21 ++++++- src/{errors.ts => errors/index.ts} | 46 ++++++-------- src/server/auth-client.test.ts | 30 +++++---- src/server/auth-client.ts | 62 ++++++++++--------- src/server/client.ts | 31 ++++++++-- src/server/index.ts | 1 - src/server/session/abstract-session-store.ts | 20 +----- .../session/stateful-session-store.test.ts | 2 +- src/server/session/stateful-session-store.ts | 2 +- .../session/stateless-session-store.test.ts | 2 +- src/server/session/stateless-session-store.ts | 17 ++++- src/server/user.ts | 16 +---- src/types/index.ts | 31 ++++++++++ 16 files changed, 238 insertions(+), 127 deletions(-) rename src/{errors.ts => errors/index.ts} (75%) create mode 100644 src/types/index.ts diff --git a/README.md b/README.md index 83c5882c..493a1f82 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.7 +npm i @auth0/nextjs-auth0@4.0.0-beta.8 ``` ### 2. Add the environment variables @@ -34,7 +34,7 @@ The `APP_BASE_URL` is the URL that your application is running on. When developi > You will need to register the follwing URLs in your Auth0 Application via the [Auth0 Dashboard](https://manage.auth0.com): > > - Add `http://localhost:3000/auth/callback` to the list of **Allowed Callback URLs** -> - Add `http://localhost:3000/auth/logout` to the list of **Allowed Logout URLs** +> - Add `http://localhost:3000` to the list of **Allowed Logout URLs** ### 3. Create the Auth0 SDK client @@ -259,9 +259,12 @@ import { getAccessToken } from "@auth0/nextjs-auth0" export default function Component() { async function fetchData() { - const token = await getAccessToken() - - // call external API with the token... + try { + const token = await auth0.getAccessToken() + // call external API with token... + } catch (err) { + // err will be an instance of AccessTokenError if an access token could not be obtained + } } return ( @@ -282,9 +285,12 @@ import { NextResponse } from "next/server" import { auth0 } from "@/lib/auth0" export async function GET() { - const token = await auth0.getAccessToken() - - // call external API with token... + try { + const token = await auth0.getAccessToken() + // call external API with token... + } catch (err) { + // err will be an instance of AccessTokenError if an access token could not be obtained + } return NextResponse.json({ message: "Success!", @@ -305,9 +311,12 @@ export default async function handler( req: NextApiRequest, res: NextApiResponse<{ message: string }> ) { - const token = await auth0.getAccessToken(req) - - // call external API with token... + try { + const token = await auth0.getAccessToken(req) + // call external API with token... + } catch (err) { + // err will be an instance of AccessTokenError if an access token could not be obtained + } res.status(200).json({ message: "Success!" }) } @@ -451,6 +460,25 @@ export async function middleware(request: NextRequest) { For a complete example using `next-intl` middleware, please see the `examples/` directory of this repository. +## ID Token claims and the user object + +By default, the following properties claims from the ID token are added to the `user` object in the session automatically: + +- `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](#beforesessionsaved)) + +> [!NOTE] +> It's best practice to limit what claims are stored on the `user` object in the session to avoid bloating the session cookie size and going over browser limits. + ## Routes The SDK mounts 6 routes: diff --git a/package.json b/package.json index 602bcb3f..6c810444 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@auth0/nextjs-auth0", - "version": "4.0.0-beta.7", + "version": "4.0.0-beta.8", "description": "Auth0 Next.js SDK", "main": "dist/index.js", "scripts": { @@ -52,6 +52,12 @@ }, "./server": { "import": "./dist/server/index.js" + }, + "./errors": { + "import": "./dist/errors/index.js" + }, + "./types": { + "import": "./dist/types/index.d.ts" } }, "dependencies": { diff --git a/src/client/helpers/get-access-token.ts b/src/client/helpers/get-access-token.ts index 76ca83f6..93736793 100644 --- a/src/client/helpers/get-access-token.ts +++ b/src/client/helpers/get-access-token.ts @@ -1,6 +1,26 @@ +import { AccessTokenError } from "../../errors" + export async function getAccessToken() { - // TODO: cache response and invalidate according to expiresAt - const tokenRes = await fetch("/auth/access-token").then((res) => res.json()) + const tokenRes = await fetch("/auth/access-token") + + if (!tokenRes.ok) { + // try to parse it as JSON and throw the error from the API + // otherwise, throw a generic error + let accessTokenError + try { + accessTokenError = await tokenRes.json() + } catch (e) { + throw new Error( + "An unexpected error occurred while trying to fetch the access token." + ) + } + + throw new AccessTokenError( + accessTokenError.error.code, + accessTokenError.error.message + ) + } - return tokenRes.token + const tokenSet = await tokenRes.json() + return tokenSet.token } diff --git a/src/client/hooks/use-user.ts b/src/client/hooks/use-user.ts index f1ea4b73..d9ed8a9b 100644 --- a/src/client/hooks/use-user.ts +++ b/src/client/hooks/use-user.ts @@ -2,14 +2,29 @@ import useSWR from "swr" -import { User } from "../../server/user" +import type { User } from "../../types" export function useUser() { - const { data, error, isLoading } = useSWR( + const { data, error, isLoading } = useSWR( "/auth/profile", - (...args) => fetch(...args).then((res) => res.json()) + (...args) => + fetch(...args).then((res) => { + if (!res.ok) { + throw new Error("Unauthorized") + } + + return res.json() + }) ) + if (error) { + return { + user: null, + isLoading: false, + error, + } + } + return { user: data, isLoading, diff --git a/src/errors.ts b/src/errors/index.ts similarity index 75% rename from src/errors.ts rename to src/errors/index.ts index 5f452bf4..b19e84af 100644 --- a/src/errors.ts +++ b/src/errors/index.ts @@ -2,11 +2,14 @@ export abstract class SdkError extends Error { public abstract code: string } +/** + * Errors that come from Auth0 in the `redirect_uri` callback may contain reflected user input via the OpenID Connect `error` and `error_description` query parameter. + * You should **not** render the error `message`, or `error` and `error_description` properties without properly escaping them first. + */ export class OAuth2Error extends SdkError { public code: string constructor({ code, message }: { code: string; message?: string }) { - // TODO: sanitize error message or add warning super( message ?? "An error occured while interacting with the authorization server." @@ -25,31 +28,6 @@ export class DiscoveryError extends SdkError { } } -export class MissingRefreshToken extends SdkError { - public code: string = "missing_refresh_token" - - constructor(message?: string) { - super( - message ?? - "The access token has expired and a refresh token was not granted." - ) - this.name = "MissingRefreshToken" - } -} - -export class RefreshTokenGrantError extends SdkError { - public code: string = "refresh_token_grant_error" - public cause: OAuth2Error - - constructor({ cause, message }: { cause: OAuth2Error; message?: string }) { - super( - message ?? "An error occured while trying to refresh the access token." - ) - this.cause = cause - this.name = "RefreshTokenGrantError" - } -} - export class MissingStateError extends SdkError { public code: string = "missing_state" @@ -104,3 +82,19 @@ export class BackchannelLogoutError extends SdkError { this.name = "BackchannelLogoutError" } } + +export enum AccessTokenErrorCode { + MISSING_SESSION = "missing_session", + MISSING_REFRESH_TOKEN = "missing_refresh_token", + FAILED_TO_REFRESH_TOKEN = "failed_to_refresh_token", +} + +export class AccessTokenError extends SdkError { + public code: string + + constructor(code: string, message: string) { + super(message) + this.name = "AccessTokenError" + this.code = code + } +} diff --git a/src/server/auth-client.test.ts b/src/server/auth-client.test.ts index f1a0565c..256674fa 100644 --- a/src/server/auth-client.test.ts +++ b/src/server/auth-client.test.ts @@ -4,9 +4,9 @@ import * as oauth from "oauth4webapi" import { describe, expect, it, vi } from "vitest" import { generateSecret } from "../test/utils" +import { SessionData } from "../types" import { AuthClient } from "./auth-client" import { decrypt, encrypt } from "./cookies" -import { SessionData } from "./session/abstract-session-store" import { StatefulSessionStore } from "./session/stateful-session-store" import { StatelessSessionStore } from "./session/stateless-session-store" import { TransactionState, TransactionStore } from "./transaction-store" @@ -1287,7 +1287,7 @@ describe("Authentication Client", async () => { expect(cookie?.expires).toEqual(new Date("1970-01-01T00:00:00.000Z")) }) - it("should return an error if the client does not have RP-Initiated Logout enabled", async () => { + it("should fallback to the /v2/logout endpoint if the client does not have RP-Initiated Logout enabled", async () => { const secret = await generateSecret(32) const transactionStore = new TransactionStore({ secret, @@ -1330,10 +1330,13 @@ describe("Authentication Client", async () => { ) const response = await authClient.handleLogout(request) - expect(response.status).toEqual(500) - expect(await response.text()).toEqual( - "An error occured while trying to initiate the logout request." - ) + expect(response.status).toEqual(307) + const logoutUrl = new URL(response.headers.get("Location")!) + expect(logoutUrl.origin).toEqual(`https://${DEFAULT.domain}`) + + // query parameters + expect(logoutUrl.searchParams.get("client_id")).toEqual(DEFAULT.clientId) + expect(logoutUrl.searchParams.get("returnTo")).toEqual(DEFAULT.appBaseUrl) }) it("should return an error if the discovery endpoint could not be fetched", async () => { @@ -2523,7 +2526,10 @@ describe("Authentication Client", async () => { const response = await authClient.handleAccessToken(request) expect(response.status).toEqual(401) expect(await response.json()).toEqual({ - error: "You are not authenticated.", + error: { + message: "The user does not have an active session.", + code: "missing_session", + }, }) // validate that the session cookie has not been set @@ -2585,9 +2591,11 @@ describe("Authentication Client", async () => { const response = await authClient.handleAccessToken(request) expect(response.status).toEqual(401) expect(await response.json()).toEqual({ - error_code: "missing_refresh_token", - error: - "The access token has expired and a refresh token was not granted.", + error: { + message: + "The access token has expired and a refresh token was not provided. The user needs to re-authenticate.", + code: "missing_refresh_token", + }, }) // validate that the session cookie has not been set @@ -3272,7 +3280,7 @@ describe("Authentication Client", async () => { } const [error, updatedTokenSet] = await authClient.getTokenSet(tokenSet) - expect(error?.code).toEqual("refresh_token_grant_error") + expect(error?.code).toEqual("failed_to_refresh_token") expect(updatedTokenSet).toBeNull() }) diff --git a/src/server/auth-client.ts b/src/server/auth-client.ts index 377e73e2..d8414665 100644 --- a/src/server/auth-client.ts +++ b/src/server/auth-client.ts @@ -3,22 +3,21 @@ import * as jose from "jose" import * as oauth from "oauth4webapi" import { + AccessTokenError, + AccessTokenErrorCode, AuthorizationCodeGrantError, AuthorizationError, BackchannelLogoutError, DiscoveryError, InvalidStateError, - MissingRefreshToken, MissingStateError, OAuth2Error, - RefreshTokenGrantError, SdkError, } from "../errors" +import { SessionData, TokenSet } from "../types" import { AbstractSessionStore, LogoutToken, - SessionData, - TokenSet, } from "./session/abstract-session-store" import { TransactionState, TransactionStore } from "./transaction-store" import { filterClaims } from "./user" @@ -176,8 +175,6 @@ export class AuthClient { ) if (error) { - // TODO: accept a logger in the constructor to log these errors - console.error(`Failed to fetch token set: ${error.message}`) return res } @@ -280,24 +277,22 @@ export class AuthClient { ) } + const returnTo = req.nextUrl.searchParams.get("returnTo") || this.appBaseUrl + if (!authorizationServerMetadata.end_session_endpoint) { - console.error( - "The Auth0 client does not have RP-initiated logout enabled. Learn how to enable it here: https://auth0.com/docs/authenticate/login/logout/log-users-out-of-auth0#enable-endpoint-discovery" - ) - return new NextResponse( - "An error occured while trying to initiate the logout request.", - { - status: 500, - } + // the Auth0 client does not have RP-initiated logout enabled, redirect to the `/v2/logout` endpoint + console.warn( + "The Auth0 client does not have RP-initiated logout enabled, the user will be redirected to the `/v2/logout` endpoint instead. Learn how to enable it here: https://auth0.com/docs/authenticate/login/logout/log-users-out-of-auth0#enable-endpoint-discovery" ) + const url = new URL("/v2/logout", this.issuer) + url.searchParams.set("returnTo", returnTo) + url.searchParams.set("client_id", this.clientMetadata.client_id) + return NextResponse.redirect(url) } const url = new URL(authorizationServerMetadata.end_session_endpoint) url.searchParams.set("client_id", this.clientMetadata.client_id) - url.searchParams.set( - "post_logout_redirect_uri", - req.nextUrl.searchParams.get("returnTo") || this.appBaseUrl - ) + url.searchParams.set("post_logout_redirect_uri", returnTo) if (session?.internal.sid) { url.searchParams.set("logout_hint", session.internal.sid) @@ -437,7 +432,10 @@ export class AuthClient { if (!session) { return NextResponse.json( { - error: "You are not authenticated.", + error: { + message: "The user does not have an active session.", + code: AccessTokenErrorCode.MISSING_SESSION, + }, }, { status: 401, @@ -450,8 +448,10 @@ export class AuthClient { if (error) { return NextResponse.json( { - error: error.message, - error_code: error.code, + error: { + message: error.message, + code: error.code, + }, }, { status: 401, @@ -520,7 +520,13 @@ export class AuthClient { ): Promise<[null, TokenSet] | [SdkError, null]> { // the access token has expired but we do not have a refresh token if (!tokenSet.refreshToken && tokenSet.expiresAt <= Date.now() / 1000) { - return [new MissingRefreshToken(), null] + return [ + new AccessTokenError( + AccessTokenErrorCode.MISSING_REFRESH_TOKEN, + "The access token has expired and a refresh token was not provided. The user needs to re-authenticate." + ), + null, + ] } // the access token has expired and we have a refresh token @@ -529,6 +535,7 @@ export class AuthClient { await this.discoverAuthorizationServerMetadata() if (discoveryError) { + console.error(discoveryError) return [discoveryError, null] } @@ -550,13 +557,12 @@ export class AuthClient { refreshTokenRes ) } catch (e: any) { + console.error(e) return [ - new RefreshTokenGrantError({ - cause: new OAuth2Error({ - code: e.error, - message: e.error_description, - }), - }), + 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." + ), null, ] } diff --git a/src/server/client.ts b/src/server/client.ts index 7c9360ef..3617ed0b 100644 --- a/src/server/client.ts +++ b/src/server/client.ts @@ -2,6 +2,8 @@ import { cookies } from "next/headers" import { NextRequest, NextResponse } from "next/server" import { NextApiRequest } from "next/types" +import { AccessTokenError, AccessTokenErrorCode } from "../errors" +import { SessionData } from "../types" import { AuthClient, AuthorizationParameters, @@ -12,7 +14,6 @@ import { RequestCookies } from "./cookies" import { AbstractSessionStore, SessionConfiguration, - SessionData, SessionDataStore, } from "./session/abstract-session-store" import { StatefulSessionStore } from "./session/stateful-session-store" @@ -179,7 +180,7 @@ export class Auth0Client { * * This method can be used in Server Components, Server Actions, Route Handlers, and middleware in the **App Router**. */ - async getAccessToken(): Promise<{ token: string; expiresAt: number }> + async getAccessToken(): Promise<{ token: string; expiresAt: number } | null> /** * getAccessToken returns the access token. @@ -188,12 +189,14 @@ export class Auth0Client { */ async getAccessToken( req: PagesRouterRequest - ): Promise<{ token: string; expiresAt: number }> + ): Promise<{ token: string; expiresAt: number } | null> /** * getAccessToken returns the access token. */ - async getAccessToken(req?: PagesRouterRequest) { + async getAccessToken( + req?: PagesRouterRequest + ): Promise<{ token: string; expiresAt: number } | null> { let session: SessionData | null = null if (req) { @@ -203,7 +206,25 @@ export class Auth0Client { } if (!session) { - return null + throw new AccessTokenError( + AccessTokenErrorCode.MISSING_SESSION, + "The user does not have an active session." + ) + } + + // if access token has expired, return null + 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." + ) + } + + 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." + ) } return { diff --git a/src/server/index.ts b/src/server/index.ts index 098b28d6..abc56a54 100644 --- a/src/server/index.ts +++ b/src/server/index.ts @@ -1,2 +1 @@ export { Auth0Client } from "./client" -export type { SessionData } from "./session/abstract-session-store" diff --git a/src/server/session/abstract-session-store.ts b/src/server/session/abstract-session-store.ts index 5f7a066e..e33975d5 100644 --- a/src/server/session/abstract-session-store.ts +++ b/src/server/session/abstract-session-store.ts @@ -1,27 +1,9 @@ +import type { SessionData } from "../../types" import { ReadonlyRequestCookies, RequestCookies, ResponseCookies, } from "../cookies" -import { User } from "../user" - -export interface TokenSet { - accessToken: string - refreshToken?: string - expiresAt: number // the time at which the access token expires in seconds since epoch -} - -export interface SessionData { - user: User - tokenSet: TokenSet - internal: { - // the session ID from the authorization server - sid: string - // the time at which the session was created in seconds since epoch - createdAt: number - } - [key: string]: unknown -} export type LogoutToken = { sub?: string; sid?: string } diff --git a/src/server/session/stateful-session-store.test.ts b/src/server/session/stateful-session-store.test.ts index 69c9b836..ef203208 100644 --- a/src/server/session/stateful-session-store.test.ts +++ b/src/server/session/stateful-session-store.test.ts @@ -1,8 +1,8 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest" import { generateSecret } from "../../test/utils" +import { SessionData } from "../../types" import { decrypt, encrypt, RequestCookies, ResponseCookies } from "../cookies" -import { SessionData } from "./abstract-session-store" import { StatefulSessionStore } from "./stateful-session-store" describe("Stateful Session Store", async () => { diff --git a/src/server/session/stateful-session-store.ts b/src/server/session/stateful-session-store.ts index 0b9360a3..f4b57283 100644 --- a/src/server/session/stateful-session-store.ts +++ b/src/server/session/stateful-session-store.ts @@ -1,7 +1,7 @@ +import { SessionData } from "../../types" import * as cookies from "../cookies" import { AbstractSessionStore, - SessionData, SessionDataStore, } from "./abstract-session-store" diff --git a/src/server/session/stateless-session-store.test.ts b/src/server/session/stateless-session-store.test.ts index 1e74608e..8e1cf2e6 100644 --- a/src/server/session/stateless-session-store.test.ts +++ b/src/server/session/stateless-session-store.test.ts @@ -1,8 +1,8 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest" import { generateSecret } from "../../test/utils" +import { SessionData } from "../../types" import { decrypt, encrypt, RequestCookies, ResponseCookies } from "../cookies" -import { SessionData } from "./abstract-session-store" import { StatelessSessionStore } from "./stateless-session-store" describe("Stateless Session Store", async () => { diff --git a/src/server/session/stateless-session-store.ts b/src/server/session/stateless-session-store.ts index a5485692..d1d4eb5f 100644 --- a/src/server/session/stateless-session-store.ts +++ b/src/server/session/stateless-session-store.ts @@ -1,5 +1,6 @@ +import { SessionData } from "../../types" import * as cookies from "../cookies" -import { AbstractSessionStore, SessionData } from "./abstract-session-store" +import { AbstractSessionStore } from "./abstract-session-store" interface StatelessSessionStoreOptions { secret: string @@ -50,6 +51,20 @@ export class StatelessSessionStore extends AbstractSessionStore { ...this.cookieConfig, maxAge, }) + + // check if the session cookie size exceeds 4096 bytes, and if so, log a warning + const cookieJarSizeTest = new cookies.ResponseCookies(new Headers()) + cookieJarSizeTest.set(this.SESSION_COOKIE_NAME, jwe.toString(), { + ...this.cookieConfig, + maxAge, + }) + if (new TextEncoder().encode(cookieJarSizeTest.toString()).length >= 4096) { + console.warn( + "The session cookie size exceeds 4096 bytes, which may cause issues in some browsers. " + + "Consider removing any unnecessary custom claims from the access token or the user profile. " + + "Alternatively, you can use a stateful session implementation to store the session data in a data store." + ) + } } async delete( diff --git a/src/server/user.ts b/src/server/user.ts index 72606a55..e662e442 100644 --- a/src/server/user.ts +++ b/src/server/user.ts @@ -1,18 +1,4 @@ -// TODO: allow a developer to define their own user interface -// since it can be overridden. Same for the session data interface. -export interface User { - sub: string - name?: string - nickname?: string - given_name?: string - family_name?: string - picture?: string - email?: string - email_verified?: boolean - org_id?: string - - [key: string]: any -} +import type { User } from "../types" const DEFAULT_ALLOWED_CLAIMS = [ "sub", diff --git a/src/types/index.ts b/src/types/index.ts new file mode 100644 index 00000000..a620d977 --- /dev/null +++ b/src/types/index.ts @@ -0,0 +1,31 @@ +export interface TokenSet { + accessToken: string + refreshToken?: string + expiresAt: number // the time at which the access token expires in seconds since epoch +} + +export interface SessionData { + user: User + tokenSet: TokenSet + internal: { + // the session ID from the authorization server + sid: string + // the time at which the session was created in seconds since epoch + createdAt: number + } + [key: string]: unknown +} + +export interface User { + sub: string + name?: string + nickname?: string + given_name?: string + family_name?: string + picture?: string + email?: string + email_verified?: boolean + org_id?: string + + [key: string]: any +}