diff --git a/.env.example b/.env.example index 4f6ca04..63f8ffc 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,20 @@ 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= + +# 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/.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..6547689 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,50 @@ 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. ``` -Server boots at `http://localhost:3001`. The AS must be reachable at `AS_BASE_URL`. +Create the SQLite schema once before first run. The migrate CLI does not read `.env`, so pass the vars inline: -Run the end-to-end smoke against a running AS: +```bash +BETTER_AUTH_SECRET= BETTER_AUTH_URL=http://localhost:3001 pnpm exec better-auth migrate -y +``` + +Then start the dev server: + +```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. + +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 ``` +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 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");