diff --git a/EXAMPLES.md b/EXAMPLES.md index 58b7c7775..6929474f8 100644 --- a/EXAMPLES.md +++ b/EXAMPLES.md @@ -32,6 +32,7 @@ - [Cookie Configuration](#cookie-configuration) - [Transaction Cookie Configuration](#transaction-cookie-configuration) - [Database sessions](#database-sessions) +- [Back-Channel Authentication](#back-channel-authentication) - [Back-Channel Logout](#back-channel-logout) - [Combining middleware](#combining-middleware) - [ID Token claims and the user object](#id-token-claims-and-the-user-object) @@ -48,8 +49,8 @@ - [On the server (Pages Router)](#on-the-server-pages-router-3) - [Middleware](#middleware-3) - [Customizing Auth Handlers](#customizing-auth-handlers) - - [Run custom code before Auth Handlers](#run-custom-code-before-auth-handlers) - - [Run code after callback](#run-code-after-callback) + - [Run custom code before Auth Handlers](#run-custom-code-before-auth-handlers) + - [Run code after callback](#run-code-after-callback) ## Passing authorization parameters @@ -164,7 +165,7 @@ On the server, the `getSession()` helper can be used in Server Components, Serve > [!NOTE] > The `getSession()` method returns a complete session object containing the user profile and all available tokens (access token, ID token, and refresh token when present). Use this method for applications that only need user identity information without calling external APIs, as it provides access to the user's profile data from the ID token without requiring additional API calls. This approach is suitable for session-only authentication patterns. -For API access, use `getAccessToken()` to get an access token, this handles automatic token refresh. +> For API access, use `getAccessToken()` to get an access token, this handles automatic token refresh. ```tsx import { auth0 } from "@/lib/auth0"; @@ -779,6 +780,7 @@ This will in turn, update the `access_token`, `id_token` and `expires_at` fields For applications where an API call might be made very close to the token's expiration time, network latency can cause the token to expire before the API receives it. To prevent this race condition, you can implement a strategy to refresh the token proactively when it's within a certain buffer period of its expiration. The general approach is as follows: + 1. Before making a sensitive API call, get the session and check the `expiresAt` timestamp from the `tokenSet`. 2. Determine if the token is within your desired buffer period (e.g., 30-90 seconds) of expiring. 3. If it is, force a token refresh by calling `auth0.getAccessToken({ refresh: true })`. @@ -987,6 +989,7 @@ export const auth0 = new Auth0Client({ ## Transaction Cookie Configuration ### Customizing Transaction Cookie Expiration + You can configure transaction cookies expiration by providing a `maxAge` proeprty for `transactionCookie`. ```ts @@ -997,11 +1000,13 @@ export const auth0 = new Auth0Client({ }, } ``` + Transaction cookies are used to maintain state during authentication flows. The SDK provides several configuration options to manage transaction cookie behavior and prevent cookie accumulation issues. ### Transaction Management Modes **Parallel Transactions (Default)** + ```ts const authClient = new Auth0Client({ enableParallelTransactions: true // Default: allows multiple concurrent logins @@ -1010,6 +1015,7 @@ const authClient = new Auth0Client({ ``` **Single Transaction Mode** + ```ts const authClient = new Auth0Client({ enableParallelTransactions: false // Only one active transaction at a time @@ -1018,11 +1024,13 @@ const authClient = new Auth0Client({ ``` **Use Parallel Transactions (Default) When:** + - Users might open multiple tabs and attempt to log in simultaneously - You want maximum compatibility with typical user behavior - Your application supports multiple concurrent authentication flows **Use Single Transaction Mode When:** + - You want to prevent cookie accumulation issues in applications with frequent login attempts - You prefer simpler transaction management - Users typically don't need multiple concurrent login flows @@ -1030,13 +1038,13 @@ const authClient = new Auth0Client({ ### Transaction Cookie Options -| Option | Type | Description | -| -------------------------- | --------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| cookieOptions.maxAge | `number` | The expiration time for transaction cookies in seconds. Defaults to `3600` (1 hour). After this time, abandoned transaction cookies will expire automatically. | -| cookieOptions.prefix | `string` | The prefix for transaction cookie names. Defaults to `__txn_`. In parallel mode, cookies are named `__txn_{state}`. In single mode, just `__txn_`. | -| cookieOptions.sameSite | `"strict" \| "lax" \| "none"` | Controls when the cookie is sent with cross-site requests. Defaults to `"lax"`. | -| cookieOptions.secure | `boolean` | When `true`, the cookie will only be sent over HTTPS connections. Automatically determined based on your application's base URL protocol if not specified. | -| cookieOptions.path | `string` | Specifies the URL path for which the cookie is valid. Defaults to `"/"`. | +| Option | Type | Description | +| ---------------------- | ----------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| cookieOptions.maxAge | `number` | The expiration time for transaction cookies in seconds. Defaults to `3600` (1 hour). After this time, abandoned transaction cookies will expire automatically. | +| cookieOptions.prefix | `string` | The prefix for transaction cookie names. Defaults to `__txn_`. In parallel mode, cookies are named `__txn_{state}`. In single mode, just `__txn_`. | +| cookieOptions.sameSite | `"strict" \| "lax" \| "none"` | Controls when the cookie is sent with cross-site requests. Defaults to `"lax"`. | +| cookieOptions.secure | `boolean` | When `true`, the cookie will only be sent over HTTPS connections. Automatically determined based on your application's base URL protocol if not specified. | +| cookieOptions.path | `string` | Specifies the URL path for which the cookie is valid. Defaults to `"/"`. | ## Database sessions @@ -1063,6 +1071,28 @@ export const auth0 = new Auth0Client({ }); ``` +## Using Client-Initiated Backchannel Authentication + +Using Client-Initiated Backchannel Authentication can be done by calling `getTokenByBackchannelAuth()`: + +```ts +import { auth0 } from "@/lib/auth0"; + +const tokenResponse = await auth0.getTokenByBackchannelAuth({ + bindingMessage: "", + loginHint: { + sub: "auth0|123456789" + } +}); +``` + +- `bindingMessage`: A human-readable message to be displayed at the consumption device and authentication device. This allows the user to ensure the transaction initiated by the consumption device is the same that triggers the action on the authentication device. +- `loginHint.sub`: The `sub` claim of the user that is trying to login using Client-Initiated Backchannel Authentication, and to which a push notification to authorize the login will be sent. + +> [!IMPORTANT] +> Using Client-Initiated Backchannel Authentication requires the feature to be enabled in the Auth0 dashboard. +> Read [the Auth0 docs](https://auth0.com/docs/get-started/authentication-and-authorization-flow/client-initiated-backchannel-authentication-flow) to learn more about Client-Initiated Backchannel Authentication. + ## Back-Channel Logout The SDK can be configured to listen to [Back-Channel Logout](https://auth0.com/docs/authenticate/login/logout/back-channel-logout) events. By default, a route will be mounted `/auth/backchannel-logout` which will verify the logout token and call the `deleteByLogoutToken` method of your session store implementation to allow you to remove the session. @@ -1424,6 +1454,7 @@ export async function middleware(request: NextRequest) { Authentication routes (`/auth/login`, `/auth/logout`, `/auth/callback`) are handled automatically by the middleware. You can intercept these routes in your middleware to run custom logic before the auth handlers execute. This approach allows you to: + - Run custom code before authentication actions (logging, analytics, validation) - Modify the response (set cookies, headers, etc.) - Implement custom redirects or early returns when needed @@ -1435,49 +1466,48 @@ The middleware-based approach provides the same level of control as v3's custom ### Run custom code before Auth Handlers Following example shows how to run custom logic before the response of `logout` handler is returned: + ```ts export async function middleware(request) { + // prepare NextResponse object from auth0 middleware + const authRes = await auth0.middleware(request); - // prepare NextResponse object from auth0 middleware - const authRes = await auth0.middleware(request); - - // The following interceptUrls can be used: - // "/auth/login" : intercept login auth handler - // "/auth/logout" : intercept logout auth handler - // "/auth/callback" : intercept callback auth handler - // "/your/login/returnTo/url" : intercept redirect after login, this is the login returnTo url - // "/your/logout/returnTo/url" : intercept redirect after logout, this is the logout returnTo url - - const interceptUrl = "/auth/logout"; - - // intercept auth handler - if (request.nextUrl.pathname === interceptUrl) { - // do custom stuff - console.log("Pre-logout code") - - // Example: Set a cookie - authRes.cookies.set('myCustomCookie', 'cookieValue', { path: '/' }); - // Example: Set another cookie with options - authRes.cookies.set({ - name: 'anotherCookie', - value: 'anotherValue', - httpOnly: true, - path: '/', - }); - - // Example: Delete a cookie - // authRes.cookies.delete('cookieNameToDelete'); - - // you can also do an early return here with your own NextResponse object - // return NextResponse.redirect(new URL('/custom-logout-page')); - } + // The following interceptUrls can be used: + // "/auth/login" : intercept login auth handler + // "/auth/logout" : intercept logout auth handler + // "/auth/callback" : intercept callback auth handler + // "/your/login/returnTo/url" : intercept redirect after login, this is the login returnTo url + // "/your/logout/returnTo/url" : intercept redirect after logout, this is the logout returnTo url + + const interceptUrl = "/auth/logout"; + + // intercept auth handler + if (request.nextUrl.pathname === interceptUrl) { + // do custom stuff + console.log("Pre-logout code"); + + // Example: Set a cookie + authRes.cookies.set("myCustomCookie", "cookieValue", { path: "/" }); + // Example: Set another cookie with options + authRes.cookies.set({ + name: "anotherCookie", + value: "anotherValue", + httpOnly: true, + path: "/" + }); - // return the original auth0-handled NextResponse object - return authRes + // Example: Delete a cookie + // authRes.cookies.delete('cookieNameToDelete'); + + // you can also do an early return here with your own NextResponse object + // return NextResponse.redirect(new URL('/custom-logout-page')); + } + + // return the original auth0-handled NextResponse object + return authRes; } ``` ### Run code after callback -Please refer to [onCallback](https://github.com/auth0/nextjs-auth0/blob/main/EXAMPLES.md#oncallback) -for details on how to run code after callback. -``` \ No newline at end of file + +Please refer to [onCallback](https://github.com/auth0/nextjs-auth0/blob/main/EXAMPLES.md#oncallback) for details on how to run code after callback. diff --git a/package.json b/package.json index 4bc9afa10..5781bee49 100644 --- a/package.json +++ b/package.json @@ -85,6 +85,7 @@ "@panva/hkdf": "^1.2.1", "jose": "^6.0.11", "oauth4webapi": "^3.1.2", + "openid-client": "^6.6.2", "swr": "^2.2.5" }, "publishConfig": { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ee3951ae9..55c7e0a5a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -20,6 +20,9 @@ importers: oauth4webapi: specifier: ^3.1.2 version: 3.7.0 + openid-client: + specifier: ^6.6.2 + version: 6.7.1 react: specifier: ^18.0.0 || ^19.0.0 || ^19.0.0-0 version: 19.0.0 @@ -1717,6 +1720,9 @@ packages: jose@6.0.12: resolution: {integrity: sha512-T8xypXs8CpmiIi78k0E+Lk7T2zlK4zDyg+o1CZ4AkOHgDg98ogdP2BeZ61lTFKFyoEwJ9RgAgN+SdM3iPgNonQ==} + jose@6.1.0: + resolution: {integrity: sha512-TTQJyoEoKcC1lscpVDCSsVgYzUDg/0Bt3WE//WiTPK6uOCQC2KZS4MpugbMWt/zyjkopgZoXhZuCi00gLudfUA==} + js-tokens@4.0.0: resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} @@ -1882,6 +1888,9 @@ packages: oauth4webapi@3.7.0: resolution: {integrity: sha512-Q52wTPUWPsVLVVmTViXPQFMW2h2xv2jnDGxypjpelCFKaOjLsm7AxYuOk1oQgFm95VNDbuggasu9htXrz6XwKw==} + oauth4webapi@3.8.1: + resolution: {integrity: sha512-olkZDELNycOWQf9LrsELFq8n05LwJgV8UkrS0cburk6FOwf8GvLam+YB+Uj5Qvryee+vwWOfQVeI5Vm0MVg7SA==} + object-assign@4.1.1: resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==} engines: {node: '>=0.10.0'} @@ -1910,6 +1919,9 @@ packages: resolution: {integrity: sha512-gXah6aZrcUxjWg2zR2MwouP2eHlCBzdV4pygudehaKXSGW4v2AsRQUK+lwwXhii6KFZcunEnmSUoYp5CXibxtA==} engines: {node: '>= 0.4'} + openid-client@6.7.1: + resolution: {integrity: sha512-kOiE4q0kNogr90hXsxPrKeEDuY+V0kkZazvZScOwZkYept9slsaQ3usXTaKkm6I04vLNuw5caBoX7UfrwC6x8w==} + optionator@0.9.4: resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==} engines: {node: '>= 0.8.0'} @@ -4228,6 +4240,8 @@ snapshots: jose@6.0.12: {} + jose@6.1.0: {} + js-tokens@4.0.0: {} js-yaml@4.1.0: @@ -4416,6 +4430,8 @@ snapshots: oauth4webapi@3.7.0: {} + oauth4webapi@3.8.1: {} + object-assign@4.1.1: {} object-inspect@1.13.4: {} @@ -4452,6 +4468,11 @@ snapshots: define-properties: 1.2.1 es-object-atoms: 1.1.1 + openid-client@6.7.1: + dependencies: + jose: 6.1.0 + oauth4webapi: 3.8.1 + optionator@0.9.4: dependencies: deep-is: 0.1.4 diff --git a/src/errors/index.ts b/src/errors/index.ts index 20ce725d0..8332da59a 100644 --- a/src/errors/index.ts +++ b/src/errors/index.ts @@ -95,6 +95,30 @@ export class BackchannelLogoutError extends SdkError { } } +export class BackchannelAuthenticationNotSupportedError extends SdkError { + public code: string = "backchannel_authentication_not_supported_error"; + + constructor() { + super( + "The authorization server does not support backchannel authentication. Learn how to enable it here: https://auth0.com/docs/get-started/applications/configure-client-initiated-backchannel-authentication" + ); + this.name = "BackchannelAuthenticationNotSupportedError"; + } +} + +export class BackchannelAuthenticationError extends SdkError { + public code: string = "backchannel_authentication_error"; + public cause?: OAuth2Error; + + constructor({ cause }: { cause?: OAuth2Error }) { + super( + "There was an error when trying to use Client-Initiated Backchannel Authentication." + ); + this.cause = cause; + this.name = "BackchannelAuthenticationError"; + } +} + export enum AccessTokenErrorCode { MISSING_SESSION = "missing_session", MISSING_REFRESH_TOKEN = "missing_refresh_token", diff --git a/src/server/auth-client.test.ts b/src/server/auth-client.test.ts index 87c6050fd..da6329c1b 100644 --- a/src/server/auth-client.test.ts +++ b/src/server/auth-client.test.ts @@ -3,6 +3,7 @@ import * as jose from "jose"; import * as oauth from "oauth4webapi"; import { afterAll, beforeAll, describe, expect, it, vi } from "vitest"; +import { BackchannelAuthenticationError } from "../errors/index.js"; import { getDefaultRoutes } from "../test/defaults.js"; import { generateSecret } from "../test/utils.js"; import { SessionData } from "../types/index.js"; @@ -64,7 +65,8 @@ ca/T0LLtgmbMmxSv/MmzIg== audience, nonce, keyPair = DEFAULT.keyPair, - onParRequest + onParRequest, + onBackchannelAuthRequest }: { tokenEndpointResponse?: oauth.TokenEndpointResponse | oauth.OAuth2Error; tokenEndpointErrorResponse?: oauth.OAuth2Error; @@ -74,6 +76,7 @@ ca/T0LLtgmbMmxSv/MmzIg== nonce?: string; keyPair?: jose.GenerateKeyPairResult; onParRequest?: (request: Request) => Promise; + onBackchannelAuthRequest?: (request: Request) => Promise; } = {}) { // this function acts as a mock authorization server return vi.fn( @@ -131,7 +134,6 @@ ca/T0LLtgmbMmxSv/MmzIg== // 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)); } @@ -142,6 +144,23 @@ ca/T0LLtgmbMmxSv/MmzIg== } ); } + // Backchannel Authorize endpoint + if (url.pathname === "/bc-authorize") { + if (onBackchannelAuthRequest) { + await onBackchannelAuthRequest(new Request(input, init)); + } + + return Response.json( + { + auth_req_id: "auth-req-id", + expires_in: 30, + interval: 0.01 + }, + { + status: 200 + } + ); + } return new Response(null, { status: 404 }); } @@ -5581,6 +5600,304 @@ ca/T0LLtgmbMmxSv/MmzIg== expect(connectionTokenSet).toBeNull(); }); }); + + describe("backchannelAuthentication", async () => { + it("should return an error if backchannel authentication is not enabled", 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, + + routes: getDefaultRoutes(), + + fetch: getMockAuthorizationServer({ + discoveryResponse: Response.json( + { + ..._authorizationServerMetadata, + backchannel_authentication_endpoint: null, + backchannel_token_delivery_modes_supported: null + }, + { + status: 200, + headers: { + "content-type": "application/json" + } + } + ) + }) + }); + + const [error, _] = await authClient.backchannelAuthentication({ + bindingMessage: "test-message", + loginHint: { + sub: DEFAULT.sub + } + }); + expect(error?.code).toEqual( + "backchannel_authentication_not_supported_error" + ); + }); + + it("should return the token set when successfully authenticated", 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, + + routes: getDefaultRoutes(), + + fetch: getMockAuthorizationServer() + }); + + const [error, res] = await authClient.backchannelAuthentication({ + bindingMessage: "test-message", + loginHint: { + sub: DEFAULT.sub + } + }); + expect(error).toBeNull(); + expect(res).toEqual({ + idTokenClaims: { + aud: DEFAULT.clientId, + auth_time: expect.any(Number), + exp: expect.any(Number), + "https://example.com/custom_claim": "value", + iat: expect.any(Number), + iss: `https://${DEFAULT.domain}/`, + nonce: expect.any(String), + sid: DEFAULT.sid, + sub: DEFAULT.sub + }, + tokenSet: { + accessToken: DEFAULT.accessToken, + expiresAt: expect.any(Number), + idToken: expect.any(String), + refreshToken: DEFAULT.refreshToken + } + }); + }); + + it("should return an error when the user rejects the authorization request", 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, + + routes: getDefaultRoutes(), + + fetch: getMockAuthorizationServer({ + tokenEndpointErrorResponse: { + error: "access_denied", + error_description: + "The end-user denied the authorization request or it has been expired" + } + }) + }); + + const [error, res] = await authClient.backchannelAuthentication({ + bindingMessage: "test-message", + loginHint: { + sub: DEFAULT.sub + } + }); + expect((error as BackchannelAuthenticationError)?.cause?.code).toEqual( + "access_denied" + ); + expect(res).toBeNull(); + }); + + it("should forward any statically configured authorization parameters", async () => { + const customScope = "openid profile email offline_access custom_scope"; + const customAudience = "urn:mystore:api"; + const customParamValue = "custom_value"; + + 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, + + routes: getDefaultRoutes(), + authorizationParameters: { + scope: customScope, + audience: customAudience, + custom_param: customParamValue + }, + fetch: getMockAuthorizationServer({ + onBackchannelAuthRequest: async (req) => { + const formBody = await req.formData(); + expect(formBody.get("scope")).toEqual(customScope); + expect(formBody.get("audience")).toEqual(customAudience); + expect(formBody.get("custom_param")).toEqual(customParamValue); + } + }) + }); + + const [error, _] = await authClient.backchannelAuthentication({ + bindingMessage: "test-message", + loginHint: { + sub: DEFAULT.sub + } + }); + + expect(error).toBeNull(); + }); + + it("should forward any dynamically specified authorization parameters", async () => { + const customScope = "openid profile email offline_access custom_scope"; + const customAudience = "urn:mystore:api"; + const customParamValue = "custom_value"; + + 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, + + routes: getDefaultRoutes(), + fetch: getMockAuthorizationServer({ + onBackchannelAuthRequest: async (req) => { + const formBody = await req.formData(); + expect(formBody.get("scope")).toEqual(customScope); + expect(formBody.get("audience")).toEqual(customAudience); + expect(formBody.get("custom_param")).toEqual(customParamValue); + } + }) + }); + + const [error, _] = await authClient.backchannelAuthentication({ + bindingMessage: "test-message", + loginHint: { + sub: DEFAULT.sub + }, + authorizationParams: { + scope: customScope, + audience: customAudience, + custom_param: customParamValue + } + }); + + expect(error).toBeNull(); + }); + + it("should give precedence to dynamically provided authorization parameters over statically configured ones", async () => { + const customScope = "openid profile email offline_access custom_scope"; + const customParamValue = "custom_value"; + + 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, + + routes: getDefaultRoutes(), + authorizationParameters: { + scope: customScope, + audience: "static-config-aud", + custom_param: customParamValue + }, + fetch: getMockAuthorizationServer({ + onBackchannelAuthRequest: async (req) => { + const formBody = await req.formData(); + expect(formBody.get("scope")).toEqual(customScope); + expect(formBody.get("audience")).toEqual( + "dynamically-specific-aud" + ); + expect(formBody.get("custom_param")).toEqual(customParamValue); + } + }) + }); + + const [error, _] = await authClient.backchannelAuthentication({ + bindingMessage: "test-message", + loginHint: { + sub: DEFAULT.sub + }, + authorizationParams: { + scope: customScope, + audience: "dynamically-specific-aud", + custom_param: customParamValue + } + }); + + expect(error).toBeNull(); + }); + }); }); const _authorizationServerMetadata = { @@ -5651,5 +5968,8 @@ 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" + pushed_authorization_request_endpoint: "https://guabu.us.auth0.com/oauth/par", + backchannel_authentication_endpoint: + "https://guabu.us.auth0.com/bc-authorize", + backchannel_token_delivery_modes_supported: ["poll"] }; diff --git a/src/server/auth-client.ts b/src/server/auth-client.ts index 7390fdf0d..e7f61d75b 100644 --- a/src/server/auth-client.ts +++ b/src/server/auth-client.ts @@ -1,6 +1,7 @@ import { NextResponse, type NextRequest } from "next/server.js"; import * as jose from "jose"; import * as oauth from "oauth4webapi"; +import * as client from "openid-client"; import packageJson from "../../package.json" with { type: "json" }; import { @@ -11,6 +12,8 @@ import { AuthorizationCodeGrantError, AuthorizationCodeGrantRequestError, AuthorizationError, + BackchannelAuthenticationError, + BackchannelAuthenticationNotSupportedError, BackchannelLogoutError, DiscoveryError, InvalidStateError, @@ -21,6 +24,8 @@ import { import { AccessTokenForConnectionOptions, AuthorizationParameters, + BackchannelAuthenticationOptions, + BackchannelAuthenticationResponse, ConnectionTokenSet, LogoutStrategy, LogoutToken, @@ -174,6 +179,7 @@ export class AuthClient { private fetch: typeof fetch; private jwksCache: jose.JWKSCacheInput; private allowInsecureRequests: boolean; + private httpTimeout: number; private httpOptions: () => oauth.HttpRequestOptions<"GET" | "POST">; private authorizationServerMetadata?: oauth.AuthorizationServer; @@ -186,10 +192,10 @@ export class AuthClient { this.fetch = options.fetch || fetch; this.jwksCache = options.jwksCache || {}; this.allowInsecureRequests = options.allowInsecureRequests ?? false; + this.httpTimeout = options.httpTimeout ?? 5000; this.httpOptions = () => { const headers = new Headers(); const enableTelemetry = options.enableTelemetry ?? true; - const timeout = options.httpTimeout ?? 5000; if (enableTelemetry) { const name = "nextjs-auth0"; const version = packageJson.version; @@ -207,7 +213,7 @@ export class AuthClient { } return { - signal: AbortSignal.timeout(timeout), + signal: AbortSignal.timeout(this.httpTimeout), headers }; }; @@ -863,6 +869,109 @@ export class AuthClient { return [null, { tokenSet, idTokenClaims: undefined }]; } + async backchannelAuthentication( + options: BackchannelAuthenticationOptions + ): Promise<[null, BackchannelAuthenticationResponse] | [SdkError, null]> { + const [discoveryError, authorizationServerMetadata] = + await this.discoverAuthorizationServerMetadata(); + if (discoveryError) { + return [discoveryError, null]; + } + + if (!authorizationServerMetadata.backchannel_authentication_endpoint) { + return [new BackchannelAuthenticationNotSupportedError(), null]; + } + + const authorizationParams = new URLSearchParams(); + authorizationParams.set("scope", DEFAULT_SCOPES); + + const mergedAuthorizationParams: AuthorizationParameters = { + // any custom params to forward to /authorize defined as configuration + ...this.authorizationParameters, + // custom parameters passed in via the query params to ensure only the confidential client can set them + ...options.authorizationParams + }; + + Object.entries(mergedAuthorizationParams).forEach(([key, val]) => + authorizationParams.set(key, String(val)) + ); + + authorizationParams.set("client_id", this.clientMetadata.client_id); + authorizationParams.set("binding_message", options.bindingMessage); + authorizationParams.set( + "login_hint", + JSON.stringify({ + format: "iss_sub", + iss: authorizationServerMetadata.issuer, + sub: options.loginHint.sub + }) + ); + + if (options.requestExpiry) { + authorizationParams.append( + "request_expiry", + options.requestExpiry.toString() + ); + } + + if (options.authorizationDetails) { + authorizationParams.append( + "authorization_details", + JSON.stringify(options.authorizationDetails) + ); + } + + const [openIdClientConfigError, openidClientConfig] = + await this.getOpenIdClientConfig(); + + if (openIdClientConfigError) { + return [openIdClientConfigError, null]; + } + + try { + const backchannelAuthenticationResponse = + await client.initiateBackchannelAuthentication( + openidClientConfig, + authorizationParams + ); + + const tokenEndpointResponse = + await client.pollBackchannelAuthenticationGrant( + openidClientConfig, + backchannelAuthenticationResponse + ); + + const accessTokenExpiresAt = + Math.floor(Date.now() / 1000) + + Number(tokenEndpointResponse.expires_in); + + return [ + null, + { + tokenSet: { + accessToken: tokenEndpointResponse.access_token, + idToken: tokenEndpointResponse.id_token, + scope: tokenEndpointResponse.scope, + refreshToken: tokenEndpointResponse.refresh_token, + expiresAt: accessTokenExpiresAt + }, + idTokenClaims: tokenEndpointResponse.claims(), + authorizationDetails: tokenEndpointResponse.authorization_details + } + ]; + } catch (e: any) { + return [ + new BackchannelAuthenticationError({ + cause: new OAuth2Error({ + code: e.error, + message: e.error_description + }) + }), + null + ]; + } + } + private async discoverAuthorizationServerMetadata(): Promise< [null, oauth.AuthorizationServer] | [SdkError, null] > { @@ -1272,6 +1381,42 @@ export class AuthClient { } return session; } + + private async getOpenIdClientConfig(): Promise< + [null, client.Configuration] | [SdkError, null] + > { + const [discoveryError, authorizationServerMetadata] = + await this.discoverAuthorizationServerMetadata(); + + if (discoveryError) { + return [discoveryError, null]; + } + + const openidClientConfig = new client.Configuration( + authorizationServerMetadata, + this.clientMetadata.client_id, + {}, + await this.getClientAuth() + ); + const httpOpts = this.httpOptions(); + const telemetryHeaders = new Headers(httpOpts.headers); + + openidClientConfig[client.customFetch] = (...args) => { + const headers = new Headers(args[1].headers); + return this.fetch(args[0], { + ...args[1], + headers: new Headers([...telemetryHeaders, ...headers]) + }); + }; + + openidClientConfig.timeout = this.httpTimeout; + + if (this.allowInsecureRequests) { + client.allowInsecureRequests(openidClientConfig); + } + + return [null, openidClientConfig]; + } } const encodeBase64 = (input: string) => { diff --git a/src/server/client.ts b/src/server/client.ts index e1bfaa32c..6a3b9c971 100644 --- a/src/server/client.ts +++ b/src/server/client.ts @@ -12,6 +12,7 @@ import { import { AccessTokenForConnectionOptions, AuthorizationParameters, + BackchannelAuthenticationOptions, LogoutStrategy, SessionData, SessionDataStore, @@ -735,6 +736,25 @@ export class Auth0Client { return this.authClient.startInteractiveLogin(options); } + /** + * Authenticates using Client-Initiated Backchannel Authentication and returns the token set and optionally the ID token claims and authorization details. + * + * This method will initialize the backchannel authentication process with Auth0, and poll the token endpoint until the authentication is complete. + * + * Using Client-Initiated Backchannel Authentication requires the feature to be enabled in the Auth0 dashboard. + * @see https://auth0.com/docs/get-started/authentication-and-authorization-flow/client-initiated-backchannel-authentication-flow + */ + async getTokenByBackchannelAuth(options: BackchannelAuthenticationOptions) { + const [error, response] = + await this.authClient.backchannelAuthentication(options); + + if (error) { + throw error; + } + + return response; + } + withPageAuthRequired( fnOrOpts?: WithPageAuthRequiredPageRouterOptions | AppRouterPageRoute, opts?: WithPageAuthRequiredAppRouterOptions diff --git a/src/types/index.ts b/src/types/index.ts index 8013429c0..3c72fb5a2 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -152,3 +152,44 @@ export interface AccessTokenForConnectionOptions { * Logout strategy options for controlling logout endpoint selection. */ export type LogoutStrategy = "auto" | "oidc" | "v2"; + +export interface BackchannelAuthenticationOptions { + /** + * Human-readable message to be displayed at the consumption device and authentication device. + * This allows the user to ensure the transaction initiated by the consumption device is the same that triggers the action on the authentication device. + */ + bindingMessage: string; + /** + * The login hint to inform which user to use. + */ + loginHint: { + /** + * The `sub` claim of the user that is trying to login using Client-Initiated Backchannel Authentication, and to which a push notification to authorize the login will be sent. + */ + sub: string; + }; + /** + * Set a custom expiry time for the CIBA flow in seconds. Defaults to 300 seconds (5 minutes) if not set. + */ + requestExpiry?: number; + /** + * Optional authorization details to use Rich Authorization Requests (RAR). + * @see https://auth0.com/docs/get-started/apis/configure-rich-authorization-requests + */ + authorizationDetails?: AuthorizationDetails[]; + /** + * Authorization Parameters to be sent with the authorization request. + */ + authorizationParams?: AuthorizationParameters; +} + +export interface BackchannelAuthenticationResponse { + tokenSet: TokenSet; + idTokenClaims?: { [key: string]: any }; + authorizationDetails?: AuthorizationDetails[]; +} + +export interface AuthorizationDetails { + readonly type: string; + readonly [parameter: string]: unknown; +}