Render SSO buttons per configured Kratos OIDC provider (todo §4); flow-view collects oidc nodes → auth-card submit buttons, server-side visibility, drop mockup #sso-toggle CSS
This commit is contained in:
10
README.md
10
README.md
@@ -177,10 +177,12 @@ Off by default — a clean clone is password-only. Kratos activates a provider p
|
|||||||
from the environment (no code, no rebuild): set `SELFSERVICE_METHODS_OIDC_ENABLED=true`
|
from the environment (no code, no rebuild): set `SELFSERVICE_METHODS_OIDC_ENABLED=true`
|
||||||
and `SELFSERVICE_METHODS_OIDC_CONFIG_PROVIDERS` to a JSON array of providers (`google`,
|
and `SELFSERVICE_METHODS_OIDC_CONFIG_PROVIDERS` to a JSON array of providers (`google`,
|
||||||
`microsoft`, …), each carrying its `client_id`/`client_secret` and referencing the
|
`microsoft`, …), each carrying its `client_id`/`client_secret` and referencing the
|
||||||
committed claims mapper `ory/kratos/oidc/claims.jsonnet`. No creds ⇒ no provider ⇒ no
|
committed claims mapper `ory/kratos/oidc/claims.jsonnet`. The themed sign-in/register
|
||||||
SSO button (§4 derives the buttons from this list). Open-source Kratos has **no native
|
pages derive one button per provider from the live flow's `oidc` nodes, so no creds ⇒ no
|
||||||
SAML** — front it with an OIDC bridge (Ory Polis) and register that bridge as a generic
|
provider ⇒ no button, and the whole SSO section disappears when none are configured — no
|
||||||
OIDC provider the same way.
|
code change to add or remove one. Open-source Kratos has **no native SAML** — front it
|
||||||
|
with an OIDC bridge (Ory Polis) and register that bridge as a generic OIDC provider the
|
||||||
|
same way.
|
||||||
|
|
||||||
### JWT signing key & rotation
|
### JWT signing key & rotation
|
||||||
|
|
||||||
|
|||||||
@@ -49,9 +49,8 @@ body:has(#forgot:target) #login { display: none; }
|
|||||||
}
|
}
|
||||||
.auth-back:hover { color: var(--text); }
|
.auth-back:hover { color: var(--text); }
|
||||||
|
|
||||||
/* ---- SSO section (toggle on/off via #sso-toggle) ---- */
|
/* ---- SSO section (rendered server-side only when providers are configured) ---- */
|
||||||
.sso { display: flex; flex-direction: column; gap: 10px; }
|
.sso { display: flex; flex-direction: column; gap: 10px; }
|
||||||
body:not(:has(#sso-toggle:checked)) .sso { display: none; }
|
|
||||||
|
|
||||||
.sso-list { display: flex; flex-direction: column; gap: 8px; }
|
.sso-list { display: flex; flex-direction: column; gap: 8px; }
|
||||||
.sso-btn {
|
.sso-btn {
|
||||||
|
|||||||
@@ -213,6 +213,7 @@ const loginFlow = (id: string): Flow => ({
|
|||||||
node({ name: "identifier", required: true, type: "email" }, "E-Mail"),
|
node({ name: "identifier", required: true, type: "email" }, "E-Mail"),
|
||||||
node({ name: "password", required: true, type: "password" }, "Password"),
|
node({ name: "password", required: true, type: "password" }, "Password"),
|
||||||
node({ name: "method", type: "submit", value: "password" }, "Sign in"),
|
node({ name: "method", type: "submit", value: "password" }, "Sign in"),
|
||||||
|
{ attributes: { name: "provider", type: "submit", value: "google" }, group: "oidc", messages: [], meta: { label: { id: 1, text: "Sign in with Google", type: "info" } }, type: "input" },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -256,6 +257,9 @@ test("renders a fetched flow as the themed auth page: fields post straight to Kr
|
|||||||
assert.match(html, /name="password"[^>]*type="password"/);
|
assert.match(html, /name="password"[^>]*type="password"/);
|
||||||
assert.match(html, /<button type="submit"[^>]*name="method" value="password">Sign in<\/button>/);
|
assert.match(html, /<button type="submit"[^>]*name="method" value="password">Sign in<\/button>/);
|
||||||
assert.match(html, /<a href="\/registration">Create one<\/a>/); // alt link to register
|
assert.match(html, /<a href="\/registration">Create one<\/a>/); // alt link to register
|
||||||
|
// Configured OIDC provider → an SSO submit button in the same form (posts provider=google).
|
||||||
|
assert.match(html, /<div class="sso"/);
|
||||||
|
assert.match(html, /<button type="submit" class="sso-btn" name="provider" value="google">.*Sign in with Google<\/span><\/button>/s);
|
||||||
// The flow-level error renders as an alert.
|
// The flow-level error renders as an alert.
|
||||||
assert.match(html, /class="alert alert-neg"/);
|
assert.match(html, /class="alert alert-neg"/);
|
||||||
assert.match(html, /The provided credentials are invalid\./);
|
assert.match(html, /The provided credentials are invalid\./);
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ test("auth-card renders head, SSO providers (text logo + icon link), body slot a
|
|||||||
sso: { providers: [
|
sso: { providers: [
|
||||||
{ label: "Continue with Google", logo: "G" },
|
{ label: "Continue with Google", logo: "G" },
|
||||||
{ label: "Continue with SAML SSO", icon: "i-shield", href: "/sso/saml" },
|
{ label: "Continue with SAML SSO", icon: "i-shield", href: "/sso/saml" },
|
||||||
|
{ label: "Sign in with Microsoft", logo: "M", name: "provider", value: "microsoft" },
|
||||||
] },
|
] },
|
||||||
body: '<div id="fields">FORM</div><button class="btn btn-primary btn-block">Sign in</button>',
|
body: '<div id="fields">FORM</div><button class="btn btn-primary btn-block">Sign in</button>',
|
||||||
alt: { text: "Don't have an account?", href: "/register", label: "Create one" },
|
alt: { text: "Don't have an account?", href: "/register", label: "Create one" },
|
||||||
@@ -25,6 +26,8 @@ test("auth-card renders head, SSO providers (text logo + icon link), body slot a
|
|||||||
assert.match(html, /<div class="sso" aria-label="Single sign-on options"><ul class="sso-list">/);
|
assert.match(html, /<div class="sso" aria-label="Single sign-on options"><ul class="sso-list">/);
|
||||||
assert.match(html, /<li><button type="button" class="sso-btn"><span class="sso-logo" aria-hidden="true">G<\/span><span class="sso-label">Continue with Google<\/span><\/button><\/li>/);
|
assert.match(html, /<li><button type="button" class="sso-btn"><span class="sso-logo" aria-hidden="true">G<\/span><span class="sso-label">Continue with Google<\/span><\/button><\/li>/);
|
||||||
assert.match(html, /<li><a class="sso-btn" href="\/sso\/saml"><span class="sso-logo" aria-hidden="true"><svg class="ico ico-sm"><use href="#i-shield"\s*\/?><\/svg><\/span><span class="sso-label">Continue with SAML SSO<\/span><\/a><\/li>/);
|
assert.match(html, /<li><a class="sso-btn" href="\/sso\/saml"><span class="sso-logo" aria-hidden="true"><svg class="ico ico-sm"><use href="#i-shield"\s*\/?><\/svg><\/span><span class="sso-label">Continue with SAML SSO<\/span><\/a><\/li>/);
|
||||||
|
// A provider with name/value submits to the form (Kratos OIDC) — type="submit", not a decorative button.
|
||||||
|
assert.match(html, /<li><button type="submit" class="sso-btn" name="provider" value="microsoft"><span class="sso-logo" aria-hidden="true">M<\/span><span class="sso-label">Sign in with Microsoft<\/span><\/button><\/li>/);
|
||||||
assert.match(html, /<\/ul><div class="auth-divider">or<\/div><\/div>/);
|
assert.match(html, /<\/ul><div class="auth-divider">or<\/div><\/div>/);
|
||||||
|
|
||||||
// Body slot lands inside .auth-form; alt footer renders text + link.
|
// Body slot lands inside .auth-form; alt footer renders text + link.
|
||||||
|
|||||||
@@ -43,6 +43,9 @@ test("maps a password login flow: csrf hidden, themed email/password fields, a s
|
|||||||
// One submit button carrying its method name/value.
|
// One submit button carrying its method name/value.
|
||||||
assert.deepEqual(view.buttons, [{ label: "Sign in", name: "method", value: "password" }]);
|
assert.deepEqual(view.buttons, [{ label: "Sign in", name: "method", value: "password" }]);
|
||||||
|
|
||||||
|
// No OIDC providers configured ⇒ no SSO buttons.
|
||||||
|
assert.deepEqual(view.sso, []);
|
||||||
|
|
||||||
// Chrome derived from the flow type.
|
// Chrome derived from the flow type.
|
||||||
assert.equal(view.title, "Sign in");
|
assert.equal(view.title, "Sign in");
|
||||||
assert.equal(view.alt?.href, "/registration");
|
assert.equal(view.alt?.href, "/registration");
|
||||||
@@ -72,15 +75,22 @@ test("maps field errors and flow-level messages by tone", () => {
|
|||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("skips oidc (SSO) nodes but keeps the default-group csrf — SSO buttons are a later item", () => {
|
test("collects oidc nodes as SSO providers (text logo = initial), keeping csrf and the password submit separate", () => {
|
||||||
const view = buildFlowView(
|
const view = buildFlowView(
|
||||||
flow([
|
flow([
|
||||||
node({ name: "csrf_token", type: "hidden", value: "tok" }),
|
node({ name: "csrf_token", type: "hidden", value: "tok" }),
|
||||||
node({ name: "provider", type: "submit", value: "google" }, { label: "Sign in with Google", group: "oidc" }),
|
node({ name: "provider", type: "submit", value: "google" }, { label: "Sign in with Google", group: "oidc" }),
|
||||||
|
node({ name: "provider", type: "submit", value: "microsoft" }, { label: "Sign in with Microsoft", group: "oidc" }),
|
||||||
node({ name: "method", type: "submit", value: "password" }, { label: "Sign in", group: "password" }),
|
node({ name: "method", type: "submit", value: "password" }, { label: "Sign in", group: "password" }),
|
||||||
]),
|
]),
|
||||||
"login",
|
"login",
|
||||||
);
|
);
|
||||||
|
// One provider button per oidc node — a submit (name/value) posting to the same Kratos form.
|
||||||
|
assert.deepEqual(view.sso, [
|
||||||
|
{ label: "Sign in with Google", logo: "G", name: "provider", value: "google" },
|
||||||
|
{ label: "Sign in with Microsoft", logo: "M", name: "provider", value: "microsoft" },
|
||||||
|
]);
|
||||||
|
// SSO nodes don't leak into hidden/buttons.
|
||||||
assert.deepEqual(view.hidden, [{ name: "csrf_token", value: "tok" }]);
|
assert.deepEqual(view.hidden, [{ name: "csrf_token", value: "tok" }]);
|
||||||
assert.deepEqual(view.buttons, [{ label: "Sign in", name: "method", value: "password" }]);
|
assert.deepEqual(view.buttons, [{ label: "Sign in", name: "method", value: "password" }]);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
// Kratos flow → themed view model (todo §4). Pure: turns a fetched self-service Flow
|
// Kratos flow → themed view model (todo §4). Pure: turns a fetched self-service Flow
|
||||||
// (src/kratos-public.ts) into the data views/auth.ejs renders — hidden inputs (incl. the
|
// (src/kratos-public.ts) into the data views/auth.ejs renders — hidden inputs (incl. the
|
||||||
// CSRF token), themed fields, submit buttons, and tone-mapped messages. The form posts
|
// CSRF token), themed fields, submit buttons, tone-mapped messages, and one SSO button per
|
||||||
// straight back to `flow.ui.action`, so Kratos owns its CSRF; we only render and map errors.
|
// configured `oidc` provider. The form posts straight back to `flow.ui.action`, so Kratos
|
||||||
// SSO/oidc buttons are skipped here — they're derived per provider in the next §4 item.
|
// owns its CSRF; we only render and map errors. No providers configured ⇒ no SSO buttons.
|
||||||
|
|
||||||
import type { Flow, FlowType, UiNode } from "./kratos-public.ts";
|
import type { Flow, FlowType, UiNode } from "./kratos-public.ts";
|
||||||
|
|
||||||
@@ -24,6 +24,14 @@ export interface FlowButton {
|
|||||||
value?: string;
|
value?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// An OIDC provider, rendered as a submit button (name/value) posting to the same Kratos form.
|
||||||
|
export interface SsoProvider {
|
||||||
|
label: string; // Kratos' own label, e.g. "Sign in with Google"
|
||||||
|
logo: string; // text logo (provider initial) — lucide ships no brand marks
|
||||||
|
name: string; // submit field (Kratos: "provider")
|
||||||
|
value: string; // provider id (Kratos: "google")
|
||||||
|
}
|
||||||
|
|
||||||
export interface FlowMessage {
|
export interface FlowMessage {
|
||||||
text: string;
|
text: string;
|
||||||
tone: "info" | "neg" | "pos" | "warn";
|
tone: "info" | "neg" | "pos" | "warn";
|
||||||
@@ -43,6 +51,7 @@ export interface FlowView extends FlowChrome {
|
|||||||
hidden: { name: string; value: string }[];
|
hidden: { name: string; value: string }[];
|
||||||
messages: FlowMessage[];
|
messages: FlowMessage[];
|
||||||
method: string;
|
method: string;
|
||||||
|
sso: SsoProvider[]; // one per configured oidc provider; empty ⇒ no SSO section
|
||||||
}
|
}
|
||||||
|
|
||||||
// Themed route → Kratos flow type. The routes mirror kratos.yml's flow ui_urls.
|
// Themed route → Kratos flow type. The routes mirror kratos.yml's flow ui_urls.
|
||||||
@@ -79,6 +88,8 @@ function tone(type: string): FlowMessage["tone"] {
|
|||||||
return "info";
|
return "info";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const ssoLogo = (value: string): string => (value.charAt(0) || "?").toUpperCase();
|
||||||
|
|
||||||
function toField(node: UiNode, name: string, type: string): FlowField {
|
function toField(node: UiNode, name: string, type: string): FlowField {
|
||||||
const value = str(node.attributes["value"]);
|
const value = str(node.attributes["value"]);
|
||||||
const autocomplete = str(node.attributes["autocomplete"]);
|
const autocomplete = str(node.attributes["autocomplete"]);
|
||||||
@@ -101,12 +112,19 @@ export function buildFlowView(flow: Flow, type: FlowType): FlowView {
|
|||||||
const hidden: { name: string; value: string }[] = [];
|
const hidden: { name: string; value: string }[] = [];
|
||||||
const fields: FlowField[] = [];
|
const fields: FlowField[] = [];
|
||||||
const buttons: FlowButton[] = [];
|
const buttons: FlowButton[] = [];
|
||||||
|
const sso: SsoProvider[] = [];
|
||||||
|
|
||||||
for (const node of flow.ui.nodes) {
|
for (const node of flow.ui.nodes) {
|
||||||
if (node.type !== "input" || node.group === "oidc") continue; // SSO buttons: next §4 item
|
if (node.type !== "input") continue;
|
||||||
const name = str(node.attributes["name"]) ?? "";
|
const name = str(node.attributes["name"]) ?? "";
|
||||||
const inputType = str(node.attributes["type"]) ?? "text";
|
const inputType = str(node.attributes["type"]) ?? "text";
|
||||||
if (inputType === "hidden") {
|
if (node.group === "oidc") {
|
||||||
|
// One submit button per configured provider; posts provider=<value> to the same form.
|
||||||
|
if (inputType === "submit" || inputType === "button") {
|
||||||
|
const value = str(node.attributes["value"]) ?? "";
|
||||||
|
sso.push({ label: node.meta.label?.text ?? value, logo: ssoLogo(value), name, value });
|
||||||
|
}
|
||||||
|
} else if (inputType === "hidden") {
|
||||||
hidden.push({ name, value: str(node.attributes["value"]) ?? "" });
|
hidden.push({ name, value: str(node.attributes["value"]) ?? "" });
|
||||||
} else if (inputType === "submit" || inputType === "button") {
|
} else if (inputType === "submit" || inputType === "button") {
|
||||||
const value = str(node.attributes["value"]);
|
const value = str(node.attributes["value"]);
|
||||||
@@ -123,6 +141,7 @@ export function buildFlowView(flow: Flow, type: FlowType): FlowView {
|
|||||||
hidden,
|
hidden,
|
||||||
messages: (flow.ui.messages ?? []).map((m) => ({ text: m.text, tone: tone(m.type) })),
|
messages: (flow.ui.messages ?? []).map((m) => ({ text: m.text, tone: tone(m.type) })),
|
||||||
method: flow.ui.method || "post",
|
method: flow.ui.method || "post",
|
||||||
|
sso,
|
||||||
...CHROME[type],
|
...CHROME[type],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
2
todo.md
2
todo.md
@@ -79,7 +79,7 @@ everything via Docker.
|
|||||||
- [x] Kratos admin client (fetch): identity CRUD + `metadata_admin` update. → `src/kratos-admin.ts` (`createKratosAdmin({baseUrl, fetchImpl})`): typed `fetch` wrappers over Kratos' admin API (admin port), no SDK, `fetchImpl`-injectable like `kratos-public.ts`; reuses that module's `KratosError` (carries `.status`). `createIdentity` (POST, 201), `getIdentity` (GET, 404⇒`null`), `listIdentities({credentialsIdentifier?, ids?, pageSize?, pageToken?})` → `{identities, nextPageToken}` (parses the keyset cursor from the `Link` rel="next" header for the §5 users list), `updateIdentity` (full PUT), `deleteIdentity` (DELETE, 204), and `updateMetadataAdmin` — the key login-completion method: `PATCH` JSON-Patch `add /metadata_admin` so it sets the roles projection whether the field is absent/null/set and never clobbers traits/state. Building block — no route/E2E yet (login completion §4 line 83 wires it; the projection feeds the tokenizer's `metadata_admin` mapper, §3). Tests-first (`kratos-admin.test.ts`, mock fetch: URLs/method/JSON-Patch body/query+pagination/Link parsing + 201/200/404/409 mapping). README **Layout** lists it. typecheck + 167 units green.
|
- [x] Kratos admin client (fetch): identity CRUD + `metadata_admin` update. → `src/kratos-admin.ts` (`createKratosAdmin({baseUrl, fetchImpl})`): typed `fetch` wrappers over Kratos' admin API (admin port), no SDK, `fetchImpl`-injectable like `kratos-public.ts`; reuses that module's `KratosError` (carries `.status`). `createIdentity` (POST, 201), `getIdentity` (GET, 404⇒`null`), `listIdentities({credentialsIdentifier?, ids?, pageSize?, pageToken?})` → `{identities, nextPageToken}` (parses the keyset cursor from the `Link` rel="next" header for the §5 users list), `updateIdentity` (full PUT), `deleteIdentity` (DELETE, 204), and `updateMetadataAdmin` — the key login-completion method: `PATCH` JSON-Patch `add /metadata_admin` so it sets the roles projection whether the field is absent/null/set and never clobbers traits/state. Building block — no route/E2E yet (login completion §4 line 83 wires it; the projection feeds the tokenizer's `metadata_admin` mapper, §3). Tests-first (`kratos-admin.test.ts`, mock fetch: URLs/method/JSON-Patch body/query+pagination/Link parsing + 201/200/404/409 mapping). README **Layout** lists it. typecheck + 167 units green.
|
||||||
- [x] Keto client (fetch): `check`, list/expand relations, write/delete tuples. → `src/keto-client.ts` (`createKetoClient({readUrl, writeUrl, fetchImpl})`): typed `fetch` wrappers over Keto's relation-tuple APIs, no SDK, `fetchImpl`-injectable like the kratos clients; read (`check`/`listRelations`/`expand`) and write (`writeTuple`/`deleteTuple`) split onto the two ports config.ts targets (4466/4467). `RelationTuple` (subject_id xor subject_set; mirrors bootstrap's roleTuple) is the wire shape for writes + the filter shape for reads via `tupleParams` (subject sets → dotted `subject_set.*` keys). `check` returns a `bool` reading `allowed` from **both** 200 (allowed) and 403 (denied) — Keto answers a denial with 403, not 200 (caught in boot-verify); other statuses fail loud via `KetoError` (carries `.status`, parallels KratosError). `writeTuple` PUTs (idempotent), `deleteTuple` DELETEs by query, `listRelations` parses `next_page_token`, `expand` returns the loose tree. Building block — no route/E2E yet (login completion §4 line 83 + guards line 86 wire it). Tests-first (`keto-client.test.ts`, mock fetch: URLs/ports/method/query+body/subject forms/allowed mapping/pagination/errors). README **Layout** lists it. Boot-verified live: full round-trip against a real keto (check false → write → true → list → expand → delete → false). typecheck + 174 units green.
|
- [x] Keto client (fetch): `check`, list/expand relations, write/delete tuples. → `src/keto-client.ts` (`createKetoClient({readUrl, writeUrl, fetchImpl})`): typed `fetch` wrappers over Keto's relation-tuple APIs, no SDK, `fetchImpl`-injectable like the kratos clients; read (`check`/`listRelations`/`expand`) and write (`writeTuple`/`deleteTuple`) split onto the two ports config.ts targets (4466/4467). `RelationTuple` (subject_id xor subject_set; mirrors bootstrap's roleTuple) is the wire shape for writes + the filter shape for reads via `tupleParams` (subject sets → dotted `subject_set.*` keys). `check` returns a `bool` reading `allowed` from **both** 200 (allowed) and 403 (denied) — Keto answers a denial with 403, not 200 (caught in boot-verify); other statuses fail loud via `KetoError` (carries `.status`, parallels KratosError). `writeTuple` PUTs (idempotent), `deleteTuple` DELETEs by query, `listRelations` parses `next_page_token`, `expand` returns the loose tree. Building block — no route/E2E yet (login completion §4 line 83 + guards line 86 wire it). Tests-first (`keto-client.test.ts`, mock fetch: URLs/ports/method/query+body/subject forms/allowed mapping/pagination/errors). README **Layout** lists it. Boot-verified live: full round-trip against a real keto (check false → write → true → list → expand → delete → false). typecheck + 174 units green.
|
||||||
- [x] Render Kratos flows: fetch flow → render fields against our themed pages → POST to `flow.ui.action` (Kratos handles its CSRF), map field errors/messages. → `src/flow-view.ts` (pure `buildFlowView(flow, type)`): maps a fetched self-service `Flow` → themed view model — hidden inputs (incl. `csrf_token`), themed fields (label from `meta.label`, type/required/autocomplete from attributes, an input icon by field semantics, node-level error message), submit buttons (name/value preserved), and tone-mapped flow messages (error→neg/success→pos/info→info); `oidc` nodes skipped (SSO is the next item). Per-flow chrome (title/sub/back/alt) + `AUTH_FLOWS` path→type map. `views/auth.ejs` renders it into the html-css-foundation auth layout, reusing the `auth-card` + `field` partials and capturing `partials/flow-body.ejs` (messages + hidden + fields + buttons) into the card body; new reusable `partials/alert.ejs` + an `.alert` design-system component (styles.css, tone tokens). `app.ts` serves the five routes via an injectable `kratos` client (server.ts builds it from `config.kratosPublicUrl`): no `?flow=` ⇒ init server-side + relay Kratos' CSRF `Set-Cookie` + 303 to `?flow=<id>`; `?flow=<id>` ⇒ `getFlow` (forwarding the browser cookie) → render; an expired/unknown flow (403/404/410) re-inits. The browser POSTs the form straight to `flow.ui.action` (Kratos owns CSRF) — no server-side `submitFlow`. Tests-first: `flow-view.test.ts` (mapping matrix: hidden/fields/buttons/icons/errors/tone/oidc-skip/chrome/AUTH_FLOWS) + `app.test.ts` integration (init 303 + CSRF relay + expired restart; rendered page posts to Kratos with the live fields + error alert) — mock `KratosPublic`. typecheck + 181 units green. Boot-verified the whole chain on the live stack: `/login` 303 → `?flow=` relaying the real `csrf_token_…` cookie, the page posts to `127.0.0.1:4433` with the live token + identifier/password + submit; registration renders the real `traits.*` fields; recovery/verification chrome correct; a stale flow id 303s back to re-init; torn down. Browser-submittable end-to-end (dev http Secure-cookie posture, login completion → our JWT cookie) is the next §4 items (lines 83/89); the full live-stack login Playwright E2E is owned by §8.
|
- [x] Render Kratos flows: fetch flow → render fields against our themed pages → POST to `flow.ui.action` (Kratos handles its CSRF), map field errors/messages. → `src/flow-view.ts` (pure `buildFlowView(flow, type)`): maps a fetched self-service `Flow` → themed view model — hidden inputs (incl. `csrf_token`), themed fields (label from `meta.label`, type/required/autocomplete from attributes, an input icon by field semantics, node-level error message), submit buttons (name/value preserved), and tone-mapped flow messages (error→neg/success→pos/info→info); `oidc` nodes skipped (SSO is the next item). Per-flow chrome (title/sub/back/alt) + `AUTH_FLOWS` path→type map. `views/auth.ejs` renders it into the html-css-foundation auth layout, reusing the `auth-card` + `field` partials and capturing `partials/flow-body.ejs` (messages + hidden + fields + buttons) into the card body; new reusable `partials/alert.ejs` + an `.alert` design-system component (styles.css, tone tokens). `app.ts` serves the five routes via an injectable `kratos` client (server.ts builds it from `config.kratosPublicUrl`): no `?flow=` ⇒ init server-side + relay Kratos' CSRF `Set-Cookie` + 303 to `?flow=<id>`; `?flow=<id>` ⇒ `getFlow` (forwarding the browser cookie) → render; an expired/unknown flow (403/404/410) re-inits. The browser POSTs the form straight to `flow.ui.action` (Kratos owns CSRF) — no server-side `submitFlow`. Tests-first: `flow-view.test.ts` (mapping matrix: hidden/fields/buttons/icons/errors/tone/oidc-skip/chrome/AUTH_FLOWS) + `app.test.ts` integration (init 303 + CSRF relay + expired restart; rendered page posts to Kratos with the live fields + error alert) — mock `KratosPublic`. typecheck + 181 units green. Boot-verified the whole chain on the live stack: `/login` 303 → `?flow=` relaying the real `csrf_token_…` cookie, the page posts to `127.0.0.1:4433` with the live token + identifier/password + submit; registration renders the real `traits.*` fields; recovery/verification chrome correct; a stale flow id 303s back to re-init; torn down. Browser-submittable end-to-end (dev http Secure-cookie posture, login completion → our JWT cookie) is the next §4 items (lines 83/89); the full live-stack login Playwright E2E is owned by §8.
|
||||||
- [ ] SSO buttons → Kratos OIDC flows. **Render per configured provider only**: derive the list from Kratos' enabled OIDC providers (no creds ⇒ no button); hide the whole SSO section when none are configured. No code change needed to add/remove a provider — config only.
|
- [x] SSO buttons → Kratos OIDC flows. **Render per configured provider only**: derive the list from Kratos' enabled OIDC providers (no creds ⇒ no button); hide the whole SSO section when none are configured. No code change needed to add/remove a provider — config only. → `flow-view.ts` now collects the login/registration flow's `oidc`-group submit nodes into `FlowView.sso` (`{label, logo, name, value}` per provider; `logo` = provider initial, lucide ships no brand marks) instead of skipping them — so the button list *is* Kratos' live provider list (none configured ⇒ `sso: []` ⇒ no section; activate/remove a provider purely via the §3 OIDC env). `auth-card.ejs` gained a submit-provider branch: a provider with `name`/`value` renders `<button type="submit" name=… value=…>` (posts `provider=<id>` to the same Kratos form, sharing its csrf hidden input); `href` still ⇒ `<a>`, neither ⇒ inert button. `auth.ejs` forwards `sso: { providers: flow.sso }`. Removed the mockup-only `body:not(:has(#sso-toggle:checked)) .sso{display:none}` rule from `auth.css` (`#sso-toggle` is a "remove for production" preview control in `html-css-foundation/Auth.html`) — visibility is now purely server-side. Tests-first: `flow-view.test.ts` (oidc→sso matrix + `sso:[]` when none), `auth-card.test.ts` (submit-provider markup), `app.test.ts` (live `/login` renders the SSO submit button in the form). README **Social sign-in (SSO)** updated (dropped the §4 forward-ref). typecheck + 181 units green. Boot-verified end-to-end: a real Kratos with the OIDC env emitted `{group:oidc, name:provider, value:google}` → `buildFlowView` derived `[{label:"Sign in with google", logo:"G", name:"provider", value:"google"}]`; clean-clone `/login` renders no `.sso` section; torn down.
|
||||||
- [ ] Login completion: read roles from Keto → write `metadata_admin` projection → tokenize → set JWT cookie.
|
- [ ] Login completion: read roles from Keto → write `metadata_admin` projection → tokenize → set JWT cookie.
|
||||||
- [ ] JWT middleware: verify signature via cached JWKS, validate `exp`/`iss`/`aud` (+clock skew), build context (user, roles).
|
- [ ] JWT middleware: verify signature via cached JWKS, validate `exp`/`iss`/`aud` (+clock skew), build context (user, roles).
|
||||||
- [ ] JWKS fetch + cache + rotation handling.
|
- [ ] JWKS fetch + cache + rotation handling.
|
||||||
|
|||||||
@@ -30,6 +30,7 @@
|
|||||||
back: flow.back,
|
back: flow.back,
|
||||||
body,
|
body,
|
||||||
method: flow.method,
|
method: flow.method,
|
||||||
|
sso: { providers: flow.sso },
|
||||||
sub: flow.sub,
|
sub: flow.sub,
|
||||||
title: flow.title,
|
title: flow.title,
|
||||||
}) %>
|
}) %>
|
||||||
|
|||||||
@@ -6,7 +6,8 @@
|
|||||||
method? (default "post"), action?
|
method? (default "post"), action?
|
||||||
back? { href, label } back link above the title
|
back? { href, label } back link above the title
|
||||||
sso? { label?, divider?, providers: Provider[] } omit / empty ⇒ no SSO section
|
sso? { label?, divider?, providers: Provider[] } omit / empty ⇒ no SSO section
|
||||||
Provider: { label, logo? (text) | icon? (sprite id), href? ⇒ <a>, else <button> }
|
Provider: { label, logo? (text) | icon? (sprite id),
|
||||||
|
href? ⇒ <a>, else name?/value? ⇒ submit <button>, else inert <button> }
|
||||||
body pre-rendered HTML placed inside .auth-form (fields + submit)
|
body pre-rendered HTML placed inside .auth-form (fields + submit)
|
||||||
alt? { text, href, label } centered footer line
|
alt? { text, href, label } centered footer line
|
||||||
%><%
|
%><%
|
||||||
@@ -20,7 +21,7 @@
|
|||||||
<div class="auth-head"><% if (back) { %><a class="auth-back" href="<%= back.href %>"><svg class="ico ico-sm" aria-hidden="true"><use href="#i-arrow-left"/></svg><%= back.label %></a><% } %><h1><%= locals.title %></h1><% if (locals.sub) { %><p class="auth-sub"><%= locals.sub %></p><% } %></div>
|
<div class="auth-head"><% if (back) { %><a class="auth-back" href="<%= back.href %>"><svg class="ico ico-sm" aria-hidden="true"><use href="#i-arrow-left"/></svg><%= back.label %></a><% } %><h1><%= locals.title %></h1><% if (locals.sub) { %><p class="auth-sub"><%= locals.sub %></p><% } %></div>
|
||||||
<% if (providers.length) { -%>
|
<% if (providers.length) { -%>
|
||||||
<div class="sso" aria-label="<%= sso.label || "Single sign-on options" %>">
|
<div class="sso" aria-label="<%= sso.label || "Single sign-on options" %>">
|
||||||
<ul class="sso-list"><% providers.forEach((p) => { %><li><% if (p.href) { %><a class="sso-btn" href="<%= p.href %>"><% } else { %><button type="button" class="sso-btn"><% } %><span class="sso-logo" aria-hidden="true"><% if (p.icon) { %><svg class="ico ico-sm"><use href="#<%= p.icon %>"/></svg><% } else { %><%= p.logo %><% } %></span><span class="sso-label"><%= p.label %></span><% if (p.href) { %></a><% } else { %></button><% } %></li><% }) %></ul>
|
<ul class="sso-list"><% providers.forEach((p) => { %><li><% if (p.href) { %><a class="sso-btn" href="<%= p.href %>"><% } else { %><button type="<%= p.name ? "submit" : "button" %>" class="sso-btn"<% if (p.name) { %> name="<%= p.name %>" value="<%= p.value %>"<% } %>><% } %><span class="sso-logo" aria-hidden="true"><% if (p.icon) { %><svg class="ico ico-sm"><use href="#<%= p.icon %>"/></svg><% } else { %><%= p.logo %><% } %></span><span class="sso-label"><%= p.label %></span><% if (p.href) { %></a><% } else { %></button><% } %></li><% }) %></ul>
|
||||||
<div class="auth-divider"><%= sso.divider || "or" %></div>
|
<div class="auth-divider"><%= sso.divider || "or" %></div>
|
||||||
</div>
|
</div>
|
||||||
<% } -%>
|
<% } -%>
|
||||||
|
|||||||
Reference in New Issue
Block a user