diff --git a/.github/workflows/examples.yml b/.github/workflows/examples.yml index fe77c03..743e72e 100644 --- a/.github/workflows/examples.yml +++ b/.github/workflows/examples.yml @@ -71,3 +71,34 @@ jobs: - name: Build example-fastify-api run: npm run build -w example-fastify-api + + example-fastify-web-call-api: + name: Web App Calling an API + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + + - name: Setup Node.js with npm caching + uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 + with: + node-version: 22 + 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-fastify from its built dist, so the + # SDK must be built before the example can build or test. + - name: Build auth0-fastify + run: npm run build -w @auth0/auth0-fastify + + - name: Build example-fastify-web-call-api + run: npm run build -w example-fastify-web-call-api + + - name: Test example-fastify-web-call-api + run: npm run test:ci -w example-fastify-web-call-api diff --git a/README.md b/README.md index e8d0314..a874418 100644 --- a/README.md +++ b/README.md @@ -54,6 +54,7 @@ Jump straight to the capability you need. The following examples can be found in the examples directory: - [Fastify Web App Example](./examples/example-fastify-web/README.md) +- [Fastify Web App Calling an API Example](./examples/example-fastify-web-call-api/README.md) - [Fastify API Example](./examples/example-fastify-api/README.md) Before running the examples, you need to install the dependencies for the monorepo and build all the packages. diff --git a/examples/example-fastify-api/src/index.ts b/examples/example-fastify-api/src/index.ts index 21ad14e..17b7686 100644 --- a/examples/example-fastify-api/src/index.ts +++ b/examples/example-fastify-api/src/index.ts @@ -43,7 +43,10 @@ fastify.register(() => { const start = async () => { try { - await fastify.listen({ port: 3000 }); + // Defaults to 3000; set PORT to run on another port (e.g. 3001 when running + // alongside the example-fastify-web-call-api web app). + const port = Number(process.env.PORT ?? 3000); + await fastify.listen({ port }); } catch (err) { fastify.log.error(err); process.exit(1); diff --git a/examples/example-fastify-web-call-api/.env.example b/examples/example-fastify-web-call-api/.env.example new file mode 100644 index 0000000..6e75bf4 --- /dev/null +++ b/examples/example-fastify-web-call-api/.env.example @@ -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-fastify-api alongside +# this app (on port 3001) to act as the resource server — see the README. +API_BASE_URL=http://localhost:3001 diff --git a/examples/example-fastify-web-call-api/README.md b/examples/example-fastify-web-call-api/README.md new file mode 100644 index 0000000..a33c947 --- /dev/null +++ b/examples/example-fastify-web-call-api/README.md @@ -0,0 +1,71 @@ +# Fastify Web App Calling an API Example + +This example shows how to use [`@auth0/auth0-fastify`](../../packages/auth0-fastify) +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 [Fastify API example](../example-fastify-api) +in this repo, which is protected by +[`@auth0/auth0-fastify-api`](../../packages/auth0-fastify-api). You run it as a +separate service alongside this web app. + +## 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-fastify-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. + +The `AUTH0_SESSION_SECRET` is the key used to encrypt the session cookie. You +can generate a secret using `openssl`: + +```shell +openssl rand -hex 64 +``` + +> [!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-fastify-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-fastify-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. diff --git a/examples/example-fastify-web-call-api/package.json b/examples/example-fastify-web-call-api/package.json new file mode 100644 index 0000000..ed60a9a --- /dev/null +++ b/examples/example-fastify-web-call-api/package.json @@ -0,0 +1,28 @@ +{ + "name": "example-fastify-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", + "jose": "^5.9.6", + "msw": "^2.11.3", + "tsx": "^4.19.2", + "typescript": "~5.8.2", + "vitest": "^3.0.5" + }, + "dependencies": { + "@auth0/auth0-fastify": "*", + "@fastify/static": "^9.1.1", + "@fastify/view": "^10.0.2", + "dotenv": "^16.4.7", + "ejs": "^3.1.10", + "fastify": "^5.2.1" + } +} diff --git a/examples/example-fastify-web-call-api/public/img/auth0.png b/examples/example-fastify-web-call-api/public/img/auth0.png new file mode 100644 index 0000000..c5c260f Binary files /dev/null and b/examples/example-fastify-web-call-api/public/img/auth0.png differ diff --git a/examples/example-fastify-web-call-api/src/index.spec.ts b/examples/example-fastify-web-call-api/src/index.spec.ts new file mode 100644 index 0000000..a915a2c --- /dev/null +++ b/examples/example-fastify-web-call-api/src/index.spec.ts @@ -0,0 +1,143 @@ +import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, test } from 'vitest'; +import { setupServer } from 'msw/node'; +import { http, HttpResponse } from 'msw'; +import { SignJWT, exportJWK, generateKeyPair } from 'jose'; +import type { FastifyInstance } from 'fastify'; + +// Env must be set BEFORE building the app (the SDK reads process.env when the +// plugin is registered). The values are placeholders; the network is fully +// mocked below. +const AUTH0_DOMAIN = 'tenant.auth0.local'; +const 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 = ''; +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; + +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-fastify-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 Set-Cookie header(s) 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: FastifyInstance; + +beforeAll(async () => { + server.listen({ onUnhandledRequest: 'bypass' }); + const kp = await generateKeyPair('RS256'); + privateKey = kp.privateKey as CryptoKey; + publicJwk = await exportJWK(kp.publicKey); + const { buildApp } = await import('./index.js'); + app = buildApp(); + await app.ready(); +}); + +afterEach(() => server.resetHandlers()); +afterAll(async () => { + server.close(); + await app.close(); +}); + +// Drives login -> callback and returns the session Cookie header. +async function login(): Promise { + const loginRes = await app.inject({ method: 'GET', url: '/auth/login' }); + expect(loginRes.statusCode).toBe(302); + const txCookie = cookieHeader(loginRes.headers['set-cookie']); + + const cbRes = await app.inject({ + method: 'GET', + url: '/auth/callback?code=fake-code', + headers: { cookie: txCookie }, + }); + expect(cbRes.statusCode).toBe(302); + return cookieHeader(cbRes.headers['set-cookie']); +} + +describe('example-fastify-web-call-api', () => { + test('login requests an access token for the configured audience', async () => { + const res = await app.inject({ method: 'GET', url: '/auth/login' }); + const authorizeUrl = new URL(res.headers['location']?.toString() ?? ''); + + expect(res.statusCode).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 app.inject({ + method: 'GET', + url: '/call-api', + headers: { cookie: sessionCookie }, + }); + + expect(res.statusCode).toBe(200); + expect(res.body).toContain('Response from the API'); + expect(res.body).toContain('Hello, auth0|user_123'); + }); + + test('/call-api redirects to login when there is no session', async () => { + const res = await app.inject({ method: 'GET', url: '/call-api' }); + expect(res.statusCode).toBe(302); + expect(res.headers['location']?.toString()).toContain('/auth/login'); + }); +}); diff --git a/examples/example-fastify-web-call-api/src/index.ts b/examples/example-fastify-web-call-api/src/index.ts new file mode 100644 index 0000000..299bc88 --- /dev/null +++ b/examples/example-fastify-web-call-api/src/index.ts @@ -0,0 +1,144 @@ +import Fastify, { FastifyReply, FastifyRequest } from 'fastify'; +import fastifyStatic from '@fastify/static'; +import fastifyView from '@fastify/view'; +import fastifyAuth0 from '@auth0/auth0-fastify'; +import ejs from 'ejs'; +import 'dotenv/config'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +// Fix to use __dirname in ES modules +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); + +// Build the Fastify app. Exported so the test suite can drive it with +// `fastify.inject()` without binding a port. +export function buildApp() { + const fastify = Fastify({ + logger: true, + }); + + // Base URL of the resource server this app calls on the user's behalf. + // + // This example calls a SEPARATE API service. The API example in this repo + // (examples/example-fastify-api) is a ready-made resource server protected by + // @auth0/auth0-fastify-api — run it alongside this app (see the README) and + // point API_BASE_URL at it. + const apiBaseUrl = process.env.API_BASE_URL ?? 'http://localhost:3001'; + + fastify.register(fastifyStatic, { + root: path.join(__dirname, '../public'), + }); + + fastify.register(fastifyView, { + engine: { + ejs: ejs, + }, + root: path.join(__dirname, '../views'), + layout: 'layout.ejs', + }); + + // Register the Auth0 plugin. + // + // Passing `audience` makes the SDK request an access token for that API when + // the user logs in. The token is then available via `getAccessToken()` and is + // used below to call the resource server on the user's behalf. + fastify.register(fastifyAuth0, { + domain: process.env.AUTH0_DOMAIN as string, + clientId: process.env.AUTH0_CLIENT_ID as string, + clientSecret: process.env.AUTH0_CLIENT_SECRET as string, + audience: process.env.AUTH0_AUDIENCE as string, + appBaseUrl: process.env.APP_BASE_URL as string, + sessionSecret: process.env.AUTH0_SESSION_SECRET as string, + }); + + fastify.get('/', async (request, reply) => { + const user = await fastify.auth0Client!.getUser({ request, reply }); + + return reply.viewAsync('index.ejs', { isLoggedIn: !!user, user: user }); + }); + + async function hasSessionPreHandler(request: FastifyRequest, reply: FastifyReply) { + const session = await fastify.auth0Client!.getSession({ request, reply }); + + if (!session) { + return reply.redirect(`/auth/login?returnTo=${encodeURIComponent(request.url)}`); + } + } + + fastify.get('/public', async (request, reply) => { + const user = await fastify.auth0Client!.getUser({ request, reply }); + + return reply.viewAsync('public.ejs', { + isLoggedIn: !!user, + user, + }); + }); + + fastify.get( + '/private', + { + preHandler: hasSessionPreHandler, + }, + async (request, reply) => { + const user = await fastify.auth0Client!.getUser({ request, reply }); + + return reply.viewAsync('private.ejs', { + isLoggedIn: !!user, + user, + }); + } + ); + + // Call the resource server on behalf of the logged-in user. + // + // 1. `getAccessToken()` returns an access token for the configured `audience` + // (requesting/refreshing it as needed). + // 2. We call the API with the token in the `Authorization` header. + // 3. The API validates the token and returns data, which we render. + fastify.get( + '/call-api', + { + preHandler: hasSessionPreHandler, + }, + async (request, reply) => { + const user = await fastify.auth0Client!.getUser({ request, reply }); + const { accessToken } = await fastify.auth0Client!.getAccessToken({ request, reply }); + + const response = await fetch(`${apiBaseUrl}/api/private`, { + headers: { Authorization: `Bearer ${accessToken}` }, + }); + + if (!response.ok) { + throw new Error(`API request failed: ${response.status} ${response.statusText}`); + } + + const apiResponse = await response.text(); + + return reply.viewAsync('api.ejs', { + isLoggedIn: !!user, + user, + audience: process.env.AUTH0_AUDIENCE as string, + apiResponse, + }); + } + ); + + return fastify; +} + +const start = async () => { + const fastify = buildApp(); + try { + await fastify.listen({ port: 3000 }); + } catch (err) { + fastify.log.error(err); + process.exit(1); + } +}; + +// Start the server only when run directly (`npm start`), not when imported by +// the test suite (which drives the app via `fastify.inject()`). +if (process.argv[1] === __filename) { + start(); +} diff --git a/examples/example-fastify-web-call-api/tsconfig.json b/examples/example-fastify-web-call-api/tsconfig.json new file mode 100644 index 0000000..200701e --- /dev/null +++ b/examples/example-fastify-web-call-api/tsconfig.json @@ -0,0 +1,22 @@ +{ + "$schema": "https://json.schemastore.org/tsconfig", + "compilerOptions": { + "esModuleInterop": true, + "incremental": false, + "isolatedModules": true, + "lib": [ + "es2022" + ], + "module": "NodeNext", + "moduleDetection": "force", + "moduleResolution": "NodeNext", + "noUncheckedIndexedAccess": true, + "resolveJsonModule": true, + "skipLibCheck": true, + "strict": true, + "target": "ES2022", + "outDir": "dist", + "rootDir": "src" + }, + "exclude": ["dist", "**/*.spec.ts"] +} diff --git a/examples/example-fastify-web-call-api/views/api.ejs b/examples/example-fastify-web-call-api/views/api.ejs new file mode 100644 index 0000000..b9b8117 --- /dev/null +++ b/examples/example-fastify-web-call-api/views/api.ejs @@ -0,0 +1,3 @@ +

