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.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
deno task serve:app-with-own-authThen open http://localhost:8001/.
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
MemorySessionStorewith a persistent store. The only persistent store that ships isEncryptedCookieSessionStore(stateless, AES-GCM cookie, in@udibo/oauth2/hono/bff). For a server-side store (Redis, Postgres, …), implement the exportedSessionStoreinterface —runSessionStoreContractTestsverifies your implementation conforms.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.)Move
issuerand all endpoint URLs out ofoauth2/server.tsinto env config. Hardcoded URLs 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.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. (The Juniperapp-with-own-authexample does exactly this.)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.

