Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion examples/example-express-api/.env.example
Original file line number Diff line number Diff line change
@@ -1,2 +1,7 @@
AUTH0_DOMAIN=
AUTH0_AUDIENCE=
AUTH0_AUDIENCE=

# Only needed for the /api/on-behalf-of endpoint.
AUTH0_CLIENT_ID=
AUTH0_CLIENT_SECRET=
AUTH0_DOWNSTREAM_AUDIENCE=
13 changes: 12 additions & 1 deletion examples/example-express-api/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,16 @@ 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:

```ts
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.

With the configuration in place, the example can be started by running:

```bash
Expand All @@ -32,5 +42,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.
23 changes: 22 additions & 1 deletion examples/example-express-api/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,14 @@ 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);
Expand All @@ -24,6 +28,23 @@ app.get('/api/private-scope', requiresAuth({ scopes: ['read:private'] }), async
res.send(`Hello, ${req.auth0.user?.sub}`);
});

// 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) => {
const tokenSet = await req.auth0.getTokenOnBehalfOf!({
audience: process.env.AUTH0_DOWNSTREAM_AUDIENCE as string,
});

// 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: process.env.AUTH0_DOWNSTREAM_AUDIENCE,
expiresAt: tokenSet.expiresAt,
scope: tokenSet.scope,
});
});

