Skip to content

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 adapterOAuth2Provider, 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

Shell
deno run -A npm:degit udibo/oauth2/templates/juniper my-app

First run

Shell
cd my-app
deno install
deno task dev

Open 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:

Shell
deno task test    # Run the tests
deno task check   # Type check, lint, and formatting
deno task build && deno task serve   # Manual build + serve

Project tour

Text
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-out

How 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:

UsernamePasswordNotes
demopasswordSeeded 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*Service instances in oauth2/server.ts, and the IdentityUserStore adapter in oauth2/identity.ts, with database-backed implementations of the same interfaces — @udibo/oauth2/testing/contract has conformance tests for yours.

  • In-memory rate-limit and lockout counters. Pass a shared RateLimitStore and LockoutStore in oauth2/identity.ts; per-process counters don't hold across instances.

  • The in-memory session Map in sessions.ts: move it to your store and add expiry and rotation.

  • How long a sign-in lasts. sessionMaxAgeMs in oauth2/server.ts is the single place that says so: it stamps the session cookie's Max-Age and 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=production marks the IDP session cookie Secure; serve the app over TLS so it is actually protected.

Every environment variable the app reads, all optional:

VariableDefaultPurpose
APP_ENVdevelopmentdevelopment / test / production. Gates demo seeding and the Secure cookie flag.
NODE_ENVdevelopmentStandard React/Node toggle; keep in sync with APP_ENV.
APP_ORIGINhttp://localhost:8000The origin the app is served from; used as the OAuth2 issuer and redirect URI.
OAUTH2_CLIENT_SECRETdev-only-secretSecret for the app's OAuth2 client. Required when APP_ENV=production (no fallback).
OTEL_DENOtrue (from .env)Enables Deno's built-in OpenTelemetry instrumentation.
OTEL_SERVICE_NAMEmy-appService name for telemetry.
DENO_SERVE_ADDRESStcp:0.0.0.0:8000Address 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).

Shell
deno task test

Learn more