Response from the API

+

The web app obtained an access token for <%= audience %> and called the resource server with it.

+
<%= apiResponse %>
diff --git a/examples/example-fastify-web-call-api/views/index.ejs b/examples/example-fastify-web-call-api/views/index.ejs new file mode 100644 index 0000000..f25c3ce --- /dev/null +++ b/examples/example-fastify-web-call-api/views/index.ejs @@ -0,0 +1,6 @@ +<% if(isLoggedIn){ %> <% if(locals.user){ %> +

Hello, <%= locals.user.name %>!

+<% } %> +<% } else{ %> +

You are not logged in.

+<% } %> diff --git a/examples/example-fastify-web-call-api/views/layout.ejs b/examples/example-fastify-web-call-api/views/layout.ejs new file mode 100644 index 0000000..f6017c4 --- /dev/null +++ b/examples/example-fastify-web-call-api/views/layout.ejs @@ -0,0 +1,62 @@ + + + + + + Auth0-Fastify Calling an API demo + + + + +
<%- body %>
+ + + diff --git a/examples/example-fastify-web-call-api/views/private.ejs b/examples/example-fastify-web-call-api/views/private.ejs new file mode 100644 index 0000000..628de3c --- /dev/null +++ b/examples/example-fastify-web-call-api/views/private.ejs @@ -0,0 +1 @@ +This is a private page. diff --git a/examples/example-fastify-web-call-api/views/public.ejs b/examples/example-fastify-web-call-api/views/public.ejs new file mode 100644 index 0000000..182b0bd --- /dev/null +++ b/examples/example-fastify-web-call-api/views/public.ejs @@ -0,0 +1 @@ +This is a public page. diff --git a/llms.txt b/llms.txt index 689d170..b75934f 100644 --- a/llms.txt +++ b/llms.txt @@ -13,6 +13,7 @@ - [Monorepo README](./README.md): Overview, package list, and how to install/build the workspace (`npm install`, `npm run build`) and run the examples. - [Fastify web app example](./examples/example-fastify-web/README.md): Runnable server-rendered web app using `@auth0/auth0-fastify`. +- [Fastify web app calling an API example](./examples/example-fastify-web-call-api/README.md): Runnable web app that logs a user in and calls a resource server (`example-fastify-api`) on their behalf using `getAccessToken()`. - [Fastify API example](./examples/example-fastify-api/README.md): Runnable protected API using `@auth0/auth0-fastify-api`. ## Development