# React Router app with auth preconfigured Your app: a [React Router](https://reactrouter.com/) **framework mode** app with server-side rendering, served by a [Hono](https://hono.dev/) server on Node through [`react-router-hono-server`](https://github.com/rphlmr/react-router-hono-server), built with Vite, Tailwind CSS 4 and the React Compiler. A complete authentication layer from [`@udibo/oauth2`](https://github.com/udibo/oauth2) is already wired up: - **Sign-up, sign-in, sign-out, and a protected page** work out of the box. - **The browser never holds a token.** The server runs the OAuth2 Backend-For-Frontend (BFF) pattern: tokens live in a server-side session behind an HttpOnly cookie, and the app reads auth state through the `@udibo/oauth2/react` adapter (`OAuth2Provider`, `useOAuth2`). - **Pages render signed in on the first paint.** The server reads the BFF session for each request (`bff.readSession`), passes it to React Router through the load context, and the root route seeds `OAuth2Provider` with it, so there is no signed-out flash and no session probe on load. - **You own the identity layer.** An embedded OAuth2 authorization server issues the tokens, and the app's own `/login` and `/signup` pages are the credential surface (via the library's `IdentityService`). No third-party IdP in the loop. - **The auth pages use the package's own components.** Sign-in, sign-up, and both password-reset pages render `@udibo/oauth2/react/components` (`SignInForm`, `SignUpForm`, `RequestPasswordResetForm`, `ResetPasswordForm`), so you inherit their accessibility — programmatic labels, `aria-invalid` / `aria-describedby`, focus moved to the first errored field, a re-announced error summary — instead of hand-rolling it. They ship unstyled with per-slot `className` hooks; theme them, or pass a function as `children` to keep the wiring and own the markup. ## Scaffold Copy this directory, or: ```bash npx degit udibo/oauth2/templates/react-router my-app ``` > This command activates once the repository is public. Until then, copy this > directory instead. ### Point `@udibo/oauth2` at a published version Inside this repository `package.json` depends on the package through `"@udibo/oauth2": "workspace:*"`, which resolves to the repository's own build. Everything else in `package.json` is a plain version range, so a copy outside the workspace needs one edit: replace that line with a version range. ```json "@udibo/oauth2": "^0.15.0" ``` Until the package is on npm, the JSR registry serves it under an npm name; add `@jsr:registry=https://npm.jsr.io` to an `.npmrc` and use `"@udibo/oauth2": "npm:@jsr/udibo__oauth2@^0.14.1"`. Tests use MSW 3, which Vitest 5 does not list among its accepted peer ranges. If your package manager warns about it, allow it (pnpm: `peerDependencyRules.allowedVersions` for `@vitest/mocker>msw` set to `3`) or pin `msw` to a 2.x release. ## First run Requires Node 24.2 or later. ```bash pnpm install pnpm dev # http://localhost:8000, with hot reloading ``` Open , click **Dashboard**, and sign in as the seeded demo user (`demo@example.com` / `password`) — or create an account and watch the server console for the email-verification link. For production: ```bash pnpm build pnpm start # node build/server/index.js ``` Other scripts: ```bash pnpm test # server tests (no build or network) and component tests (jsdom + MSW) pnpm typecheck # react-router typegen, then tsc ``` In development, editing a server file reloads the server module, which resets the in-memory stores (accounts and sessions) — sign in again after a server change. ## Project tour ``` server/ index.ts # the server entry: logs requests (query values redacted), mounts the auth # layer, hands the rest to React Router auth.ts # /oauth2, /auth, /identity, /api as one Hono app (tested with app.request) api.ts # your protected API (/api/*), guarded by bff.protect() config.ts # zod-validated environment (see below) sessions.ts # the issuer's login session (in-memory demo store) load-context.ts # builds the router context: BFF session + demo-account flag oauth2/ server.ts # authorization server + BFF + resource server, one process identity.ts # sign-up/sign-in/reset/verify flows + console "mailer" app/ root.tsx # html shell, seeded from the load context context.ts # the typed router context the server provides routes.ts # layout, home, login, signup, dashboard, verify-email, # forgot-password, reset-password routes/ # one module per page, each with a colocated *.test.tsx lib/identity.ts # posts the login/signup forms to /identity/* oauth2/browser-client.ts # browser-safe BffClient for the React adapter test/ # shared test setup, MSW server, route-stub renderer, server-build stub ``` ### How a sign-in flows 1. A signed-out request for `/dashboard` hits the route's `loader`, which redirects (a document redirect, so the browser leaves React Router) to the BFF's `/auth/login?return_to=/dashboard` before any HTML is sent. 2. The BFF redirects to the authorization server's `/oauth2/authorize`; with no issuer session, that bounces to the `/login` page carrying the in-flight authorize URL as `return_to`. 3. The login form posts to `/identity/signin`. On success the server opens the issuer session and redirects into `bff.loginContinuation(returnTo)`, which resumes the authorize URL. 4. The authorize endpoint issues a code to the BFF's `/auth/callback`, which exchanges it in-process and stores the tokens in the server-side session — the browser gets only the HttpOnly `oauth2_session` cookie and lands back on `/dashboard`. An authorize request with `prompt=login` or `max_age` asks for a fresh sign-in. The authorize handler in `server/auth.ts` reports when the issuer session signed in as `auth_time`. It reports `reauthenticated: true` only when the session signed in for this exact authorize URL within the last ten minutes, and otherwise sends the user to `/login` again. Signing out via the header button hits `/auth/logout`, which revokes the refresh token and clears both sessions. The loader is what protects the page. A rendering guard such as `` is for client-rendered apps; here the check runs on the server, and `/api/*` is guarded separately by `bff.protect()`. ## Seed & configuration **Seeded account** (in-memory, recreated on every restart; **skipped when `APP_ENV=production`** so no known credential ships to a live deploy): | Email | Password | Notes | | ------------------ | ---------- | -------------------------------- | | `demo@example.com` | `password` | Email already marked as verified | **Email delivery is the server console.** Sign-up prints the verification link (`/verify-email?token=…`) to the terminal running the server, and **Forgot your password?** on the sign-in page prints a reset link (`/reset-password?token=…`) the same way — both links land on real pages that consume the token. Swap the two hooks in `server/oauth2/identity.ts` for your mailer. **Environment variables** (all optional; see `.env.example`, loaded with `--env-file-if-exists` when a `.env` exists). `server/config.ts` validates them once at startup and lists every invalid variable at once: | Variable | Default | Purpose | | ---------------------- | ----------------------- | --------------------------------------------------------------------------------------------------------------------- | | `ORIGIN` | `http://localhost:8000` | Public origin the app is served from. Issuer, redirect URIs, and emailed links derive from it; https ⇒ Secure cookies | | `PORT` | `8000` | Port `pnpm start` listens on. `pnpm dev` uses the port in `vite.config.ts` | | `APP_ENV` | `development` | `production` skips seeding the demo user. Independent of `NODE_ENV` | | `OAUTH2_CLIENT_SECRET` | `dev-only-secret` | Secret for the app's OAuth2 client. **Required** when `APP_ENV=production` (no fallback) | Everything is hermetic: in-memory stores, no database, no external services. ## Going to production The demo shortcuts to replace before shipping: - Swap the `Memory*Service` stores (`server/oauth2/server.ts`) and the in-memory session `Map` (`server/sessions.ts`) for persistent implementations — `@udibo/oauth2/testing/contract` has conformance tests for yours. - **The BFF session store.** `server/oauth2/server.ts` passes `HonoBff` a `MemorySessionStore`, separate from the issuer's login `Map` in `sessions.ts`, so a restart signs everyone out and a second instance does not know the sessions the first holds. Replace it with a shared stateful store, or `EncryptedCookieSessionStore` when its [revocation and cookie limits](https://github.com/udibo/oauth2/blob/main/docs/guides/production-deployment.md#cookies-and-sessions) suit your app. - **Pending browser login.** The BFF's `authRequestStorage` is a `MemoryAuthRequestStorageFactory`, which keeps each pending `state` and PKCE verifier in this process, so a callback that reaches another instance fails. Replace it with `EncryptedCookieAuthRequestStorage` or a factory over shared storage. See [environment configuration](https://github.com/udibo/oauth2/blob/main/docs/guides/deploy-across-environments.md) for both options. - Set `OAUTH2_CLIENT_SECRET` — the app refuses to start in production without it, since the development fallback is public in the template source. - Wire a real mailer into `server/oauth2/identity.ts`, and add rate limiting on the identity endpoints (`IdentityService` accepts a `rateLimiter`). `RateLimiter` and `AccountLockout` require a `store`: give them a `RateLimitStore` and a `LockoutStore` that every instance shares, or the memory stores for a single process only. - Keep request logs free of credentials. `server/index.ts` turns off `react-router-hono-server`'s default logger, which prints each raw query string — reset and verification tokens, callback codes — and mounts `requestLogger()` from `@udibo/oauth2/hono/log`, which logs every query value as `[redacted]`. If you switch to your own logger, record the target from `redactedRequestTarget(c.req.url)` instead of the raw URL. - Serve over HTTPS (cookies become `Secure` automatically via `ORIGIN`). - Decide how long a sign-in lasts. `sessionMaxAgeMs` in `server/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. - Pages that depend on the session send `Cache-Control: private, no-store` (the root route's `headers` export). Keep that on any page you render from the session.