diff --git a/EXAMPLES.md b/EXAMPLES.md index 27b50d1b..8b1e0b48 100644 --- a/EXAMPLES.md +++ b/EXAMPLES.md @@ -32,6 +32,10 @@ - [Custom routes](#custom-routes) - [Testing helpers](#testing-helpers) - [`generateSessionCookie`](#generatesessioncookie) +- [Getting access tokens for connections](#getting-access-tokens-for-connections) + - [On the server (App Router)](#on-the-server-app-router-3) + - [On the server (Pages Router)](#on-the-server-pages-router-3) + - [Middleware](#middleware-3) ## Passing authorization parameters @@ -752,3 +756,134 @@ const sessionCookieValue = await generateSessionCookie( } ) ``` + +## Getting access tokens for connections +You can retrieve an access token for a connection using the `getAccessTokenForConnection()` method, which accepts an object with the following properties: +- `connection`: The federated connection for which an access token should be retrieved. +- `login_hint`: The optional login_hint parameter to pass to the `/authorize` endpoint. + +### On the server (App Router) + +On the server, the `getAccessTokenForConnection()` helper can be used in Server Routes, Server Actions and Server Components to get an access token for a connection. + +> [!IMPORTANT] +> Server Components cannot set cookies. Calling `getAccessTokenForConnection()` 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. +> +> It is recommended to call `getAccessTokenForConnection(req, res)` in the middleware if you need to refresh the token in a Server Component as this will ensure the token is refreshed and correctly persisted. + +For example: + +```ts +import { NextResponse } from "next/server" + +import { auth0 } from "@/lib/auth0" + +export async function GET() { + try { + const token = await auth0.getAccessTokenForConnection({ connection: 'google-oauth2' }) + // 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!", + }) +} +``` + +### On the server (Pages Router) + +On the server, the `getAccessTokenForConnection({}, req, res)` helper can be used in `getServerSideProps` and API routes to get an access token for a connection, like so: + +```ts +import type { NextApiRequest, NextApiResponse } from "next" + +import { auth0 } from "@/lib/auth0" + +export default async function handler( + req: NextApiRequest, + res: NextApiResponse<{ message: string }> +) { + try { + const token = await auth0.getAccessTokenForConnection({ connection: 'google-oauth2' }, req, res) + } catch (err) { + // err will be an instance of AccessTokenError if an access token could not be obtained + } + + res.status(200).json({ message: "Success!" }) +} +``` + +### Middleware + +In middleware, the `getAccessTokenForConnection({}, req, res)` helper can be used to get an access token for a connection, like so: + +```tsx +import { NextRequest, NextResponse } from "next/server" + +import { auth0 } from "@/lib/auth0" + +export async function middleware(request: NextRequest) { + const authRes = await auth0.middleware(request) + + if (request.nextUrl.pathname.startsWith("/auth")) { + return authRes + } + + const session = await auth0.getSession(request) + + if (!session) { + // user is not authenticated, redirect to login page + return NextResponse.redirect(new URL("/auth/login", request.nextUrl.origin)) + } + + const accessToken = await auth0.getAccessTokenForConnection({ connection: 'google-oauth2' }, request, authRes) + + // the headers from the auth middleware should always be returned + return authRes +} +``` + +> [!IMPORTANT] +> The `request` and `response` objects must be passed as a parameters to the `getAccessTokenForConnection({}, request, response)` method when called from a middleware to ensure that the refreshed access token can be accessed within the same request. + +If you are using the Pages Router and are calling the `getAccessTokenForConnection` method in both the middleware and an API Route or `getServerSideProps`, it's recommended to propagate the headers from the middleware, as shown below. This will ensure that calling `getAccessTokenForConnection` in the API Route or `getServerSideProps` will not result in the access token being refreshed again. + +```ts +import { NextRequest, NextResponse } from "next/server" + +import { auth0 } from "@/lib/auth0" + +export async function middleware(request: NextRequest) { + const authRes = await auth0.middleware(request) + + if (request.nextUrl.pathname.startsWith("/auth")) { + return authRes + } + + const session = await auth0.getSession(request) + + if (!session) { + // user is not authenticated, redirect to login page + return NextResponse.redirect(new URL("/auth/login", request.nextUrl.origin)) + } + + const accessToken = await auth0.getAccessTokenForConnection({ connection: 'google-oauth2' }, request, authRes) + + // create a new response with the updated request headers + const resWithCombinedHeaders = NextResponse.next({ + request: { + headers: request.headers, + }, + }) + + // set the response headers (set-cookie) from the auth response + authRes.headers.forEach((value, key) => { + resWithCombinedHeaders.headers.set(key, value) + }) + + // the headers from the auth middleware should always be returned + return resWithCombinedHeaders +} +``` diff --git a/examples/with-shadcn/middleware.ts b/examples/with-shadcn/middleware.ts index f732b64a..a2a20253 100644 --- a/examples/with-shadcn/middleware.ts +++ b/examples/with-shadcn/middleware.ts @@ -3,7 +3,7 @@ import type { NextRequest } from "next/server" import { auth0 } from "./lib/auth0" export async function middleware(request: NextRequest) { - return await auth0.middleware(request) + return await auth0.middleware(request); } export const config = { diff --git a/examples/with-shadcn/package.json b/examples/with-shadcn/package.json index 08dbaef1..92af0ce2 100644 --- a/examples/with-shadcn/package.json +++ b/examples/with-shadcn/package.json @@ -9,7 +9,7 @@ "lint": "next lint" }, "dependencies": { - "@auth0/nextjs-auth0": "^4.0.0", + "@auth0/nextjs-auth0": "^4.0.1", "@radix-ui/react-avatar": "^1.1.1", "@radix-ui/react-collapsible": "^1.1.1", "@radix-ui/react-dialog": "^1.1.2", diff --git a/examples/with-shadcn/pnpm-lock.yaml b/examples/with-shadcn/pnpm-lock.yaml index ccf0b049..c7fb5f96 100644 --- a/examples/with-shadcn/pnpm-lock.yaml +++ b/examples/with-shadcn/pnpm-lock.yaml @@ -9,8 +9,8 @@ importers: .: dependencies: '@auth0/nextjs-auth0': - specifier: ^4.0.0 - version: 4.0.0(next@15.0.2(react-dom@19.0.0-rc-02c0e824-20241028(react@19.0.0-rc-02c0e824-20241028))(react@19.0.0-rc-02c0e824-20241028))(react-dom@19.0.0-rc-02c0e824-20241028(react@19.0.0-rc-02c0e824-20241028))(react@19.0.0-rc-02c0e824-20241028) + specifier: ^4.0.1 + version: 4.0.1(next@15.0.2(react-dom@19.0.0-rc-02c0e824-20241028(react@19.0.0-rc-02c0e824-20241028))(react@19.0.0-rc-02c0e824-20241028))(react-dom@19.0.0-rc-02c0e824-20241028(react@19.0.0-rc-02c0e824-20241028))(react@19.0.0-rc-02c0e824-20241028) '@radix-ui/react-avatar': specifier: ^1.1.1 version: 1.1.1(@types/react-dom@18.3.1)(@types/react@18.3.12)(react-dom@19.0.0-rc-02c0e824-20241028(react@19.0.0-rc-02c0e824-20241028))(react@19.0.0-rc-02c0e824-20241028) @@ -91,8 +91,8 @@ packages: resolution: {integrity: sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==} engines: {node: '>=10'} - '@auth0/nextjs-auth0@4.0.0': - resolution: {integrity: sha512-pFnbGXMjNNYRB4jHvjBDOZdvsvYeyIJn0LILD+g8tl8dUStS90spAd3ziPY/YOiaeIejqm5Iy7uYhNlHITJlUg==} + '@auth0/nextjs-auth0@4.0.1': + resolution: {integrity: sha512-K9XY9e0DWWdqwhsAUK3ZKJ6PJsyFzfPnWl3Dof3YXcJyqgGs/d1G7ZJT9qFeHqa61zIUYNybCmyzLP41DCN00g==} peerDependencies: next: ^14.0.0 || ^15.0.0 react: ^18.0.0 || ^19.0.0 || ^19.0.0-0 @@ -2158,7 +2158,7 @@ snapshots: '@alloc/quick-lru@5.2.0': {} - '@auth0/nextjs-auth0@4.0.0(next@15.0.2(react-dom@19.0.0-rc-02c0e824-20241028(react@19.0.0-rc-02c0e824-20241028))(react@19.0.0-rc-02c0e824-20241028))(react-dom@19.0.0-rc-02c0e824-20241028(react@19.0.0-rc-02c0e824-20241028))(react@19.0.0-rc-02c0e824-20241028)': + '@auth0/nextjs-auth0@4.0.1(next@15.0.2(react-dom@19.0.0-rc-02c0e824-20241028(react@19.0.0-rc-02c0e824-20241028))(react@19.0.0-rc-02c0e824-20241028))(react-dom@19.0.0-rc-02c0e824-20241028(react@19.0.0-rc-02c0e824-20241028))(react@19.0.0-rc-02c0e824-20241028)': dependencies: '@edge-runtime/cookies': 5.0.2 '@panva/hkdf': 1.2.1 diff --git a/src/errors/index.ts b/src/errors/index.ts index f4a650e3..e34abc83 100644 --- a/src/errors/index.ts +++ b/src/errors/index.ts @@ -98,3 +98,49 @@ export class AccessTokenError extends SdkError { this.code = code; } } + +/** + * Enum representing error codes related to access tokens for connections. + */ +export enum AccessTokenForConnectionErrorCode { + /** + * The session is missing. + */ + MISSING_SESSION = "missing_session", + + /** + * The refresh token is missing. + */ + MISSING_REFRESH_TOKEN = "missing_refresh_token", + + /** + * Failed to exchange the refresh token. + */ + FAILED_TO_EXCHANGE = "failed_to_exchange_refresh_token" +} + +/** + * Error class representing an access token for connection error. + * Extends the `SdkError` class. + */ +export class AccessTokenForConnectionError extends SdkError { + /** + * The error code associated with the access token error. + */ + public code: string; + public cause?: OAuth2Error; + + /** + * Constructs a new `AccessTokenForConnectionError` instance. + * + * @param code - The error code. + * @param message - The error message. + * @param cause - The OAuth2 cause of the error. + */ + constructor(code: string, message: string, cause?: OAuth2Error) { + super(message); + this.name = "AccessTokenForConnectionError"; + this.code = code; + this.cause = cause; + } +} diff --git a/src/server/auth-client.test.ts b/src/server/auth-client.test.ts index 56f1b21d..48ff3727 100644 --- a/src/server/auth-client.test.ts +++ b/src/server/auth-client.test.ts @@ -56,6 +56,7 @@ ca/T0LLtgmbMmxSv/MmzIg== function getMockAuthorizationServer({ tokenEndpointResponse, + tokenEndpointErrorResponse, discoveryResponse, audience, nonce, @@ -63,6 +64,7 @@ ca/T0LLtgmbMmxSv/MmzIg== onParRequest }: { tokenEndpointResponse?: oauth.TokenEndpointResponse | oauth.OAuth2Error; + tokenEndpointErrorResponse?: oauth.OAuth2Error; discoveryResponse?: Response; audience?: string; nonce?: string; @@ -96,6 +98,12 @@ ca/T0LLtgmbMmxSv/MmzIg== .setAudience(audience ?? DEFAULT.clientId) .setExpirationTime("2h") .sign(keyPair.privateKey); + + if (tokenEndpointErrorResponse) { + return Response.json(tokenEndpointErrorResponse, { + status: 400 + }); + } return Response.json( tokenEndpointResponse ?? { token_type: "Bearer", @@ -4153,6 +4161,279 @@ ca/T0LLtgmbMmxSv/MmzIg== }); }); }); + + describe("getConnectionTokenSet", async () => { + it("should call for an access token when no connection token set in the session", async () => { + const secret = await generateSecret(32); + const transactionStore = new TransactionStore({ + secret + }); + const sessionStore = new StatelessSessionStore({ + secret + }); + const fetchSpy = getMockAuthorizationServer({ + tokenEndpointResponse: { + token_type: "Bearer", + access_token: DEFAULT.accessToken, + expires_in: 86400 // expires in 10 days + } as oauth.TokenEndpointResponse + }); + + const authClient = new AuthClient({ + transactionStore, + sessionStore, + + domain: DEFAULT.domain, + clientId: DEFAULT.clientId, + clientSecret: DEFAULT.clientSecret, + + secret, + appBaseUrl: DEFAULT.appBaseUrl, + + fetch: fetchSpy + }); + + const expiresAt = Math.floor(Date.now() / 1000) - 10 * 24 * 60 * 60; // expired 10 days ago + const tokenSet = { + accessToken: DEFAULT.accessToken, + refreshToken: DEFAULT.refreshToken, + expiresAt + }; + + const response = await authClient.getConnectionTokenSet( + tokenSet, + undefined, + { connection: "google-oauth2", login_hint: "000100123" } + ); + const [error, connectionTokenSet] = response; + expect(error).toBe(null); + expect(fetchSpy).toHaveBeenCalled(); + expect(connectionTokenSet).toEqual({ + accessToken: DEFAULT.accessToken, + connection: "google-oauth2", + expiresAt: expect.any(Number) + }); + }); + + it("should return access token from the session when connection token set in the session is not expired", async () => { + const secret = await generateSecret(32); + const transactionStore = new TransactionStore({ + secret + }); + const sessionStore = new StatelessSessionStore({ + secret + }); + const fetchSpy = vi.fn(); + const authClient = new AuthClient({ + transactionStore, + sessionStore, + + domain: DEFAULT.domain, + clientId: DEFAULT.clientId, + clientSecret: DEFAULT.clientSecret, + + secret, + appBaseUrl: DEFAULT.appBaseUrl, + + fetch: fetchSpy + }); + + const expiresAt = Math.floor(Date.now() / 1000) - 10 * 24 * 60 * 60; // expired 10 days ago + const tokenSet = { + accessToken: DEFAULT.accessToken, + refreshToken: DEFAULT.refreshToken, + expiresAt + }; + + const response = await authClient.getConnectionTokenSet( + tokenSet, + { + connection: "google-oauth2", + accessToken: "fc_at", + expiresAt: Math.floor(Date.now() / 1000) + 86400 + }, + { connection: "google-oauth2", login_hint: "000100123" } + ); + const [error, connectionTokenSet] = response; + expect(error).toBe(null); + expect(connectionTokenSet).toEqual({ + accessToken: "fc_at", + connection: "google-oauth2", + expiresAt: expect.any(Number) + }); + expect(fetchSpy).not.toHaveBeenCalled(); + }); + + it("should call for an access token when connection token set in the session is expired", async () => { + const secret = await generateSecret(32); + const transactionStore = new TransactionStore({ + secret + }); + const sessionStore = new StatelessSessionStore({ + secret + }); + const fetchSpy = getMockAuthorizationServer({ + tokenEndpointResponse: { + token_type: "Bearer", + access_token: DEFAULT.accessToken, + expires_in: 86400 // expires in 10 days + } as oauth.TokenEndpointResponse + }); + const authClient = new AuthClient({ + transactionStore, + sessionStore, + + domain: DEFAULT.domain, + clientId: DEFAULT.clientId, + clientSecret: DEFAULT.clientSecret, + + secret, + appBaseUrl: DEFAULT.appBaseUrl, + + fetch: fetchSpy + }); + + const expiresAt = Math.floor(Date.now() / 1000) - 10 * 24 * 60 * 60; // expired 10 days ago + const tokenSet = { + accessToken: DEFAULT.accessToken, + refreshToken: DEFAULT.refreshToken, + expiresAt + }; + + const response = await authClient.getConnectionTokenSet( + tokenSet, + { connection: "google-oauth2", accessToken: "fc_at", expiresAt }, + { connection: "google-oauth2", login_hint: "000100123" } + ); + const [error, connectionTokenSet] = response; + expect(error).toBe(null); + expect(connectionTokenSet).toEqual({ + accessToken: DEFAULT.accessToken, + connection: "google-oauth2", + expiresAt: expect.any(Number) + }); + expect(fetchSpy).toHaveBeenCalled(); + }); + + it("should return an error if the discovery endpoint could not be fetched", 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({ + discoveryResponse: new Response(null, { status: 500 }) + }) + }); + + const expiresAt = Math.floor(Date.now() / 1000) - 10 * 24 * 60 * 60; // expired 10 days ago + const tokenSet = { + accessToken: DEFAULT.accessToken, + refreshToken: DEFAULT.refreshToken, + expiresAt + }; + + const [error, connectionTokenSet] = + await authClient.getConnectionTokenSet(tokenSet, undefined, { + connection: "google-oauth2" + }); + expect(error?.code).toEqual("discovery_error"); + expect(connectionTokenSet).toBeNull(); + }); + + it("should return an error if the token set does not contain a refresh token", 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() + }); + + const expiresAt = Math.floor(Date.now() / 1000) - 10 * 24 * 60 * 60; // expired 10 days ago + const tokenSet = { + accessToken: DEFAULT.accessToken, + expiresAt + }; + + const [error, connectionTokenSet] = + await authClient.getConnectionTokenSet(tokenSet, undefined, { + connection: "google-oauth2" + }); + expect(error?.code).toEqual("missing_refresh_token"); + expect(connectionTokenSet).toBeNull(); + }); + + it("should return an error and capture it as the cause when exchange failed", 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({ + tokenEndpointErrorResponse: { + error: "some-error-code", + error_description: "some-error-description" + } + }) + }); + + const expiresAt = Math.floor(Date.now() / 1000) - 10 * 24 * 60 * 60; // expired 10 days ago + const tokenSet = { + accessToken: DEFAULT.accessToken, + refreshToken: DEFAULT.refreshToken, + expiresAt + }; + + const [error, connectionTokenSet] = + await authClient.getConnectionTokenSet(tokenSet, undefined, { + connection: "google-oauth2" + }); + expect(error?.code).toEqual("failed_to_exchange_refresh_token"); + expect(error?.cause?.code).toEqual("some-error-code"); + expect(error?.cause?.message).toEqual("some-error-description"); + expect(connectionTokenSet).toBeNull(); + }); + }); }); const _authorizationServerMetadata = { diff --git a/src/server/auth-client.ts b/src/server/auth-client.ts index 2156ca84..b1684a48 100644 --- a/src/server/auth-client.ts +++ b/src/server/auth-client.ts @@ -9,12 +9,20 @@ import { AuthorizationError, BackchannelLogoutError, DiscoveryError, + AccessTokenForConnectionError, + AccessTokenForConnectionErrorCode, InvalidStateError, MissingStateError, OAuth2Error, SdkError } from "../errors"; -import { LogoutToken, SessionData, TokenSet } from "../types"; +import { + ConnectionTokenSet, + AccessTokenForConnectionOptions, + LogoutToken, + SessionData, + TokenSet +} from "../types"; import { toSafeRedirect } from "../utils/url-helpers"; import { AbstractSessionStore } from "./session/abstract-session-store"; import { TransactionState, TransactionStore } from "./transaction-store"; @@ -49,6 +57,35 @@ const DEFAULT_SCOPES = ["openid", "profile", "email", "offline_access"].join( " " ); +/** + * A constant representing the grant type for federated connection access token exchange. + * + * This grant type is used in OAuth token exchange scenarios where a federated connection + * access token is required. It is specific to Auth0's implementation and follows the + * "urn:auth0:params:oauth:grant-type:token-exchange:federated-connection-access-token" format. + */ +const GRANT_TYPE_FEDERATED_CONNECTION_ACCESS_TOKEN = + "urn:auth0:params:oauth:grant-type:token-exchange:federated-connection-access-token"; + +/** + * Constant representing the subject type for a refresh token. + * This is used in OAuth 2.0 token exchange to specify that the token being exchanged is a refresh token. + * + * @see {@link https://tools.ietf.org/html/rfc8693#section-3.1 RFC 8693 Section 3.1} + */ +const SUBJECT_TYPE_REFRESH_TOKEN = + "urn:ietf:params:oauth:token-type:refresh_token"; + +/** + * A constant representing the token type for federated connection access tokens. + * This is used to specify the type of token being requested from Auth0. + * + * @constant + * @type {string} + */ +const REQUESTED_TOKEN_TYPE_FEDERATED_CONNECTION_ACCESS_TOKEN = + "http://auth0.com/oauth/token-type/federated-connection-access-token"; + export interface AuthorizationParameters { /** * The list of scopes to request authorization for. @@ -936,6 +973,126 @@ export class AuthClient { ? this.domain : `https://${this.domain}`; } + + /** + * Exchanges a refresh token for an access token for a connection. + * + * This method performs a token exchange using the provided refresh token and connection details. + * It first checks if the refresh token is present in the `tokenSet`. If not, it returns an error. + * Then, it constructs the necessary parameters for the token exchange request and performs + * the request to the authorization server's token endpoint. + * + * @returns {Promise<[AccessTokenForConnectionError, null] | [null, ConnectionTokenSet]>} A promise that resolves to a tuple. + * The first element is either an `AccessTokenForConnectionError` if an error occurred, or `null` if the request was successful. + * The second element is either `null` if an error occurred, or a `ConnectionTokenSet` object + * containing the access token, expiration time, and scope if the request was successful. + * + * @throws {AccessTokenForConnectionError} If the refresh token is missing or if there is an error during the token exchange process. + */ + async getConnectionTokenSet( + tokenSet: TokenSet, + connectionTokenSet: ConnectionTokenSet | undefined, + options: AccessTokenForConnectionOptions + ): Promise<[AccessTokenForConnectionError, null] | [null, ConnectionTokenSet]> { + // If we do not have a refresh token + // and we do not have a connection token set in the cache or the one we have is expired, + // there is noting to retrieve and we return an error. + if ( + !tokenSet.refreshToken && + (!connectionTokenSet || + connectionTokenSet.expiresAt <= Date.now() / 1000) + ) { + return [ + new AccessTokenForConnectionError( + AccessTokenForConnectionErrorCode.MISSING_REFRESH_TOKEN, + "A refresh token was not present, Connection Access Token requires a refresh token. The user needs to re-authenticate.", + ), + null + ]; + } + + // If we do have a refresh token, + // and we do not have a connection token set in the cache or the one we have is expired, + // we need to exchange the refresh token for a connection access token. + if ( + tokenSet.refreshToken && + (!connectionTokenSet || + connectionTokenSet.expiresAt <= Date.now() / 1000) + ) { + const params = new URLSearchParams(); + + params.append("connection", options.connection); + params.append("subject_token_type", SUBJECT_TYPE_REFRESH_TOKEN); + params.append("subject_token", tokenSet.refreshToken); + params.append( + "requested_token_type", + REQUESTED_TOKEN_TYPE_FEDERATED_CONNECTION_ACCESS_TOKEN + ); + + if (options.login_hint) { + params.append("login_hint", options.login_hint); + } + + const [discoveryError, authorizationServerMetadata] = + await this.discoverAuthorizationServerMetadata(); + + if (discoveryError) { + console.error(discoveryError); + return [discoveryError, null]; + } + + const httpResponse = await oauth.genericTokenEndpointRequest( + authorizationServerMetadata, + this.clientMetadata, + await this.getClientAuth(), + GRANT_TYPE_FEDERATED_CONNECTION_ACCESS_TOKEN, + params, + { + [oauth.customFetch]: this.fetch, + [oauth.allowInsecureRequests]: this.allowInsecureRequests + } + ); + + let tokenEndpointResponse: oauth.TokenEndpointResponse; + try { + tokenEndpointResponse = await oauth.processGenericTokenEndpointResponse( + authorizationServerMetadata, + this.clientMetadata, + httpResponse + ); + } catch (err: any) { + console.error(err); + return [ + new AccessTokenForConnectionError( + AccessTokenForConnectionErrorCode.FAILED_TO_EXCHANGE, + "There was an error trying to exchange the refresh token for a connection access token. Check the server logs for more information.", + new OAuth2Error({ + code: err.error, + message: err.error_description + }) + ), + null + ]; + } + + return [ + null, + { + accessToken: tokenEndpointResponse.access_token, + expiresAt: + Math.floor(Date.now() / 1000) + + Number(tokenEndpointResponse.expires_in), + scope: tokenEndpointResponse.scope, + connection: options.connection + } + ]; + } + + return [null, connectionTokenSet] as [ + null, + ConnectionTokenSet + ]; + } } const encodeBase64 = (input: string) => { diff --git a/src/server/client.ts b/src/server/client.ts index dc0cd373..ed5e80e1 100644 --- a/src/server/client.ts +++ b/src/server/client.ts @@ -3,8 +3,17 @@ import { cookies } from "next/headers"; import { NextRequest, NextResponse } from "next/server"; import { NextApiRequest, NextApiResponse } from "next/types"; -import { AccessTokenError, AccessTokenErrorCode } from "../errors"; -import { SessionData, SessionDataStore } from "../types"; +import { + AccessTokenError, + AccessTokenErrorCode, + AccessTokenForConnectionError, + AccessTokenForConnectionErrorCode, +} from "../errors"; +import { + AccessTokenForConnectionOptions, + SessionData, + SessionDataStore +} from "../types"; import { AuthClient, AuthorizationParameters, @@ -314,20 +323,8 @@ export class Auth0Client { req?: PagesRouterRequest | NextRequest, res?: PagesRouterResponse | NextResponse ): Promise<{ token: string; expiresAt: number; scope?: string }> { - let session: SessionData | null = null; - - if (req) { - if (req instanceof NextRequest) { - // middleware usage - session = await this.sessionStore.get(req.cookies); - } else { - // pages router usage - session = await this.sessionStore.get(this.createRequestCookies(req)); - } - } else { - // app router usage: Server Components, Server Actions, Route Handlers - session = await this.sessionStore.get(await cookies()); - } + const session: SessionData | null = + req ? await this.getSession(req) : await this.getSession(); if (!session) { throw new AccessTokenError( @@ -349,47 +346,14 @@ export class Auth0Client { tokenSet.expiresAt !== session.tokenSet.expiresAt || tokenSet.refreshToken !== session.tokenSet.refreshToken ) { - if (req && res) { - if (req instanceof NextRequest && res instanceof NextResponse) { - // middleware usage - await this.sessionStore.set(req.cookies, res.cookies, { - ...session, - tokenSet - }); - } else { - // pages router usage - const resHeaders = new Headers(); - const resCookies = new ResponseCookies(resHeaders); - const pagesRouterRes = res as PagesRouterResponse; - - await this.sessionStore.set( - this.createRequestCookies(req as PagesRouterRequest), - resCookies, - { - ...session, - tokenSet - } - ); - - for (const [key, value] of resHeaders.entries()) { - pagesRouterRes.setHeader(key, value); - } - } - } else { - // app router usage: Server Components, Server Actions, Route Handlers - 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." - ); - } - } - } + await this.saveToSession( + { + ...session, + tokenSet + }, + req, + res + ); } return { @@ -399,6 +363,122 @@ export class Auth0Client { }; } + /** + * Retrieves an access token for a connection. + * + * This method can be used in Server Components, Server Actions, and Route Handlers in the **App Router**. + * + * NOTE: Server Components cannot set cookies. Calling `getAccessTokenForConnection()` 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. + * It is recommended to call `getAccessTokenForConnection(req, res)` in the middleware if you need to retrieve the access token in a Server Component to ensure the updated token set is persisted. + */ + async getAccessTokenForConnection( + options: AccessTokenForConnectionOptions + ): Promise<{ token: string; expiresAt: number }>; + + /** + * Retrieves an access token for a connection. + * + * This method can be used in middleware and `getServerSideProps`, API routes in the **Pages Router**. + */ + async getAccessTokenForConnection( + options: AccessTokenForConnectionOptions, + req: PagesRouterRequest | NextRequest | undefined, + res: PagesRouterResponse | NextResponse | undefined + ): Promise<{ token: string; expiresAt: number }>; + + /** + * Retrieves an access token for a connection. + * + * This method attempts to obtain an access token for a specified connection. + * It first checks if a session exists, either from the provided request or from cookies. + * If no session is found, it throws a `AccessTokenForConnectionError` indicating + * that the user does not have an active session. + * + * @param {AccessTokenForConnectionOptions} options - Options for retrieving an access token for a connection. + * @param {PagesRouterRequest | NextRequest} [req] - An optional request object from which to extract session information. + * @param {PagesRouterResponse | NextResponse} [res] - An optional response object from which to extract session information. + * + * @throws {AccessTokenForConnectionError} If the user does not have an active session. + * @throws {Error} If there is an error during the token exchange process. + * + * @returns {Promise<{ token: string; expiresAt: number; scope?: string }} An object containing the access token and its expiration time. + */ + async getAccessTokenForConnection( + options: AccessTokenForConnectionOptions, + req?: PagesRouterRequest | NextRequest, + res?: PagesRouterResponse | NextResponse + ): Promise<{ token: string; expiresAt: number; scope?: string }> { + const session: SessionData | null = + req ? await this.getSession(req) : await this.getSession(); + + if (!session) { + throw new AccessTokenForConnectionError( + AccessTokenForConnectionErrorCode.MISSING_SESSION, + "The user does not have an active session." + ); + } + + // Find the connection token set in the session + const existingTokenSet = session.connectionTokenSets?.find( + (tokenSet) => tokenSet.connection === options.connection + ); + + const [error, retrievedTokenSet] = + await this.authClient.getConnectionTokenSet( + session.tokenSet, + existingTokenSet, + options + ); + + if (error !== null) { + throw error; + } + + // If we didnt have a corresponding connection token set in the session + // or if the one we have in the session does not match the one we received + // We want to update the store incase we retrieved a token set. + if ( + retrievedTokenSet && + (!existingTokenSet || + retrievedTokenSet.accessToken !== existingTokenSet.accessToken || + retrievedTokenSet.expiresAt !== existingTokenSet.expiresAt || + retrievedTokenSet.scope !== existingTokenSet.scope) + ) { + let tokenSets; + + // If we already had the connection token set in the session + // we need to update the item in the array + // If not, we need to add it. + if (existingTokenSet) { + tokenSets = session.connectionTokenSets?.map((tokenSet) => + tokenSet.connection === options.connection + ? retrievedTokenSet + : tokenSet + ); + } else { + tokenSets = [ + ...(session.connectionTokenSets || []), + retrievedTokenSet + ]; + } + + await this.saveToSession( + { + ...session, + connectionTokenSets: tokenSets + }, + req, + res + ); + } + + return { + token: retrievedTokenSet.accessToken, + scope: retrievedTokenSet.scope, + expiresAt: retrievedTokenSet.expiresAt + }; + } + /** * updateSession updates the session of the currently authenticated user. If the user does not have a session, an error is thrown. * @@ -510,4 +590,43 @@ export class Auth0Client { return new RequestCookies(headers); } + + private async saveToSession( + data: SessionData, + req?: PagesRouterRequest | NextRequest, + res?: PagesRouterResponse | NextResponse + ) { + if (req && res) { + if (req instanceof NextRequest && res instanceof NextResponse) { + // middleware usage + await this.sessionStore.set(req.cookies, res.cookies, data); + } else { + // pages router usage + const resHeaders = new Headers(); + const resCookies = new ResponseCookies(resHeaders); + const pagesRouterRes = res as PagesRouterResponse; + + await this.sessionStore.set( + this.createRequestCookies(req as PagesRouterRequest), + resCookies, + data + ); + + for (const [key, value] of resHeaders.entries()) { + pagesRouterRes.setHeader(key, value); + } + } + } else { + // app router usage: Server Components, Server Actions, Route Handlers + try { + await this.sessionStore.set(await cookies(), await cookies(), data); + } 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." + ); + } + } + } + } } diff --git a/src/server/session/stateless-session-store.test.ts b/src/server/session/stateless-session-store.test.ts index 75c0ee80..33619ea4 100644 --- a/src/server/session/stateless-session-store.test.ts +++ b/src/server/session/stateless-session-store.test.ts @@ -45,6 +45,40 @@ describe("Stateless Session Store", async () => { expect(await sessionStore.get(requestCookies)).toBeNull(); }); + + it("should return the decrypted session cookie if it exists with connection", 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) + }, + federatedConnectionTokenSets: [ + { + connection: "google-oauth", + accessToken: "google-at-123", + expiresAt: 123456 + } + ] + }; + const encryptedCookieValue = await encrypt(session, secret); + + const headers = new Headers(); + headers.append("cookie", `__session=${encryptedCookieValue}`); + const requestCookies = new RequestCookies(headers); + + const sessionStore = new StatelessSessionStore({ + secret + }); + + expect(await sessionStore.get(requestCookies)).toEqual(session); + }); }); describe("set", async () => { diff --git a/src/server/session/stateless-session-store.ts b/src/server/session/stateless-session-store.ts index 6944695d..7bbbe681 100644 --- a/src/server/session/stateless-session-store.ts +++ b/src/server/session/stateless-session-store.ts @@ -1,4 +1,6 @@ -import { SessionData } from "../../types"; +import type { JWTPayload } from "jose"; + +import { ConnectionTokenSet, SessionData } from "../../types"; import * as cookies from "../cookies"; import { AbstractSessionStore, @@ -16,6 +18,8 @@ interface StatelessSessionStoreOptions { } export class StatelessSessionStore extends AbstractSessionStore { + connectionTokenSetsCookieName = "__FC"; + constructor({ secret, rolling, @@ -39,7 +43,30 @@ export class StatelessSessionStore extends AbstractSessionStore { return null; } - return cookies.decrypt(cookieValue, this.secret); + const originalSession = await cookies.decrypt( + cookieValue, + this.secret + ); + + // As connection access tokens are stored in seperate cookies, + // we need to get all cookies and only use those that are prefixed with `this.connectionTokenSetsCookieName` + const connectionTokenSets = await Promise.all( + this.getConnectionTokenSetsCookies(reqCookies).map( + (cookie) => + cookies.decrypt( + cookie.value, + this.secret + ) + ) + ); + + return { + ...originalSession, + // Ensure that when there are no connection token sets, we omit the property. + ...(connectionTokenSets.length + ? { connectionTokenSets } + : {}) + }; } /** @@ -50,36 +77,93 @@ export class StatelessSessionStore extends AbstractSessionStore { resCookies: cookies.ResponseCookies, session: SessionData ) { - const jwe = await cookies.encrypt(session, this.secret); + const { connectionTokenSets, ...originalSession } = session; const maxAge = this.calculateMaxAge(session.internal.createdAt); + + await this.storeInCookie( + reqCookies, + resCookies, + originalSession, + this.sessionCookieName, + maxAge + ); + + // Store connection access tokens, each in its own cookie + if (connectionTokenSets?.length) { + await Promise.all( + connectionTokenSets.map((connectionTokenSet, index) => + this.storeInCookie( + reqCookies, + resCookies, + connectionTokenSet, + `${this.connectionTokenSetsCookieName}_${index}`, + maxAge + ) + ) + ); + } + } + + async delete( + reqCookies: cookies.RequestCookies, + resCookies: cookies.ResponseCookies + ) { + resCookies.delete(this.sessionCookieName); + this.getConnectionTokenSetsCookies(reqCookies).forEach((cookie) => + resCookies.delete(cookie.name) + ); + } + + private async storeInCookie( + reqCookies: cookies.RequestCookies, + resCookies: cookies.ResponseCookies, + session: JWTPayload, + cookieName: string, + maxAge: number + ) { + const jwe = await cookies.encrypt(session, this.secret); + const cookieValue = jwe.toString(); - resCookies.set(this.sessionCookieName, jwe.toString(), { + resCookies.set(cookieName, jwe.toString(), { ...this.cookieConfig, maxAge }); // to enable read-after-write in the same request for middleware - reqCookies.set(this.sessionCookieName, cookieValue); + reqCookies.set(cookieName, cookieValue); // 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.sessionCookieName, cookieValue, { + cookieJarSizeTest.set(cookieName, cookieValue, { ...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." - ); + // if the cookie is the session cookie, log a warning with additional information about the claims and user profile. + if (cookieName === this.sessionCookieName) { + console.warn( + `The ${cookieName} 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." + ); + } else { + console.warn( + `The ${cookieName} cookie size exceeds 4096 bytes, which may cause issues in some browsers. ` + + "You can use a stateful session implementation to store the session data in a data store." + ); + } + } } - async delete( - _reqCookies: cookies.RequestCookies, - resCookies: cookies.ResponseCookies + private getConnectionTokenSetsCookies( + cookies: cookies.RequestCookies | cookies.ResponseCookies ) { - await resCookies.delete(this.sessionCookieName); + return cookies + .getAll() + .filter((cookie) => + cookie.name.startsWith(this.connectionTokenSetsCookieName) + ); } } diff --git a/src/types/index.ts b/src/types/index.ts index 4f937442..c2d193a7 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -5,6 +5,14 @@ export interface TokenSet { expiresAt: number; // the time at which the access token expires in seconds since epoch } +export interface ConnectionTokenSet { + accessToken: string; + scope?: string; + expiresAt: number; // the time at which the access token expires in seconds since epoch + connection: string; + [key: string]: unknown; +} + export interface SessionData { user: User; tokenSet: TokenSet; @@ -14,6 +22,7 @@ export interface SessionData { // the time at which the session was created in seconds since epoch createdAt: number; }; + connectionTokenSets?: ConnectionTokenSet[]; [key: string]: unknown; } @@ -85,3 +94,18 @@ export type { TransactionStoreOptions, TransactionState } from "../server/transaction-store"; + +/** + * Options for retrieving a connection access token. + */ +export interface AccessTokenForConnectionOptions { + /** + * The connection name for while you want to retrieve the access token. + */ + connection: string; + + /** + * An optional login hint to pass to the authorization server. + */ + login_hint?: string; +}