Built-in OAuth2 login-challenge handler (todo §6); /oauth2/login resolves a Hydra login challenge via the Kratos session — skip→accept(subject), live session→accept(identity id), no session→bounce to /login?return_to back here so Kratos lands on the challenge once signed in. New src/hydra-admin.ts (fetch client: get/accept/reject login request + HydraError, mirrors the kratos/keto clients) + src/oauth-login.ts (pure resolveLoginChallenge); wired in app.ts (the absolute return URL 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 return_to into the flow init), config.hydraAdminUrl (default http://hydra:4445), server builds the client, compose web now gates on hydra healthy (the app consumes it). A stale/invalid/consumed challenge (Hydra 4xx — back button, slow login) degrades to a recoverable 400, not a 500; a genuine Hydra 5xx outage still surfaces as 500. Tests-first: hydra-admin/oauth-login units + app/config/compose HTTP integration + full-stack e2e/oauth-login.spec.ts (compose.e2e-oauth.yml — registers an OAuth2 client, starts an auth flow, asserts the unauthenticated bounce and the authenticated accept; boot-verified then torn down). Stability-reviewer run as a local PR: APPROVE, no Critical/High; addressed its one warning (4xx→400 degrade). Deferred §9: document that prod allowed_return_urls entries must be exact origins with a trailing /. typecheck + 253 units + 8 visual + oauth-login E2E green. Consent handler + client registration are the next §6 items.

This commit is contained in:
2026-06-18 21:45:24 +02:00
parent bfc9f80b61
commit 3c8090e8e3
15 changed files with 524 additions and 14 deletions

View File

@@ -9,6 +9,7 @@ import { fileURLToPath } from "node:url";
import { createApp, type AppOptions } from "./app.ts";
import { CSRF_COOKIE, issueCsrfToken } from "./csrf.ts";
import { can, check, GuardError, requireSession } from "./guards.ts";
import { HydraError, type HydraAdmin } from "./hydra-admin.ts";
import { staticJwks } from "./jwks.ts";
import type { ExpandTree, KetoClient, RelationTuple, SubjectSet } from "./keto-client.ts";
import type { Identity, KratosAdmin } from "./kratos-admin.ts";
@@ -493,6 +494,72 @@ test("logout (CSRF-guarded POST): valid token revokes the Kratos session + clear
assert.equal((await post("", `_csrf=${token}`)).status, 403); // no cookie to match
});
// OAuth2 login challenge (§6): another app logs in *through* us; Hydra hands the browser here.
const stubHydra = (over: Partial<HydraAdmin> = {}): HydraAdmin => ({
acceptLoginRequest: async () => ({ redirect: "http://127.0.0.1:4444/oauth2/auth?login_verifier=v" }),
getLoginRequest: async () => ({ challenge: "chal1", skip: false, subject: "" }),
rejectLoginRequest: async () => { throw new Error("unused"); },
...over,
});
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" };
let acceptedSubject: string | undefined;
const hydra = stubHydra({ acceptLoginRequest: async (_c, b) => { acceptedSubject = b.subject; return { redirect: "http://127.0.0.1:4444/oauth2/auth?login_verifier=v" }; } });
const signedIn = createApp({ hydra, kratos: withWhoami(async () => ({ active: true, identity }) as Session) });
await new Promise<void>((r) => signedIn.listen(0, r));
t.after(() => signedIn.close());
const base = `http://localhost:${(signedIn.address() as AddressInfo).port}`;
// Signed in: accept the challenge with the Kratos identity → 303 to Hydra's resume URL.
const accept = await fetch(base + "/oauth2/login?login_challenge=chal1", { headers: { cookie: "plainpages_session=s" }, redirect: "manual" });
assert.equal(accept.status, 303);
assert.match(accept.headers.get("location") ?? "", /\/oauth2\/auth\?login_verifier=v/);
assert.equal(acceptedSubject, identity.id);
// Missing login_challenge → 400 (someone hit the endpoint directly).
assert.equal((await fetch(base + "/oauth2/login", { redirect: "manual" })).status, 400);
// A stale/invalid/consumed challenge (Hydra 4xx — back button, slow login) degrades to a
// recoverable 400, not a 500. A genuine Hydra outage (5xx) still surfaces as a 500.
const staleHydra = stubHydra({ getLoginRequest: async () => { throw new HydraError("gone", 410, ""); } });
const stale = createApp({ hydra: staleHydra, kratos: withWhoami(async () => null) });
await new Promise<void>((r) => stale.listen(0, r));
t.after(() => stale.close());
const staleBase = `http://localhost:${(stale.address() as AddressInfo).port}`;
assert.equal((await fetch(staleBase + "/oauth2/login?login_challenge=gone", { redirect: "manual" })).status, 400);
const downHydra = stubHydra({ getLoginRequest: async () => { throw new HydraError("down", 503, ""); } });
const down = createApp({ hydra: downHydra, 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/login?login_challenge=x`, { redirect: "manual" })).status, 500);
// Not signed in: bounce to the themed login, return_to carrying an absolute URL back to here.
const anon = createApp({ hydra: stubHydra(), kratos: withWhoami(async () => null) });
await new Promise<void>((r) => anon.listen(0, r));
t.after(() => anon.close());
const bounce = await fetch(`http://localhost:${(anon.address() as AddressInfo).port}/oauth2/login?login_challenge=chal1`, { redirect: "manual" });
assert.equal(bounce.status, 303);
const loc = bounce.headers.get("location") ?? "";
assert.match(loc, /^\/login\?return_to=/);
assert.match(decodeURIComponent(loc.split("return_to=")[1]!), /^http:\/\/[^/]+\/oauth2\/login\?login_challenge=chal1$/);
});
test("/login?return_to=… bakes the return target into the Kratos flow init (§6 OAuth bounce)", async (t) => {
let seenReturnTo: string | undefined;
const kratos: KratosPublic = {
...mockKratos(async () => { throw new Error("unused"); }),
initBrowserFlow: async (_t, opts) => { seenReturnTo = opts?.returnTo; return { flow: { id: "f1", ui: { action: "", method: "post", nodes: [] } }, setCookie: [] }; },
};
const app = createApp({ kratos });
await new Promise<void>((r) => app.listen(0, r));
t.after(() => app.close());
const returnTo = "http://127.0.0.1:3000/oauth2/login?login_challenge=c";
await fetch(`http://localhost:${(app.address() as AddressInfo).port}/login?return_to=${encodeURIComponent(returnTo)}`, { redirect: "manual" });
assert.equal(seenReturnTo, returnTo);
});
// 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) => {
const mk = (email: string, over: Partial<Identity> = {}): Identity =>

