app-with-own-auth example
A single-process Hono app that runs an OAuth2 authorization server, a BFF, and a resource server together. The canonical "I want to own my identity layer" topology — your app is also the IDP.
Use this shape when:
You want full control over the login flow, consent screen, and user data, with no third-party IDP in the loop.
You're shipping a SaaS where your users sign in to your platform (not to Google / Auth0 / Udibo).
You want to issue your own tokens for third-party clients (M2M partners, mobile apps, etc.) as well as power your own SPA.
If instead you want to use Udibo (or another IDP) for authentication, see
app-with-external-auth/. It's this same SPA + BFF + resource-server shape with
the embedded authorization server and its /login, /consent, and /device
routes removed — compare the two folders' oauth2/server.ts and main.ts to
see exactly what delegated auth drops.
What this example includes
Authorization server (
HonoAuthorizationServer) mounted at/oauth2/*with the standard endpoints:/authorize,/token,/revoke,/introspect,/device_authorization, plus/.well-known/oauth-authorization-servermetadata.Supported grants: authorization code (with PKCE), refresh token, client credentials, and device authorization (RFC 8628).
Real login UI at
/loginbacked by theMemoryUserService(seeded with two demo users:adminanduser, bothpassword).Sign-up UI at
/create-accountthat creates a user, auto-signs-in, and hands back to the OAuth2 flow — resuming an in-flight authorize URL or starting a fresh/auth/loginwhen reached as a normal entry point. This is the handoffroutes/login.ts(only ever reached mid-authorize) never has to do, and it's why the example ships it: an own-auth app almost always owns sign-up too. (The BFF lifts that decision into the library itself asbff.loginContinuation().)Password reset at
/forgot-password+/reset-password— the library'sIdentityServiceflow end to end: an enumeration-safe request form mints a single-use, expiring token (hashed at rest viaMemoryTokenFlowStore), the consume form validates without burning the token, and a completed reset revokes the user's other IDP sessions. No email transport in the example — the reset link is logged to the server console; a real app swaps the two delivery hooks inoauth2/identity.tsfor its mailer.Email verification at
/verify-email— sign-up with an email mints a verification link (also console-logged) whose consumption marks the user verified, with distinct expired vs. invalid outcomes.Real consent UI at
/consentthat records the user's decision in a server-side one-time record (keyed by a short-lived cookie, bound to the user/client/scope) before redirecting back to/oauth2/authorize— the decision never rides the authorize URL, which the client builds and could forge. The consent handler narrows the granted scope to the user'smaxScopefield — so anadmin-only endpoint rejects tokens issued for the regularuser.Re-authentication for
prompt=loginandmax_age. The authorize handler inmain.tsreports when the IDP session signed in asauth_time. It reportsreauthenticated: trueonly when that session signed in for this exact authorize URL within the last ten minutes, and otherwise sends the user to/login, which records the URL it returns to on the new session.Device-verification UI at
/devicefor the RFC 8628 device flow.BFF (
HonoBff) mounted at/auth/*wrapping aDirectClientwith a client secret against the in-process auth server vialocalAuthServerFetch— no socket roundtrip for token exchanges. ItsresolveUserhook populatessession.userafter the code exchange by looking the user up through the shared in-processtokenService— this auth server is OAuth2-only (noid_token), so the BFF can't decode claims from a JWT; a BFF talking to a separate OIDC provider would call/userinfo(or introspection) here instead.Resource server (
HonoResourceServer) sharing the auth server's in-processtokenServiceso bearer-token validation skips introspection entirely.Interactive SPA homepage at
/with sign-in, sign-out, and endpoint-call buttons that show how scope governs access.
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, then start it:
pnpm install
pnpm build
pnpm --filter example-app-with-own-auth startThen open http://localhost:8001/. Use
pnpm --filter example-app-with-own-auth dev to restart on file changes
(node --watch). The api-service and app-with-external-auth examples use
this app as their identity provider.
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.
| Variable | Default | Meaning |
|---|---|---|
PORT | 8001 | Port to listen on (0 picks a free one) |
PUBLIC_URL | http://localhost:PORT | Origin of this app; it is the OAuth2 issuer and base of emailed links |
API_SERVICE_ORIGIN | http://localhost:8002 | Origin of api-service, whose /dev/callback redirect is registered |
EXTERNAL_APP_ORIGIN | http://localhost:8003 | Origin of app-with-external-auth, whose /auth/callback is registered |
Run the tests with pnpm --filter example-app-with-own-auth test. The main
suite drives the whole sign-in, consent, device, reset and verify flows
in-process through app.request(); serve.test.ts starts the real Node server
on a free port.
What to change to make this production-ready
Replace
MemoryUserService,MemoryClientService,MemoryTokenService,MemoryAuthorizationCodeService, andMemoryDeviceAuthorizationServicewith DB-backed implementations of the corresponding interfaces. The contract test runners in@udibo/oauth2/testing/contractwill verify your implementations conform.Replace the BFF's
MemorySessionStoreandMemoryAuthRequestStorageFactory, which keep sessions and pending sign-ins in this one process. The stateless choices that ship areEncryptedCookieSessionStoreandEncryptedCookieAuthRequestStorage(AES-GCM cookies, in@udibo/oauth2/hono/bff): give each store instance a secret of its own, used by no other app; every instance that shares a store's secret can read its cookies. For server-side storage (Redis, Postgres, …), implementSessionStoreandAuthRequestStorageFactory—runSessionStoreContractTestsandrunAuthRequestStorageContractTestsverify your implementations conform.Remove the
secure: falsecookie overrides — the BFF cookie defaults tosecure: true, and theidp_sessioncookie insessions.tsalso hardcodessecure: false. The example setsfalseonly so it works over local HTTP; production should always be HTTPS. (The in-memorysessionsMap insessions.tsis also a demo store — replace it alongsideMemorySessionStore.)Set
PUBLIC_URL(the issuer) to your deployed HTTPS origin and register your real clients' redirect URIs in place of the companion-example origins inoauth2/server.ts. The localhost defaults ship with the demo because the example must stand up without setup.Persist consent decisions per
(userId, clientId, scope)so users aren't re-prompted on every login. The example always shows the consent screen for visibility.Calibrate the password work factor if you stick with the built-in PBKDF2 helper —
new PasswordIdentityService({ iterations }), defaulting to 600,000 — or swap it for argon2id / scrypt through thePasswordHasherLikeseam. Persist the credential'sparamsfield either way: it records the algorithm and work factor, which is what lets the factor be raised later and applied one login at a time instead of by a forced reset.Bind
markEmailVerifiedto the address the link was minted for. This example's store ignores the second argument, so a link requested for one address would verify whichever address the account holds when it is clicked. A real store predicates the update on the id and the email.Add CSRF protection on
/login,/create-account, and/consent— the example omits it for readability. Use Hono's CSRF middleware or a similar pattern. The BFF itself ships CSRF protection on by default (a requiredx-csrfheader on credentialed requests); this example passescsrf: falseonly because the homepage uses hand-writtenfetchcalls. Real SPAs use the React adapter (or theBffClient's wrappedfetch), which sends the header automatically — so drop the override and keep the secure default.Add rate limiting on
/login,/create-account,/forgot-password,/oauth2/token, and any other endpoint that gates authentication or token issuance.Wire real email transport — the reset and verification flows are complete (token lifecycle, enumeration safety, session revocation), but delivery is console-only: replace the two hooks in
oauth2/identity.tswith your mailer. Enqueue the send instead of awaiting your mail provider in-request: the forgot-password response body is enumeration-safe, but an awaited send would make response timing reveal which emails have accounts. Add MFA, lockout, and captcha as needed.RateLimiterandAccountLockoutrequire astore: give them aRateLimitStoreand aLockoutStorethat every instance shares, orMemoryRateLimitStoreandMemoryLockoutStorefor a single process only.Wire OpenTelemetry / your tracing system through the auth server's request handlers and the BFF's middleware so you can correlate sign-ins to sessions to API calls.
Rotate signing keys if/when you swap opaque tokens for JWT access tokens. The current
MemoryTokenServiceissues opaque tokens validated through the token service — JWT support is a future addition.
Trusted-first-party SPA shortcut
The example's consent handler shows the consent page even for its own SPA so the per-user scope-narrowing behaviour stays visible. If all your clients are first-party you can skip consent two ways:
Omit
handleConsententirely. With no handler the framework treats the request as consented and grants the accepted scope without a prompt — the simplest path when you don't need to narrow scope at the consent step.Auto-approve inside
handleConsentwhen you still want to cap scope per user. Replace theif (decision === null)branch inmain.ts'shandleConsentwith:TypeScript return Promise.resolve({ approved: true, scope: BasicScope.intersection(requestedScope.toString(), u.maxScope), });
Third-party clients (apps your users authorize to access their data on your behalf) should always go through the full consent screen.
Variation: standalone IDP without an app frontend
If you're running an IDP that serves other applications and doesn't have its
own SPA, omit the HonoBff and the /api/* mount:
Drop the
HonoBffconstruction inoauth2/server.ts.Drop the
bffexport.Drop
app.route("/auth", bff.routes())andapp.route("/api", api)inmain.ts.Optionally drop
routes/api.ts,routes/home.ts, and the SPA-side homepage UI.
What remains is a pure RFC 6749 issuer that other apps point their
IntrospectionTokenReader at. See api-service/ for the consuming side.

