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:
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;
|
||||
}
|
||||
Reference in New Issue
Block a user