An example implementation of @jmondi/oauth2-server using a Hono server and a SvelteKit client. It wires the package into a realistic app — a full authorization-code + PKCE flow with real user consent, OpenID Connect, token refresh and revocation, and a browser client that consumes it. The goal is a blueprint you can read end to end, not a "hello world".
Note
This repo targets @jmondi/oauth2-server v5 (currently 5.0.0-rc.2), which is what enables the Fetch vanilla adapter and the OIDC endpoints used here. npm latest is still v4, so the v5-only APIs in this example are expected.
- Authorization Code + PKCE — S256 is mandatory; the seeded clients are public (no secret).
- A real consent step —
GET /authorizenever auto-approves; the consent form honors both accept and deny. - OpenID Connect —
id_token(RS256) on the code flow, plus discovery, JWKS, and userinfo endpoints. - Refresh & revocation — a refresh-token grant and an RFC 7009 revoke endpoint.
- Server-rendered auth UI — login + consent forms in Hono JSX, behind Origin-based CSRF.
- Fetch-native — Hono's
Request/Responsebridged to the package via thevanillaadapter. - Browser SPA client — SvelteKit (Svelte 5) holding the access token in memory only.
- Server — Hono on Node (
@hono/node-server), listening on port3000with all routes under the/apiprefix. - Database — PostgreSQL via Drizzle ORM (postgres.js driver).
- Views — server-rendered login + consent forms using Hono JSX.
- Tests — Vitest integration suite running against a real Postgres test database.
- Client — SvelteKit (Svelte 5) app in
web/.
The OAuth2 HTTP endpoints bridge Hono's Fetch Request/Response to the package via the @jmondi/oauth2-server/vanilla adapter (requestFromVanilla / responseToVanilla / handleVanillaError).
| Route | Purpose |
|---|---|
POST /api/oauth2/token |
token endpoint (authorization_code, refresh_token) |
POST /api/oauth2/revoke |
token revocation |
GET /api/oauth2/authorize |
starts the flow; redirects to login or consent (never auto-approves) |
GET/POST /api/login |
server-rendered login form + session cookie |
GET/POST /api/scopes |
server-rendered consent form; POST completes or denies the request |
POST /api/logout |
clears the session cookie |
GET/POST /api/oauth2/userinfo |
OIDC userinfo (bearer-authenticated, scope-filtered) |
GET /.well-known/openid-configuration |
OIDC discovery document |
GET /.well-known/jwks.json |
public signing key (JWKS) |
OIDC is enabled on the authorization-code flow. Requesting the openid scope adds an id_token (RS256) to the token response. OIDC tokens are signed with an RSA key from OIDC_PRIVATE_KEY (or an ephemeral key generated at boot if unset — handy for dev, but tokens won't survive a restart). The seeded OIDC Demo Client is granted openid, email, and profile.
Prerequisites: Node.js >= 22, pnpm (npm i -g pnpm), and Docker for Postgres.
cp -n .env.example .env # the defaults already match the bundled docker-compose
pnpm install
cd web && pnpm install && cd .. # the web client is a standalone pnpm project
docker compose up -d # Postgres on localhost:8888
pnpm db:migrate
pnpm db:seedThen run both processes. The simplest path is two terminals:
pnpm dev # server on http://localhost:3000 (tsx watch)
cd web && pnpm dev # client on http://localhost:5173Or run both at once with a Procfile manager — Overmind (brew install overmind) or Foreman (gem install foreman):
overmind start # or: foreman startpnpm db:seed creates:
- User —
jason@example.com/password123 - Sample Client (public, PKCE) —
0e2ec2df-ee53-4327-a472-9d78c278bdbb, scopescontacts.read contacts.write - OIDC Demo Client (public, PKCE) —
9b8c7d6e-5f40-4a3b-8c2d-1e0f9a8b7c6d, scopesopenid email profile
Both clients are public (no secret) with redirect URI http://localhost:5173/callback, so PKCE (S256) is mandatory.
In the browser: start both servers, open the client at http://localhost:5173/login, sign in with the seeded user, approve the consent screen, and you'll land on the callback with tokens.
By hand with curl (the OIDC Demo Client, end to end). The login/consent forms are browser routes protected by Origin-based CSRF, so we use a cookie jar and send a matching Origin header:
CLIENT_ID=9b8c7d6e-5f40-4a3b-8c2d-1e0f9a8b7c6d
REDIRECT=http://localhost:5173/callback
JAR=$(mktemp)
# 1. PKCE: generate a verifier and its S256 challenge
VERIFIER=$(openssl rand -hex 32)
CHALLENGE=$(printf '%s' "$VERIFIER" | openssl dgst -binary -sha256 | openssl base64 | tr '+/' '-_' | tr -d '=')
STATE=$(openssl rand -hex 8)
QUERY="response_type=code&client_id=$CLIENT_ID&redirect_uri=$REDIRECT&scope=openid%20email%20profile&state=$STATE&code_challenge=$CHALLENGE&code_challenge_method=S256"
# 2. Log in — sets the "jid" session cookie (302 back to /authorize)
curl -s -c "$JAR" -H "Origin: http://localhost:3000" \
--data-urlencode "email=jason@example.com" --data-urlencode "password=password123" \
"http://localhost:3000/api/login?$QUERY" -o /dev/null
# 3. Consent — approve, and the server redirects to the callback with ?code=...
LOCATION=$(curl -s -b "$JAR" -H "Origin: http://localhost:3000" \
--data-urlencode "accept=yes" \
"http://localhost:3000/api/scopes?$QUERY" -o /dev/null -w '%{redirect_url}')
CODE=$(printf '%s' "$LOCATION" | sed -n 's/.*[?&]code=\([^&]*\).*/\1/p')
# (sending accept=no instead yields ...callback?error=access_denied — consent is real)
# 4. Exchange the code (+ the original verifier) for tokens
TOKENS=$(curl -s http://localhost:3000/api/oauth2/token \
--data-urlencode grant_type=authorization_code \
--data-urlencode "client_id=$CLIENT_ID" \
--data-urlencode "redirect_uri=$REDIRECT" \
--data-urlencode "code=$CODE" \
--data-urlencode "code_verifier=$VERIFIER")
echo "$TOKENS" # { access_token, refresh_token, id_token, token_type, expires_in, scope }
# 5. Call userinfo with the access token
ACCESS=$(printf '%s' "$TOKENS" | node -e 'console.log(JSON.parse(require("fs").readFileSync(0)).access_token)')
curl -s http://localhost:3000/api/oauth2/userinfo -H "Authorization: Bearer $ACCESS"
# -> {"email":"jason@example.com","name":"Jason Example","sub":"..."}
# 6. Refresh
REFRESH=$(printf '%s' "$TOKENS" | node -e 'console.log(JSON.parse(require("fs").readFileSync(0)).refresh_token)')
curl -s http://localhost:3000/api/oauth2/token \
--data-urlencode grant_type=refresh_token \
--data-urlencode "client_id=$CLIENT_ID" \
--data-urlencode "refresh_token=$REFRESH"GET /api/oauth2/authorize validates the request and then redirects to /api/login (no session) or /api/scopes (session present) — it never auto-approves. POST /api/scopes reads the user's decision: accept=yes calls completeAuthorizationRequest and issues a code; anything else bounces back to the client's redirect_uri with error=access_denied. This is the consent step a real authorization server must implement, and it's the part most "hello world" examples skip.
The SvelteKit client in web/ demonstrates a public client: it generates a PKCE verifier/challenge and a state (full-entropy, via crypto.getRandomValues), keeps them in sessionStorage across the redirect, verifies state on the callback, and sends no client_secret.
To keep the access token out of script-readable storage, it is held in memory and is intentionally cleared on page reload (use the refresh button to mint a new one). See the security caveat below.
Warning
This repo optimizes for being readable and runnable on localhost. Don't ship it as-is — at least change the following:
- Session cookie
Secure— gated toNODE_ENV === "production"here so the demo works over plainhttp://localhost. Production must serve over HTTPS withSecureon. SESSION_SECRET— the browser session cookie (jid) is an HS256 JWT signed with a secret that is deliberately separate from the OIDC RSA key (different trust domains). A hardcoded insecure default is used if unset (with a warning) — set a long randomSESSION_SECRETin production.OIDC_PRIVATE_KEY— set a stable PEM so issued tokens survive restarts; otherwise an ephemeral key is generated at boot.- Browser token storage — the SPA stores the refresh token in a JS-readable cookie for demo visibility only. That is an XSS → token-theft (account-takeover) vector. A production browser app should not hold the refresh token at all; use a Backend-for-Frontend (BFF) that keeps it server-side behind a
Secure; HttpOnly; SameSitecookie. This is called out inweb/src/lib/browser_storage.ts. - Trusted proxy —
lastLoginIPis read fromX-Forwarded-For, which is client-spoofable unless a trusted reverse proxy sets it. - Consent persistence — this demo asks for consent on every authorization; a real OP typically remembers prior grants per user+client.
Important
The RFC 7009 revoke endpoint force-expires both access and refresh tokens, but authenticateRevoke defaults to true, so the request must authenticate the client (client_id, plus client_secret for a confidential client). An unauthenticated revoke returns a silent 200 and revokes nothing — RFC 7009 returns 200 even for invalid tokens, so a failed revoke is indistinguishable from a successful one. The suite asserts revocation without the endpoint: by force-expiring the stored row directly, and through the /userinfo guard (getByAccessToken + isAccessTokenRevoked).
pnpm test # Vitest integration suite against a real Postgres "oauth_test" dbThe suite covers the auth-code + PKCE happy path, refresh, revocation and userinfo, OIDC claims, the consent accept/deny branches, PKCE negatives (missing challenge/verifier, plain rejected), redirect_uri mismatch, and login hardening (unknown-email and null-hash both return a generic 401). It runs serially against one shared database with between-test truncation.