Skip to content
Merged
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
31 changes: 31 additions & 0 deletions .github/workflows/examples.yml
Original file line number Diff line number Diff line change
Expand Up @@ -76,3 +76,34 @@ jobs:

- name: Test example-express-mcd
run: npm run test:ci -w example-express-mcd

example-express-web-call-api:
name: Web App Calling an API
runs-on: ubuntu-latest

steps:
- name: Checkout code
uses: actions/checkout@v4

- name: Setup Node.js with npm caching
uses: actions/setup-node@v6
with:
node-version: 24
package-manager-cache: false

- name: Update npm
run: npm install -g npm@11.10.0

- name: Install dependencies
run: npm install

# The example imports @auth0/auth0-express from its built dist, so the
# SDK must be built before the example can build or test.
- name: Build auth0-express
run: npm run build -w @auth0/auth0-express

- name: Build example-express-web-call-api
run: npm run build -w example-express-web-call-api

- name: Test example-express-web-call-api
run: npm run test:ci -w example-express-web-call-api
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ The following examples can be found in the examples directory:

- [Express Web App Example](./examples/example-express-web/README.md)
- [Express API Example](./examples/example-express-api/README.md)
- [Express Web App Calling an API Example](./examples/example-express-web-call-api/README.md)

Before running the examples, you need to install the dependencies for the monorepo and build all the packages.

