Skip to content
Udibo
On this page
View Markdown

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:

Shell
pnpm install
pnpm build

By default this example points at app-with-own-auth/ on port 8001 as its IDP. Start both:

Shell
# 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 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.

VariableDefaultMeaning
PORT8003Port to listen on (0 picks a free one)
PUBLIC_URLhttp://localhost:PORTOrigin of this app; ${PUBLIC_URL}/auth/callback is the redirect URI
IDP_BASE_URLhttp://localhost:8001Origin of the identity provider
IDP_CLIENT_IDspaClient this app is registered as
IDP_CLIENT_SECRETspa-secretSecret for IDP_CLIENT_ID
API_SERVICE_URLhttp://localhost:8002/apiSeparate 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_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.