Accept a verification or recovery code pasted with whitespace around it
CI / full-gate (push) Failing after 16s

This commit is contained in:
2026-10-31 12:41:11 +01:00
parent 7cf37acb1b
commit ead604f49d
13 changed files with 194 additions and 85 deletions
+13 -27
View File
@@ -51,9 +51,7 @@ export interface LogoutFlow {
}
export interface FlowSubmission {
body: unknown; // parsed JSON: the re-rendered flow on 400, the success payload on 200
location: string | null; // redirect target (Location header, or a 422 redirect_browser_to)
ok: boolean; // status === 200
location: string | null; // where Kratos sends the browser next
setCookie: string[];
status: number;
}
@@ -74,23 +72,10 @@ export interface KratosPublic {
createLogoutFlow(opts?: { cookie?: string }): Promise<LogoutFlow | null>; // null ⇒ no active session (401)
getFlow(type: FlowType, id: string, opts?: { cookie?: string }): Promise<Flow>;
initBrowserFlow(type: FlowType, opts?: { cookie?: string; returnTo?: string }): Promise<FlowInit>;
submitFlow(action: string, opts: { body: string; contentType?: string; cookie?: string }): Promise<FlowSubmission>;
submitFlow(type: FlowType, flowId: string, opts: { body: string; cookie?: string }): Promise<FlowSubmission>;
whoami(opts?: { cookie?: string; tokenizeAs?: string }): Promise<Session | null>;
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
function parseBody(text: string): unknown {
if (!text) return null;
try {
return JSON.parse(text);
} catch {
return text;
}
}
export function createKratosPublic(config: { baseUrl: string; fetchImpl?: typeof fetch }): KratosPublic {
const base = config.baseUrl.replace(/\/+$/, "");
const http = config.fetchImpl ?? fetch;
@@ -129,16 +114,17 @@ export function createKratosPublic(config: { baseUrl: string; fetchImpl?: typeof
return (await res.json()) as Flow;
},
async submitFlow(action, opts) {
const h = headers(opts.cookie);
h["content-type"] = opts.contentType ?? "application/x-www-form-urlencoded";
// Manual redirect so we can read a 303 Location instead of following it server-side.
const res = await http(action, { body: opts.body, headers: h, method: "POST", redirect: "manual" });
const body = parseBody(await res.text());
const location =
res.headers.get("location") ??
(isRecord(body) && typeof body["redirect_browser_to"] === "string" ? body["redirect_browser_to"] : null);
return { body, location, ok: res.status === 200, setCookie: res.headers.getSetCookie(), status: res.status };
async submitFlow(type, flowId, opts) {
const url = new URL(`${base}/self-service/${type}`);
url.searchParams.set("flow", flowId);
// Asked for HTML, Kratos answers with the redirects a browser posting to it directly gets, so
// every success/error/expiry branch stays Kratos'. Its nosurf demands a Referer, which this
// POST lacks, only on an https request: valid while KRATOS_PUBLIC_URL is in-cluster http.
const h = { ...headers(opts.cookie), accept: "text/html", "content-type": "application/x-www-form-urlencoded" };
const res = await http(url, { body: opts.body, headers: h, method: "POST", redirect: "manual" });
if (res.status >= 500) throw new KratosError(`Kratos submit ${type} flow failed (${res.status})`, res.status, await res.text());
await res.body?.cancel();
return { location: res.headers.get("location"), setCookie: res.headers.getSetCookie(), status: res.status };
},
async whoami(opts = {}) {