From b6432523806d64588c713d45c33b727b46ac1003 Mon Sep 17 00:00:00 2001 From: Shaikat Haque Date: Mon, 13 Jul 2026 21:39:23 -0400 Subject: [PATCH 1/3] chore: complete .env.example with key generation instructions - Add generation command for BETTER_AUTH_SECRET - Add a jose one-liner to generate the ES256 signing JWKS - Document the AS_JWKS_URI default Co-Authored-By: Claude Fable 5 --- .env.example | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/.env.example b/.env.example index 4f6ca04..e14fc75 100644 --- a/.env.example +++ b/.env.example @@ -1,4 +1,5 @@ # Better Auth +# Generate a secret with: openssl rand -base64 32 BETTER_AUTH_SECRET= BETTER_AUTH_URL=http://localhost:3001 @@ -18,11 +19,13 @@ NODE_ENV=development AUTH_UI_ISSUER_ID= AS_ISSUER_ID= # Where to fetch the AS's published JWKS for verifying inbound JWTs. +# Defaults to AS_BASE_URL/oauth/jwks if unset. AS_JWKS_URI= # Channel mode: backchannel (server-to-server, production) | frontchannel (dev only) INTERACTION_CHANNEL=backchannel # auth-ui's full private JWKS (JSON; published with private fields stripped at /.well-known/jwks.json). -# Generate any ES256 keypair, then format as: {"keys":[]} +# Generate one with: +# node --input-type=module -e "import {generateKeyPair, exportJWK, calculateJwkThumbprint} from 'jose'; const {privateKey} = await generateKeyPair('ES256', {extractable: true}); const jwk = await exportJWK(privateKey); jwk.kid = await calculateJwkThumbprint(jwk); jwk.alg = 'ES256'; console.log(JSON.stringify({keys: [jwk]}))" AUTH_UI_JWKS= # Optional explicit signing-key kid. If unset, the resolver picks the only key, then by alg, then first. AUTH_UI_SIGNING_KID= From 649b40a0a6e2e79dc7e3bb4b0885285ed5be021e Mon Sep 17 00:00:00 2001 From: Shaikat Haque Date: Mon, 13 Jul 2026 21:46:23 -0400 Subject: [PATCH 2/3] docs: fix README setup instructions - npm to pnpm; correct migrate command (better-auth, not @better-auth/cli) with the inline env vars it needs - Add Prerequisites section pointing at the AS README provisioning - Track data/ via .gitkeep so the default SQLITE_DB_PATH resolves on a fresh clone - Expand the smoke test section: what it covers and what it bypasses - Unify naming to Interaction Protocol - Ignore CLAUDE.md (local agent notes) Co-Authored-By: Claude Fable 5 --- .gitignore | 3 +++ README.md | 39 +++++++++++++++++++++++++++++---------- data/.gitkeep | 0 3 files changed, 32 insertions(+), 10 deletions(-) create mode 100644 data/.gitkeep diff --git a/.gitignore b/.gitignore index d1c8d4d..c4ac6bd 100644 --- a/.gitignore +++ b/.gitignore @@ -9,3 +9,6 @@ data/*.sqlite-journal .DS_Store next-env.d.ts tsconfig.tsbuildinfo + +# Local agent notes, not part of the reference implementation +CLAUDE.md diff --git a/README.md b/README.md index 4b99305..f43046c 100644 --- a/README.md +++ b/README.md @@ -12,7 +12,7 @@ This app ships **unbranded** — the default look is a neutral reference UI, mea - **Rebrand from one file.** [`src/brand/brand.ts`](src/brand/brand.ts) is the single source of truth for product name, logo, font, colors, and sign-in panel copy. Colors flow into CSS variables; copy and assets are read from the active brand. No brand value is hardcoded elsewhere. - **Drop in your logo.** Set `logoMark` to an image under `/public/brand`, or keep the neutral built-in mark. -- **Or replace the UI entirely.** Any app that speaks the same component protocol to the AS can stand in for this one — this repo is a working reference, not a requirement. +- **Or replace the UI entirely.** Any app that speaks the same Interaction Protocol to the AS can stand in for this one — this repo is a working reference, not a requirement. ## The pattern: Externalized Login & Consent @@ -30,7 +30,7 @@ This app ships **unbranded** — the default look is a neutral reference UI, mea ### State and protocol - The **AS holds no per-transaction state**; the browser carries only an opaque **authorization id**. -- **auth-ui** holds the user session (Better Auth), not the OAuth transaction. It talks to the AS over a small component protocol authenticated by **per-request mutual JWT** (no bearer tokens): +- **auth-ui** holds the user session (Better Auth), not the OAuth transaction. It talks to the AS over the **Interaction Protocol** (specified in `INTERACTION_PROTOCOL.md` in the `typescript-oauth-server` repo), authenticated by **per-request mutual JWT** (no bearer tokens): - `GET /api/authorizations/{id}` — fetch the in-flight authorization. - `POST /api/authorizations/{id}/decision` — submit the user's approve/deny decision. - `GET /api/users/{id}` (on auth-ui) — the AS calls back to resolve user claims. @@ -50,29 +50,48 @@ This separation matches the architecture Authlete is designed around: the engine - Sign-in / sign-up / forgot-password (Better Auth — email + password today). - Consent surface for an in-flight AS authorization (`/authorizations/[id]`). - Account self-service: `/settings/account`, `/settings/security` via `better-auth-ui`. -- Server-to-server client of the AS's component protocol (`src/lib/as-client.ts`). +- Server-to-server client of the AS's Interaction Protocol (`src/lib/as-client.ts`). - Server actions that bridge user decisions back to the AS (`src/server/authorization-actions.ts`). - End-to-end smoke harness (`scripts/smoke-e2e.mjs`). +## Prerequisites + +- An **Authlete 3.0 service**, provisioned as described in the `typescript-oauth-server` README (service JWK Set registered, test RP client created). +- The AS running and reachable at `AS_BASE_URL` (default `http://localhost:3000`). + ## Run locally ```bash -npm install +pnpm install cp .env.example .env -# Fill in BETTER_AUTH_SECRET (32+ chars): openssl rand -base64 32 -# Fill in AS_BASE_URL, AS_JWKS_URI, AUTH_UI_JWKS — see .env.example for the full set -npx @better-auth/cli migrate # create the SQLite schema at SQLITE_DB_PATH (./data ships in the repo) -npm run dev +# Fill in BETTER_AUTH_SECRET, AS_BASE_URL, AUTH_UI_JWKS — see .env.example +# for the full set and key generation commands. +``` + +Create the SQLite schema once before first run. The migrate CLI does not read `.env`, so pass the vars inline: + +```bash +BETTER_AUTH_SECRET= BETTER_AUTH_URL=http://localhost:3001 pnpm exec better-auth migrate -y ``` -Server boots at `http://localhost:3001`. The AS must be reachable at `AS_BASE_URL`. +Then start the dev server: -Run the end-to-end smoke against a running AS: +```bash +pnpm dev +``` + +Server boots at `http://localhost:3001`. + +## End-to-end smoke test + +`scripts/smoke-e2e.mjs` drives the full flow against a running AS and auth-ui: authorize → login → decision → code → token → userinfo → introspect → revoke. ```bash node --env-file=.env scripts/smoke-e2e.mjs ``` +Expect `E2E COMPLETE` at the end. Note the script exercises the protocol, not the UI: it signs up a user via API and signs the decision JWT itself instead of clicking through the login and consent screens. + ## Roadmap Authentication and consent grow here; the AS does not change for these. diff --git a/data/.gitkeep b/data/.gitkeep new file mode 100644 index 0000000..e69de29 From c75523ea7eb8d9c84f21b163b450a23f302d2166 Mon Sep 17 00:00:00 2001 From: Shaikat Haque Date: Mon, 13 Jul 2026 22:02:38 -0400 Subject: [PATCH 3/3] fix: make smoke test portable and actually assert failures - RP_CLIENT_ID is now a required env var instead of a hardcoded client id; RP_REDIRECT_URI is overridable - Fail with non-zero exit on state mismatch, wrong userinfo claims, inactive introspection, failed revocation, and a revoked token that is still accepted (all previously printed but never failed) - Document why the introspection Basic creds are placeholders Verified: full run prints E2E COMPLETE with exit 0; missing RP_CLIENT_ID exits 1 with a clear error. Co-Authored-By: Claude Fable 5 --- .env.example | 7 +++++++ README.md | 2 ++ scripts/smoke-e2e.mjs | 33 +++++++++++++++++++++++++-------- 3 files changed, 34 insertions(+), 8 deletions(-) diff --git a/.env.example b/.env.example index e14fc75..63f8ffc 100644 --- a/.env.example +++ b/.env.example @@ -29,3 +29,10 @@ INTERACTION_CHANNEL=backchannel AUTH_UI_JWKS= # Optional explicit signing-key kid. If unset, the resolver picks the only key, then by alg, then first. AUTH_UI_SIGNING_KID= + +# End-to-end smoke test (scripts/smoke-e2e.mjs) +# Client id of the test RP registered on the Authlete service — see the +# typescript-oauth-server README, "Provisioning". +RP_CLIENT_ID= +# Must match the redirect URI registered on the test client. +RP_REDIRECT_URI=http://localhost:4040 diff --git a/README.md b/README.md index f43046c..6547689 100644 --- a/README.md +++ b/README.md @@ -86,6 +86,8 @@ Server boots at `http://localhost:3001`. `scripts/smoke-e2e.mjs` drives the full flow against a running AS and auth-ui: authorize → login → decision → code → token → userinfo → introspect → revoke. +Set `RP_CLIENT_ID` in `.env` to the test client registered on your Authlete service, then: + ```bash node --env-file=.env scripts/smoke-e2e.mjs ``` diff --git a/scripts/smoke-e2e.mjs b/scripts/smoke-e2e.mjs index 913e2fd..fd92bf4 100644 --- a/scripts/smoke-e2e.mjs +++ b/scripts/smoke-e2e.mjs @@ -9,6 +9,9 @@ * * Usage: * node --env-file=.env scripts/smoke-e2e.mjs + * + * Requires RP_CLIENT_ID in the env — the test RP client registered on the + * Authlete service (see .env.example). */ import { SignJWT, importJWK } from "jose"; @@ -21,19 +24,24 @@ const AUTH_UI_ISSUER_ID = process.env.AUTH_UI_ISSUER_ID || AUTH_UI_BASE_URL; const AUTH_UI_JWKS = JSON.parse(required("AUTH_UI_JWKS")); const SIGNING_JWK = AUTH_UI_JWKS.keys[0]; -const RP_CLIENT_ID = "2234376661"; -const RP_REDIRECT_URI = "http://localhost:4040"; +const RP_CLIENT_ID = required("RP_CLIENT_ID"); +const RP_REDIRECT_URI = process.env.RP_REDIRECT_URI || "http://localhost:4040"; const RP_SCOPE = "openid profile email"; function required(name) { const v = process.env[name]; if (!v) { - console.error(`Missing env var: ${name}`); + console.error(`Missing env var: ${name} (see .env.example)`); process.exit(1); } return v; } +function fail(label, detail = "") { + console.error(` ❌ ${label}${detail ? `: ${detail}` : ""}`); + process.exit(1); +} + function b64url(buf) { return buf.toString("base64").replace(/=/g, "").replace(/\+/g, "-").replace(/\//g, "_"); } @@ -140,8 +148,10 @@ const parsedRp = new URL(rpUrl); const code = parsedRp.searchParams.get("code"); const returnedState = parsedRp.searchParams.get("state"); ok("RP URL", rpUrl); -ok("code", code?.slice(0, 16) + "…"); -ok("state matches", returnedState === state ? "yes" : "NO"); +if (!code) fail("no code in RP redirect", rpUrl); +if (returnedState !== state) fail("state mismatch", `sent ${state}, got ${returnedState}`); +ok("code", code.slice(0, 16) + "…"); +ok("state matches"); // 5) RP exchanges code for tokens step(5, "RP /token exchange (PKCE)"); @@ -170,8 +180,8 @@ const ui = await fetch(`${AS_BASE_URL}/oauth/userinfo`, { headers: { authorization: `Bearer ${codeExchange.access_token}` }, }).then((r) => r.json()); console.log(" Response body:", JSON.stringify(ui, null, 2)); -const ok6 = ui.sub === userId && ui.email === email; -ok6 ? ok("live claims round-tripped end-to-end") : console.error(" ❌ claims wrong or missing"); +if (ui.sub !== userId || ui.email !== email) fail("claims wrong or missing"); +ok("live claims round-tripped end-to-end"); // 7) /introspect step(7, "RS introspects access_token via /oauth/introspect"); @@ -179,11 +189,16 @@ const introspectRes = await fetch(`${AS_BASE_URL}/oauth/introspect`, { method: "POST", headers: { "content-type": "application/x-www-form-urlencoded", + // The reference AS's introspection auth is a stub: it only checks that a + // Basic Authorization header is present (see typescript-oauth-server + // src/routes/introspect.ts), so any credentials work here. authorization: `Basic ${Buffer.from("rs:placeholder").toString("base64")}`, }, body: new URLSearchParams({ token: codeExchange.access_token }), }).then((r) => r.json()); console.log(" Introspection:", JSON.stringify(introspectRes, null, 2)); +if (introspectRes.active !== true) fail("introspection did not report the token active"); +ok("token reported active"); // 8) /revoke step(8, "RP revokes the access_token"); @@ -195,6 +210,7 @@ const revokeRes = await fetch(`${AS_BASE_URL}/oauth/revoke`, { client_id: RP_CLIENT_ID, }), }); +if (!revokeRes.ok) fail("revocation failed", String(revokeRes.status)); ok("revocation status", String(revokeRes.status)); // 9) Verify revoked token rejected @@ -202,6 +218,7 @@ step(9, "Confirm revoked token is rejected by /userinfo"); const ui2 = await fetch(`${AS_BASE_URL}/oauth/userinfo`, { headers: { authorization: `Bearer ${codeExchange.access_token}` }, }); -ok("status after revoke", String(ui2.status) + " (expecting 401)"); +if (ui2.status !== 401) fail("revoked token still accepted", `status ${ui2.status}`); +ok("revoked token rejected", "401"); console.log("\n══════════════════ E2E COMPLETE ══════════════════\n");