Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 11 additions & 1 deletion .env.example
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
# Better Auth
# Generate a secret with: openssl rand -base64 32
BETTER_AUTH_SECRET=
BETTER_AUTH_URL=http://localhost:3001

Expand All @@ -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":[<jwk-with-d>]}
# 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
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -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
41 changes: 31 additions & 10 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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.
Expand All @@ -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=<your-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.
Expand Down
Empty file added data/.gitkeep
Empty file.
33 changes: 25 additions & 8 deletions scripts/smoke-e2e.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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, "_");
}
Expand Down Expand Up @@ -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)");
Expand Down Expand Up @@ -170,20 +180,25 @@ 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");
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");
Expand All @@ -195,13 +210,15 @@ 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
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");