// Public route (no authentication required)
app.get('/api/public', async (req: Request, res: Response) => {
res.send('Hello world!');
Expand Down
69 changes: 68 additions & 1 deletion packages/auth0-express-api/EXAMPLES.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,9 @@
- [Using environment variables](#using-environment-variables)
- [Configuring a `customFetch` implementation](#configuring-a-customfetch-implementation)
- [The `ApiClient` instance](#the-apiclient-instance)
- [Calling another API on behalf of the user](#calling-another-api-on-behalf-of-the-user)
- [Exchanging a different token](#exchanging-a-different-token)
- [Reading the delegation chain](#reading-the-delegation-chain)
- [Protecting API Routes](#protecting-api-routes)
- [Basic authentication](#basic-authentication)
- [Requiring specific scopes](#requiring-specific-scopes)
Expand Down Expand Up @@ -57,6 +60,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`. A partial set is rejected at startup rather than on the first request that needs it.

Example `.env` file:

```env
Expand Down Expand Up @@ -114,6 +119,69 @@ Once the SDK is registered, an instance of the Auth0 `ApiClient` is available vi

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/authenticate/custom-token-exchange), built on [RFC 8693](https://datatracker.ietf.org/doc/html/rfc8693).

This requires client credentials, so configure `clientId` together with either `clientSecret` or `clientAssertionSigningKey`.

```ts
import { requiresAuth } from '@auth0/auth0-express-api';

router.get('/orders', requiresAuth(), async (req, res) => {
const { accessToken } = await req.auth0.getTokenOnBehalfOf!({
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 token being exchanged is the one this request was authenticated with, which is why the route needs `requiresAuth()`. You never have to pass it yourself.

Alongside `accessToken`, the result carries `expiresAt` (seconds since the Unix epoch), and `scope`, `tokenType` and `issuedTokenType` when the tenant returns them.

> [!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.

### Exchanging a different token

Pass `subjectToken` to exchange a token other than the current request's. This is for cases where the token comes from somewhere else, such as one held for a background job:

```ts
router.post('/sync', async (req, res) => {
const { accessToken } = await req.auth0.getTokenOnBehalfOf!({
audience: 'https://orders.example.com',
subjectToken: await loadStoredToken(req.body.jobId),
});

res.json(await syncOrders(accessToken));
});
```

### 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';

router.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!),
});
});
```

## Protecting API Routes

### Basic authentication
Expand Down Expand Up @@ -265,7 +333,6 @@ app.get(
}
);
```
```

### Using claimCheck for custom logic

Expand Down
1 change: 1 addition & 0 deletions packages/auth0-express-api/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down
3 changes: 2 additions & 1 deletion packages/auth0-express-api/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -31,14 +31,15 @@
"@vitest/coverage-v8": "^4.1.2",
"eslint": "^9.39.2",
"express": "^5.2.1",
"jose": "^6.2.3",
"msw": "^2.12.14",
"supertest": "^7.2.2",
"typescript": "~5.9.3",
"typescript-eslint": "^8.58.0",
"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"
Expand Down
67 changes: 67 additions & 0 deletions packages/auth0-express-api/src/config.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,7 @@ describe('getConfig', () => {
process.env.AUTH0_DOMAIN = 'test.auth0.com';
process.env.AUTH0_AUDIENCE = 'https://api.example.com';
process.env.AUTH0_CLIENT_ID = 'test_client_id';
process.env.AUTH0_CLIENT_SECRET = 'test_secret';

const config = getConfig();

Expand All @@ -121,6 +122,7 @@ describe('getConfig', () => {
it('should load optional clientSecret from AUTH0_CLIENT_SECRET', () => {
process.env.AUTH0_DOMAIN = 'test.auth0.com';
process.env.AUTH0_AUDIENCE = 'https://api.example.com';
process.env.AUTH0_CLIENT_ID = 'test_client_id';
process.env.AUTH0_CLIENT_SECRET = 'test_secret';

const config = getConfig();
Expand Down Expand Up @@ -173,6 +175,7 @@ describe('getConfig', () => {
it('should preserve clientAssertionSigningKey when provided', () => {
process.env.AUTH0_DOMAIN = 'test.auth0.com';
process.env.AUTH0_AUDIENCE = 'https://api.example.com';
process.env.AUTH0_CLIENT_ID = 'test_client_id';

const signingKey = 'test_key';
const config = getConfig({ clientAssertionSigningKey: signingKey });
Expand All @@ -192,6 +195,7 @@ describe('getConfig', () => {
it('should load clientAssertionSigningKey from AUTH0_CLIENT_ASSERTION_SIGNING_KEY', () => {
process.env.AUTH0_DOMAIN = 'test.auth0.com';
process.env.AUTH0_AUDIENCE = 'https://api.example.com';
process.env.AUTH0_CLIENT_ID = 'test_client_id';
process.env.AUTH0_CLIENT_ASSERTION_SIGNING_KEY = '-----BEGIN PRIVATE KEY-----\ntest_key\n-----END PRIVATE KEY-----';

const config = getConfig();
Expand All @@ -202,6 +206,7 @@ describe('getConfig', () => {
it('should prefer explicit clientAssertionSigningKey over environment variable', () => {
process.env.AUTH0_DOMAIN = 'test.auth0.com';
process.env.AUTH0_AUDIENCE = 'https://api.example.com';
process.env.AUTH0_CLIENT_ID = 'test_client_id';
process.env.AUTH0_CLIENT_ASSERTION_SIGNING_KEY = '-----BEGIN PRIVATE KEY-----\nenv_key\n-----END PRIVATE KEY-----';

const config = getConfig({
Expand All @@ -214,6 +219,7 @@ describe('getConfig', () => {
it('should work with both clientAssertionSigningKey and clientAssertionSigningAlg', () => {
process.env.AUTH0_DOMAIN = 'test.auth0.com';
process.env.AUTH0_AUDIENCE = 'https://api.example.com';
process.env.AUTH0_CLIENT_ID = 'test_client_id';
process.env.AUTH0_CLIENT_ASSERTION_SIGNING_KEY = '-----BEGIN PRIVATE KEY-----\ntest_key\n-----END PRIVATE KEY-----';

const config = getConfig({
Expand All @@ -223,4 +229,65 @@ describe('getConfig', () => {
expect(config.clientAssertionSigningKey).toBe('-----BEGIN PRIVATE KEY-----\ntest_key\n-----END PRIVATE KEY-----');
expect(config.clientAssertionSigningAlg).toBe('RS256');
});

describe('client credentials', () => {
beforeEach(() => {
process.env.AUTH0_DOMAIN = 'test.auth0.com';
process.env.AUTH0_AUDIENCE = 'https://api.example.com';
});

it('should accept a configuration with no credentials at all', () => {
const config = getConfig();

expect(config.clientId).toBeUndefined();
expect(config.clientSecret).toBeUndefined();
expect(config.clientAssertionSigningKey).toBeUndefined();
});

it('should accept clientId with clientSecret', () => {
expect(() => getConfig({ clientId: 'test_client_id', clientSecret: 'test_secret' })).not.toThrow();
});

it('should accept clientId with clientAssertionSigningKey', () => {
expect(() =>
getConfig({ clientId: 'test_client_id', clientAssertionSigningKey: 'test_key' })
).not.toThrow();
});

it('should accept a credential pair split across config and environment', () => {
process.env.AUTH0_CLIENT_SECRET = 'env_secret';

expect(() => getConfig({ clientId: 'test_client_id' })).not.toThrow();
});

it('should throw when clientId is provided without a credential', () => {
expect(() => getConfig({ clientId: 'test_client_id' })).toThrow(
"'clientId' was provided without a client credential"
);
});

it('should throw when AUTH0_CLIENT_ID is set without a credential', () => {
process.env.AUTH0_CLIENT_ID = 'env_client_id';

expect(() => getConfig()).toThrow("'clientId' was provided without a client credential");
});

it('should throw when clientSecret is provided without a clientId', () => {
expect(() => getConfig({ clientSecret: 'test_secret' })).toThrow(
"A client credential was provided without a 'clientId'"
);
});

it('should throw when clientAssertionSigningKey is provided without a clientId', () => {
expect(() => getConfig({ clientAssertionSigningKey: 'test_key' })).toThrow(
"A client credential was provided without a 'clientId'"
);
});

it('should not throw for clientAssertionSigningAlg on its own', () => {
// The algorithm is a choice, not a credential, so an unused value is
// harmless and not worth failing startup over.
expect(() => getConfig({ clientAssertionSigningAlg: 'RS384' })).not.toThrow();
});
});
});
26 changes: 25 additions & 1 deletion packages/auth0-express-api/src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,9 +15,14 @@ function stripProtocol(url: string | undefined): string | undefined {
* - AUTH0_CLIENT_SECRET: Auth0 application client secret (optional)
* - AUTH0_CLIENT_ASSERTION_SIGNING_KEY: Private key for client assertion (optional)
*
* Client credentials are optional. They are only needed to call a downstream
* API on behalf of the caller. If any one of them is provided, though, the set
* has to be complete, because half a credential is never intentional.
*
* @param config - Partial configuration object
* @returns Complete Auth0ApiOptions configuration
* @throws Error if required fields (domain, audience) are missing
* @throws Error if required fields (domain, audience) are missing, or if client
* credentials are partially configured
*/
export function getConfig(config: Partial<Auth0ApiOptions> = {}): Auth0ApiOptions {
const mergedConfig = {
Expand All @@ -38,5 +43,24 @@ export function getConfig(config: Partial<Auth0ApiOptions> = {}): Auth0ApiOption
throw new Error("'audience' is required. Provide it via config or AUTH0_AUDIENCE environment variable.");
}

const hasClientAuth = Boolean(mergedConfig.clientSecret || mergedConfig.clientAssertionSigningKey);

// Caught here rather than on the first exchange, which would otherwise fail a
// real request in production long after the app started up cleanly.
if (mergedConfig.clientId && !hasClientAuth) {
throw new Error(
"'clientId' was provided without a client credential. Also provide 'clientSecret' or " +
"'clientAssertionSigningKey', via config or the AUTH0_CLIENT_SECRET or " +
'AUTH0_CLIENT_ASSERTION_SIGNING_KEY environment variables.'
);
}

if (hasClientAuth && !mergedConfig.clientId) {
throw new Error(
"A client credential was provided without a 'clientId'. Also provide 'clientId', via config " +
'or the AUTH0_CLIENT_ID environment variable.'
);
}

return mergedConfig;
}
Loading