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 (
DirectClientwith a client secret) configured with realfetchagainst the IDP's endpoints. NolocalAuthServerFetch— 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/loginredirects the browser to the IDP's/oauth2/authorize;/auth/callbackexchanges the returned code server-side.Resource server (
HonoResourceServer) withIntrospectionTokenReadervalidating 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
fetchcalls send thex-csrfheader themselves; a real SPA gets it from the React adapter orBffClient's wrappedfetch. 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 withbff.proxy(). The/api/*routes above are validated in this process; this one is forwarded to theapi-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.getTokenand seed BFF sessions viacreateTestSessionfrom@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:
DirectClient.fetch—localAuthServerFetch(authServer)(own auth) vs. realfetch(external IDP).Resource server's token service — the shared in-process
tokenService(own auth) vs. anIntrospectionTokenReaderconfigured with the IDP'sintrospectionEndpoint,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:
pnpm install
pnpm buildBy default this example points at app-with-own-auth/ on port 8001 as its IDP.
Start both:
# 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 startThen open http://localhost:8003/. 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.
What to change to make this production-ready
Set
IDP_BASE_URL,PUBLIC_URL,IDP_CLIENT_ID,IDP_CLIENT_SECRET,API_SERVICE_URLin the environment and drop the demo fallbacks inconfig.ts. These are the production swap points. In production:IDP_BASE_URLpoints at Udibo (or whichever IDP you're using).PUBLIC_URLis your deployed app URL.IDP_CLIENT_ID/IDP_CLIENT_SECRETcome from registering this app as a client in the IDP's admin UI.
Register your
redirect_uriwith 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
MemorySessionStoreandMemoryAuthRequestStorageFactory, 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 withunknown state parameter. For more than one instance, passEncryptedCookieSessionStoreandEncryptedCookieAuthRequestStorage, 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 itsmaxAgeMs), or aSessionStoreand anAuthRequestStorageFactoryover a database or cache every instance shares, verified withrunSessionStoreContractTestsandrunAuthRequestStorageContractTests.Remove the
secure: falsecookie override on the BFF — it defaults tosecure: true; the example setsfalseonly so it works over local HTTP.Cache introspection responses until the token's
exp— the defaultIntrospectionTokenReadercalls the IDP on every request. Caching is left out for clarity but is essential in production.Replace the
resolveUserintrospection 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'sresolveUserreturns only{ sub, username }. Real apps typically look up an internal user record keyed onsubso 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.

