Skip to content

app-with-own-auth (Juniper)

A SaaS app that owns its identity layer — it issues, protects, and serves its own SPA — built with Juniper (SSR React on Deno) and @udibo/oauth2.

This is the React/SSR counterpart to examples/hono/app-with-own-auth. It's the first example to exercise the React adapter (OAuth2Provider, useOAuth2, RequireAuth) against the BFF, so it doubles as the template a real Juniper dashboard follows.

What it demonstrates

  • BFF (Backend-For-Frontend). The browser never holds a token. The React SPA authenticates through the BFF's /auth/* endpoints behind an HttpOnly session cookie. Token exchange and refresh happen in-process via localAuthServerFetch — a browser → API call costs one inbound request and zero outbound.

  • One process, three roles. An embedded authorization server (the IDP), the BFF, and a resource server all run together (oauth2/server.ts). The resource server shares the auth server's token service, so bearer tokens validate in-process with no introspection round-trip.

  • No consent handler needed. The SPA is this IDP's own first-party client, so the authorize route configures no handleConsent — with no handler the framework grants without a prompt. Per-user limits still apply: ScopedTokenService narrows every issued token to the signed-in user's maxScope, so the regular user gets 403 from the admin-scoped endpoint while admin succeeds.

  • The React adapter. routes/main.tsx wraps the app in <OAuth2Provider>; routes/index.tsx uses useOAuth2() for { isAuthenticated, user, login, logout } to gate the protected section (so the landing page stays viewable when signed out), and wraps that section in <RequireAuth> as a belt-and-suspenders demo — <RequireAuth> is the tool for a standalone protected route, where it auto-redirects to login when signed out.

Layout

Text
oauth2/
  server.ts          # server-only: authorization server + BFF + resource server
  browser-client.ts  # browser-safe: the BffClient for the provider
sessions.ts          # the IDP login session (separate from the BFF token session)
html.ts              # escape helper for the server-rendered login page
routes/
  main.ts            # root server middleware (logging)
  main.tsx           # root layout — <OAuth2Provider> wraps the app
  index.tsx          # the SPA: useOAuth2 + RequireAuth + scope-gated API calls
  login.ts           # IDP login form (server-rendered HTML)
  logout.ts          # IDP sign-out (clears the IDP session)
  oauth2/main.ts     # the authorization server's endpoints  -> /oauth2/*
  auth/main.ts       # the BFF's browser endpoints           -> /auth/*
  api/main.ts        # protected resources (bff.protect)      -> /api/*
main.ts, main.tsx    # auto-generated by `deno task build`

Run it

Shell
deno task dev      # http://localhost:8004  (or: deno task build && deno task serve)

Click Sign in, log in as admin / password (or user / password), then try the three API buttons. As user, GET /api/admin returns 403.

The demo client also registers a second redirect URI (http://localhost:8005/auth/callback) so the sibling examples/juniper/app-with-external-auth (port 8005) can pair with this server as its external IDP for local development.

Eliminating the first-paint flash (SSR initialState)

This example uses the provider's on-mount session probe for simplicity, so the first render is briefly unauthenticated. To render the authenticated shell on the server instead, resolve the session in a server loader and pass it as <OAuth2Provider initialState={...}>. The Udibo dashboard does this; the BFF's sessionHandler() (used internally by /auth/session) is the building block.

OAuth2Provider re-syncs to a changed initialState (keyed on the authenticated/user identity), so in an SSR+SPA app where the root loader re-runs on client-side navigations — e.g. landing on / after a BFF login whose callback completes server-side — the provider reflects the updated server-resolved state without a full page reload. (With a BffClient there's no client-side authenticated event, so this re-sync is what flips the UI to signed-in post-login.)

Testing

The root middleware uses requestLogger() from @udibo/oauth2/hono/log. Both request and response lines redact every query value, including callback codes and state. Proxy and tracing logs need their own redaction configuration.

main.test.ts runs entirely in-process (server.request(...), no sockets, no Docker). Protected-endpoint tests skip the browser redirect flow by minting a token into the shared token service and injecting a BFF session with createTestSession from @udibo/oauth2/hono/bff/testing.

Shell
deno task test
deno task check