Built-in OAuth2 consent-challenge handler (todo §6); /oauth2/consent grants scopes to a client logging in through us. New src/oauth-consent.ts (pure, sibling of oauth-login.ts): resolveConsentChallenge auto-accepts a first-party client (Hydra metadata.first_party===true) or a Hydra-skipped one, else returns a view to show the themed consent screen; acceptConsent re-reads the challenge so scopes/audience are never client-supplied; rejectConsent → access_denied. The grant carries an OIDC session.id_token with email/name projected from the Kratos identity (whoami traits, omitted when absent). src/hydra-admin.ts gains the consent half (get/accept/reject consent + types; login/consent URL builder folded into one reqUrl(kind,…) + shared put()). Wired in app.ts at GET|POST /oauth2/consent (gated on hydra+kratos): GET shows/auto-accepts (sets the CSRF cookie when fresh), POST is CSRF-guarded (same signed double-submit as /logout) and dispatches allow→accept / else→reject → 303 to Hydra; a stale/consumed challenge (Hydra 4xx) degrades to a recoverable 400, a real outage (5xx) → 500 (mirrors /oauth2/login). views/oauth-consent.ejs + partials/consent-body.ejs reuse the auth-card, listing the requested scopes (friendly labels for the standard OIDC ones) with Allow/Deny submit buttons. Tests-first: hydra-admin consent contracts + oauth-consent skip/first-party/third-party/audience/id_token/refetch/reject matrix + app HTTP integration (auto-accept / screen+CSRF cookie / allow+deny / forged-CSRF→403 / missing→400 / stale→400 / outage→500). Stability-reviewer run as a local PR: APPROVE, no Critical/High. Extended e2e/oauth-login.spec.ts to drive the whole authorization-code flow against real Hydra — login accept → follow login_verifier through Hydra → web's consent screen (third-party e2e-login, scopes listed) → Allow → consent_verifier → client callback with a real code (per-host cookie jars, Hydra resume URLs rebased onto the compose host). typecheck + 262 units + 8 visual + OAuth login+consent E2E green. OAuth2 client registration is the next §6 item.
This commit is contained in:
21
README.md
21
README.md
@@ -241,10 +241,11 @@ docker compose -f compose.yml -f compose.e2e-auth.yml run --build --rm e2e # r
|
|||||||
docker compose -f compose.yml -f compose.e2e-auth.yml down -v # tear down after
|
docker compose -f compose.yml -f compose.e2e-auth.yml down -v # tear down after
|
||||||
```
|
```
|
||||||
|
|
||||||
**OAuth2 login challenge** (`oauth-login.spec.ts`) — another app logs in *through* us: it boots the
|
**OAuth2 login + consent** (`oauth-login.spec.ts`) — another app logs in *through* us: it boots the
|
||||||
real stack (incl. Hydra), registers an OAuth2 client, starts an authorization flow, and proves the
|
real stack (incl. Hydra), registers an OAuth2 client, starts an authorization flow, and drives the
|
||||||
§6 `/oauth2/login` handler bounces an unauthenticated user to the themed login and **accepts** the
|
§6 handlers end-to-end — `/oauth2/login` bounces an unauthenticated user to the themed login and
|
||||||
challenge once a Kratos session exists.
|
**accepts** the challenge once a Kratos session exists; `/oauth2/consent` then shows the consent
|
||||||
|
screen for the third-party client and **Allow** drives Hydra to issue the authorization code.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker compose -f compose.yml -f compose.e2e-oauth.yml run --build --rm e2e # run the suite
|
docker compose -f compose.yml -f compose.e2e-oauth.yml run --build --rm e2e # run the suite
|
||||||
@@ -498,7 +499,10 @@ in the menu or first-party pages needs Hydra.
|
|||||||
The **login challenge** is wired (`src/oauth-login.ts` at `/oauth2/login`): Hydra hands
|
The **login challenge** is wired (`src/oauth-login.ts` at `/oauth2/login`): Hydra hands
|
||||||
the browser here, the app resolves it against the Kratos session and accepts (or bounces
|
the browser here, the app resolves it against the Kratos session and accepts (or bounces
|
||||||
an unauthenticated user to the themed login, returning here once signed in). The **consent
|
an unauthenticated user to the themed login, returning here once signed in). The **consent
|
||||||
challenge** is next.
|
challenge** is wired too (`src/oauth-consent.ts` at `/oauth2/consent`): a first-party client
|
||||||
|
(its Hydra `metadata.first_party: true`) — or one Hydra already skipped — is auto-granted the
|
||||||
|
requested scopes; any other client gets a themed consent screen whose CSRF-guarded Allow/Deny
|
||||||
|
accepts or rejects. id_token claims (email, name) come from the Kratos identity.
|
||||||
|
|
||||||
## Stateless — no application database
|
## Stateless — no application database
|
||||||
|
|
||||||
@@ -541,8 +545,9 @@ src/jwks.ts JwksProvider — resolve the verify key by kid; createJwksP
|
|||||||
src/kratos-public.ts createKratosPublic(): Kratos public-API fetch client — self-service flow init/get/submit, browser logout, whoami, session→JWT tokenize (§4)
|
src/kratos-public.ts createKratosPublic(): Kratos public-API fetch client — self-service flow init/get/submit, browser logout, whoami, session→JWT tokenize (§4)
|
||||||
src/kratos-admin.ts createKratosAdmin(): Kratos admin-API fetch client — identity CRUD + surgical metadata_public update (login role projection, §4)
|
src/kratos-admin.ts createKratosAdmin(): Kratos admin-API fetch client — identity CRUD + surgical metadata_public update (login role projection, §4)
|
||||||
src/keto-client.ts createKetoClient(): Keto fetch client — check / list / expand relations (read API) + write / delete tuples (write API) (§4)
|
src/keto-client.ts createKetoClient(): Keto fetch client — check / list / expand relations (read API) + write / delete tuples (write API) (§4)
|
||||||
src/hydra-admin.ts createHydraAdmin(): Hydra admin-API fetch client — OAuth2 login challenge get/accept/reject (§6)
|
src/hydra-admin.ts createHydraAdmin(): Hydra admin-API fetch client — OAuth2 login + consent challenge get/accept/reject (§6)
|
||||||
src/oauth-login.ts resolveLoginChallenge(): authenticate a Hydra login challenge via the Kratos session → accept, or bounce to /login (§6)
|
src/oauth-login.ts resolveLoginChallenge(): authenticate a Hydra login challenge via the Kratos session → accept, or bounce to /login (§6)
|
||||||
|
src/oauth-consent.ts resolveConsentChallenge()/acceptConsent()/rejectConsent(): auto-accept first-party, else show the consent screen → grant scopes (§6)
|
||||||
src/flow-view.ts buildFlowView(): Kratos self-service Flow → themed view model (fields, hidden csrf, buttons, tone-mapped messages) for views/auth.ejs (§4)
|
src/flow-view.ts buildFlowView(): Kratos self-service Flow → themed view model (fields, hidden csrf, buttons, tone-mapped messages) for views/auth.ejs (§4)
|
||||||
src/login.ts completeLogin()/remintSession(): login completion + TTL re-mint — roles from Keto → metadata_public projection → tokenize → session JWT cookie (§4)
|
src/login.ts completeLogin()/remintSession(): login completion + TTL re-mint — roles from Keto → metadata_public projection → tokenize → session JWT cookie (§4)
|
||||||
src/gen-jwks.ts generateJwks() + CLI: mint the ES256 session-tokenizer signing JWKS (§3); see JWT signing key & rotation
|
src/gen-jwks.ts generateJwks() + CLI: mint the ES256 session-tokenizer signing JWKS (§3); see JWT signing key & rotation
|
||||||
@@ -567,13 +572,13 @@ src/discovery.ts discoverPlugins(): scan plugins/, import + validate each pl
|
|||||||
src/router.ts matchRoute()/allowedMethods()/isAuthorized(): map method+path → plugin route, params, permission gate (§2)
|
src/router.ts matchRoute()/allowedMethods()/isAuthorized(): map method+path → plugin route, params, permission gate (§2)
|
||||||
src/view-resolver.ts renderPluginView(): render plugins/<id>/views/<view>.ejs; plugin views can include() core partials (§2)
|
src/view-resolver.ts renderPluginView(): render plugins/<id>/views/<view>.ejs; plugin views can include() core partials (§2)
|
||||||
src/menu-config.ts loadMenuConfig()/defineMenu(): read config/menu.ts (central override + branding), validated at boot (§2)
|
src/menu-config.ts loadMenuConfig()/defineMenu(): read config/menu.ts (central override + branding), validated at boot (§2)
|
||||||
views/ Core EJS templates: index (app-shell dashboard), admin/ (Users/Groups/Roles lists + create/edit/detail + delete-confirm), auth (themed Kratos flows), 403/404/500, partials/ (shell, nav tree, filter bar, data table, pagination, field, auth card, alert, flow + admin bodies, menu/popover, theme switch, icon sprite)
|
views/ Core EJS templates: index (app-shell dashboard), admin/ (Users/Groups/Roles lists + create/edit/detail + delete-confirm), auth (themed Kratos flows), oauth-consent (OAuth2 consent screen), 403/404/500, partials/ (shell, nav tree, filter bar, data table, pagination, field, auth card, alert, flow + consent + admin bodies, menu/popover, theme switch, icon sprite)
|
||||||
public/ Static assets under /public/ (css/styles.css + auth.css, favicon, robots.txt)
|
public/ Static assets under /public/ (css/styles.css + auth.css, favicon, robots.txt)
|
||||||
config/menu.ts Central menu override + branding (optional; defaults apply if absent)
|
config/menu.ts Central menu override + branding (optional; defaults apply if absent)
|
||||||
ory/ Ory service config (kratos/: identity schema, kratos.yml, oidc/ SSO claims mapper, tokenizer/ session→JWT claims mapper + dev signing JWKS; keto/: keto.yml + namespaces.keto.ts OPL — role/group/resource; hydra/hydra.yml: OAuth2 issuer + login/consent URLs → /oauth2/*) + storage init (postgres/init/init.sql: one DB per service)
|
ory/ Ory service config (kratos/: identity schema, kratos.yml, oidc/ SSO claims mapper, tokenizer/ session→JWT claims mapper + dev signing JWKS; keto/: keto.yml + namespaces.keto.ts OPL — role/group/resource; hydra/hydra.yml: OAuth2 issuer + login/consent URLs → /oauth2/*) + storage init (postgres/init/init.sql: one DB per service)
|
||||||
plugins/ Drop-in plugin folders (scanned at /app/plugins; bind-mount or bake in) (planned)
|
plugins/ Drop-in plugin folders (scanned at /app/plugins; bind-mount or bake in) (planned)
|
||||||
docs/ Reference docs (plugin-contract.md — the authoritative plugin API)
|
docs/ Reference docs (plugin-contract.md — the authoritative plugin API)
|
||||||
e2e/ Playwright E2E: visual.spec (design system, Ory-free) + auth-refresh.spec (token timeout/re-mint) + oauth-login.spec (OAuth2 login challenge), full Ory stack; Dockerfile.e2e + compose.e2e[-auth|-oauth].yml run them
|
e2e/ Playwright E2E: visual.spec (design system, Ory-free) + auth-refresh.spec (token timeout/re-mint) + oauth-login.spec (OAuth2 login + consent → authorization code), full Ory stack; Dockerfile.e2e + compose.e2e[-auth|-oauth].yml run them
|
||||||
html-css-foundation/ HTML design mockups — the source for the building-block
|
html-css-foundation/ HTML design mockups — the source for the building-block
|
||||||
partials; reference the stylesheets in public/css/.
|
partials; reference the stylesheets in public/css/.
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
import { expect, test } from "@playwright/test";
|
import { expect, test } from "@playwright/test";
|
||||||
|
|
||||||
// Full-stack OAuth2 login-challenge E2E (§6): another app logs in *through* plainpages. Hydra
|
// Full-stack OAuth2 login + consent E2E (§6): another app logs in *through* plainpages. Hydra
|
||||||
// starts an authorization flow and hands the browser to web's /oauth2/login; web resolves it via
|
// starts an authorization flow and hands the browser to web's /oauth2/login; web resolves it via
|
||||||
// the Kratos session and accepts (Hydra then continues to consent + token issuance). We drive the
|
// the Kratos session and accepts, Hydra continues to web's /oauth2/consent, web shows the themed
|
||||||
// flow over HTTP (fetch, manual cookies) because the browser hosts differ on the compose network;
|
// consent screen, and Allow drives Hydra to issue the authorization code. We drive the flow over
|
||||||
// this exercises web's server-side challenge handling. The browser-UI login is owned by §8.
|
// HTTP (fetch, per-host cookie jars) because the browser hosts differ on the compose network; this
|
||||||
|
// exercises web's server-side challenge handling. The browser-UI login is owned by §8.
|
||||||
const WEB = process.env.BASE_URL ?? "http://web:3000";
|
const WEB = process.env.BASE_URL ?? "http://web:3000";
|
||||||
const KRATOS = process.env.KRATOS_PUBLIC_URL ?? "http://kratos:4433";
|
const KRATOS = process.env.KRATOS_PUBLIC_URL ?? "http://kratos:4433";
|
||||||
const HYDRA_PUBLIC = process.env.HYDRA_PUBLIC_URL ?? "http://hydra:4444";
|
const HYDRA_PUBLIC = process.env.HYDRA_PUBLIC_URL ?? "http://hydra:4444";
|
||||||
@@ -22,6 +23,28 @@ function relayCookies(res: Response): string {
|
|||||||
return res.headers.getSetCookie().map((c) => c.split(";", 1)[0]!).filter((kv) => kv.split("=")[1] !== "").join("; ");
|
return res.headers.getSetCookie().map((c) => c.split(";", 1)[0]!).filter((kv) => kv.split("=")[1] !== "").join("; ");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Per-host cookie jar (the browser keeps Hydra's flow cookies separate from web's CSRF cookie).
|
||||||
|
type Jar = Map<string, string>;
|
||||||
|
function absorb(jar: Jar, res: Response): void {
|
||||||
|
for (const line of res.headers.getSetCookie()) {
|
||||||
|
const kv = line.split(";", 1)[0]!;
|
||||||
|
const name = kv.slice(0, kv.indexOf("="));
|
||||||
|
const value = kv.slice(kv.indexOf("=") + 1);
|
||||||
|
if (value === "") jar.delete(name);
|
||||||
|
else jar.set(name, value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const jarCookie = (jar: Jar): string => [...jar].map(([k, v]) => `${k}=${v}`).join("; ");
|
||||||
|
// Hydra's resume URLs carry its issuer host (127.0.0.1:4444), unreachable from the runner —
|
||||||
|
// rebase onto the compose-network host so we can follow them.
|
||||||
|
function onHydra(url: string): string {
|
||||||
|
const u = new URL(url);
|
||||||
|
const host = new URL(HYDRA_PUBLIC);
|
||||||
|
u.protocol = host.protocol;
|
||||||
|
u.host = host.host;
|
||||||
|
return u.toString();
|
||||||
|
}
|
||||||
|
|
||||||
// Register a confidential OAuth2 client (admin API) so we can start an authorization flow.
|
// Register a confidential OAuth2 client (admin API) so we can start an authorization flow.
|
||||||
async function createClient(): Promise<string> {
|
async function createClient(): Promise<string> {
|
||||||
const res = await fetch(`${HYDRA_ADMIN}/admin/clients`, {
|
const res = await fetch(`${HYDRA_ADMIN}/admin/clients`, {
|
||||||
@@ -42,10 +65,12 @@ async function createClient(): Promise<string> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Hit Hydra's authorization endpoint; it redirects to web's login URL carrying a login_challenge.
|
// Hit Hydra's authorization endpoint; it redirects to web's login URL carrying a login_challenge.
|
||||||
async function startAuthFlow(clientId: string): Promise<string> {
|
// `jar` (when given) absorbs Hydra's flow cookies, needed to follow the login/consent verifiers.
|
||||||
|
async function startAuthFlow(clientId: string, jar?: Jar): Promise<string> {
|
||||||
const auth = new URL(`${HYDRA_PUBLIC}/oauth2/auth`);
|
const auth = new URL(`${HYDRA_PUBLIC}/oauth2/auth`);
|
||||||
auth.search = new URLSearchParams({ client_id: clientId, redirect_uri: "http://127.0.0.1:3000/callback", response_type: "code", scope: "openid", state: "0123456789abcdef0123456789abcdef" }).toString();
|
auth.search = new URLSearchParams({ client_id: clientId, redirect_uri: "http://127.0.0.1:3000/callback", response_type: "code", scope: "openid", state: "0123456789abcdef0123456789abcdef" }).toString();
|
||||||
const res = await fetch(auth, { redirect: "manual" });
|
const res = await fetch(auth, { redirect: "manual" });
|
||||||
|
if (jar) absorb(jar, res);
|
||||||
expect([302, 303], `auth flow start: ${res.status}`).toContain(res.status);
|
expect([302, 303], `auth flow start: ${res.status}`).toContain(res.status);
|
||||||
const location = res.headers.get("location") ?? "";
|
const location = res.headers.get("location") ?? "";
|
||||||
expect(location, "Hydra redirects to our login URL").toContain("/oauth2/login");
|
expect(location, "Hydra redirects to our login URL").toContain("/oauth2/login");
|
||||||
@@ -90,3 +115,49 @@ test("Hydra login challenge: an unauthenticated user bounces to /login, an authe
|
|||||||
expect(resume, "accepted → back to Hydra's /oauth2/auth to continue").toContain("/oauth2/auth");
|
expect(resume, "accepted → back to Hydra's /oauth2/auth to continue").toContain("/oauth2/auth");
|
||||||
expect(resume, "carries Hydra's login_verifier").toContain("login_verifier");
|
expect(resume, "carries Hydra's login_verifier").toContain("login_verifier");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("Hydra consent challenge: web shows the third-party consent screen; Allow → authorization code", async () => {
|
||||||
|
test.setTimeout(60_000);
|
||||||
|
const hydra: Jar = new Map(); // Hydra's flow cookies, needed to follow the verifiers
|
||||||
|
const web: Jar = new Map(); // web's CSRF cookie
|
||||||
|
|
||||||
|
// Log in and accept the login challenge → Hydra resume URL (as in the login test).
|
||||||
|
const challenge = await startAuthFlow(await createClient(), hydra);
|
||||||
|
const session = await kratosLogin();
|
||||||
|
const accepted = await fetch(`${WEB}/oauth2/login?login_challenge=${challenge}`, { headers: { cookie: `plainpages_session=${session}` }, redirect: "manual" });
|
||||||
|
expect(accepted.status).toBe(303);
|
||||||
|
|
||||||
|
// Follow the login_verifier through Hydra → web's /oauth2/consent?consent_challenge=…
|
||||||
|
const toConsent = await fetch(onHydra(accepted.headers.get("location") ?? ""), { headers: { cookie: jarCookie(hydra) }, redirect: "manual" });
|
||||||
|
absorb(hydra, toConsent);
|
||||||
|
const consentLoc = toConsent.headers.get("location") ?? "";
|
||||||
|
expect(consentLoc, `→ web consent (${toConsent.status})`).toContain("/oauth2/consent");
|
||||||
|
const consentChallenge = new URL(consentLoc).searchParams.get("consent_challenge")!;
|
||||||
|
|
||||||
|
// web shows the themed consent screen for this third-party client, listing the requested scope.
|
||||||
|
const screen = await fetch(`${WEB}/oauth2/consent?consent_challenge=${consentChallenge}`, { headers: { cookie: `plainpages_session=${session}` }, redirect: "manual" });
|
||||||
|
expect(screen.status).toBe(200);
|
||||||
|
absorb(web, screen);
|
||||||
|
const html = await screen.text();
|
||||||
|
expect(html).toContain("Authorize e2e-login");
|
||||||
|
expect(html).toContain("openid");
|
||||||
|
const csrf = html.match(/name="_csrf" value="([^"]+)"/)?.[1];
|
||||||
|
expect(csrf, "consent form carries a CSRF token").toBeTruthy();
|
||||||
|
|
||||||
|
// Allow → web accepts the consent → Hydra resume URL with a consent_verifier.
|
||||||
|
const allow = await fetch(`${WEB}/oauth2/consent`, {
|
||||||
|
body: new URLSearchParams({ _csrf: csrf!, consent_challenge: consentChallenge, decision: "allow" }).toString(),
|
||||||
|
headers: { "content-type": "application/x-www-form-urlencoded", cookie: `${jarCookie(web)}; plainpages_session=${session}` },
|
||||||
|
method: "POST",
|
||||||
|
redirect: "manual",
|
||||||
|
});
|
||||||
|
expect(allow.status, `allow consent: ${await allow.clone().text()}`).toBe(303);
|
||||||
|
const consentResume = allow.headers.get("location") ?? "";
|
||||||
|
expect(consentResume, "carries Hydra's consent_verifier").toContain("consent_verifier");
|
||||||
|
|
||||||
|
// Follow the consent_verifier through Hydra → the client callback with an authorization code.
|
||||||
|
const toCallback = await fetch(onHydra(consentResume), { headers: { cookie: jarCookie(hydra) }, redirect: "manual" });
|
||||||
|
const callback = toCallback.headers.get("location") ?? "";
|
||||||
|
expect(callback, `→ client callback (${toCallback.status})`).toContain("/callback");
|
||||||
|
expect(new URL(callback).searchParams.get("code"), "an authorization code is issued").toBeTruthy();
|
||||||
|
});
|
||||||
|
|||||||
@@ -496,11 +496,16 @@ test("logout (CSRF-guarded POST): valid token revokes the Kratos session + clear
|
|||||||
|
|
||||||
// OAuth2 login challenge (§6): another app logs in *through* us; Hydra hands the browser here.
|
// OAuth2 login challenge (§6): another app logs in *through* us; Hydra hands the browser here.
|
||||||
const stubHydra = (over: Partial<HydraAdmin> = {}): HydraAdmin => ({
|
const stubHydra = (over: Partial<HydraAdmin> = {}): HydraAdmin => ({
|
||||||
|
acceptConsentRequest: async () => ({ redirect: "http://127.0.0.1:4444/oauth2/auth?consent_verifier=v" }),
|
||||||
acceptLoginRequest: async () => ({ redirect: "http://127.0.0.1:4444/oauth2/auth?login_verifier=v" }),
|
acceptLoginRequest: async () => ({ redirect: "http://127.0.0.1:4444/oauth2/auth?login_verifier=v" }),
|
||||||
|
getConsentRequest: async () => ({ challenge: "cons1", client: { client_name: "Acme Reports" }, requested_scope: ["openid", "profile"], skip: false, subject: OAUTH_SUBJECT }),
|
||||||
getLoginRequest: async () => ({ challenge: "chal1", skip: false, subject: "" }),
|
getLoginRequest: async () => ({ challenge: "chal1", skip: false, subject: "" }),
|
||||||
|
rejectConsentRequest: async () => ({ redirect: "http://acme.example/cb?error=access_denied" }),
|
||||||
rejectLoginRequest: async () => { throw new Error("unused"); },
|
rejectLoginRequest: async () => { throw new Error("unused"); },
|
||||||
...over,
|
...over,
|
||||||
});
|
});
|
||||||
|
const OAUTH_SUBJECT = "01902d5e-7b6c-7e3a-9f21-3c8d1e0a4b55";
|
||||||
|
const oauthSession = (): Session => ({ active: true, identity: { id: OAUTH_SUBJECT, traits: { email: "ada@x.io" } } });
|
||||||
|
|
||||||
test("OAuth2 login challenge (/oauth2/login): a Kratos session accepts via Hydra; no session bounces to /login; missing challenge → 400", async (t) => {
|
test("OAuth2 login challenge (/oauth2/login): a Kratos session accepts via Hydra; no session bounces to /login; missing challenge → 400", async (t) => {
|
||||||
const identity = { id: "01902d5e-7b6c-7e3a-9f21-3c8d1e0a4b55" };
|
const identity = { id: "01902d5e-7b6c-7e3a-9f21-3c8d1e0a4b55" };
|
||||||
@@ -560,6 +565,68 @@ test("/login?return_to=… bakes the return target into the Kratos flow init (§
|
|||||||
assert.equal(seenReturnTo, returnTo);
|
assert.equal(seenReturnTo, returnTo);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("OAuth2 consent challenge (/oauth2/consent): skip auto-accepts; a third-party shows the screen; allow/deny POST; CSRF-guarded; missing/stale challenge", async (t) => {
|
||||||
|
const csrfSecret = "consent-secret";
|
||||||
|
let granted: { grant_scope?: string[]; session?: unknown } | undefined;
|
||||||
|
const hydra = stubHydra({
|
||||||
|
acceptConsentRequest: async (_c, b) => { granted = b; return { redirect: "http://127.0.0.1:4444/oauth2/auth?consent_verifier=v" }; },
|
||||||
|
rejectConsentRequest: async () => ({ redirect: "http://acme.example/cb?error=access_denied" }),
|
||||||
|
});
|
||||||
|
const app = createApp({ csrfSecret, hydra, kratos: withWhoami(async () => oauthSession()) });
|
||||||
|
await new Promise<void>((r) => app.listen(0, r));
|
||||||
|
t.after(() => app.close());
|
||||||
|
const base = `http://localhost:${(app.address() as AddressInfo).port}`;
|
||||||
|
const token = issueCsrfToken(csrfSecret);
|
||||||
|
const post = (body: string) =>
|
||||||
|
fetch(base + "/oauth2/consent", { body, headers: { "content-type": "application/x-www-form-urlencoded", cookie: `${CSRF_COOKIE}=${token}` }, method: "POST", redirect: "manual" });
|
||||||
|
|
||||||
|
// Third-party (default stub: not first-party, not skipped) → 200 consent screen listing the
|
||||||
|
// client + scopes, with a CSRF cookie its form echoes back; posts to our own /oauth2/consent.
|
||||||
|
const page = await fetch(base + "/oauth2/consent?consent_challenge=cons1", { redirect: "manual" });
|
||||||
|
assert.equal(page.status, 200);
|
||||||
|
const html = await page.text();
|
||||||
|
assert.match(html, /Authorize Acme Reports/);
|
||||||
|
assert.match(html, /openid/);
|
||||||
|
assert.match(html, /profile/);
|
||||||
|
assert.match(html, /action="\/oauth2\/consent"/);
|
||||||
|
assert.match(page.headers.get("set-cookie") ?? "", /plainpages_csrf=/);
|
||||||
|
|
||||||
|
// Allow → 303 to Hydra, granting the scopes re-read from the challenge (never form-supplied) +
|
||||||
|
// id_token claims from the Kratos identity.
|
||||||
|
const allow = await post(`_csrf=${token}&consent_challenge=cons1&decision=allow`);
|
||||||
|
assert.equal(allow.status, 303);
|
||||||
|
assert.match(allow.headers.get("location") ?? "", /\/oauth2\/auth\?consent_verifier=v/);
|
||||||
|
assert.deepEqual(granted?.grant_scope, ["openid", "profile"]);
|
||||||
|
assert.deepEqual(granted?.session, { id_token: { email: "ada@x.io" } });
|
||||||
|
|
||||||
|
// Deny → 303 back to the client with access_denied.
|
||||||
|
const deny = await post(`_csrf=${token}&consent_challenge=cons1&decision=deny`);
|
||||||
|
assert.equal(deny.status, 303);
|
||||||
|
assert.equal(deny.headers.get("location"), "http://acme.example/cb?error=access_denied");
|
||||||
|
|
||||||
|
// Forged/missing CSRF → 403 (no Hydra call); missing challenge → 400.
|
||||||
|
assert.equal((await post("decision=allow")).status, 403);
|
||||||
|
assert.equal((await fetch(base + "/oauth2/consent", { redirect: "manual" })).status, 400);
|
||||||
|
|
||||||
|
// A Hydra-skipped client auto-accepts on GET (no screen) → 303 to Hydra.
|
||||||
|
const skip = createApp({ hydra: stubHydra({ getConsentRequest: async () => ({ challenge: "cons1", requested_scope: ["openid"], skip: true, subject: OAUTH_SUBJECT }) }), kratos: withWhoami(async () => oauthSession()) });
|
||||||
|
await new Promise<void>((r) => skip.listen(0, r));
|
||||||
|
t.after(() => skip.close());
|
||||||
|
const auto = await fetch(`http://localhost:${(skip.address() as AddressInfo).port}/oauth2/consent?consent_challenge=cons1`, { redirect: "manual" });
|
||||||
|
assert.equal(auto.status, 303);
|
||||||
|
assert.match(auto.headers.get("location") ?? "", /consent_verifier=v/);
|
||||||
|
|
||||||
|
// A stale challenge (Hydra 4xx) degrades to 400; a genuine outage (5xx) surfaces as 500.
|
||||||
|
const stale = createApp({ hydra: stubHydra({ getConsentRequest: async () => { throw new HydraError("gone", 410, ""); } }), kratos: withWhoami(async () => null) });
|
||||||
|
await new Promise<void>((r) => stale.listen(0, r));
|
||||||
|
t.after(() => stale.close());
|
||||||
|
assert.equal((await fetch(`http://localhost:${(stale.address() as AddressInfo).port}/oauth2/consent?consent_challenge=gone`, { redirect: "manual" })).status, 400);
|
||||||
|
const down = createApp({ hydra: stubHydra({ getConsentRequest: async () => { throw new HydraError("down", 503, ""); } }), kratos: withWhoami(async () => null) });
|
||||||
|
await new Promise<void>((r) => down.listen(0, r));
|
||||||
|
t.after(() => down.close());
|
||||||
|
assert.equal((await fetch(`http://localhost:${(down.address() as AddressInfo).port}/oauth2/consent?consent_challenge=x`, { redirect: "manual" })).status, 500);
|
||||||
|
});
|
||||||
|
|
||||||
// Built-in Users admin screen (§5): gate + every CRUD action over HTTP against a mock Kratos admin.
|
// Built-in Users admin screen (§5): gate + every CRUD action over HTTP against a mock Kratos admin.
|
||||||
test("admin Users screen: gate, list/filter, create, edit, deactivate, delete, recovery (CSRF-guarded)", async (t) => {
|
test("admin Users screen: gate, list/filter, create, edit, deactivate, delete, recovery (CSRF-guarded)", async (t) => {
|
||||||
const mk = (email: string, over: Partial<Identity> = {}): Identity =>
|
const mk = (email: string, over: Partial<Identity> = {}): Identity =>
|
||||||
|
|||||||
51
src/app.ts
51
src/app.ts
@@ -23,6 +23,7 @@ import type { KratosAdmin } from "./kratos-admin.ts";
|
|||||||
import { KratosError, type KratosPublic } from "./kratos-public.ts";
|
import { KratosError, type KratosPublic } from "./kratos-public.ts";
|
||||||
import { clearSessionCookie, completeLogin, remintSession, sessionCookie } from "./login.ts";
|
import { clearSessionCookie, completeLogin, remintSession, sessionCookie } from "./login.ts";
|
||||||
import { resolveLoginChallenge } from "./oauth-login.ts";
|
import { resolveLoginChallenge } from "./oauth-login.ts";
|
||||||
|
import { acceptConsent, rejectConsent, resolveConsentChallenge } from "./oauth-consent.ts";
|
||||||
import { DEFAULT_MENU, type MenuConfig } from "./menu-config.ts";
|
import { DEFAULT_MENU, type MenuConfig } from "./menu-config.ts";
|
||||||
import type { Plugin, RouteResult } from "./plugin.ts";
|
import type { Plugin, RouteResult } from "./plugin.ts";
|
||||||
import { allowedMethods, isAuthorized, matchRoute } from "./router.ts";
|
import { allowedMethods, isAuthorized, matchRoute } from "./router.ts";
|
||||||
@@ -236,6 +237,56 @@ export function createApp(options: AppOptions = {}): Server {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// OAuth2 consent challenge (§6): after login Hydra hands the browser here. A first-party
|
||||||
|
// (or Hydra-skipped) client is auto-granted its scopes; a third-party client gets the themed
|
||||||
|
// consent screen, whose CSRF-guarded POST accepts (Allow) or rejects (Deny). Provider-only.
|
||||||
|
if (hydra && kratos && pathname === "/oauth2/consent") {
|
||||||
|
const consentDeps = { hydra, kratos };
|
||||||
|
try {
|
||||||
|
if (method === "GET" || method === "HEAD") {
|
||||||
|
const challenge = ctx.url.searchParams.get("consent_challenge");
|
||||||
|
if (!challenge) {
|
||||||
|
res.writeHead(400, { "content-type": "text/plain; charset=utf-8" }).end("Missing consent_challenge");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const { redirect, view } = await resolveConsentChallenge(consentDeps, challenge, req.headers.cookie);
|
||||||
|
if (redirect) {
|
||||||
|
res.writeHead(303, { location: redirect }).end();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Third-party: show the consent screen, carrying a CSRF token its form echoes back.
|
||||||
|
if (csrf.fresh) res.appendHeader("set-cookie", csrfCookie(csrf.token, { secure: secureCookies }));
|
||||||
|
sendHtml(res, 200, await render("oauth-consent", { brand: menu.branding.name, consent: view, csrfField: CSRF_FIELD, csrfToken: csrf.token }));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (method === "POST") {
|
||||||
|
const form = await readFormBody(req);
|
||||||
|
if (!verifyCsrfRequest({ cookieHeader: req.headers.cookie, secret: csrfSecret, submitted: form.get(CSRF_FIELD) })) {
|
||||||
|
sendHtml(res, 403, await render("403", { title: "Forbidden" }));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const challenge = form.get("consent_challenge");
|
||||||
|
if (!challenge) {
|
||||||
|
res.writeHead(400, { "content-type": "text/plain; charset=utf-8" }).end("Missing consent_challenge");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const redirect = form.get("decision") === "allow"
|
||||||
|
? await acceptConsent(consentDeps, challenge, req.headers.cookie)
|
||||||
|
: await rejectConsent(consentDeps, challenge);
|
||||||
|
res.writeHead(303, { location: redirect }).end();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
// Stale/invalid/consumed challenge (Hydra 4xx — back button, slow login, re-used URL):
|
||||||
|
// recoverable 400, not a 500. A genuine Hydra outage (5xx) rethrows → 500.
|
||||||
|
if (err instanceof HydraError && err.status < 500) {
|
||||||
|
res.writeHead(400, { "content-type": "text/plain; charset=utf-8" }).end("This authorization request has expired. Please start again from the application you were signing in to.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Login completion: where Kratos lands the browser after authenticating (kratos.yml).
|
// Login completion: where Kratos lands the browser after authenticating (kratos.yml).
|
||||||
// Mint our session JWT — read roles from Keto, project onto the identity, tokenize —
|
// Mint our session JWT — read roles from Keto, project onto the identity, tokenize —
|
||||||
// and store it as the cookie; no active session bounces back to sign in (§4).
|
// and store it as the cookie; no active session bounces back to sign in (§4).
|
||||||
|
|||||||
@@ -51,6 +51,32 @@ test("rejectLoginRequest PUTs the error and returns Hydra's redirect_to", async
|
|||||||
assert.deepEqual(JSON.parse(calls[0]!.body!), { error: "access_denied", error_description: "no" });
|
assert.deepEqual(JSON.parse(calls[0]!.body!), { error: "access_denied", error_description: "no" });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("getConsentRequest GETs the consent challenge and returns the request", async () => {
|
||||||
|
const request = { challenge: CHALLENGE, client: { client_name: "Acme" }, requested_scope: ["openid", "email"], skip: false, subject: SUBJECT };
|
||||||
|
const { calls, fetchImpl } = recorder(() => res(200, request));
|
||||||
|
const out = await createHydraAdmin({ baseUrl: BASE, fetchImpl }).getConsentRequest(CHALLENGE);
|
||||||
|
assert.deepEqual(out, request);
|
||||||
|
assert.equal(calls[0]!.method, "GET");
|
||||||
|
assert.match(calls[0]!.url, /\/admin\/oauth2\/auth\/requests\/consent\?consent_challenge=a1b2c3d4e5f6$/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("acceptConsentRequest PUTs the grant + id_token session and returns Hydra's redirect_to", async () => {
|
||||||
|
const { calls, fetchImpl } = recorder(() => res(200, { redirect_to: "http://hydra/oauth2/auth?consent_verifier=v" }));
|
||||||
|
const body = { grant_scope: ["openid"], remember: true, remember_for: 0, session: { id_token: { email: "a@b.c" } } };
|
||||||
|
const out = await createHydraAdmin({ baseUrl: BASE, fetchImpl }).acceptConsentRequest(CHALLENGE, body);
|
||||||
|
assert.equal(out.redirect, "http://hydra/oauth2/auth?consent_verifier=v");
|
||||||
|
assert.equal(calls[0]!.method, "PUT");
|
||||||
|
assert.match(calls[0]!.url, /\/admin\/oauth2\/auth\/requests\/consent\/accept\?consent_challenge=a1b2c3d4e5f6$/);
|
||||||
|
assert.deepEqual(JSON.parse(calls[0]!.body!), body);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("rejectConsentRequest PUTs the error and returns Hydra's redirect_to", async () => {
|
||||||
|
const { calls, fetchImpl } = recorder(() => res(200, { redirect_to: "http://client/cb?error=access_denied" }));
|
||||||
|
const out = await createHydraAdmin({ baseUrl: BASE, fetchImpl }).rejectConsentRequest(CHALLENGE, { error: "access_denied" });
|
||||||
|
assert.equal(out.redirect, "http://client/cb?error=access_denied");
|
||||||
|
assert.match(calls[0]!.url, /\/admin\/oauth2\/auth\/requests\/consent\/reject\?consent_challenge=a1b2c3d4e5f6$/);
|
||||||
|
});
|
||||||
|
|
||||||
test("a non-2xx response throws a HydraError carrying the status", async () => {
|
test("a non-2xx response throws a HydraError carrying the status", async () => {
|
||||||
const { fetchImpl } = recorder(() => res(404, { error: "Not Found" }));
|
const { fetchImpl } = recorder(() => res(404, { error: "Not Found" }));
|
||||||
await assert.rejects(
|
await assert.rejects(
|
||||||
|
|||||||
@@ -7,6 +7,7 @@
|
|||||||
export interface OAuth2Client {
|
export interface OAuth2Client {
|
||||||
client_id?: string;
|
client_id?: string;
|
||||||
client_name?: string;
|
client_name?: string;
|
||||||
|
metadata?: Record<string, unknown>; // arbitrary client metadata; `first_party: true` ⇒ auto-consent (§6)
|
||||||
}
|
}
|
||||||
|
|
||||||
// A login request Hydra hands us at /oauth2/login. `skip` ⇒ Hydra already authenticated this
|
// A login request Hydra hands us at /oauth2/login. `skip` ⇒ Hydra already authenticated this
|
||||||
@@ -27,6 +28,32 @@ export interface AcceptLogin {
|
|||||||
subject: string;
|
subject: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A consent request Hydra hands us at /oauth2/consent. `skip` ⇒ already consented (or a
|
||||||
|
// skip-consent client); else we show the scope screen (or auto-accept a first-party client).
|
||||||
|
export interface ConsentRequest {
|
||||||
|
challenge: string;
|
||||||
|
client?: OAuth2Client;
|
||||||
|
request_url?: string;
|
||||||
|
requested_access_token_audience?: string[];
|
||||||
|
requested_scope?: string[];
|
||||||
|
skip: boolean;
|
||||||
|
subject: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// OIDC claims surfaced to the client: id_token (always) / access_token (introspection only).
|
||||||
|
export interface ConsentSession {
|
||||||
|
access_token?: Record<string, unknown>;
|
||||||
|
id_token?: Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AcceptConsent {
|
||||||
|
grant_access_token_audience?: string[];
|
||||||
|
grant_scope?: string[];
|
||||||
|
remember?: boolean;
|
||||||
|
remember_for?: number; // seconds; 0 ⇒ for the browser-session lifetime
|
||||||
|
session?: ConsentSession;
|
||||||
|
}
|
||||||
|
|
||||||
export interface RejectRequest {
|
export interface RejectRequest {
|
||||||
error?: string;
|
error?: string;
|
||||||
error_description?: string;
|
error_description?: string;
|
||||||
@@ -50,8 +77,11 @@ export class HydraError extends Error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface HydraAdmin {
|
export interface HydraAdmin {
|
||||||
|
acceptConsentRequest(challenge: string, body: AcceptConsent): Promise<Completed>;
|
||||||
acceptLoginRequest(challenge: string, body: AcceptLogin): Promise<Completed>;
|
acceptLoginRequest(challenge: string, body: AcceptLogin): Promise<Completed>;
|
||||||
|
getConsentRequest(challenge: string): Promise<ConsentRequest>;
|
||||||
getLoginRequest(challenge: string): Promise<LoginRequest>;
|
getLoginRequest(challenge: string): Promise<LoginRequest>;
|
||||||
|
rejectConsentRequest(challenge: string, body: RejectRequest): Promise<Completed>;
|
||||||
rejectLoginRequest(challenge: string, body: RejectRequest): Promise<Completed>;
|
rejectLoginRequest(challenge: string, body: RejectRequest): Promise<Completed>;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -60,8 +90,8 @@ export function createHydraAdmin(config: { baseUrl: string; fetchImpl?: typeof f
|
|||||||
const http = config.fetchImpl ?? fetch;
|
const http = config.fetchImpl ?? fetch;
|
||||||
const json = { "content-type": "application/json" };
|
const json = { "content-type": "application/json" };
|
||||||
// Hydra keys the login/consent handshake off a ?login_challenge=/?consent_challenge= query.
|
// Hydra keys the login/consent handshake off a ?login_challenge=/?consent_challenge= query.
|
||||||
const loginUrl = (challenge: string, action = "") =>
|
const reqUrl = (kind: "consent" | "login", challenge: string, action = "") =>
|
||||||
`${base}/admin/oauth2/auth/requests/login${action}?login_challenge=${encodeURIComponent(challenge)}`;
|
`${base}/admin/oauth2/auth/requests/${kind}${action}?${kind}_challenge=${encodeURIComponent(challenge)}`;
|
||||||
|
|
||||||
async function fail(action: string, res: Response): Promise<never> {
|
async function fail(action: string, res: Response): Promise<never> {
|
||||||
throw new HydraError(`Hydra admin ${action} failed (${res.status})`, res.status, await res.text());
|
throw new HydraError(`Hydra admin ${action} failed (${res.status})`, res.status, await res.text());
|
||||||
@@ -71,19 +101,36 @@ export function createHydraAdmin(config: { baseUrl: string; fetchImpl?: typeof f
|
|||||||
return { redirect: ((await res.json()) as { redirect_to: string }).redirect_to };
|
return { redirect: ((await res.json()) as { redirect_to: string }).redirect_to };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const put = (action: string, url: string, body: unknown) =>
|
||||||
|
http(url, { body: JSON.stringify(body), headers: json, method: "PUT" }).then((r) => complete(action, r));
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
async acceptConsentRequest(challenge, body) {
|
||||||
|
return put("accept consent", reqUrl("consent", challenge, "/accept"), body);
|
||||||
|
},
|
||||||
|
|
||||||
async acceptLoginRequest(challenge, body) {
|
async acceptLoginRequest(challenge, body) {
|
||||||
return complete("accept login", await http(loginUrl(challenge, "/accept"), { body: JSON.stringify(body), headers: json, method: "PUT" }));
|
return put("accept login", reqUrl("login", challenge, "/accept"), body);
|
||||||
|
},
|
||||||
|
|
||||||
|
async getConsentRequest(challenge) {
|
||||||
|
const res = await http(reqUrl("consent", challenge));
|
||||||
|
if (res.status !== 200) return fail("get consent request", res);
|
||||||
|
return (await res.json()) as ConsentRequest;
|
||||||
},
|
},
|
||||||
|
|
||||||
async getLoginRequest(challenge) {
|
async getLoginRequest(challenge) {
|
||||||
const res = await http(loginUrl(challenge));
|
const res = await http(reqUrl("login", challenge));
|
||||||
if (res.status !== 200) return fail("get login request", res);
|
if (res.status !== 200) return fail("get login request", res);
|
||||||
return (await res.json()) as LoginRequest;
|
return (await res.json()) as LoginRequest;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
async rejectConsentRequest(challenge, body) {
|
||||||
|
return put("reject consent", reqUrl("consent", challenge, "/reject"), body);
|
||||||
|
},
|
||||||
|
|
||||||
async rejectLoginRequest(challenge, body) {
|
async rejectLoginRequest(challenge, body) {
|
||||||
return complete("reject login", await http(loginUrl(challenge, "/reject"), { body: JSON.stringify(body), headers: json, method: "PUT" }));
|
return put("reject login", reqUrl("login", challenge, "/reject"), body);
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
78
src/oauth-consent.test.ts
Normal file
78
src/oauth-consent.test.ts
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
// OAuth2 consent-challenge resolution (§6): given a Hydra consent challenge, auto-accept a
|
||||||
|
// first-party (or Hydra-skipped) client granting the requested scopes, else show a consent
|
||||||
|
// screen; on submit accept (allow) or reject (deny). id_token claims come from the Kratos identity.
|
||||||
|
import { test } from "node:test";
|
||||||
|
import assert from "node:assert/strict";
|
||||||
|
import type { AcceptConsent, ConsentRequest, HydraAdmin } from "./hydra-admin.ts";
|
||||||
|
import type { KratosPublic, Session } from "./kratos-public.ts";
|
||||||
|
import { acceptConsent, rejectConsent, resolveConsentChallenge } from "./oauth-consent.ts";
|
||||||
|
|
||||||
|
const CHALLENGE = "cons-1";
|
||||||
|
const SUBJECT = "01902d5e-7b6c-7e3a-9f21-3c8d1e0a4b55";
|
||||||
|
const REDIRECT = "http://hydra/oauth2/auth?consent_verifier=v";
|
||||||
|
const DENIED = "http://client/cb?error=access_denied";
|
||||||
|
|
||||||
|
function stubHydra(consent: ConsentRequest, capture?: (b: AcceptConsent) => void): HydraAdmin {
|
||||||
|
return {
|
||||||
|
acceptConsentRequest: async (_c, body) => { capture?.(body); return { redirect: REDIRECT }; },
|
||||||
|
acceptLoginRequest: async () => { throw new Error("unused"); },
|
||||||
|
getConsentRequest: async () => consent,
|
||||||
|
getLoginRequest: async () => { throw new Error("unused"); },
|
||||||
|
rejectConsentRequest: async () => ({ redirect: DENIED }),
|
||||||
|
rejectLoginRequest: async () => { throw new Error("unused"); },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const stubKratos = (whoami: KratosPublic["whoami"]): KratosPublic => ({
|
||||||
|
createLogoutFlow: async () => null,
|
||||||
|
getFlow: async () => { throw new Error("unused"); },
|
||||||
|
initBrowserFlow: async () => { throw new Error("unused"); },
|
||||||
|
submitFlow: async () => { throw new Error("unused"); },
|
||||||
|
whoami,
|
||||||
|
});
|
||||||
|
const sessionWith = (traits?: Record<string, unknown>): Session => ({ active: true, identity: { id: SUBJECT, ...(traits ? { traits } : {}) } });
|
||||||
|
const consent = (over: Partial<ConsentRequest> = {}): ConsentRequest =>
|
||||||
|
({ challenge: CHALLENGE, client: { client_name: "Acme Reports" }, requested_scope: ["openid", "profile"], skip: false, subject: SUBJECT, ...over });
|
||||||
|
|
||||||
|
test("a Hydra-skipped client auto-accepts, granting the requested scopes + audience + id_token from the identity", async () => {
|
||||||
|
let granted: AcceptConsent | undefined;
|
||||||
|
const hydra = stubHydra(consent({ requested_access_token_audience: ["https://api"], requested_scope: ["openid", "email"], skip: true }), (b) => { granted = b; });
|
||||||
|
const kratos = stubKratos(async () => sessionWith({ email: "ada@x.io", name: { first: "Ada", last: "Lovelace" } }));
|
||||||
|
const out = await resolveConsentChallenge({ hydra, kratos }, CHALLENGE, "plainpages_session=s");
|
||||||
|
assert.equal(out.redirect, REDIRECT);
|
||||||
|
assert.equal(out.view, undefined);
|
||||||
|
assert.deepEqual(granted?.grant_scope, ["openid", "email"]);
|
||||||
|
assert.deepEqual(granted?.grant_access_token_audience, ["https://api"]);
|
||||||
|
assert.deepEqual(granted?.session, { id_token: { email: "ada@x.io", name: "Ada Lovelace" } });
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a first-party client (metadata.first_party) auto-accepts even without skip; no identity ⇒ no id_token", async () => {
|
||||||
|
let granted: AcceptConsent | undefined;
|
||||||
|
const hydra = stubHydra(consent({ client: { client_name: "Internal", metadata: { first_party: true } }, requested_scope: ["openid"] }), (b) => { granted = b; });
|
||||||
|
const out = await resolveConsentChallenge({ hydra, kratos: stubKratos(async () => null) }, CHALLENGE, undefined);
|
||||||
|
assert.equal(out.redirect, REDIRECT);
|
||||||
|
assert.deepEqual(granted?.grant_scope, ["openid"]);
|
||||||
|
assert.equal(granted?.session, undefined);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a third-party client shows the consent screen (no auto-accept)", async () => {
|
||||||
|
let accepted = false;
|
||||||
|
const hydra = stubHydra(consent(), () => { accepted = true; });
|
||||||
|
const out = await resolveConsentChallenge({ hydra, kratos: stubKratos(async () => null) }, CHALLENGE, undefined);
|
||||||
|
assert.equal(out.redirect, undefined);
|
||||||
|
assert.deepEqual(out.view, { challenge: CHALLENGE, client: "Acme Reports", scopes: ["openid", "profile"] });
|
||||||
|
assert.equal(accepted, false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("acceptConsent re-fetches the challenge and grants its scopes (never client-supplied)", async () => {
|
||||||
|
let granted: AcceptConsent | undefined;
|
||||||
|
const hydra = stubHydra(consent(), (b) => { granted = b; });
|
||||||
|
const redirect = await acceptConsent({ hydra, kratos: stubKratos(async () => sessionWith({ email: "ada@x.io" })) }, CHALLENGE, "plainpages_session=s");
|
||||||
|
assert.equal(redirect, REDIRECT);
|
||||||
|
assert.deepEqual(granted?.grant_scope, ["openid", "profile"]);
|
||||||
|
assert.deepEqual(granted?.session, { id_token: { email: "ada@x.io" } });
|
||||||
|
});
|
||||||
|
|
||||||
|
test("rejectConsent rejects with access_denied → the client's error redirect", async () => {
|
||||||
|
const redirect = await rejectConsent({ hydra: stubHydra(consent()), kratos: stubKratos(async () => null) }, CHALLENGE);
|
||||||
|
assert.equal(redirect, DENIED);
|
||||||
|
});
|
||||||
78
src/oauth-consent.ts
Normal file
78
src/oauth-consent.ts
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
// OAuth2 consent-challenge handler (todo §6): after login, Hydra hands the browser to
|
||||||
|
// /oauth2/consent?consent_challenge=… (hydra.yml urls.consent). A first-party client (or one
|
||||||
|
// Hydra already skipped) is auto-granted the requested scopes; a third-party client shows the
|
||||||
|
// themed consent screen, then accept (allow) / reject (deny). id_token claims (email/name) come
|
||||||
|
// from the Kratos identity. OAuth2-provider role only — no first-party page needs this (README).
|
||||||
|
import type { AcceptConsent, ConsentRequest, HydraAdmin, OAuth2Client } from "./hydra-admin.ts";
|
||||||
|
import type { KratosPublic } from "./kratos-public.ts";
|
||||||
|
|
||||||
|
// Remember the grant for the browser-session lifetime (0): a client re-authorizing while the
|
||||||
|
// Kratos session lives doesn't re-prompt on every token refresh (mirrors oauth-login).
|
||||||
|
const REMEMBER_FOR = 0;
|
||||||
|
|
||||||
|
export interface OAuthConsentDeps {
|
||||||
|
hydra: HydraAdmin;
|
||||||
|
kratos: KratosPublic;
|
||||||
|
}
|
||||||
|
|
||||||
|
// What to show on the consent screen for a third-party client.
|
||||||
|
export interface ConsentView {
|
||||||
|
challenge: string;
|
||||||
|
client: string; // display name
|
||||||
|
scopes: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
// A consent challenge resolves to either an immediate redirect (auto-accepted) or a render
|
||||||
|
// decision (show the consent screen).
|
||||||
|
export interface ConsentResolution {
|
||||||
|
redirect?: string;
|
||||||
|
view?: ConsentView;
|
||||||
|
}
|
||||||
|
|
||||||
|
const isFirstParty = (client?: OAuth2Client): boolean => client?.metadata?.first_party === true;
|
||||||
|
const clientName = (client?: OAuth2Client): string => client?.client_name || client?.client_id || "the application";
|
||||||
|
|
||||||
|
// id_token claims from Kratos traits (email + a joined name); undefined ⇒ omit the session.
|
||||||
|
function idTokenClaims(traits?: Record<string, unknown>): Record<string, unknown> | undefined {
|
||||||
|
if (!traits) return undefined;
|
||||||
|
const claims: Record<string, unknown> = {};
|
||||||
|
if (typeof traits.email === "string") claims.email = traits.email;
|
||||||
|
const name = traits.name as { first?: string; last?: string } | undefined;
|
||||||
|
const full = [name?.first, name?.last].filter(Boolean).join(" ");
|
||||||
|
if (full) claims.name = full;
|
||||||
|
return Object.keys(claims).length ? claims : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Accept a consent request, granting exactly the scopes/audience Hydra asked for (re-read from
|
||||||
|
// the challenge, never client-submitted) plus id_token claims from the current Kratos session.
|
||||||
|
async function accept(deps: OAuthConsentDeps, consent: ConsentRequest, cookie: string | undefined): Promise<string> {
|
||||||
|
const session = await deps.kratos.whoami(cookie ? { cookie } : {});
|
||||||
|
const idToken = idTokenClaims(session?.identity?.traits);
|
||||||
|
const body: AcceptConsent = {
|
||||||
|
grant_access_token_audience: consent.requested_access_token_audience ?? [],
|
||||||
|
grant_scope: consent.requested_scope ?? [],
|
||||||
|
remember: true,
|
||||||
|
remember_for: REMEMBER_FOR,
|
||||||
|
...(idToken ? { session: { id_token: idToken } } : {}),
|
||||||
|
};
|
||||||
|
return (await deps.hydra.acceptConsentRequest(consent.challenge, body)).redirect;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resolve a consent challenge: skip / first-party ⇒ auto-accept; else show the consent screen.
|
||||||
|
export async function resolveConsentChallenge(deps: OAuthConsentDeps, challenge: string, cookie: string | undefined): Promise<ConsentResolution> {
|
||||||
|
const consent = await deps.hydra.getConsentRequest(challenge);
|
||||||
|
if (consent.skip || isFirstParty(consent.client)) {
|
||||||
|
return { redirect: await accept(deps, consent, cookie) };
|
||||||
|
}
|
||||||
|
return { view: { challenge, client: clientName(consent.client), scopes: consent.requested_scope ?? [] } };
|
||||||
|
}
|
||||||
|
|
||||||
|
// The user allowed: re-fetch the challenge (don't trust the form for scopes) and accept.
|
||||||
|
export async function acceptConsent(deps: OAuthConsentDeps, challenge: string, cookie: string | undefined): Promise<string> {
|
||||||
|
return accept(deps, await deps.hydra.getConsentRequest(challenge), cookie);
|
||||||
|
}
|
||||||
|
|
||||||
|
// The user denied: reject so Hydra redirects back to the client with access_denied.
|
||||||
|
export async function rejectConsent(deps: OAuthConsentDeps, challenge: string): Promise<string> {
|
||||||
|
return (await deps.hydra.rejectConsentRequest(challenge, { error: "access_denied", error_description: "The user denied the request." })).redirect;
|
||||||
|
}
|
||||||
@@ -11,10 +11,14 @@ const SUBJECT = "01902d5e-7b6c-7e3a-9f21-3c8d1e0a4b55";
|
|||||||
const SELF = "http://127.0.0.1:3000/oauth2/login?login_challenge=chal-1";
|
const SELF = "http://127.0.0.1:3000/oauth2/login?login_challenge=chal-1";
|
||||||
|
|
||||||
function stubHydra(login: LoginRequest, capture?: (b: AcceptLogin) => void): HydraAdmin {
|
function stubHydra(login: LoginRequest, capture?: (b: AcceptLogin) => void): HydraAdmin {
|
||||||
|
const unused = async () => { throw new Error("unused"); };
|
||||||
return {
|
return {
|
||||||
|
acceptConsentRequest: unused,
|
||||||
acceptLoginRequest: async (_c, body) => { capture?.(body); return { redirect: "http://hydra/oauth2/auth?login_verifier=v" }; },
|
acceptLoginRequest: async (_c, body) => { capture?.(body); return { redirect: "http://hydra/oauth2/auth?login_verifier=v" }; },
|
||||||
|
getConsentRequest: unused,
|
||||||
getLoginRequest: async () => login,
|
getLoginRequest: async () => login,
|
||||||
rejectLoginRequest: async () => { throw new Error("unused"); },
|
rejectConsentRequest: unused,
|
||||||
|
rejectLoginRequest: unused,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
const stubKratos = (whoami: KratosPublic["whoami"]): KratosPublic => ({
|
const stubKratos = (whoami: KratosPublic["whoami"]): KratosPublic => ({
|
||||||
|
|||||||
2
todo.md
2
todo.md
@@ -103,7 +103,7 @@ everything via Docker.
|
|||||||
|
|
||||||
## 6. Hydra — OAuth2/OIDC provider (can ship after the rest)
|
## 6. Hydra — OAuth2/OIDC provider (can ship after the rest)
|
||||||
- [x] Login-challenge handler: authenticate via Kratos session, accept/reject. → `src/hydra-admin.ts` (`createHydraAdmin`): typed `fetch` wrappers over Hydra's OAuth2 admin API (port 4445, no SDK, `fetchImpl`-injectable like the kratos/keto clients) — `getLoginRequest`/`acceptLoginRequest`/`rejectLoginRequest` + a `HydraError` carrying `.status`. `src/oauth-login.ts` (`resolveLoginChallenge`, pure): `getLoginRequest` → **skip** (Hydra already authenticated the subject) ⇒ accept it without touching Kratos; a live **Kratos session** (`whoami`) ⇒ accept with that identity as the subject (`remember`, browser-session lifetime); **no session** ⇒ bounce to our themed `/login?return_to=<absolute self URL>`, so Kratos lands back on the challenge once signed in. Wired into `app.ts` at `GET /oauth2/login` (gated on `hydra`+`kratos` present; missing `login_challenge`→400; the absolute return target derives from the request Host + the SECURE_COOKIES scheme — a spoofed Host can't escape, Kratos validates `return_to` against its allow-list); `/login` now bakes a `return_to` into the Kratos flow init so the round-trip works. `config.ts` gains `hydraAdminUrl` (default `http://hydra:4445`); `server.ts` builds the client; `compose.yml` `web` now gates on `hydra` healthy (the app consumes it). Tests-first: `hydra-admin.test.ts` (request contracts + error mapping), `oauth-login.test.ts` (skip/session/no-session matrix), `app.test.ts` (HTTP: accept→Hydra redirect / no-session→/login bounce / missing-challenge→400 / `/login` return_to forwarding), `config.test.ts` + `compose.test.ts` (web↦hydra dep). Full-stack E2E `e2e/oauth-login.spec.ts` (`compose.e2e-oauth.yml`): boots the real stack incl. Hydra, registers an OAuth2 client, starts an authorization flow, asserts the unauthenticated bounce **and** the authenticated accept (→ Hydra `/oauth2/auth?…login_verifier=…`) — green, then torn down. Stability-reviewer run as a local PR: APPROVE, no Critical/High; addressed its one stability warning — a stale/invalid/consumed challenge (Hydra 4xx, user-reachable via back button/slow login) now degrades to a recoverable 400 instead of a 500, while a genuine Hydra 5xx outage still surfaces as 500 (mirrors the themed-flow + §4 re-mint hardening). Deferred (reviewer-scoped, §9): document that prod `allowed_return_urls` entries must be exact origins with a trailing `/` (the return_to safety leans on Kratos' allow-list). typecheck + 253 units + 8 visual E2E green. Consent handler + client registration are the next §6 items.
|
- [x] Login-challenge handler: authenticate via Kratos session, accept/reject. → `src/hydra-admin.ts` (`createHydraAdmin`): typed `fetch` wrappers over Hydra's OAuth2 admin API (port 4445, no SDK, `fetchImpl`-injectable like the kratos/keto clients) — `getLoginRequest`/`acceptLoginRequest`/`rejectLoginRequest` + a `HydraError` carrying `.status`. `src/oauth-login.ts` (`resolveLoginChallenge`, pure): `getLoginRequest` → **skip** (Hydra already authenticated the subject) ⇒ accept it without touching Kratos; a live **Kratos session** (`whoami`) ⇒ accept with that identity as the subject (`remember`, browser-session lifetime); **no session** ⇒ bounce to our themed `/login?return_to=<absolute self URL>`, so Kratos lands back on the challenge once signed in. Wired into `app.ts` at `GET /oauth2/login` (gated on `hydra`+`kratos` present; missing `login_challenge`→400; the absolute return target derives from the request Host + the SECURE_COOKIES scheme — a spoofed Host can't escape, Kratos validates `return_to` against its allow-list); `/login` now bakes a `return_to` into the Kratos flow init so the round-trip works. `config.ts` gains `hydraAdminUrl` (default `http://hydra:4445`); `server.ts` builds the client; `compose.yml` `web` now gates on `hydra` healthy (the app consumes it). Tests-first: `hydra-admin.test.ts` (request contracts + error mapping), `oauth-login.test.ts` (skip/session/no-session matrix), `app.test.ts` (HTTP: accept→Hydra redirect / no-session→/login bounce / missing-challenge→400 / `/login` return_to forwarding), `config.test.ts` + `compose.test.ts` (web↦hydra dep). Full-stack E2E `e2e/oauth-login.spec.ts` (`compose.e2e-oauth.yml`): boots the real stack incl. Hydra, registers an OAuth2 client, starts an authorization flow, asserts the unauthenticated bounce **and** the authenticated accept (→ Hydra `/oauth2/auth?…login_verifier=…`) — green, then torn down. Stability-reviewer run as a local PR: APPROVE, no Critical/High; addressed its one stability warning — a stale/invalid/consumed challenge (Hydra 4xx, user-reachable via back button/slow login) now degrades to a recoverable 400 instead of a 500, while a genuine Hydra 5xx outage still surfaces as 500 (mirrors the themed-flow + §4 re-mint hardening). Deferred (reviewer-scoped, §9): document that prod `allowed_return_urls` entries must be exact origins with a trailing `/` (the return_to safety leans on Kratos' allow-list). typecheck + 253 units + 8 visual E2E green. Consent handler + client registration are the next §6 items.
|
||||||
- [ ] Consent-challenge handler: show / auto-accept first-party, grant scopes, accept/reject.
|
- [x] Consent-challenge handler: show / auto-accept first-party, grant scopes, accept/reject. → `src/hydra-admin.ts` gains the consent half of the handshake (`getConsentRequest`/`acceptConsentRequest`/`rejectConsentRequest` + `ConsentRequest`/`AcceptConsent`/`ConsentSession` types; the login/consent URL builder folded into one `reqUrl(kind,…)` + a shared `put()`). `src/oauth-consent.ts` (pure, sibling of `oauth-login.ts`): `resolveConsentChallenge` → **skip** (Hydra already consented / a skip-consent client) or **first-party** (the client's Hydra `metadata.first_party === true`) ⇒ auto-accept, else return a `view` to show the themed consent screen; `acceptConsent` (re-reads the challenge so scopes/audience are **never** client-supplied) + `rejectConsent` (access_denied). The grant carries an OIDC `session.id_token` with `email`/`name` projected from the Kratos identity (`whoami` traits; absent ⇒ omitted). Wired in `app.ts` at `GET|POST /oauth2/consent` (gated on `hydra`+`kratos`): GET shows/auto-accepts (sets the page CSRF cookie when fresh), POST is **CSRF-guarded** (same signed double-submit as `/logout`) and dispatches `decision=allow`→accept / else→reject → 303 to Hydra; a stale/consumed challenge (Hydra 4xx) degrades to a recoverable 400, a genuine outage (5xx) → 500 (mirrors `/oauth2/login`). `views/oauth-consent.ejs` + `partials/consent-body.ejs` reuse the auth-card: the consent screen lists the requested scopes (friendly labels for the standard OIDC ones) with Allow/Deny submit buttons. Tests-first: `hydra-admin.test.ts` (consent request contracts), `oauth-consent.test.ts` (skip/first-party/third-party/audience/id_token/accept-refetch/reject matrix), `app.test.ts` HTTP integration (auto-accept / screen render+CSRF cookie / allow+deny / forged-CSRF→403 / missing→400 / stale→400 / outage→500). Stability-reviewer run as a local PR: APPROVE, no Critical/High. Extended the full-stack E2E `e2e/oauth-login.spec.ts` to drive the **whole** authorization-code flow against real Hydra — login accept → follow the login_verifier through Hydra → web's consent screen (third-party client `e2e-login`, scopes listed) → Allow → consent_verifier → the client callback with a real `code` (per-host cookie jars; Hydra resume URLs rebased onto the compose host). typecheck + 262 units + 8 visual + OAuth login+consent E2E green; stack torn down. OAuth2 client registration is the next §6 item.
|
||||||
- [ ] OAuth2 client registration (admin UI or CLI).
|
- [ ] OAuth2 client registration (admin UI or CLI).
|
||||||
- [ ] Run the architecture and the product reviewer agents on the _whole_ project, not just the latest changes, and address their issues.
|
- [ ] Run the architecture and the product reviewer agents on the _whole_ project, not just the latest changes, and address their issues.
|
||||||
- [ ] Go over all comments in the code and the README and try to make it shorter and more information dense. Remove not strictly needed stuff.
|
- [ ] Go over all comments in the code and the README and try to make it shorter and more information dense. Remove not strictly needed stuff.
|
||||||
|
|||||||
36
views/oauth-consent.ejs
Normal file
36
views/oauth-consent.ejs
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
<%#
|
||||||
|
Themed OAuth2 consent page (todo §6): shown when a third-party client wants access and the
|
||||||
|
user must approve. Reuses the auth layout + auth-card; the form posts (Allow/Deny) to our own
|
||||||
|
/oauth2/consent route, CSRF-guarded (consent-body carries the token). Auto theme (styles.css).
|
||||||
|
%><%
|
||||||
|
const brand = locals.brand || "Plainpages";
|
||||||
|
const body = include("partials/consent-body", { challenge: consent.challenge, csrfField, csrfToken, scopes: consent.scopes });
|
||||||
|
%><!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
<title>Authorize <%= consent.client %></title>
|
||||||
|
<link rel="stylesheet" href="/public/css/styles.css" />
|
||||||
|
<link rel="stylesheet" href="/public/css/auth.css" />
|
||||||
|
<link rel="icon" href="/public/favicon.svg" />
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<%- include("partials/icons") %>
|
||||||
|
<main class="auth-stage">
|
||||||
|
<div class="auth">
|
||||||
|
<div class="auth-brand">
|
||||||
|
<span class="brand-mark"><svg class="ico ico-sm"><use href="#i-box" /></svg></span>
|
||||||
|
<span class="brand-name"><%= brand %></span>
|
||||||
|
</div>
|
||||||
|
<%- include("partials/auth-card", {
|
||||||
|
action: "/oauth2/consent",
|
||||||
|
body,
|
||||||
|
method: "post",
|
||||||
|
sub: `${consent.client} wants access to your account.`,
|
||||||
|
title: `Authorize ${consent.client}`,
|
||||||
|
}) %>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
17
views/partials/consent-body.ejs
Normal file
17
views/partials/consent-body.ejs
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
<%#
|
||||||
|
OAuth2 consent form body (todo §6): the inner of the auth-card form — the CSRF + challenge
|
||||||
|
hidden inputs, the requested scopes, then Allow / Deny submit buttons (one `decision` field).
|
||||||
|
Locals: challenge, csrfField, csrfToken, scopes (string[]). Captured by views/oauth-consent.ejs.
|
||||||
|
-%>
|
||||||
|
<% const labels = { email: "Your email address", offline_access: "Stay signed in (offline access)", openid: "Verify your identity", profile: "Your basic profile (name)" }; -%>
|
||||||
|
<input type="hidden" name="<%= csrfField %>" value="<%= csrfToken %>">
|
||||||
|
<input type="hidden" name="consent_challenge" value="<%= challenge %>">
|
||||||
|
<% if (scopes.length) { -%>
|
||||||
|
<ul class="plain-list consent-scopes">
|
||||||
|
<% scopes.forEach((s) => { -%>
|
||||||
|
<li><strong><%= s %></strong><% if (labels[s]) { %> — <%= labels[s] %><% } %></li>
|
||||||
|
<% }) -%>
|
||||||
|
</ul>
|
||||||
|
<% } -%>
|
||||||
|
<button type="submit" class="btn btn-block btn-primary" name="decision" value="allow">Allow</button>
|
||||||
|
<button type="submit" class="btn btn-block" name="decision" value="deny">Deny</button>
|
||||||
Reference in New Issue
Block a user