# `app-with-external-auth` example A single-process Hono app — SPA + BFF + own resource server — delegating authentication to an **external** identity provider over real HTTP. The canonical "I'm using Udibo (or another IDP) for login" topology. Use this shape when: - Your users sign in via Udibo, Auth0, Cognito, Google, Okta, or any other RFC 6749 / 6750 / 7662 IDP. - You want full control over your SPA and your API, but you're not building the identity layer yourself. This example is **structurally identical** to `app-with-own-auth/` — same Hono mount layout, same SPA homepage, same `/api/*` shape, same scope handling, same `HonoBff` and `HonoResourceServer` usage. Diff the two folders to see exactly what changes between self-hosted and delegated auth. ## What this example includes - **OAuth2 client** (`DirectClient` with a client secret) configured with real `fetch` against the IDP's endpoints. No `localAuthServerFetch` — token exchanges and refreshes go over the network like any other HTTP call. - **BFF** (`HonoBff`) mounted at `/auth/*` exposing `/auth/login`, `/auth/callback`, `/auth/logout`, `/auth/session`. `/auth/login` redirects the browser to the IDP's `/oauth2/authorize`; `/auth/callback` exchanges the returned code server-side. - **Resource server** (`HonoResourceServer`) with `IntrospectionTokenReader` validating bearer tokens via RFC 7662 introspection against the IDP. - **Protected routes** with scope gating: `/api/me`, `/api/write`, `/api/admin`. - **BFF CSRF protection left on** (the default). The homepage's hand-written `fetch` calls send the `x-csrf` header themselves; a real SPA gets it from the React adapter or `BffClient`'s wrapped `fetch`. This matters most for the proxied mount below — a wildcard proxy is exactly what you don't want to expose without it. - **A proxied route to a separate API** at `/remote-api/*`, mounted with `bff.proxy()`. The `/api/*` routes above are validated in this process; this one is forwarded to the `api-service/` example on port 8002 with the session's access token attached and the response streamed back. Both topologies, one app, so the difference is a diff. - **Interactive SPA homepage** at `/` with sign-in, sign-out, and endpoint-call buttons. No login form or consent UI here — those live on the IDP. - **Tests** that stub `tokenReader.getToken` and seed BFF sessions via `createTestSession` from `@udibo/oauth2/hono/bff/testing`. The introspection wire format is covered by the framework's own tests; the example tests focus on routing, scope gates, and error responses. ## The migration path is the diff The two `app-*` examples differ in exactly two configuration points: 1. **`DirectClient.fetch`** — `localAuthServerFetch(authServer)` (own auth) vs. real `fetch` (external IDP). 2. **Resource server's token service** — the shared in-process `tokenService` (own auth) vs. an `IntrospectionTokenReader` configured with the IDP's `introspectionEndpoint`, `clientId`, `clientSecret` (external IDP). Plus the structural cleanup that follows: external-auth apps don't host `/oauth2/*`, `/login`, `/logout`, `/consent`, or `/device` — those endpoints belong to the IDP. Diff `app-with-own-auth/` and this folder to see the migration as a real patch. ## Running it This example is a pnpm workspace member that depends on the package through `@udibo/oauth2: workspace:*`, which resolves to the package's built `dist/`. From the repository root, install and build once: ```bash pnpm install pnpm build ``` By default this example points at `app-with-own-auth/` on port 8001 as its IDP. Start both: ```bash # Terminal 1 — the IDP pnpm --filter example-app-with-own-auth start # Terminal 2 — this app pnpm --filter example-app-with-external-auth start # Terminal 3 (optional) — the separate API behind /remote-api/* pnpm --filter example-api-service start ``` Then open . Without terminal 3 the page works normally; only the `/remote-api/private` button returns `502`. Use `pnpm --filter example-app-with-external-auth dev` to restart on file changes (`node --watch`). Configuration is read from the environment by `config.ts`, which validates it at startup and lists every invalid variable at once. Every variable is optional; the defaults pair this app with the local examples. | Variable | Default | Meaning | | ------------------- | --------------------------- | ------------------------------------------------------- | | `PORT` | `8003` | Port to listen on (`0` picks a free one) | | `PUBLIC_URL` | `http://localhost:PORT` | Origin of this app; `${PUBLIC_URL}/auth/callback` is the redirect URI | | `IDP_BASE_URL` | `http://localhost:8001` | Origin of the identity provider | | `IDP_CLIENT_ID` | `spa` | Client this app is registered as | | `IDP_CLIENT_SECRET` | `spa-secret` | Secret for `IDP_CLIENT_ID` | | `API_SERVICE_URL` | `http://localhost:8002/api` | Separate API that `/remote-api/*` proxies to | Run the tests with `pnpm --filter example-app-with-external-auth test`. They stub `tokenReader.getToken` and call the app in-process. `serve.test.ts` starts the real Node server on a free port, and `remote-api.test.ts` points `/remote-api/*` at a local upstream on a free port to show the access token being forwarded. Configuring this app for local development, preview deployments, and production — including what to do about preview URLs that change every deploy — is covered in [Local, preview, and production for a relying party](https://www.udibo.com/docs/oauth2/guides/deploy-across-environments). ## What to change to make this production-ready - **Set `IDP_BASE_URL`, `PUBLIC_URL`, `IDP_CLIENT_ID`, `IDP_CLIENT_SECRET`, `API_SERVICE_URL` in the environment and drop the demo fallbacks in `config.ts`.** These are the production swap points. In production: - `IDP_BASE_URL` points at Udibo (or whichever IDP you're using). - `PUBLIC_URL` is your deployed app URL. - `IDP_CLIENT_ID` / `IDP_CLIENT_SECRET` come from registering this app as a client in the IDP's admin UI. - **Register your `redirect_uri` with the IDP**. The default is `${PUBLIC_URL}/auth/callback`. The IDP rejects any redirect URI not in its registered list. - **Choose the BFF's two stores for your deployment.** The example passes `MemorySessionStore` and `MemoryAuthRequestStorageFactory`, which keep sessions and pending sign-ins in this one process: a restart signs everyone out, and once requests land on more than one instance a session is unknown on the others (the user appears signed out) and a callback served elsewhere fails with `unknown state parameter`. For more than one instance, pass `EncryptedCookieSessionStore` and `EncryptedCookieAuthRequestStorage`, each with a secret of its own that every instance shares and no other app uses (stateless, in `@udibo/oauth2/hono/bff`; sign-out cannot invalidate a copied session cookie before its `maxAgeMs`), or a `SessionStore` and an `AuthRequestStorageFactory` over a database or cache every instance shares, verified with `runSessionStoreContractTests` and `runAuthRequestStorageContractTests`. - **Remove the `secure: false` cookie override** on the BFF — it defaults to `secure: true`; the example sets `false` only so it works over local HTTP. - **Cache introspection responses** until the token's `exp` — the default `IntrospectionTokenReader` calls the IDP on every request. Caching is left out for clarity but is essential in production. - **Replace the `resolveUser` introspection call with a userinfo (OIDC) call** if your IDP supports it. Introspection is intended for resource servers; userinfo is intended for user identity. Both work, but userinfo gives you richer claims (email, name, picture, custom attributes) and is the OIDC-canonical path. - **Wire your own user record** alongside the IDP's `sub`. The example's `resolveUser` returns only `{ sub, username }`. Real apps typically look up an internal user record keyed on `sub` so they can attach app-specific data (role, tenant, preferences) without round-tripping to the IDP every time. - **Add rate limiting and logging** on `/api/*`. - **Wire OpenTelemetry / your tracing system** through the BFF middleware so you can correlate sessions to API calls and onwards. - **Handle IDP logout (OIDC `end_session_endpoint`)**. The example's "Sign out" button clears the local BFF session but doesn't sign the user out of the IDP. A real app should also call the IDP's logout endpoint so the next sign-in shows the IDP's login form instead of silently re-authenticating.