§6 review checkpoint (todo §6); ran the architecture + product reviewers on the whole project (weighted to the Hydra OAuth2 surfaces) and addressed their findings — no Critical from either. Fixed tests-first: (HIGH, arch) /oauth2/logout was published to Hydra (hydra.yml urls.logout) and asserted in hydra.test.ts but had no handler — a dead/published contract; added hydra-admin.acceptLogoutRequest (PUT logout/accept via the shared reqUrl(kind…)) + a GET /oauth2/logout branch that accepts the RP-initiated logout_challenge → 303 to Hydra's post-logout redirect (missing→400, stale 4xx→recoverable 400, 5xx→500, byte-identical degrade to the login/consent siblings; GET-accept is safe since the challenge is Hydra-minted+single-use; the first-party POST /logout still owns ending the Kratos session + JWT cookie). (HIGH, arch) added oauth2 to RESERVED_PLUGIN_IDS so a plugins/oauth2/ folder can't silently shadow the provider routes (the route surface the §4 reserved-id fix missed; discovery now refuses it loud). (Product Blocker) the third-party consent screen now names the signed-in account — "Signed in as <email>" (ConsentView.account from whoami) — plus a CSRF-guarded "Not you? Sign out" form, so consent is informed on shared devices. (MEDIUM, arch) consent accept() now projects id_token claims only when the live Kratos session subject === the challenge subject Hydra bound at login, never leaking a mismatched session's email/name into the issued token (guards the auto-accept path too). (Product nits) register-form confidential-vs-public guidance + a client-detail "delete and re-register / secret shown once" note (no-edit friction + lost-secret). New tests across discovery (reserved oauth2), hydra-admin (acceptLogoutRequest contract), oauth-consent (subject-match + account-in-view), app.test (logout 303/400/500 matrix, consent identity+sign-out, client form/detail copy); e2e/oauth-login.spec asserts the consent screen names the account. Stability-reviewer run as a local PR: APPROVE, no Critical/High — addressed its doc/comment follow-ups (README §6 documents the logout handler + consent identity line; a comment notes the GET-accept is Hydra-validated). Deferred (reviewer-scoped): the host internal route-table (arch M1, now a pure dedup once H1/H2 are point-fixed) → §9; the RP-initiated-logout browser/live E2E → §8; redirect-URI scheme allowlist + safeUrl() → §7; full client edit / empty-list state / success-flash → §8/polish. typecheck + 279 units green; full-stack OAuth2 login+consent E2E verified live against real Hydra v26.2.0 then torn down.
This commit is contained in:
@@ -17,6 +17,7 @@ function stubHydra(consent: ConsentRequest, capture?: (b: AcceptConsent) => void
|
||||
return {
|
||||
acceptConsentRequest: async (_c, body) => { capture?.(body); return { redirect: REDIRECT }; },
|
||||
acceptLoginRequest: unused,
|
||||
acceptLogoutRequest: unused,
|
||||
createClient: unused,
|
||||
deleteClient: unused,
|
||||
getClient: unused,
|
||||
@@ -59,15 +60,31 @@ test("a first-party client (metadata.first_party) auto-accepts even without skip
|
||||
assert.equal(granted?.session, undefined);
|
||||
});
|
||||
|
||||
test("a third-party client shows the consent screen (no auto-accept)", async () => {
|
||||
test("a third-party client shows the consent screen carrying the signed-in account (no auto-accept)", async () => {
|
||||
let accepted = false;
|
||||
const hydra = stubHydra(consent(), () => { accepted = true; });
|
||||
const out = await resolveConsentChallenge({ hydra, kratos: stubKratos(async () => null) }, CHALLENGE, undefined);
|
||||
const kratos = stubKratos(async () => sessionWith({ email: "ada@x.io" }));
|
||||
const out = await resolveConsentChallenge({ hydra, kratos }, CHALLENGE, "plainpages_session=s");
|
||||
assert.equal(out.redirect, undefined);
|
||||
assert.deepEqual(out.view, { challenge: CHALLENGE, client: "Acme Reports", scopes: ["openid", "profile"] });
|
||||
assert.deepEqual(out.view, { account: "ada@x.io", challenge: CHALLENGE, client: "Acme Reports", scopes: ["openid", "profile"] });
|
||||
assert.equal(accepted, false);
|
||||
});
|
||||
|
||||
test("the consent screen omits the account when there's no session", async () => {
|
||||
const out = await resolveConsentChallenge({ hydra: stubHydra(consent()), kratos: stubKratos(async () => null) }, CHALLENGE, undefined);
|
||||
assert.equal(out.view?.account, undefined);
|
||||
});
|
||||
|
||||
test("id_token claims are only projected when the session subject matches the challenge (else omitted)", async () => {
|
||||
let granted: AcceptConsent | undefined;
|
||||
const hydra = stubHydra(consent(), (b) => { granted = b; });
|
||||
// A session whose identity differs from the challenge subject must not leak its claims into the grant.
|
||||
const other: Session = { active: true, identity: { id: "01902d5e-0000-7e3a-9f21-3c8d1e0a4b55", traits: { email: "mallory@x.io" } } };
|
||||
const redirect = await acceptConsent({ hydra, kratos: stubKratos(async () => other) }, CHALLENGE, "plainpages_session=s");
|
||||
assert.equal(redirect, REDIRECT);
|
||||
assert.equal(granted?.session, undefined);
|
||||
});
|
||||
|
||||
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; });
|
||||
|
||||
Reference in New Issue
Block a user