diff --git a/examples/example-express-api/.env.example b/examples/example-express-api/.env.example index 09f47ba..ae48045 100644 --- a/examples/example-express-api/.env.example +++ b/examples/example-express-api/.env.example @@ -1,2 +1,7 @@ AUTH0_DOMAIN= -AUTH0_AUDIENCE= \ No newline at end of file +AUTH0_AUDIENCE= + +# Only needed for the /api/on-behalf-of endpoint. +AUTH0_CLIENT_ID= +AUTH0_CLIENT_SECRET= +AUTH0_DOWNSTREAM_AUDIENCE= diff --git a/examples/example-express-api/README.md b/examples/example-express-api/README.md index ffd6a5f..951e54b 100644 --- a/examples/example-express-api/README.md +++ b/examples/example-express-api/README.md @@ -14,11 +14,32 @@ npm install Rename `.env.example` to `.env` and configure the domain and audience: -```ts +```env AUTH0_DOMAIN=YOUR_AUTH0_DOMAIN AUTH0_AUDIENCE=YOUR_AUTH0_AUDIENCE ``` +The `/api/on-behalf-of` endpoint additionally needs a confidential client and a second API to call: + +```env +AUTH0_CLIENT_ID=YOUR_AUTH0_CLIENT_ID +AUTH0_CLIENT_SECRET=YOUR_AUTH0_CLIENT_SECRET +AUTH0_DOWNSTREAM_AUDIENCE=THE_AUDIENCE_OF_THE_API_YOU_WANT_TO_CALL +``` + +Leave these unset and every other endpoint still works. `/api/on-behalf-of` answers `501` when `AUTH0_DOWNSTREAM_AUDIENCE` is missing, rather than calling the tenant with nothing to exchange for. + +### Tenant setup for `/api/on-behalf-of` + +On-Behalf-Of exchange also needs the tenant configured for it. The credentials above are not enough on their own: + +- This API is registered as a **Custom API client**, so it can authenticate to the token endpoint as a client. +- **On-Behalf-Of Token Exchange** is turned on for that client, under its **Token Exchange** settings. The toggle is on the client doing the exchanging, not on the downstream API. +- There is a **user-delegated client grant** from that client to `AUTH0_DOWNSTREAM_AUDIENCE`. +- User consent is skipped for `AUTH0_DOWNSTREAM_AUDIENCE`, since this is a first-party client. + +See [Calling another API on behalf of the user](../../packages/auth0-express-api/EXAMPLES.md#calling-another-api-on-behalf-of-the-user) for the full list and the dashboard steps. + With the configuration in place, the example can be started by running: ```bash @@ -32,5 +53,6 @@ The example API has the following endpoints: - `GET /api/public`: A public endpoint that can be accessed without authentication. - `GET /api/private`: A private endpoint that can only be accessed by authenticated users. - `GET /api/private-scope`: A private endpoint that can only be accessed by authenticated users with the `read:private` scope. +- `GET /api/on-behalf-of`: A private endpoint that exchanges the caller's access token for one issued to `AUTH0_DOWNSTREAM_AUDIENCE`, still representing the same user. -In order to call the `/api/private` and `/api/private-scope` endpoints, you need to include an `Authorization` header with a valid access token. +In order to call the `/api/private`, `/api/private-scope` and `/api/on-behalf-of` endpoints, you need to include an `Authorization` header with a valid access token. diff --git a/examples/example-express-api/src/index.ts b/examples/example-express-api/src/index.ts index f02a521..eacd21e 100644 --- a/examples/example-express-api/src/index.ts +++ b/examples/example-express-api/src/index.ts @@ -1,15 +1,19 @@ import express, { Request, Response } from 'express'; -import { createAuth0Api, requiresAuth } from '@auth0/auth0-express-api'; +import { createAuth0Api, requiresAuth, TokenExchangeError } from '@auth0/auth0-express-api'; import 'dotenv/config'; const app = express(); app.use(express.json()); -// Mount Auth0 API router +// Mount Auth0 API router. +// The client credentials are only needed by /api/on-behalf-of below. Leave them +// unset and the rest of the example still works. const auth0Router = createAuth0Api({ domain: process.env.AUTH0_DOMAIN as string, audience: process.env.AUTH0_AUDIENCE as string, + clientId: process.env.AUTH0_CLIENT_ID, + clientSecret: process.env.AUTH0_CLIENT_SECRET, }); app.use(auth0Router); @@ -24,6 +28,46 @@ app.get('/api/private-scope', requiresAuth({ scopes: ['read:private'] }), async res.send(`Hello, ${req.auth0.user?.sub}`); }); +// Only this route needs a downstream API to call. Read once so the route can +// say what is missing instead of sending an undefined audience to the tenant. +const downstreamAudience = process.env.AUTH0_DOWNSTREAM_AUDIENCE; + +// Protected route that exchanges the caller's token for one issued to a +// downstream API, still on behalf of the same user. +app.get('/api/on-behalf-of', requiresAuth(), async (req: Request, res: Response) => { + if (!downstreamAudience) { + res.status(501).json({ error: 'AUTH0_DOWNSTREAM_AUDIENCE is not set' }); + return; + } + + try { + // `requiresAuth()` verified the token, so `req.auth0.token` is the raw token + // to exchange. Passing it is the route's job. + const tokenSet = await req.auth0.client.getTokenOnBehalfOf(req.auth0.token!, { + audience: downstreamAudience, + }); + + // In a real API you would now call the downstream service with this token: + // fetch(url, { headers: { authorization: `Bearer ${tokenSet.accessToken}` } }) + res.json({ + sub: req.auth0.user?.sub, + downstreamAudience, + expiresAt: tokenSet.expiresAt, + scope: tokenSet.scope, + }); + } catch (error) { + // Usually a missing grant or a client that is not allowed to exchange. The + // tenant's wording belongs in your logs, not in the response. + if (error instanceof TokenExchangeError) { + console.error(error.code, error.cause?.error_description); + } else { + console.error(error); + } + + res.status(502).json({ error: 'exchange_failed' }); + } +}); + // Public route (no authentication required) app.get('/api/public', async (req: Request, res: Response) => { res.send('Hello world!'); diff --git a/packages/auth0-express-api/EXAMPLES.md b/packages/auth0-express-api/EXAMPLES.md index d5cb187..b243dd1 100644 --- a/packages/auth0-express-api/EXAMPLES.md +++ b/packages/auth0-express-api/EXAMPLES.md @@ -4,7 +4,6 @@ - [Basic configuration](#basic-configuration) - [Using environment variables](#using-environment-variables) - [Configuring a `customFetch` implementation](#configuring-a-customfetch-implementation) -- [The `ApiClient` instance](#the-apiclient-instance) - [Protecting API Routes](#protecting-api-routes) - [Basic authentication](#basic-authentication) - [Requiring specific scopes](#requiring-specific-scopes) @@ -14,6 +13,13 @@ - [Using claimEquals](#using-claimequals) - [Using claimIncludes](#using-claimincludes) - [Using claimCheck for custom logic](#using-claimcheck-for-custom-logic) +- [API as a client](#api-as-a-client) + - [The `ApiClient` instance](#the-apiclient-instance) + - [Calling another API on behalf of the user](#calling-another-api-on-behalf-of-the-user) + - [Prerequisites](#prerequisites) + - [Handling a failed exchange](#handling-a-failed-exchange) + - [Exchanging a different token](#exchanging-a-different-token) + - [Reading the delegation chain](#reading-the-delegation-chain) ## Configuration @@ -57,6 +63,8 @@ Supported environment variables: - `AUTH0_CLIENT_SECRET` - Your Auth0 application client secret (optional) - `AUTH0_CLIENT_ASSERTION_SIGNING_KEY` - Private key for client assertion signing (optional) +The client credentials are only needed to call a downstream API on behalf of the caller. If you provide any of them, provide a complete set: `AUTH0_CLIENT_ID` plus either `AUTH0_CLIENT_SECRET` or `AUTH0_CLIENT_ASSERTION_SIGNING_KEY`. An incomplete set is reported when you make the call, not at startup, because nothing else in the SDK needs them. + Example `.env` file: ```env @@ -108,12 +116,6 @@ app.use(createAuth0Api({ })); ``` -## The `ApiClient` instance - -Once the SDK is registered, an instance of the Auth0 `ApiClient` is available via `req.auth0.client`. This instance can be used to call any of the methods available on the `ApiClient`, such as `verifyAccessToken()`. - -For the complete list of available methods, please refer to the [@auth0/auth0-api-js SDK documentation](https://github.com/auth0/auth0-auth-js/blob/main/packages/auth0-api-js/README.md). - ## Protecting API Routes ### Basic authentication @@ -127,12 +129,12 @@ app.get( '/protected-api', requiresAuth(), async (req, res) => { - res.json({ message: `Hello, ${req.auth0.user.sub}` }); + res.json({ message: `Hello, ${req.auth0.user!.sub}` }); } ); ``` -The SDK exposes the claims, extracted from the token, as the `user` property on the `req.auth0` object. +The SDK exposes the claims, extracted from the token, as the `user` property on the `req.auth0` object. It is optional on the type, because only `requiresAuth()` sets it, so a route behind that middleware can assert it with `!`. ### Requiring specific scopes @@ -207,7 +209,7 @@ app.get( '/protected-api', requiresAuth(), async (req, res) => { - res.json({ message: `Hello, ${req.auth0.user.name}` }); + res.json({ message: `Hello, ${req.auth0.user!.name}` }); } ); ``` @@ -265,7 +267,6 @@ app.get( } ); ``` -``` ### Using claimCheck for custom logic @@ -296,3 +297,149 @@ The second parameter is an optional configuration object that can include a cust > [!NOTE] > All claim authorization middlewares should be used **after** `requiresAuth()` to ensure a valid token is present. They will return 401 errors if the token doesn't meet the required claim conditions. + +## API as a client + +Everything above is about **token verification**: a token arrives at your API and the SDK checks it before your route runs. This section is the other direction, where your API acts as a **client** of Auth0 and asks it for a token, usually so it can call another API. + +Both come out of the same router. `requiresAuth()` verifies the incoming token, and `req.auth0.client` is the client you call to get a new one. Acting as a client needs credentials, so configure `clientId` together with either `clientSecret` or `clientAssertionSigningKey`. + +### The `ApiClient` instance + +Once the SDK is registered, an instance of the Auth0 `ApiClient` is available via `req.auth0.client`. This instance can be used to call any of the methods available on the `ApiClient`, such as `verifyAccessToken()`. + +It is attached to every request, including unauthenticated ones, and it is the same instance each time rather than one built per request. + +For the complete list of available methods, please refer to the [@auth0/auth0-api-js SDK documentation](https://github.com/auth0/auth0-auth-js/blob/main/packages/auth0-api-js/README.md). + +### Calling another API on behalf of the user + +When your API needs to call a second API, it should not forward its own access token. That token was issued for your audience, not the next one. Instead, exchange it for a token issued for the downstream API, still representing the same user. This is [On-Behalf-Of token exchange](https://auth0.com/docs/secure/call-apis-on-users-behalf/on-behalf-of-token-exchange), built on [RFC 8693](https://datatracker.ietf.org/doc/html/rfc8693). + +#### Prerequisites + +Client credentials alone are not enough. Both the SDK and the tenant need setting up first: + +- Configure `clientId` together with either `clientSecret` or `clientAssertionSigningKey`. A public client cannot do this exchange, because the actor identity comes from client authentication. +- Register your API as a **Custom API client** in the Auth0 Dashboard, so it has a client identity to authenticate with. Only a Custom API client associated with a resource server can exchange tokens this way. +- Turn on **On-Behalf-Of Token Exchange** for that same client, under its **Token Exchange** settings. This is the toggle that allows the exchange, and it lives on the client doing the exchanging. Nothing needs enabling on the downstream API. +- Create a **user-delegated client grant** from that client to the downstream API, covering the scopes you plan to request. The scopes you can ask for come from this grant and the user's own RBAC policies. +- Skip user consent for the downstream API, since the client is first-party. + +Without these, the exchange fails at the tenant rather than in the SDK. See [On-Behalf-Of token exchange](https://auth0.com/docs/secure/call-apis-on-users-behalf/on-behalf-of-token-exchange) for the current dashboard steps. + +```ts +import { requiresAuth } from '@auth0/auth0-express-api'; + +app.get('/orders', requiresAuth(), async (req, res) => { + const { accessToken } = await req.auth0.client.getTokenOnBehalfOf(req.auth0.token!, { + audience: 'https://orders.example.com', + scope: 'read:orders', + }); + + const orders = await fetch('https://orders.example.com/orders', { + headers: { authorization: `Bearer ${accessToken}` }, + }); + + res.json(await orders.json()); +}); +``` + +The first argument is the token to exchange, and passing it is your job. On a route behind `requiresAuth()`, `req.auth0.token` is the token the SDK just verified, which is the one you want here. + +> [!NOTE] +> Because the subject token is a plain argument, the same method works for any token your API holds, not only the current request's. That is what makes a background job or a stored token possible. See [Exchanging a different token](#exchanging-a-different-token). + +> [!CAUTION] +> `req.auth0.token` is a live credential. It is defined as non-enumerable, so `JSON.stringify(req.auth0)`, `{ ...req.auth0 }` and `console.log(req.auth0)` all leave it out. Tools that read properties by name can still reach it, and the redaction rules your logger applies to the `Authorization` header will not cover it. Keep it out of logs, error reports and responses, and do not hold on to `req` after the response is sent. + +Alongside `accessToken`, the result carries `expiresAt` (seconds since the Unix epoch), and `scope`, `tokenType` and `issuedTokenType` when the tenant returns them. + +> [!NOTE] +> `getTokenOnBehalfOf()` calls the token endpoint every time. Nothing is cached for you, so calling it on every request adds a round trip to Auth0 on every request and counts against your tenant's rate limits. Use `expiresAt` to cache the result, keyed on the user **and** the downstream audience and scope, and expire it a little before `expiresAt`. An exchanged token is a user credential, so whatever holds it has to be scoped per user and cleared when their session ends. + +> [!NOTE] +> The downstream API sees a token whose `sub` is still the end user, with your API recorded as the actor in the `act` claim. Authorization decisions downstream continue to be about the user, not about your service. + +#### Handling a failed exchange + +The exchange runs in your route handler, so nothing in the SDK turns a failure into an HTTP response, and what happens if you leave it unhandled depends on your Express version. On **Express 5** the rejection is forwarded to your error handler, which returns a 500 by default. On **Express 4** it is not: the request hangs with no response and the rejection goes unhandled, which under Node's default settings takes the process down. Catch it either way and decide what the caller should see: + +```ts +import { requiresAuth, TokenExchangeError } from '@auth0/auth0-express-api'; + +app.get('/orders', requiresAuth(), async (req, res) => { + try { + const { accessToken } = await req.auth0.client.getTokenOnBehalfOf(req.auth0.token!, { + audience: 'https://orders.example.com', + }); + res.json(await fetchOrders(accessToken)); + } catch (error) { + // Narrowing is needed because a caught value is `unknown` under `strict`. + // `cause` is what the tenant actually said, e.g. that the client is not + // authorized for this audience. Useful in your logs, not in the response. + if (error instanceof TokenExchangeError) { + console.error(error.code, error.cause?.error_description); + } + res.status(502).json({ error: 'downstream_unavailable' }); + } +}); +``` + +The errors you can get are: + +| `error.code` | What happened | +| --- | --- | +| `missing_client_auth_error` | The router has no client credentials, or has a `clientId` without a matching `clientSecret` or `clientAssertionSigningKey`. A configuration bug, not a request problem. | +| `token_exchange_error` | The tenant rejected the exchange, or the subject token you passed was missing or unusable. Read `error.cause?.error_description`. | + +A missing subject token also comes back as a `token_exchange_error`, with the message `subject_token is required`. That usually means the route is not behind `requiresAuth()`, so `req.auth0.token` was `undefined` and the non-null assertion was wrong. On that path the error is raised locally and there is no `cause`, which is why the example reads it with `?.`. + +A `token_exchange_error` is otherwise a tenant setup problem on first run, so check the [prerequisites](#prerequisites) before treating it as a runtime failure. Do not pass `error.cause?.error_description` through to the caller, since it describes your tenant configuration. + +> [!NOTE] +> `TokenExchangeError` and `MissingClientAuthError` come from `@auth0/auth0-auth-js` and do not extend `AuthError`, so `error instanceof AuthError` will not catch them. Narrow to the specific class as above, or branch on `error.code`, which every error this SDK surfaces carries. + +#### Exchanging a different token + +Nothing ties the subject token to the current request, so you can exchange a token that came from somewhere else, such as one held for a background job: + +```ts +app.post('/sync', requiresAuth({ scopes: 'run:jobs' }), async (req, res) => { + // A token your API stored earlier for this job, not something the caller sent. + // The lookup is scoped to the caller, so naming someone else's job resolves + // to nothing rather than to their token. + const subjectToken = await loadStoredToken(req.auth0.user!.sub, req.body.jobId); + + const { accessToken } = await req.auth0.client.getTokenOnBehalfOf(subjectToken, { + audience: 'https://orders.example.com', + }); + + res.json(await syncOrders(accessToken)); +}); +``` + +The exchange can fail here for the same reasons as anywhere else, so wrap it as shown in [Handling a failed exchange](#handling-a-failed-exchange). Left out above to keep the example about the subject token. + +> [!WARNING] +> Never take the subject token from the request body, query string or headers. Doing so lets a caller nominate any token they hold as the subject, and your API will exchange it as though it had verified it. Pass only tokens your API obtained and stored itself, or `req.auth0.token`, and keep the route protected so you still know who is asking. + +#### Reading the delegation chain + +A token obtained through an exchange carries an `act` claim naming the actor that requested it. If your API is itself called by another service, you can read that chain: + +```ts +import { requiresAuth, getCurrentActor, getDelegationChain } from '@auth0/auth0-express-api'; + +app.get('/whoami', requiresAuth(), async (req, res) => { + res.json({ + user: req.auth0.user!.sub, + // The service that called us, or undefined if the user called us directly. + actor: getCurrentActor(req.auth0.user!), + // Every actor, newest first, e.g. ['service-b', 'service-a']. + chain: getDelegationChain(req.auth0.user!), + }); +}); +``` + +Auth0 limits the delegation chain to **five nested levels**. Each exchange adds one, so a subject token that already carries four is rejected: you get a `token_exchange_error` whose `cause?.error_description` says the `act` claim depth exceeds the maximum allowed limit of 4. A long chain of services each calling the next on behalf of the user is not something to design around. diff --git a/packages/auth0-express-api/README.md b/packages/auth0-express-api/README.md index ac8cf1d..8b0c550 100644 --- a/packages/auth0-express-api/README.md +++ b/packages/auth0-express-api/README.md @@ -25,6 +25,7 @@ Jump straight to the capability you need. | [Environment variables](./EXAMPLES.md#using-environment-variables) | Configure from `AUTH0_*` env vars instead of hardcoding | | [Protect an API route (`requiresAuth`)](#protecting-api-routes) | Require a valid bearer access token | | [Read token claims (`req.auth0.user`)](#protecting-api-routes) | Access claims extracted from the verified token | +| [Call another API on behalf of the user](./EXAMPLES.md#calling-another-api-on-behalf-of-the-user) | Exchange the caller's token for one issued to a downstream API | | [Require specific scopes](./EXAMPLES.md#requiring-specific-scopes) | Gate routes with `scopesInclude` (match all or any) | | [Authorization with claims](#authorization-with-claims) | Restrict routes with `claimEquals`, `claimIncludes`, `claimCheck` | | [Custom token / user type](#custom-types) | Type your custom claims via module augmentation | @@ -71,7 +72,7 @@ app.get( '/protected-api', requiresAuth(), async (req, res) => { - res.json({ message: `Hello, ${req.auth0.user.sub}` }); + res.json({ message: `Hello, ${req.auth0.user!.sub}` }); } ); ``` @@ -131,7 +132,7 @@ app.get( '/protected-api', requiresAuth(), async (req, res) => { - res.json({ message: `Hello, ${req.auth0.user.name}` }); + res.json({ message: `Hello, ${req.auth0.user!.name}` }); } ); ``` diff --git a/packages/auth0-express-api/package.json b/packages/auth0-express-api/package.json index 980306c..96bd479 100644 --- a/packages/auth0-express-api/package.json +++ b/packages/auth0-express-api/package.json @@ -38,7 +38,7 @@ "vitest": "^4.1.2" }, "dependencies": { - "@auth0/auth0-api-js": "^1.4.0" + "@auth0/auth0-api-js": "^1.6.1" }, "peerDependencies": { "express": "^4.18.0 || ^5.0.0" diff --git a/packages/auth0-express-api/src/exports.spec.ts b/packages/auth0-express-api/src/exports.spec.ts new file mode 100644 index 0000000..830d237 --- /dev/null +++ b/packages/auth0-express-api/src/exports.spec.ts @@ -0,0 +1,113 @@ +import { describe, it, expect } from 'vitest'; +import * as sdk from './index.js'; +import { getCurrentActor, getDelegationChain, InvalidRequestError } from './index.js'; +import type { Token } from './index.js'; + +describe('public exports', () => { + // Type-only exports disappear at runtime, so this covers values only. It is + // here to make a dropped or renamed export a failing test rather than a + // silent break for consumers. + it('should export exactly the documented value surface', () => { + expect(Object.keys(sdk).sort()).toEqual([ + 'AuthError', + 'InvalidRequestError', + 'MissingClientAuthError', + 'TokenExchangeError', + 'VerifyAccessTokenError', + 'claimCheck', + 'claimEquals', + 'claimIncludes', + 'createAuth0Api', + 'getCurrentActor', + 'getDelegationChain', + 'requiresAuth', + 'scopesInclude', + ]); + }); + + it('should not export the ApiClient class, only its type', () => { + // `createAuth0Api()` is the only way to get a client, so an app cannot build + // a second one with different credentials and skip this package's config and + // environment variable handling. The type is still exported for annotations, + // which leaves nothing behind at runtime. + expect('ApiClient' in sdk).toBe(false); + }); + + it('should not export the pre-verification token extractor', () => { + // `req.auth0.token` is the supported way to reach the raw token, and unlike + // `getToken()` it is only set on a request this API has already verified. + expect('getToken' in sdk).toBe(false); + }); + + it('should not export unsupported feature surfaces', () => { + for (const name of [ + 'ProtectedResourceMetadata', + 'ProtectedResourceMetadataBuilder', + 'BearerMethod', + 'SigningAlgorithm', + 'GrantType', + 'InvalidDpopProofError', + 'MissingTransactionError', + ]) { + expect(name in sdk).toBe(false); + } + }); + + it('should not export surfaces for features that are not implemented yet', () => { + // Token Vault and Custom Token Exchange land in their own changes. Publishing + // their option and result types now would commit us to shapes those changes + // have not settled on. `MissingRequiredArgumentError` is unreachable, because + // `getConfig()` rejects a missing domain or audience before api-js sees it. + for (const name of [ + 'getAccessTokenForConnection', + 'getTokenByExchangeProfile', + 'TokenForConnectionError', + 'MissingRequiredArgumentError', + ]) { + expect(name in sdk).toBe(false); + } + }); +}); + +describe('act claim helpers', () => { + // `req.auth0.user` is a `Token`. These pass one straight through, which is + // only possible because `Token` declares `act`. + const delegated: Token = { + sub: 'user_123', + aud: 'urn:api', + iss: 'https://auth0.local/', + act: { sub: 'service-b', act: { sub: 'service-a' } }, + }; + + const direct: Token = { + sub: 'user_123', + aud: 'urn:api', + iss: 'https://auth0.local/', + }; + + it('should return the outermost actor', () => { + expect(getCurrentActor(delegated)).toBe('service-b'); + }); + + it('should return undefined for a token that was not exchanged', () => { + expect(getCurrentActor(direct)).toBeUndefined(); + }); + + it('should return the delegation chain from newest to oldest', () => { + expect(getDelegationChain(delegated)).toEqual(['service-b', 'service-a']); + }); + + it('should return an empty chain for a token that was not exchanged', () => { + expect(getDelegationChain(direct)).toEqual([]); + }); + + it('should throw the re-exported InvalidRequestError on a malformed act claim', () => { + // Also proves the re-exported class is the same one the helpers throw, which + // would not hold if two copies of @auth0/auth0-api-js were resolved. + const malformed = { ...direct, act: { sub: '' } } as Token; + + expect(() => getCurrentActor(malformed)).toThrow(InvalidRequestError); + expect(() => getCurrentActor(malformed)).toThrow('Invalid "act" claim'); + expect(() => getDelegationChain(malformed)).toThrow(InvalidRequestError); + }); +}); diff --git a/packages/auth0-express-api/src/index.spec.ts b/packages/auth0-express-api/src/index.spec.ts index 161f3f8..ddd4e21 100644 --- a/packages/auth0-express-api/src/index.spec.ts +++ b/packages/auth0-express-api/src/index.spec.ts @@ -99,6 +99,35 @@ test('should return 200 when valid token', async () => { expect(res.body.message).toBe('OK'); }); +test('should expose the verified access token on the request', async () => { + const app = express(); + app.use(express.json()); + + const router = createAuth0Api({ + domain: domain, + audience: '', + }); + + const accessToken = await generateToken(domain, 'user_123', ''); + + // Read out of band rather than returned in the body. The docs tell consumers + // to keep this credential out of responses, so the tests do the same. + let capturedToken: string | undefined; + + router.get('/test', requiresAuth(), async (req, res) => { + capturedToken = req.auth0.token; + res.json({ sub: req.auth0.user?.sub }); + }); + + app.use(router); + + const res = await request(app).get('/test').set('authorization', `Bearer ${accessToken}`); + + expect(res.status).toBe(200); + expect(capturedToken).toBe(accessToken); + expect(res.body.sub).toBe('user_123'); +}); + test('should return 401 when no issuer in token', async () => { const app = express(); app.use(express.json()); @@ -327,3 +356,254 @@ test('should return 200 when valid scope in token', async () => { expect(res.status).toBe(200); expect(res.body.message).toBe('OK'); }); + +test('should exchange the verified token on behalf of the user', async () => { + let body: URLSearchParams | undefined; + + server.use( + http.post(mockOpenIdConfiguration.token_endpoint, async ({ request: req }) => { + body = new URLSearchParams(await req.text()); + return HttpResponse.json({ + access_token: '', + issued_token_type: 'urn:ietf:params:oauth:token-type:access_token', + expires_in: 3600, + scope: 'read:orders', + token_type: 'Bearer', + }); + }) + ); + + const app = express(); + app.use(express.json()); + + const router = createAuth0Api({ + domain: domain, + audience: '', + clientId: '', + clientSecret: '', + }); + + const accessToken = await generateToken(domain, 'user_123', ''); + + router.get('/test', requiresAuth(), async (req, res) => { + const tokenSet = await req.auth0.client.getTokenOnBehalfOf(req.auth0.token!, { + audience: 'https://orders.example.com', + scope: 'read:orders', + }); + res.json(tokenSet); + }); + + app.use(router); + + const res = await request(app).get('/test').set('authorization', `Bearer ${accessToken}`); + + expect(res.status).toBe(200); + expect(res.body.accessToken).toBe(''); + expect(res.body.scope).toBe('read:orders'); + // Normalised to lowercase on the way through oauth4webapi. + expect(res.body.tokenType).toBe('bearer'); + expect(typeof res.body.expiresAt).toBe('number'); + + // The token the request was authenticated with is what gets exchanged. + expect(Object.fromEntries(body!.entries())).toEqual({ + grant_type: 'urn:ietf:params:oauth:grant-type:token-exchange', + subject_token: accessToken, + subject_token_type: 'urn:ietf:params:oauth:token-type:access_token', + requested_token_type: 'urn:ietf:params:oauth:token-type:access_token', + audience: 'https://orders.example.com', + scope: 'read:orders', + client_id: '', + client_secret: '', + }); +}); + +test('should exchange an explicitly provided subject token', async () => { + let body: URLSearchParams | undefined; + + server.use( + http.post(mockOpenIdConfiguration.token_endpoint, async ({ request: req }) => { + body = new URLSearchParams(await req.text()); + return HttpResponse.json({ + access_token: '', + issued_token_type: 'urn:ietf:params:oauth:token-type:access_token', + expires_in: 3600, + token_type: 'Bearer', + }); + }) + ); + + const app = express(); + app.use(express.json()); + + const router = createAuth0Api({ + domain: domain, + audience: '', + clientId: '', + clientSecret: '', + }); + + // No requiresAuth() and no Authorization header. The client is attached to + // every request, so a route can exchange a token it got from somewhere else. + router.get('/test', async (req, res) => { + const tokenSet = await req.auth0.client.getTokenOnBehalfOf('', { + audience: 'https://orders.example.com', + }); + res.json(tokenSet); + }); + + app.use(router); + + const res = await request(app).get('/test'); + + expect(res.status).toBe(200); + expect(res.body.accessToken).toBe(''); + expect(body!.get('subject_token')).toBe(''); +}); + +test('should throw when there is no subject token to exchange', async () => { + const app = express(); + app.use(express.json()); + + const router = createAuth0Api({ + domain: domain, + audience: '', + clientId: '', + clientSecret: '', + }); + + let error: Error | undefined; + + // `requiresAuth()` never ran, so `req.auth0.token` is undefined and the + // non-null assertion the route makes is wrong. + router.get('/test', async (req, res) => { + try { + await req.auth0.client.getTokenOnBehalfOf(req.auth0.token!, { audience: 'https://orders.example.com' }); + } catch (e) { + error = e as Error; + } + res.json({ message: error?.message }); + }); + + app.use(router); + + const res = await request(app).get('/test'); + + expect(res.status).toBe(200); + expect(res.body.message).toContain('subject_token is required'); + expect(error).toHaveProperty('code', 'token_exchange_error'); +}); + +test('should throw when exchanging without client credentials configured', async () => { + const app = express(); + app.use(express.json()); + + const router = createAuth0Api({ + domain: domain, + audience: '', + }); + + const accessToken = await generateToken(domain, 'user_123', ''); + + let error: Error | undefined; + + router.get('/test', requiresAuth(), async (req, res) => { + try { + await req.auth0.client.getTokenOnBehalfOf(req.auth0.token!, { audience: 'https://orders.example.com' }); + res.json({ message: 'OK' }); + } catch (e) { + error = e as Error; + res.json({ message: (e as Error).message }); + } + }); + + app.use(router); + + const res = await request(app).get('/test').set('authorization', `Bearer ${accessToken}`); + + expect(res.status).toBe(200); + expect(res.body.message).toContain('client secret or client assertion signing key must be provided'); + expect(error).toHaveProperty('code', 'missing_client_auth_error'); +}); + +test('should throw when exchanging with a client id but no secret or assertion key', async () => { + const app = express(); + app.use(express.json()); + + // A half-configured client. `clientId` alone cannot authenticate, so this is + // the same failure as having no credentials at all. + const router = createAuth0Api({ + domain: domain, + audience: '', + clientId: '', + }); + + const accessToken = await generateToken(domain, 'user_123', ''); + + let error: Error | undefined; + + router.get('/test', requiresAuth(), async (req, res) => { + try { + await req.auth0.client.getTokenOnBehalfOf(req.auth0.token!, { audience: 'https://orders.example.com' }); + res.json({ message: 'OK' }); + } catch (e) { + error = e as Error; + res.json({ message: (e as Error).message }); + } + }); + + app.use(router); + + const res = await request(app).get('/test').set('authorization', `Bearer ${accessToken}`); + + expect(res.status).toBe(200); + expect(error).toHaveProperty('code', 'missing_client_auth_error'); +}); + +test('should surface what the tenant said when the exchange is rejected', async () => { + server.use( + http.post(mockOpenIdConfiguration.token_endpoint, () => + HttpResponse.json( + { + error: 'access_denied', + error_description: 'Client is not authorized to access https://orders.example.com.', + }, + { status: 403 } + ) + ) + ); + + const app = express(); + app.use(express.json()); + + const router = createAuth0Api({ + domain: domain, + audience: '', + clientId: '', + clientSecret: '', + }); + + const accessToken = await generateToken(domain, 'user_123', ''); + + let error: (Error & { code?: string; cause?: { error?: string; error_description?: string } }) | undefined; + + router.get('/test', requiresAuth(), async (req, res) => { + try { + await req.auth0.client.getTokenOnBehalfOf(req.auth0.token!, { audience: 'https://orders.example.com' }); + res.json({ message: 'OK' }); + } catch (e) { + error = e as typeof error; + res.json({ message: (e as Error).message }); + } + }); + + app.use(router); + + const res = await request(app).get('/test').set('authorization', `Bearer ${accessToken}`); + + expect(res.status).toBe(200); + expect(error).toHaveProperty('code', 'token_exchange_error'); + // The tenant's own wording is preserved on `cause`, which is what a consumer + // needs to tell a misconfigured tenant apart from a bad request. + expect(error?.cause?.error).toBe('access_denied'); + expect(error?.cause?.error_description).toBe('Client is not authorized to access https://orders.example.com.'); +}); diff --git a/packages/auth0-express-api/src/index.ts b/packages/auth0-express-api/src/index.ts index d72f7f0..ed4b5aa 100644 --- a/packages/auth0-express-api/src/index.ts +++ b/packages/auth0-express-api/src/index.ts @@ -7,4 +7,56 @@ export { claimIncludes } from './middleware/claim-includes.js'; export { claimCheck } from './middleware/claim-check.js'; export { scopesInclude } from './middleware/scopes-include.js'; export type { ScopesIncludeOptions } from './middleware/scopes-include.js'; -export type { ClaimAuthOptions, ClaimCheckFunction, JSONPrimitive } from './middleware/claim-auth.js'; \ No newline at end of file +export type { ClaimAuthOptions, ClaimCheckFunction, JSONPrimitive } from './middleware/claim-auth.js'; + +/** + * Re-exports from `@auth0/auth0-api-js`. + * + * Only what this package's own surface already exposes, so that consumers can + * name those types without installing and pinning `@auth0/auth0-api-js` + * themselves. Anything api-js can do that this package does not is left out on + * purpose, including DPoP, Multi Custom Domains, Protected Resource Metadata, + * the discovery cache and sessions, and is added by the change that implements + * it rather than ahead of time. + * + * Also left out is `getToken()`, which extracts a token from a request before it + * has been verified. Use `req.auth0.token` instead, which is only set once + * `requiresAuth()` has verified it. + */ + +/** + * Type only, on purpose. `req.auth0.client` is an `ApiClient`, so the type is + * already part of this package's published surface and consumers need the name + * to annotate it. The class itself stays unexported so the only way to get a + * client is `createAuth0Api()`. Exporting the constructor would let an app build + * a second client with different credentials, skipping this package's + * configuration and environment variable handling. + */ +export type { ApiClient } from '@auth0/auth0-api-js'; + +// RFC 8693 actor claim helpers. Both accept `req.auth0.user` directly. +export { getCurrentActor, getDelegationChain } from '@auth0/auth0-api-js'; + +// The errors reachable through this package's surface, exported as values so +// `instanceof` works. `VerifyAccessTokenError` comes from `requiresAuth()`. +// `MissingClientAuthError` and `TokenExchangeError` come from calling Auth0 as a +// client through `req.auth0.client`. `InvalidRequestError` comes from the actor +// claim helpers above, on a malformed `act` claim. +// +// These are two hierarchies, not one. `VerifyAccessTokenError` and +// `InvalidRequestError` extend `AuthError`, whose `cause` is an +// `AuthErrorCause`. `MissingClientAuthError` and `TokenExchangeError` come from +// `@auth0/auth0-auth-js` and do not extend `AuthError`, so an +// `instanceof AuthError` check will not catch them, and their `cause` carries +// the tenant's `error` and `error_description` instead. Checking `error.code` is +// the sturdier option either way, since `instanceof` also fails if an app ends +// up with a second copy of api-js that npm could not deduplicate. +export { + AuthError, + InvalidRequestError, + MissingClientAuthError, + TokenExchangeError, + VerifyAccessTokenError, +} from '@auth0/auth0-api-js'; + +export type { ActClaim, AuthErrorCause, OnBehalfOfTokenOptions, OnBehalfOfTokenResult } from '@auth0/auth0-api-js'; \ No newline at end of file diff --git a/packages/auth0-express-api/src/middleware/require-auth.spec.ts b/packages/auth0-express-api/src/middleware/require-auth.spec.ts new file mode 100644 index 0000000..852fe2d --- /dev/null +++ b/packages/auth0-express-api/src/middleware/require-auth.spec.ts @@ -0,0 +1,240 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { NextFunction, Request, Response } from 'express'; +import type { ApiClient } from '@auth0/auth0-api-js'; +import { requiresAuth } from './require-auth.js'; + +describe('requiresAuth', () => { + let mockNext: ReturnType; + let verifyAccessToken: ReturnType; + + beforeEach(() => { + mockNext = vi.fn(); + verifyAccessToken = vi.fn(); + }); + + const createMockClient = () => ({ verifyAccessToken }) as unknown as ApiClient; + + const createMockRequest = ( + authorization?: string, + client: ApiClient | undefined = createMockClient() + ): Partial => ({ + headers: authorization ? { authorization } : {}, + // Cast because the router, not the caller, supplies the rest of `req.auth0`. + auth0: { client } as Request['auth0'], + }); + + const createMockResponse = (): Partial => ({ + status: vi.fn().mockReturnThis(), + header: vi.fn().mockReturnThis(), + json: vi.fn().mockReturnThis(), + end: vi.fn().mockReturnThis(), + }); + + it('should throw when the Auth0 router is not registered', async () => { + const req = { headers: {}, auth0: {} as Request['auth0'] } as Partial; + + await expect( + requiresAuth()(req as Request, createMockResponse() as Response, mockNext as NextFunction) + ).rejects.toThrow('Auth0 ApiClient not found on request'); + }); + + it('should return 401 with a bare Bearer challenge when no token is present', async () => { + const req = createMockRequest(); + const res = createMockResponse(); + + await requiresAuth()(req as Request, res as Response, mockNext as NextFunction); + + expect(res.status).toHaveBeenCalledWith(401); + expect(res.header).toHaveBeenCalledWith('WWW-Authenticate', 'Bearer'); + expect(verifyAccessToken).not.toHaveBeenCalled(); + expect(mockNext).not.toHaveBeenCalled(); + }); + + it.each([ + ['a non-bearer scheme', 'Basic dXNlcjpwYXNz'], + ['a scheme with no credentials', 'Bearer'], + ['more parts than expected', 'Bearer token extra'], + ])('should return 401 for %s', async (_label, authorization) => { + const req = createMockRequest(authorization); + const res = createMockResponse(); + + await requiresAuth()(req as Request, res as Response, mockNext as NextFunction); + + expect(res.status).toHaveBeenCalledWith(401); + // Same path as no header at all, so the challenge has to come back too. + // Without it a client has nothing telling it which scheme to retry with. + expect(res.header).toHaveBeenCalledWith('WWW-Authenticate', 'Bearer'); + expect(verifyAccessToken).not.toHaveBeenCalled(); + expect(mockNext).not.toHaveBeenCalled(); + }); + + it('should accept a lowercase bearer scheme', async () => { + verifyAccessToken.mockResolvedValue({ sub: 'user_123' }); + const req = createMockRequest('bearer '); + + await requiresAuth()(req as Request, createMockResponse() as Response, mockNext as NextFunction); + + expect(verifyAccessToken).toHaveBeenCalledWith({ accessToken: '' }); + expect(mockNext).toHaveBeenCalled(); + }); + + describe('on a verified request', () => { + const claims = { sub: 'user_123', aud: 'urn:api', iss: 'https://auth0.local/' }; + + it('should populate both the verified claims and the raw token', async () => { + verifyAccessToken.mockResolvedValue(claims); + const req = createMockRequest('Bearer '); + + await requiresAuth()(req as Request, createMockResponse() as Response, mockNext as NextFunction); + + expect(req.auth0!.user).toEqual(claims); + expect(req.auth0!.token).toBe(''); + expect(mockNext).toHaveBeenCalled(); + }); + + it('should keep the raw token out of anything that enumerates req.auth0', async () => { + verifyAccessToken.mockResolvedValue(claims); + const req = createMockRequest('Bearer '); + + await requiresAuth()(req as Request, createMockResponse() as Response, mockNext as NextFunction); + + expect(JSON.stringify(req.auth0)).not.toContain(''); + expect({ ...req.auth0 }).not.toHaveProperty('token'); + expect(Object.keys(req.auth0!)).not.toContain('token'); + }); + + it('should keep the client attached by the router', async () => { + verifyAccessToken.mockResolvedValue(claims); + const client = createMockClient(); + const req = createMockRequest('Bearer ', client); + + await requiresAuth()(req as Request, createMockResponse() as Response, mockNext as NextFunction); + + expect(req.auth0!.client).toBe(client); + }); + }); + + describe('scopes', () => { + it.each([ + ['a single required scope', 'read:messages', 'read:messages write:messages'], + ['every required scope', ['read:messages', 'write:messages'], 'read:messages write:messages'], + ])('should call next() when the token has %s', async (_label, required, scope) => { + verifyAccessToken.mockResolvedValue({ sub: 'user_123', scope }); + const req = createMockRequest('Bearer '); + + await requiresAuth({ scopes: required })( + req as Request, + createMockResponse() as Response, + mockNext as NextFunction + ); + + expect(mockNext).toHaveBeenCalled(); + }); + + it('should accept a scope claim that is already an array', async () => { + verifyAccessToken.mockResolvedValue({ sub: 'user_123', scope: ['read:messages'] }); + const req = createMockRequest('Bearer '); + + await requiresAuth({ scopes: 'read:messages' })( + req as Request, + createMockResponse() as Response, + mockNext as NextFunction + ); + + expect(mockNext).toHaveBeenCalled(); + }); + + it('should return 403 insufficient_scope when a required scope is missing', async () => { + verifyAccessToken.mockResolvedValue({ sub: 'user_123', scope: 'read:messages' }); + const req = createMockRequest('Bearer '); + const res = createMockResponse(); + + await requiresAuth({ scopes: ['read:messages', 'write:messages'] })( + req as Request, + res as Response, + mockNext as NextFunction + ); + + expect(res.status).toHaveBeenCalledWith(403); + expect(res.json).toHaveBeenCalledWith({ + error: 'insufficient_scope', + error_description: 'Insufficient scopes', + }); + expect(mockNext).not.toHaveBeenCalled(); + }); + + it('should return 403 when the token carries no scope claim at all', async () => { + verifyAccessToken.mockResolvedValue({ sub: 'user_123' }); + const req = createMockRequest('Bearer '); + const res = createMockResponse(); + + await requiresAuth({ scopes: 'read:messages' })( + req as Request, + res as Response, + mockNext as NextFunction + ); + + expect(res.status).toHaveBeenCalledWith(403); + expect(mockNext).not.toHaveBeenCalled(); + }); + + it('should not expose the raw token when the scope check fails', async () => { + verifyAccessToken.mockResolvedValue({ sub: 'user_123', scope: 'read:messages' }); + const req = createMockRequest('Bearer '); + + await requiresAuth({ scopes: 'write:messages' })( + req as Request, + createMockResponse() as Response, + mockNext as NextFunction + ); + + expect(req.auth0!.token).toBeUndefined(); + expect(req.auth0!.user).toBeUndefined(); + }); + }); + + describe('when verification fails', () => { + it('should surface the reason for a verify_access_token_error', async () => { + verifyAccessToken.mockRejectedValue( + Object.assign(new Error('"exp" claim timestamp check failed'), { + code: 'verify_access_token_error', + }) + ); + const req = createMockRequest('Bearer '); + const res = createMockResponse(); + + await requiresAuth()(req as Request, res as Response, mockNext as NextFunction); + + expect(res.status).toHaveBeenCalledWith(401); + expect(res.json).toHaveBeenCalledWith({ + error: 'invalid_token', + error_description: '"exp" claim timestamp check failed', + }); + expect(mockNext).not.toHaveBeenCalled(); + }); + + it('should fall back to a generic reason for any other error', async () => { + verifyAccessToken.mockRejectedValue(new Error('socket hang up')); + const req = createMockRequest('Bearer '); + const res = createMockResponse(); + + await requiresAuth()(req as Request, res as Response, mockNext as NextFunction); + + expect(res.status).toHaveBeenCalledWith(401); + expect(res.json).toHaveBeenCalledWith({ + error: 'invalid_token', + error_description: 'Invalid token', + }); + }); + + it('should not expose the raw token', async () => { + verifyAccessToken.mockRejectedValue(new Error('socket hang up')); + const req = createMockRequest('Bearer '); + + await requiresAuth()(req as Request, createMockResponse() as Response, mockNext as NextFunction); + + expect(req.auth0!.token).toBeUndefined(); + expect(req.auth0!.user).toBeUndefined(); + }); + }); +}); diff --git a/packages/auth0-express-api/src/middleware/require-auth.ts b/packages/auth0-express-api/src/middleware/require-auth.ts index 144069c..0224b06 100644 --- a/packages/auth0-express-api/src/middleware/require-auth.ts +++ b/packages/auth0-express-api/src/middleware/require-auth.ts @@ -47,8 +47,17 @@ export function requiresAuth(options: RequiresAuthOptions = {}) { return sendBearerError(res, 403, 'insufficient_scope', 'Insufficient scopes'); } - req.auth0 = req.auth0 || {}; req.auth0.user = token; + // Only ever set together with `user`, so the raw token on the request is + // guaranteed to be one this API has already verified. Non-enumerable so + // that logging or serialising `req.auth0` cannot leak a live credential + // past the redaction rules apps have for the `Authorization` header. + Object.defineProperty(req.auth0, 'token', { + value: accessToken, + enumerable: false, + writable: true, + configurable: true, + }); next(); } catch (error) { // eslint-disable-next-line @typescript-eslint/no-explicit-any diff --git a/packages/auth0-express-api/src/router.ts b/packages/auth0-express-api/src/router.ts index d2612b4..f7f78d1 100644 --- a/packages/auth0-express-api/src/router.ts +++ b/packages/auth0-express-api/src/router.ts @@ -25,10 +25,12 @@ export function createAuth0Api(options: Partial = {}): Router { customFetch: config.customFetch, }); - // Attach client and requiresAuth to router locals + // Spread rather than reassigned, so enumerable properties another middleware + // put on `req.auth0` survive. A spread drops the non-enumerable `token`, but + // no supported ordering has it set before this runs, since `requiresAuth()` + // needs the client this middleware attaches. router.use((req, res, next) => { - req.auth0 = req.auth0 || {}; - req.auth0.client = apiClient; + req.auth0 = { ...req.auth0, client: apiClient }; next(); }); diff --git a/packages/auth0-express-api/src/types.ts b/packages/auth0-express-api/src/types.ts index 1b0e655..08732ef 100644 --- a/packages/auth0-express-api/src/types.ts +++ b/packages/auth0-express-api/src/types.ts @@ -1,3 +1,5 @@ +import type { ActClaim } from '@auth0/auth0-api-js'; + export interface RequiresAuthOptions { scopes?: string | string[]; } @@ -7,6 +9,12 @@ export interface Token { aud: string | string[]; iss: string; scope?: string; + /** + * The RFC 8693 actor claim, present when this token was obtained through a + * token exchange. Pass the token to `getCurrentActor()` or + * `getDelegationChain()` to read the delegation chain. + */ + act?: ActClaim; [claim: string]: unknown; } @@ -24,17 +32,20 @@ export interface Auth0ApiOptions { audience?: string; /** * The optional client ID of the application. - * Required when using the `getAccessTokenForConnection` method. + * Required when calling Auth0 as a client through `req.auth0.client`, for + * example `getTokenOnBehalfOf()`. */ clientId?: string; /** * The optional client secret of the application. - * At least one of `clientSecret` or `clientAssertionSigningKey` is required when using the `getAccessTokenForConnection` method. + * At least one of `clientSecret` or `clientAssertionSigningKey` is required + * when calling Auth0 as a client through `req.auth0.client`. */ clientSecret?: string; /** * The optional client assertion signing key to use. - * At least one of `clientSecret` or `clientAssertionSigningKey` is required when using the `getAccessTokenForConnection` method. + * At least one of `clientSecret` or `clientAssertionSigningKey` is required + * when calling Auth0 as a client through `req.auth0.client`. */ clientAssertionSigningKey?: string | CryptoKey; /** diff --git a/packages/auth0-express-api/src/types/express.ts b/packages/auth0-express-api/src/types/express.ts index 3a865ae..c8ce2f0 100644 --- a/packages/auth0-express-api/src/types/express.ts +++ b/packages/auth0-express-api/src/types/express.ts @@ -5,9 +5,44 @@ declare global { namespace Express { interface Request { auth0: { + /** + * The verified claims of the access token. + * Set by `requiresAuth()` once verification succeeds. + */ user?: Token; + /** + * The raw access token string, exactly as it arrived in the + * `Authorization` header. + * + * Set by `requiresAuth()` at the same point as {@link user}, so it is + * only ever present on a request whose token this API has verified. + * Pass it to {@link client} as the subject token when calling a + * downstream API on behalf of the caller. + * + * Non-enumerable, so `JSON.stringify()`, object spreads and + * `console.log()` leave it out. Read it directly, and keep it out of + * logs, error reports and responses. + */ token?: string; - client?: ApiClient; + /** + * The `ApiClient` instance built from the router configuration. + * + * Attached by the router on every request, before `requiresAuth()` runs, + * so it is available even on unauthenticated requests. Use it to call + * Auth0 as a client, for example to exchange the caller's token for one + * targeting a downstream API. + * + * @example + * ```ts + * router.get('/orders', requiresAuth(), async (req, res) => { + * const { accessToken } = await req.auth0.client.getTokenOnBehalfOf(req.auth0.token!, { + * audience: 'https://orders.example.com', + * }); + * res.json(await fetchOrders(accessToken)); + * }); + * ``` + */ + client: ApiClient; }; } }