View File

@@ -15,12 +15,14 @@ import { PLUGINS_DIR } from "./discovery.ts";
import { GuardError } from "./guards.ts";
import { AUTH_FLOWS, buildFlowView } from "./flow-view.ts";
import { runRequestHooks, runResponseHooks } from "./hooks.ts";
import { HydraError, type HydraAdmin } from "./hydra-admin.ts";
import type { JwksProvider } from "./jwks.ts";
import { resolveSession, type VerifyOptions } from "./jwt-middleware.ts";
import type { KetoClient } from "./keto-client.ts";
import type { KratosAdmin } from "./kratos-admin.ts";
import { KratosError, type KratosPublic } from "./kratos-public.ts";
import { clearSessionCookie, completeLogin, remintSession, sessionCookie } from "./login.ts";
import { resolveLoginChallenge } from "./oauth-login.ts";
import { DEFAULT_MENU, type MenuConfig } from "./menu-config.ts";
import type { Plugin, RouteResult } from "./plugin.ts";
import { allowedMethods, isAuthorized, matchRoute } from "./router.ts";
@@ -35,6 +37,7 @@ export interface AppOptions {
// Off by default so edits show live; the app itself never inspects the environment.
cache?: boolean;
csrfSecret?: string; // HMAC key for the double-submit CSRF token (config.csrfSecret); random if omitted
hydra?: HydraAdmin; // Hydra admin client; with kratos enables the OAuth2 login challenge (§6)
jwks?: JwksProvider; // verify the session JWT → ctx.user/roles (§4); absent ⇒ always anonymous
keto?: KetoClient; // Keto client; with kratos+kratosAdmin enables login completion (§4)
kratos?: KratosPublic; // Kratos public client; enables the themed self-service routes (§4)
@@ -52,6 +55,7 @@ export function createApp(options: AppOptions = {}): Server {
const cache = options.cache ?? false;
const csrfSecret = options.csrfSecret ?? randomBytes(32).toString("hex"); // server passes config; tests pass their own
const secureCookies = options.secureCookies ?? false;
const hydra = options.hydra;
const jwks = options.jwks;
const keto = options.keto;
const kratos = options.kratos;
@@ -184,7 +188,10 @@ export function createApp(options: AppOptions = {}): Server {
const flowId = ctx.url.searchParams.get("flow");
if (!flowId) {
// No flow yet: init one server-side, relay Kratos' CSRF cookie, bounce to ?flow=<id>.
const { flow, setCookie } = await kratos.initBrowserFlow(flowType, cookie ? { cookie } : {});
// A `return_to` (e.g. the OAuth2 login challenge bouncing here, §6) is baked into the
// flow so Kratos lands back there after login instead of the default completion route.
const returnTo = ctx.url.searchParams.get("return_to") ?? undefined;
const { flow, setCookie } = await kratos.initBrowserFlow(flowType, { ...(cookie ? { cookie } : {}), ...(returnTo ? { returnTo } : {}) });
if (setCookie.length) res.appendHeader("set-cookie", setCookie);
res.writeHead(303, { location: `${pathname}?flow=${flow.id}` }).end();
return;
@@ -201,6 +208,34 @@ export function createApp(options: AppOptions = {}): Server {
return;
}
// OAuth2 login challenge (§6): Hydra hands the browser here when another app logs in
// *through* us. Resolve it via the Kratos session and accept; an unauthenticated user
// bounces to our themed login and returns here once signed in. Challenge looked up over
// Hydra's admin API. Nothing first-party needs this — it's the OAuth2-provider role only.
if (hydra && kratos && pathname === "/oauth2/login" && (method === "GET" || method === "HEAD")) {
const challenge = ctx.url.searchParams.get("login_challenge");
if (!challenge) {
res.writeHead(400, { "content-type": "text/plain; charset=utf-8" }).end("Missing login_challenge");
return;
}
// Absolute return target so Kratos lands back here post-login. Host reflects what the
// browser used (so it matches Kratos' allowed_return_urls); scheme follows SECURE_COOKIES.
// A spoofed Host can't escape — Kratos validates return_to against its allow-list.
const origin = `${secureCookies ? "https" : "http"}://${req.headers.host ?? "127.0.0.1:3000"}`;
const selfUrl = `${origin}/oauth2/login?login_challenge=${encodeURIComponent(challenge)}`;
try {
const { redirect } = await resolveLoginChallenge({ hydra, kratos }, challenge, req.headers.cookie, selfUrl);
res.writeHead(303, { location: redirect }).end();
} catch (err) {
// A stale/invalid/consumed challenge (Hydra 4xx — back button, slow login, re-used URL) is
// user-reachable: tell them to restart rather than 500. A 5xx (Hydra down) rethrows → 500.
if (err instanceof HydraError && err.status < 500) {
res.writeHead(400, { "content-type": "text/plain; charset=utf-8" }).end("This sign-in request has expired. Please start again from the application you were signing in to.");
} else throw err;
}
return;
}
// Login completion: where Kratos lands the browser after authenticating (kratos.yml).
// 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).

View File

@@ -1,7 +1,7 @@
// Guards the dev/prod compose split + stack ordering (§3): every image is pinned to an
// exact version (AGENTS.md), long-running Ory services carry readiness healthchecks so
// `depends_on: service_healthy` works, the web app waits for the services it talks to
// (kratos + keto, per config.ts), prod publishes no internal Ory ports while dev exposes
// (kratos + keto + hydra), prod publishes no internal Ory ports while dev exposes
// the ones a browser must reach, and the visual E2E stays Ory-free. Real boot is verified
// by running the stack; this catches edits.
import { test } from "node:test";
@@ -40,9 +40,10 @@ test("long-running Ory services declare readiness healthchecks", () => {
`${svc} probes :${port}/health/ready`);
});
test("web waits for kratos and keto to be healthy before starting", () => {
test("web waits for kratos, keto and hydra to be healthy before starting", () => {
assert.match(webBlock, /depends_on:/, "web declares dependencies");
for (const svc of ["kratos", "keto"])
// hydra: the §6 OAuth2 login/consent handler talks to its admin API.
for (const svc of ["kratos", "keto", "hydra"])
assert.match(webBlock, new RegExp(`${svc}:\\s*\\n\\s*condition:\\s*service_healthy`),
`web waits for ${svc} healthy`);
});

View File

@@ -19,6 +19,7 @@ test("loads dev defaults when the environment is empty", () => {
assert.equal(c.kratosAdminUrl, "http://kratos:4434");
assert.equal(c.ketoReadUrl, "http://keto:4466");
assert.equal(c.ketoWriteUrl, "http://keto:4467");
assert.equal(c.hydraAdminUrl, "http://hydra:4445");
assert.match(c.cookieSecret, /dev-insecure/);
assert.match(c.csrfSecret, /dev-insecure/);
assert.equal(c.jwtClockSkewSec, 60); // default exp/nbf leeway for Kratos↔web clock drift

View File

@@ -12,6 +12,7 @@ export interface Config {
cacheTemplates: boolean;
cookieSecret: string;
csrfSecret: string;
hydraAdminUrl: string;
jwksUrl: string;
jwtAudience: string | undefined;
jwtClockSkewSec: number;
@@ -87,6 +88,8 @@ export function loadConfig(env: Env = process.env): Config {
cacheTemplates: readBool(env, "CACHE_TEMPLATES", false),
cookieSecret: readSecret(env, "COOKIE_SECRET", "dev-insecure-cookie-secret", requireSecure),
csrfSecret: readSecret(env, "CSRF_SECRET", "dev-insecure-csrf-secret", requireSecure),
// Hydra admin API — the OAuth2 login/consent challenge handshake (§6); not on the first-party path.
hydraAdminUrl: readUrl(env, "HYDRA_ADMIN_URL", "http://hydra:4445"),
// §4 verifier reads the same key the Kratos tokenizer signs with (kratos.yml jwks_url).
// Kratos doesn't republish it over HTTP, so default to a file:// of the tokenizer JWKS
// mounted into web (compose.yml). Prod overrides with a real key (README: rotation).

60
src/hydra-admin.test.ts Normal file
View File

@@ -0,0 +1,60 @@
// Hydra admin-API client (§6): typed fetch wrappers over Ory Hydra's OAuth2 login/consent
// challenge handshake. Guards the request contracts (URLs, method, login_challenge query,
// JSON body) and the result mapping (200 → request/redirect, non-2xx → HydraError). Live
// wiring is verified by the OAuth login E2E.
import { test } from "node:test";
import assert from "node:assert/strict";
import { createHydraAdmin, HydraError } from "./hydra-admin.ts";
const BASE = "http://hydra:4445";
const CHALLENGE = "a1b2c3d4e5f6";
const SUBJECT = "01902d5e-7b6c-7e3a-9f21-3c8d1e0a4b55";
function res(status: number, body?: unknown): Response {
const h = new Headers();
if (body !== undefined) h.set("content-type", "application/json");
return new Response(body === undefined ? null : JSON.stringify(body), { status, headers: h });
}
function recorder(handler: (url: string, init: RequestInit | undefined) => Response) {
const calls: { body: string | undefined; method: string; url: string }[] = [];
const fetchImpl = (async (input: unknown, init?: RequestInit) => {
calls.push({ body: init?.body as string | undefined, method: init?.method ?? "GET", url: String(input) });
return handler(String(input), init);
}) as typeof fetch;
return { calls, fetchImpl };
}
test("getLoginRequest GETs the login challenge and returns the request", async () => {
const request = { challenge: CHALLENGE, client: { client_id: "c1" }, requested_scope: ["openid"], skip: false, subject: "" };
const { calls, fetchImpl } = recorder(() => res(200, request));
const out = await createHydraAdmin({ baseUrl: BASE, fetchImpl }).getLoginRequest(CHALLENGE);
assert.deepEqual(out, request);
assert.equal(calls[0]!.method, "GET");
assert.match(calls[0]!.url, /\/admin\/oauth2\/auth\/requests\/login\?login_challenge=a1b2c3d4e5f6$/);
});
test("acceptLoginRequest PUTs the subject and returns Hydra's redirect_to", async () => {
const { calls, fetchImpl } = recorder(() => res(200, { redirect_to: "http://hydra/oauth2/auth?login_verifier=v" }));
const out = await createHydraAdmin({ baseUrl: BASE, fetchImpl }).acceptLoginRequest(CHALLENGE, { remember: true, remember_for: 0, subject: SUBJECT });
assert.equal(out.redirect, "http://hydra/oauth2/auth?login_verifier=v");
assert.equal(calls[0]!.method, "PUT");
assert.match(calls[0]!.url, /\/admin\/oauth2\/auth\/requests\/login\/accept\?login_challenge=a1b2c3d4e5f6$/);
assert.deepEqual(JSON.parse(calls[0]!.body!), { remember: true, remember_for: 0, subject: SUBJECT });
});
test("rejectLoginRequest 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 }).rejectLoginRequest(CHALLENGE, { error: "access_denied", error_description: "no" });
assert.equal(out.redirect, "http://client/cb?error=access_denied");
assert.equal(calls[0]!.method, "PUT");
assert.match(calls[0]!.url, /\/admin\/oauth2\/auth\/requests\/login\/reject\?login_challenge=a1b2c3d4e5f6$/);
assert.deepEqual(JSON.parse(calls[0]!.body!), { error: "access_denied", error_description: "no" });
});
test("a non-2xx response throws a HydraError carrying the status", async () => {
const { fetchImpl } = recorder(() => res(404, { error: "Not Found" }));
await assert.rejects(
createHydraAdmin({ baseUrl: BASE, fetchImpl }).getLoginRequest("gone"),
(e: unknown) => e instanceof HydraError && e.status === 404,
);
});

89
src/hydra-admin.ts Normal file
View File

@@ -0,0 +1,89 @@
// Hydra admin-API client (todo §6): typed `fetch` wrappers over Ory Hydra's OAuth2 admin
// endpoints (internal admin port) — the login/consent challenge handshake other apps log in
// *through* us with. Built-in `fetch` only, no SDK dep (AGENTS.md); `fetchImpl`-injectable
// like the kratos/keto clients. We authenticate the user (login) and grant scopes (consent);
// Hydra mints the tokens.
export interface OAuth2Client {
client_id?: string;
client_name?: string;
}
// A login request Hydra hands us at /oauth2/login. `skip` ⇒ Hydra already authenticated this
// subject (honour it, don't re-prompt); otherwise we authenticate via the Kratos session.
export interface LoginRequest {
challenge: string;
client?: OAuth2Client;
request_url?: string;
requested_scope?: string[];
skip: boolean;
subject: string;
}
export interface AcceptLogin {
acr?: string;
remember?: boolean;
remember_for?: number; // seconds; 0 ⇒ for the browser-session lifetime
subject: string;
}
export interface RejectRequest {
error?: string;
error_description?: string;
}
// Hydra's answer to an accept/reject: the URL to send the browser to, to resume the flow.
export interface Completed {
redirect: string;
}
// Carries the HTTP status so a caller can branch (parallels KratosError/KetoError).
export class HydraError extends Error {
body: string;
status: number;
constructor(message: string, status: number, body: string) {
super(message);
this.body = body;
this.name = "HydraError";
this.status = status;
}
}
export interface HydraAdmin {
acceptLoginRequest(challenge: string, body: AcceptLogin): Promise<Completed>;
getLoginRequest(challenge: string): Promise<LoginRequest>;
rejectLoginRequest(challenge: string, body: RejectRequest): Promise<Completed>;
}
export function createHydraAdmin(config: { baseUrl: string; fetchImpl?: typeof fetch }): HydraAdmin {
const base = config.baseUrl.replace(/\/+$/, "");
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)}`;
async function fail(action: string, res: Response): Promise<never> {
throw new HydraError(`Hydra admin ${action} failed (${res.status})`, res.status, await res.text());
}
async function complete(action: string, res: Response): Promise<Completed> {
if (res.status !== 200) return fail(action, res);
return { redirect: ((await res.json()) as { redirect_to: string }).redirect_to };
}
return {
async acceptLoginRequest(challenge, body) {
return complete("accept login", await http(loginUrl(challenge, "/accept"), { body: JSON.stringify(body), headers: json, method: "PUT" }));
},
async getLoginRequest(challenge) {
const res = await http(loginUrl(challenge));
if (res.status !== 200) return fail("get login request", res);
return (await res.json()) as LoginRequest;
},
async rejectLoginRequest(challenge, body) {
return complete("reject login", await http(loginUrl(challenge, "/reject"), { body: JSON.stringify(body), headers: json, method: "PUT" }));
},
};
}

53
src/oauth-login.test.ts Normal file
View File

@@ -0,0 +1,53 @@
// OAuth2 login-challenge resolution (§6): given a Hydra login challenge, authenticate the user
// via their Kratos session and accept — or bounce an unauthenticated user to the Kratos login UI.
import { test } from "node:test";
import assert from "node:assert/strict";
import type { AcceptLogin, HydraAdmin, LoginRequest } from "./hydra-admin.ts";
import type { KratosPublic, Session } from "./kratos-public.ts";
import { resolveLoginChallenge } from "./oauth-login.ts";
const CHALLENGE = "chal-1";
const SUBJECT = "01902d5e-7b6c-7e3a-9f21-3c8d1e0a4b55";
const SELF = "http://127.0.0.1:3000/oauth2/login?login_challenge=chal-1";
function stubHydra(login: LoginRequest, capture?: (b: AcceptLogin) => void): HydraAdmin {
return {
acceptLoginRequest: async (_c, body) => { capture?.(body); return { redirect: "http://hydra/oauth2/auth?login_verifier=v" }; },
getLoginRequest: async () => login,
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 session = (id: string): Session => ({ active: true, identity: { id } });
test("a live Kratos session accepts the login with that subject → Hydra redirect", async () => {
let accepted: AcceptLogin | undefined;
const hydra = stubHydra({ challenge: CHALLENGE, skip: false, subject: "" }, (b) => { accepted = b; });
const out = await resolveLoginChallenge({ hydra, kratos: stubKratos(async () => session(SUBJECT)) }, CHALLENGE, "plainpages_session=s", SELF);
assert.equal(out.redirect, "http://hydra/oauth2/auth?login_verifier=v");
assert.equal(accepted?.subject, SUBJECT);
assert.equal(accepted?.remember, true);
});
test("skip (Hydra already authenticated) accepts the request's subject without checking Kratos", async () => {
let accepted: AcceptLogin | undefined;
let whoamiCalled = false;
const hydra = stubHydra({ challenge: CHALLENGE, skip: true, subject: SUBJECT }, (b) => { accepted = b; });
const kratos = stubKratos(async () => { whoamiCalled = true; return null; });
const out = await resolveLoginChallenge({ hydra, kratos }, CHALLENGE, undefined, SELF);
assert.equal(out.redirect, "http://hydra/oauth2/auth?login_verifier=v");
assert.equal(accepted?.subject, SUBJECT);
assert.equal(whoamiCalled, false, "skip short-circuits the Kratos check");
});
test("no Kratos session bounces to the themed login UI, returning here once authenticated", async () => {
const hydra = stubHydra({ challenge: CHALLENGE, skip: false, subject: "" });
const out = await resolveLoginChallenge({ hydra, kratos: stubKratos(async () => null) }, CHALLENGE, undefined, SELF);
assert.equal(out.redirect, `/login?return_to=${encodeURIComponent(SELF)}`);
});

42
src/oauth-login.ts Normal file
View File

@@ -0,0 +1,42 @@
// OAuth2 login-challenge handler (todo §6): when another app logs in *through* plainpages,
// Hydra hands the browser to /oauth2/login?login_challenge=… (hydra.yml urls.login). We
// authenticate the user with their existing Kratos session and accept the request; Hydra then
// proceeds to consent and mints the tokens. No first-party page needs this — it's the OAuth2
// provider role only (README).
import type { HydraAdmin } from "./hydra-admin.ts";
import type { KratosPublic } from "./kratos-public.ts";
// Remember the Hydra login for the browser-session lifetime (0), so a client re-authorizing
// doesn't re-run this on every token refresh while the Kratos session lives.
const REMEMBER_FOR = 0;
export interface OAuthLoginDeps {
hydra: HydraAdmin;
kratos: KratosPublic;
}
export interface LoginResolution {
redirect: string;
}
// Resolve a login challenge:
// - skip (Hydra already authenticated the subject) → accept it, don't re-prompt.
// - a live Kratos session → accept with that identity as the subject.
// - no session → send the browser to our themed Kratos
// login, returning to `selfUrl` (this challenge) once authenticated, where whoami succeeds.
export async function resolveLoginChallenge(
deps: OAuthLoginDeps,
challenge: string,
cookie: string | undefined,
selfUrl: string,
): Promise<LoginResolution> {
const login = await deps.hydra.getLoginRequest(challenge);
if (login.skip) {
return deps.hydra.acceptLoginRequest(challenge, { subject: login.subject });
}
const session = await deps.kratos.whoami(cookie ? { cookie } : {});
if (session?.identity) {
return deps.hydra.acceptLoginRequest(challenge, { remember: true, remember_for: REMEMBER_FOR, subject: session.identity.id });
}
return { redirect: `/login?return_to=${encodeURIComponent(selfUrl)}` };
}

View File

@@ -2,6 +2,7 @@ import { createApp } from "./app.ts";
import { loadConfig } from "./config.ts";
import { discoverPlugins } from "./discovery.ts";
import { runBootHooks } from "./hooks.ts";
import { createHydraAdmin } from "./hydra-admin.ts";
import { createJwksProvider } from "./jwks.ts";
import { createKetoClient } from "./keto-client.ts";
import { createKratosAdmin } from "./kratos-admin.ts";
@@ -14,6 +15,8 @@ const menu = await loadMenuConfig(); // config/menu.ts override + branding — f
const kratos = createKratosPublic({ baseUrl: config.kratosPublicUrl });
const kratosAdmin = createKratosAdmin({ baseUrl: config.kratosAdminUrl });
const keto = createKetoClient({ readUrl: config.ketoReadUrl, writeUrl: config.ketoWriteUrl });
// Hydra admin client for the OAuth2 login/consent challenge handshake (§6).
const hydra = createHydraAdmin({ baseUrl: config.hydraAdminUrl });
// Session-JWT verify key: primed at boot from the configured JWKS (file mount, base64 inline,
// or fetched http), then served from cache with TTL refresh + rotation-on-miss (§4).
const jwks = await createJwksProvider(config.jwksUrl);
@@ -26,6 +29,7 @@ const server = createApp({
auth: { audience: config.jwtAudience, clockSkewSec: config.jwtClockSkewSec, issuer: config.jwtIssuer },
cache: config.cacheTemplates,
csrfSecret: config.csrfSecret,
hydra,
jwks,
keto,
kratos,