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:
@@ -7,6 +7,7 @@
|
||||
export interface OAuth2Client {
|
||||
client_id?: 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
|
||||
@@ -27,6 +28,32 @@ export interface AcceptLogin {
|
||||
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 {
|
||||
error?: string;
|
||||
error_description?: string;
|
||||
@@ -50,8 +77,11 @@ export class HydraError extends Error {
|
||||
}
|
||||
|
||||
export interface HydraAdmin {
|
||||
acceptConsentRequest(challenge: string, body: AcceptConsent): Promise<Completed>;
|
||||
acceptLoginRequest(challenge: string, body: AcceptLogin): Promise<Completed>;
|
||||
getConsentRequest(challenge: string): Promise<ConsentRequest>;
|
||||
getLoginRequest(challenge: string): Promise<LoginRequest>;
|
||||
rejectConsentRequest(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 json = { "content-type": "application/json" };
|
||||
// Hydra keys the login/consent handshake off a ?login_challenge=/?consent_challenge= query.
|
||||
const loginUrl = (challenge: string, action = "") =>
|
||||
`${base}/admin/oauth2/auth/requests/login${action}?login_challenge=${encodeURIComponent(challenge)}`;
|
||||
const reqUrl = (kind: "consent" | "login", challenge: string, action = "") =>
|
||||
`${base}/admin/oauth2/auth/requests/${kind}${action}?${kind}_challenge=${encodeURIComponent(challenge)}`;
|
||||
|
||||
async function fail(action: string, res: Response): Promise<never> {
|
||||
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 };
|
||||
}
|
||||
|
||||
const put = (action: string, url: string, body: unknown) =>
|
||||
http(url, { body: JSON.stringify(body), headers: json, method: "PUT" }).then((r) => complete(action, r));
|
||||
|
||||
return {
|
||||
async acceptConsentRequest(challenge, body) {
|
||||
return put("accept consent", reqUrl("consent", challenge, "/accept"), 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) {
|
||||
const res = await http(loginUrl(challenge));
|
||||
const res = await http(reqUrl("login", challenge));
|
||||
if (res.status !== 200) return fail("get login request", res);
|
||||
return (await res.json()) as LoginRequest;
|
||||
},
|
||||
|
||||
async rejectConsentRequest(challenge, body) {
|
||||
return put("reject consent", reqUrl("consent", challenge, "/reject"), 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);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user