Expand Down
7 changes: 5 additions & 2 deletions examples/example-express-api/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,8 +31,11 @@ app.get('/api/public', async (req: Request, res: Response) => {

const start = async () => {
try {
app.listen(3000, () => {
console.log('API server listening on http://localhost:3000');
// Defaults to 3000; set PORT to run on another port (e.g. 3001 when running
// alongside the example-express-web-call-api web app).
const port = Number(process.env.PORT ?? 3000);
app.listen(port, () => {
console.log(`API server listening on http://localhost:${port}`);
});
} catch (err) {
console.error(err);
Expand Down
13 changes: 13 additions & 0 deletions examples/example-express-web-call-api/.env.example
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
AUTH0_DOMAIN=
AUTH0_CLIENT_ID=
AUTH0_CLIENT_SECRET=
AUTH0_SESSION_SECRET=
APP_BASE_URL=http://localhost:3000

# The identifier (audience) of the API this app calls on the user's behalf.
# This must match the audience the resource server validates.
AUTH0_AUDIENCE=

# Base URL of the resource server. Run examples/example-express-api alongside
# this app (on port 3001) to act as the resource server — see the README.
API_BASE_URL=http://localhost:3001
80 changes: 80 additions & 0 deletions examples/example-express-web-call-api/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
# Express Web App Calling an API Example

This example shows how to use [`@auth0/auth0-express`](../../packages/auth0-express)
to log a user in, request an access token for an API (`audience`), and call a
resource server on the user's behalf.

The resource server is the existing [Express API example](../example-express-api)
in this repo, which is protected by
[`@auth0/auth0-express-api`](../../packages/auth0-express-api). You run it as a
separate service alongside this web app.

> **Why a separate service?** `@auth0/auth0-express` and
> `@auth0/auth0-express-api` each augment the global Express `Request` type with
> an incompatible `req.auth0` shape, so they cannot be compiled together in one
> app. A web app and a resource server are separate concerns anyway — this
> example models that by calling the API example over HTTP.

## Install dependencies

From the repository root:

```bash
npm install
npm run build
```

## Configuration

This example needs an Auth0 **Regular Web Application** (for the web app) and an
Auth0 **API** (the `audience`). Configure both this example and the
`example-express-api` to use the same API.

Rename `.env.example` to `.env` here and fill in the values:

```env
AUTH0_DOMAIN=YOUR_AUTH0_DOMAIN
AUTH0_CLIENT_ID=YOUR_CLIENT_ID
AUTH0_CLIENT_SECRET=YOUR_CLIENT_SECRET
AUTH0_SESSION_SECRET=A_LONG_RANDOM_SECRET
APP_BASE_URL=http://localhost:3000
AUTH0_AUDIENCE=YOUR_API_AUDIENCE
API_BASE_URL=http://localhost:3001
```

`AUTH0_AUDIENCE` must be the identifier of the API registered in your Auth0
tenant. The resource server validates tokens against this same audience.

> [!IMPORTANT]
> In the Auth0 Dashboard, add `http://localhost:3000/auth/callback` to **Allowed
> Callback URLs** and `http://localhost:3000` to **Allowed Logout URLs**.

## Run

Start the resource server (the API example) on port 3001 in one terminal:

```bash
# in examples/example-express-api (configure its .env with the same AUTH0_AUDIENCE)
PORT=3001 npm start
```

Start this web app on port 3000 in another terminal:

```bash
# in examples/example-express-web-call-api
npm start
```

Open http://localhost:3000, log in, then visit **Call API** (`/call-api`). The
web app requests an access token for `AUTH0_AUDIENCE` and calls
`GET /api/private` on the resource server with it, then renders the response.

## Test

```bash
npm test
```

The test mocks Auth0 (discovery, JWKS, token endpoint) and the downstream API,
then drives a full login and the `/call-api` flow with `supertest` — no live
services required.
31 changes: 31 additions & 0 deletions examples/example-express-web-call-api/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
{
"name": "example-express-web-call-api",
"version": "1.0.0",
"description": "",
"type": "module",
"scripts": {
"start": "tsx src/index.ts --project tsconfig.json",
"build": "tsc --project tsconfig.json",
"test": "vitest run",
"test:ci": "vitest run"
},
"devDependencies": {
"@types/ejs": "^3.1.5",
"@types/express": "^5.0.6",
"@types/express-ejs-layouts": "^2.5.4",
"@types/supertest": "^6.0.3",
"jose": "^5.9.6",
"msw": "^2.12.14",
"supertest": "^7.2.2",
"tsx": "^4.21.0",
"typescript": "~5.9.3",
"vitest": "^4.1.2"
},
"dependencies": {
"@auth0/auth0-express": "*",
"dotenv": "^17.2.3",
"ejs": "^4.0.1",
"express": "^5.2.1",
"express-ejs-layouts": "^2.5.1"
}
}
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
132 changes: 132 additions & 0 deletions examples/example-express-web-call-api/src/index.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
import { afterAll, afterEach, beforeAll, describe, expect, test } from 'vitest';
import { setupServer } from 'msw/node';
import { http, HttpResponse } from 'msw';
import { SignJWT, exportJWK, generateKeyPair } from 'jose';
import request from 'supertest';
import type { Express } from 'express';

// Env must be set BEFORE importing the app (the SDK reads process.env at import
// time). The values are placeholders; the network is fully mocked below.
const AUTH0_DOMAIN = 'tenant.auth0.local';
const CLIENT_ID = '<client_id>';
const AUDIENCE = 'https://api.example.com';

process.env.AUTH0_DOMAIN = AUTH0_DOMAIN;
process.env.AUTH0_CLIENT_ID = CLIENT_ID;
process.env.AUTH0_CLIENT_SECRET = '<client_secret>';
process.env.AUTH0_SESSION_SECRET = 'a-session-secret-of-at-least-32-characters-long';
process.env.APP_BASE_URL = 'http://localhost:3000';
process.env.AUTH0_AUDIENCE = AUDIENCE;
process.env.API_BASE_URL = 'http://api.local';

const KID = 'test-key-1';
let privateKey: CryptoKey;
let publicJwk: Record<string, unknown>;

const discovery = {
issuer: `https://${AUTH0_DOMAIN}/`,
authorization_endpoint: `https://${AUTH0_DOMAIN}/authorize`,
token_endpoint: `https://${AUTH0_DOMAIN}/oauth/token`,
end_session_endpoint: `https://${AUTH0_DOMAIN}/logout`,
jwks_uri: `https://${AUTH0_DOMAIN}/.well-known/jwks.json`,
};

const server = setupServer(
http.get(`https://${AUTH0_DOMAIN}/.well-known/openid-configuration`, () =>
HttpResponse.json(discovery)
),
http.get(discovery.jwks_uri, () =>
HttpResponse.json({ keys: [{ ...publicJwk, kid: KID, alg: 'RS256', use: 'sig' }] })
),
http.post(discovery.token_endpoint, async () => {
const now = Math.floor(Date.now() / 1000);
const idToken = await new SignJWT({ name: 'Jane Doe', email: 'jane@example.com' })
.setProtectedHeader({ alg: 'RS256', kid: KID })
.setIssuer(discovery.issuer)
.setAudience(CLIENT_ID)
.setSubject('auth0|user_123')
.setIssuedAt(now)
.setExpirationTime(now + 3600)
.sign(privateKey);
const accessToken = await new SignJWT({ scope: 'openid profile' })
.setProtectedHeader({ alg: 'RS256', kid: KID })
.setIssuer(discovery.issuer)
.setAudience(AUDIENCE)
.setSubject('auth0|user_123')
.setIssuedAt(now)
.setExpirationTime(now + 3600)
.sign(privateKey);
return HttpResponse.json({
access_token: accessToken,
id_token: idToken,
token_type: 'Bearer',
expires_in: 3600,
});
}),
// The downstream resource server (examples/example-express-api), mocked. Its
// /api/private route returns plain text and requires a bearer token; here we
// echo the subject only when a Bearer token is present so the test can assert
// the token was forwarded.
http.get('http://api.local/api/private', ({ request: req }) => {
const auth = req.headers.get('authorization') ?? '';
if (!auth.startsWith('Bearer ')) {
return new HttpResponse('Unauthorized', { status: 401 });
}
return new HttpResponse('Hello, auth0|user_123');
})
);

// Join a Set-Cookie header into a Cookie request header (name=value pairs).
const cookieHeader = (h: string | string[] | undefined) =>
(Array.isArray(h) ? h : [h ?? '']).map((c) => c.split(';')[0]).join('; ');

let app: Express;

beforeAll(async () => {
server.listen({ onUnhandledRequest: 'bypass' });
const kp = await generateKeyPair('RS256');
privateKey = kp.privateKey as CryptoKey;
publicJwk = await exportJWK(kp.publicKey);
({ app } = await import('./index.js'));
});

afterEach(() => server.resetHandlers());
afterAll(() => server.close());

// Drives login -> callback and returns the session Cookie header.
async function login(): Promise<string> {
const loginRes = await request(app).get('/auth/login');
expect(loginRes.status).toBe(302);
const txCookie = cookieHeader(loginRes.headers['set-cookie']);

const cbRes = await request(app).get('/auth/callback').query({ code: 'fake-code' }).set('Cookie', txCookie);
expect(cbRes.status).toBe(302);
return cookieHeader(cbRes.headers['set-cookie']);
}

describe('example-express-web-call-api', () => {
test('login requests an access token for the configured audience', async () => {
const res = await request(app).get('/auth/login');
const authorizeUrl = new URL(res.headers['location']?.toString() ?? '');

expect(res.status).toBe(302);
expect(authorizeUrl.host).toBe(AUTH0_DOMAIN);
expect(authorizeUrl.searchParams.get('audience')).toBe(AUDIENCE);
});

test('after login, /call-api forwards the access token and renders the API response', async () => {
const sessionCookie = await login();

const res = await request(app).get('/call-api').set('Cookie', sessionCookie);

expect(res.status).toBe(200);
expect(res.text).toContain('Response from the API');
expect(res.text).toContain('Hello, auth0|user_123');
});

test('/call-api redirects to login when there is no session', async () => {
const res = await request(app).get('/call-api');
expect(res.status).toBe(302);
expect(res.headers['location']).toContain('/auth/login');
});
});
Loading
Loading