Files
plainpages/src/oauth-consent.ts
lilleman 521c09fa2d §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.
2026-06-19 11:47:06 +02:00

86 lines
4.6 KiB
TypeScript

// 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 {
account?: string; // the signed-in user's email — shown so consent is informed (whose account)
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 } : {});
// Only project id_token claims when the session's identity matches the subject Hydra bound at
// login — never leak a mismatched session's email/name into the issued token (defensive).
const idToken = session?.identity?.id === consent.subject ? idTokenClaims(session?.identity?.traits) : undefined;
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) };
}
// Third party: name the signed-in account on the screen so the user sees whose access they grant.
const session = await deps.kratos.whoami(cookie ? { cookie } : {});
const email = session?.identity?.traits?.email;
const account = typeof email === "string" ? email : undefined;
return { view: { challenge, client: clientName(consent.client), scopes: consent.requested_scope ?? [], ...(account ? { account } : {}) } };
}
// 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;
}