Juniper + OAuth2 Starter
A Juniper (SSR React on Deno) app with
authentication already wired up using
@udibo/oauth2. Your app owns its identity
layer: an embedded authorization server, a Backend-For-Frontend the browser
signs in through, and a resource server protecting your API — all in one
process.
What you get out of the box:
Sign-up, sign-in, and sign-out pages backed by your own user store, with credentials running through the library's
IdentityService— per-identifier rate limiting, account lockout, and equal work on every failed sign-in, so response timing never reveals which usernames exist.A BFF — the browser never holds a token, only an HttpOnly session cookie.
A protected page (
/profile) and a protected API (/api/me).The React adapter —
OAuth2Provider,useOAuth2(), and<RequireAuth>.Zero external services — in-memory stores and a seeded demo user mean it runs locally and in CI with no database, cloud account, or signup anywhere.
Create your app
deno run -A npm:degit udibo/oauth2/templates/juniper my-appFirst run
cd my-app
deno install
deno task devOpen http://localhost:8000. Sign in as demo / password, or click Create
one to make your own account. Once signed in, visit Profile to see your
session and call the protected API.
Other tasks:
deno task test # Run the tests
deno task check # Type check, lint, and formatting
deno task build && deno task serve # Manual build + serveProject tour
oauth2/
server.ts # server-only: authorization server + BFF + resource server
identity.ts # own-auth flows: IdentityService + rate limiting + lockout
browser-client.ts # browser-safe: the BffClient for the provider
sessions.ts # the IDP login session (separate from the BFF token session)
routes/
main.ts # root server middleware (logging)
main.tsx # root layout — <OAuth2Provider> wraps the app
index.tsx # home: session display, sign in / sign out
profile.tsx # protected page (<RequireAuth>) + protected API call
login.ts/.tsx # sign-in form and its server loader/action
signup.ts/.tsx # sign-up form and its server loader/action
logout.ts # IDP sign-out (chained after BFF sign-out)
oauth2/main.ts # the authorization server's endpoints -> /oauth2/*
auth/main.ts # the BFF's browser endpoints -> /auth/*
api/main.ts # protected API (bff.protect()) -> /api/*
main.ts, main.tsx # auto-generated by `deno task build`
main.test.ts # hermetic tests: sign-up → sign-in → API → sign-outHow a sign-in flows: the SPA's login() hits /auth/login, which redirects to
/oauth2/authorize; with no IDP session that bounces to your /login form,
whose action authenticates and resumes the flow (bff.loginContinuation); the
callback stores tokens server-side and sets the session cookie. Sign-out chains
/auth/logout (clears the token session) into /logout (clears the IDP
session).
The credential layer
/login and /signup don't check passwords themselves — they call
IdentityService (oauth2/identity.ts), the library's own-auth orchestrator,
which gives the starter three properties a hand-rolled check usually misses:
No username-enumeration oracle. Sign-in throttles before the account lookup and hashes a password on every failure branch, so an unknown username costs the same time and returns the same answer as a known one with the wrong password.
Rate limiting. 10 attempts per identifier per 15 minutes, reset on a successful sign-in.
Account lockout. 10 consecutive wrong passwords locks the account for 15 minutes, rejected with the same uniform answer — no lockout oracle either.
Both counters are in-memory, so they are per-process and reset on restart. Pass
a shared RateLimitStore / LockoutStore (your database or Redis) once you run
more than one instance. IdentityService also ships password reset, email
verification, magic links, and one-time codes; this starter wires only sign-in
and sign-up. See the @udibo/oauth2/identity docs and the
templates/react-router starter, which mounts the full set.
Credentialed fetch calls from the browser to /api/* or /auth/session must
send the session cookie and an x-csrf: 1 header (see routes/profile.tsx).
Seed & configuration
Everything runs locally and in CI with in-memory stores — no cloud account, no
external service, nothing to sign up for. oauth2/server.ts registers the app's
own OAuth2 client at startup and seeds one demo account:
| Username | Password | Notes |
|---|---|---|
demo | password | Seeded only when APP_ENV != production |
Accounts created through /signup live in the same in-memory store and vanish
on restart. To change or remove seeding, edit the DEMO_USER block in
oauth2/server.ts.
Going to production
routes/main.ts mounts requestLogger() from @udibo/oauth2/hono/log. It logs
request paths and parameter names with every query value redacted, including
callback codes and state. Configure proxy and tracing logs separately.
The starter's shortcuts, and what replaces each:
In-memory stores. Replace the
Memory*Serviceinstances inoauth2/server.ts, and theIdentityUserStoreadapter inoauth2/identity.ts, with database-backed implementations of the same interfaces —@udibo/oauth2/testing/contracthas conformance tests for yours.In-memory rate-limit and lockout counters. Pass a shared
RateLimitStoreandLockoutStoreinoauth2/identity.ts; per-process counters don't hold across instances.The in-memory session
Mapinsessions.ts: move it to your store and add expiry and rotation.How long a sign-in lasts.
sessionMaxAgeMsinoauth2/server.tsis the single place that says so: it stamps the session cookie'sMax-Ageand bounds the session server-side. The template states the package default of 14 days explicitly so the knob is visible; raise it for a longer "stay signed in", and if you pass your own session store with its own maximum age, keep the two in agreement — the BFF refuses a configuration whose cookie would expire before the session does.OAUTH2_CLIENT_SECRET. The app refuses to start in production without it, since the development fallback is public in this template's source.HTTPS.
APP_ENV=productionmarks the IDP session cookieSecure; serve the app over TLS so it is actually protected.
Every environment variable the app reads, all optional:
| Variable | Default | Purpose |
|---|---|---|
APP_ENV | development | development / test / production. Gates demo seeding and the Secure cookie flag. |
NODE_ENV | development | Standard React/Node toggle; keep in sync with APP_ENV. |
APP_ORIGIN | http://localhost:8000 | The origin the app is served from; used as the OAuth2 issuer and redirect URI. |
OAUTH2_CLIENT_SECRET | dev-only-secret | Secret for the app's OAuth2 client. Required when APP_ENV=production (no fallback). |
OTEL_DENO | true (from .env) | Enables Deno's built-in OpenTelemetry instrumentation. |
OTEL_SERVICE_NAME | my-app | Service name for telemetry. |
DENO_SERVE_ADDRESS | tcp:0.0.0.0:8000 | Address deno task serve listens on. |
.env holds development defaults, .env.test and .env.production layer on
top for those environments, and .env.example documents everything — copy it to
.env and adjust as your app grows.
Testing
main.test.ts runs entirely in-process (server.request(...), no sockets, no
external services). A small cookie-jar helper drives the real redirect chain,
covering sign-up → sign-in → protected API → sign-out, plus the guard rails (bad
credentials, duplicate usernames, open-redirect protection).
deno task testLearn more
@udibo/oauth2documentation — see theexamples/directory for the richer demos this starter is distilled from

