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:
2026-06-17 18:20:45 +02:00
parent 0928f9dd39
commit 26a7821611
9 changed files with 54 additions and 15 deletions

View File

@@ -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`
and `SELFSERVICE_METHODS_OIDC_CONFIG_PROVIDERS` to a JSON array of providers (`google`,
`microsoft`, …), each carrying its `client_id`/`client_secret` and referencing the
committed claims mapper `ory/kratos/oidc/claims.jsonnet`. No creds ⇒ no provider ⇒ no
SSO button (§4 derives the buttons from this list). 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.
committed claims mapper `ory/kratos/oidc/claims.jsonnet`. The themed sign-in/register
pages derive one button per provider from the live flow's `oidc` nodes, so no creds ⇒ no
provider ⇒ no button, and the whole SSO section disappears when none are configured — no
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

View File

@@ -49,9 +49,8 @@ body:has(#forgot:target) #login { display: none; }
}
.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; }
body:not(:has(#sso-toggle:checked)) .sso { display: none; }
.sso-list { display: flex; flex-direction: column; gap: 8px; }
.sso-btn {

View File

@@ -213,6 +213,7 @@ const loginFlow = (id: string): Flow => ({
node({ name: "identifier", required: true, type: "email" }, "E-Mail"),
node({ name: "password", required: true, type: "password" }, "Password"),
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, /<button type="submit"[^>]*name="method" value="password">Sign in<\/button>/);
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.
assert.match(html, /class="alert alert-neg"/);
assert.match(html, /The provided credentials are invalid\./);

View File

@@ -14,6 +14,7 @@ test("auth-card renders head, SSO providers (text logo + icon link), body slot a
sso: { providers: [
{ label: "Continue with Google", logo: "G" },
{ 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>',
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, /<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>/);
// 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>/);
// Body slot lands inside .auth-form; alt footer renders text + link.

View File

@@ -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.
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.
assert.equal(view.title, "Sign in");
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(
flow([
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: "microsoft" }, { label: "Sign in with Microsoft", group: "oidc" }),
node({ name: "method", type: "submit", value: "password" }, { label: "Sign in", group: "password" }),
]),
"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.buttons, [{ label: "Sign in", name: "method", value: "password" }]);
});

View File

@@ -1,8 +1,8 @@
// 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
// CSRF token), themed fields, submit buttons, and tone-mapped messages. The form posts
// straight back to `flow.ui.action`, so Kratos owns its CSRF; we only render and map errors.
// SSO/oidc buttons are skipped here — they're derived per provider in the next §4 item.
// CSRF token), themed fields, submit buttons, tone-mapped messages, and one SSO button per
// configured `oidc` provider. The form posts straight back to `flow.ui.action`, so Kratos
// owns its CSRF; we only render and map errors. No providers configured ⇒ no SSO buttons.
import type { Flow, FlowType, UiNode } from "./kratos-public.ts";
@@ -24,6 +24,14 @@ export interface FlowButton {
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 {
text: string;
tone: "info" | "neg" | "pos" | "warn";
@@ -43,6 +51,7 @@ export interface FlowView extends FlowChrome {
hidden: { name: string; value: string }[];
messages: FlowMessage[];
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.
@@ -79,6 +88,8 @@ function tone(type: string): FlowMessage["tone"] {
return "info";
}
const ssoLogo = (value: string): string => (value.charAt(0) || "?").toUpperCase();
function toField(node: UiNode, name: string, type: string): FlowField {
const value = str(node.attributes["value"]);
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 fields: FlowField[] = [];
const buttons: FlowButton[] = [];
const sso: SsoProvider[] = [];
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 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"]) ?? "" });
} else if (inputType === "submit" || inputType === "button") {
const value = str(node.attributes["value"]);
@@ -123,6 +141,7 @@ export function buildFlowView(flow: Flow, type: FlowType): FlowView {
hidden,
messages: (flow.ui.messages ?? []).map((m) => ({ text: m.text, tone: tone(m.type) })),
method: flow.ui.method || "post",
sso,
...CHROME[type],
};
}

View File

@@ -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] 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.
- [ ] 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.
- [ ] JWT middleware: verify signature via cached JWKS, validate `exp`/`iss`/`aud` (+clock skew), build context (user, roles).
- [ ] JWKS fetch + cache + rotation handling.

View File

@@ -30,6 +30,7 @@
back: flow.back,
body,
method: flow.method,
sso: { providers: flow.sso },
sub: flow.sub,
title: flow.title,
}) %>

View File

@@ -6,7 +6,8 @@
method? (default "post"), action?
back? { href, label } back link above the title
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)
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>
<% if (providers.length) { -%>
<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>
<% } -%>