diff --git a/cms/src/components/authProvider/FormModal.vue b/cms/src/components/authProvider/FormModal.vue index afa2c5e3d2..ff68d49b63 100644 --- a/cms/src/components/authProvider/FormModal.vue +++ b/cms/src/components/authProvider/FormModal.vue @@ -68,15 +68,12 @@ function isValidClientId(value: string): boolean { return !/\s/.test(trimmed); } +// OIDC treats the audience as an opaque string. Auth0 issues URL-shaped API +// identifiers, but Zitadel and Keycloak use plain IDs, so requiring a URL here +// would lock those providers out for no gain — the API only checks non-empty. function isValidAudience(value: string): boolean { const trimmed = value.trim(); - if (!trimmed) return false; - try { - const url = new URL(trimmed); - return url.protocol === "http:" || url.protocol === "https:"; - } catch { - return false; - } + return trimmed.length > 0 && !/\s/.test(trimmed); } const providerValidations = ref([]); @@ -100,7 +97,7 @@ watch( (x) => isValidClientId(x.clientId ?? ""), ); validate( - "Audience must be an absolute URL (e.g. https://api.example.com)", + "Audience must not be empty or contain whitespace", "audience", providerValidations.value, p, diff --git a/ory-kratos-setup-poc/Caddyfile b/ory-kratos-setup-poc/Caddyfile new file mode 100644 index 0000000000..86cc7b860e --- /dev/null +++ b/ory-kratos-setup-poc/Caddyfile @@ -0,0 +1,14 @@ +auth.luminary.local { + tls internal + reverse_proxy hydra:4444 +} + +login.luminary.local { + tls internal + reverse_proxy login-consent:4456 +} + +admin.luminary.local { + tls internal + reverse_proxy admin:4457 +} diff --git a/ory-kratos-setup-poc/admin/server.js b/ory-kratos-setup-poc/admin/server.js new file mode 100644 index 0000000000..6e06597d1d --- /dev/null +++ b/ory-kratos-setup-poc/admin/server.js @@ -0,0 +1,237 @@ +import { createServer } from "node:http"; + +const KRATOS_ADMIN_URL = process.env.KRATOS_ADMIN_URL || "http://kratos:4434"; +const HYDRA_ADMIN_URL = process.env.HYDRA_ADMIN_URL || "http://hydra:4445"; +const PORT = process.env.PORT || 4457; + +function escapeHtml(value) { + return String(value ?? "").replace( + /[&<>"']/g, + (c) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[c], + ); +} + +async function adminFetch(base, path, options) { + const res = await fetch(`${base}${path}`, { + ...options, + headers: { "Content-Type": "application/json", ...(options?.headers || {}) }, + }); + if (res.status === 204) return null; + const body = await res.json().catch(() => null); + if (!res.ok) throw new Error(`${base}${path} -> ${res.status}: ${JSON.stringify(body)}`); + return body; +} + +const kratos = (path, options) => adminFetch(KRATOS_ADMIN_URL, path, options); +const hydra = (path, options) => adminFetch(HYDRA_ADMIN_URL, path, options); + +function layout(title, active, body, flash) { + return ` + + + +${escapeHtml(title)} — Ory admin + + + +

Ory admin

+

Local dev tooling for the Kratos + Hydra guest-auth PoC.

+ +${flash ? `
${escapeHtml(flash.text)}
` : ""} +${body} + +`; +} + +function flashFromQuery(url) { + const msg = url.searchParams.get("msg"); + if (!msg) return null; + return { text: msg, type: url.searchParams.get("err") ? "error" : "ok" }; +} + +function redirect(res, location) { + res.writeHead(302, { Location: location }); + res.end(); +} + +function matchesQuery(haystackParts, q) { + if (!q) return true; + return haystackParts.join(" ").toLowerCase().includes(q); +} + +async function handleIdentitiesGet(url, res) { + const q = (url.searchParams.get("q") || "").trim().toLowerCase(); + const identities = await kratos("/admin/identities?page_size=250"); + + const rows = identities + .filter((identity) => matchesQuery([identity.id, identity.traits?.email, identity.traits?.name], q)) + .map( + (identity) => ` + + ${escapeHtml(identity.id)} + ${escapeHtml(identity.traits?.email || "—")} + ${escapeHtml(identity.traits?.name || "—")} + ${escapeHtml(identity.state)} + ${escapeHtml(new Date(identity.created_at).toLocaleString())} + +
+ +
+
+ +
+ + `, + ) + .join(""); + + const body = ` +
+
+ +
+ ${identities.length} identit${identities.length === 1 ? "y" : "ies"} +
+ ${ + rows + ? ` + + ${rows} +
IDEmailNameStateCreated
` + : `
No identities match.
` + } + `; + + res.writeHead(200, { "Content-Type": "text/html" }); + res.end(layout("Identities", "identities", body, flashFromQuery(url))); +} + +async function handleIdentityDelete(id, res) { + await kratos(`/admin/identities/${encodeURIComponent(id)}`, { method: "DELETE" }); + redirect(res, `/identities?msg=${encodeURIComponent("Identity deleted.")}`); +} + +async function handleIdentityRevokeSessions(id, res) { + await kratos(`/admin/identities/${encodeURIComponent(id)}/sessions`, { method: "DELETE" }); + // Kratos owns the login session; the Hydra grant survives it, so both have to be revoked to force a fresh consent. + await Promise.allSettled([ + hydra(`/admin/oauth2/auth/sessions/consent?subject=${encodeURIComponent(id)}&all=true`, { method: "DELETE" }), + hydra(`/admin/oauth2/auth/sessions/login?subject=${encodeURIComponent(id)}`, { method: "DELETE" }), + ]); + redirect(res, `/identities?msg=${encodeURIComponent("Kratos and Hydra sessions revoked.")}`); +} + +async function handleClientsGet(url, res) { + const q = (url.searchParams.get("q") || "").trim().toLowerCase(); + const clients = await hydra("/admin/clients?page_size=250"); + + const rows = clients + .filter((client) => matchesQuery([client.client_id, client.client_name], q)) + .map( + (client) => ` + + ${escapeHtml(client.client_id)} + ${escapeHtml(client.client_name || "—")} + ${escapeHtml((client.grant_types || []).join(", "))} + ${escapeHtml(client.scope || "—")} + +
+ +
+ + `, + ) + .join(""); + + const body = ` +
+
+ +
+ ${clients.length} client${clients.length === 1 ? "" : "s"} +
+ ${ + rows + ? ` + + ${rows} +
Client IDNameGrant typesScope
` + : `
No clients match.
` + } + `; + + res.writeHead(200, { "Content-Type": "text/html" }); + res.end(layout("OAuth clients", "clients", body, flashFromQuery(url))); +} + +async function handleClientDelete(id, res) { + await hydra(`/admin/clients/${encodeURIComponent(id)}`, { method: "DELETE" }); + redirect(res, `/clients?msg=${encodeURIComponent("Client deleted.")}`); +} + +const server = createServer(async (req, res) => { + const url = new URL(req.url, `http://${req.headers.host}`); + try { + if ((url.pathname === "/" || url.pathname === "") && req.method === "GET") return redirect(res, "/identities"); + if (url.pathname === "/identities" && req.method === "GET") return await handleIdentitiesGet(url, res); + if (url.pathname === "/clients" && req.method === "GET") return await handleClientsGet(url, res); + + const deleteIdentityMatch = url.pathname.match(/^\/identities\/([^/]+)\/delete$/); + if (deleteIdentityMatch && req.method === "POST") + return await handleIdentityDelete(decodeURIComponent(deleteIdentityMatch[1]), res); + + const revokeMatch = url.pathname.match(/^\/identities\/([^/]+)\/revoke-sessions$/); + if (revokeMatch && req.method === "POST") + return await handleIdentityRevokeSessions(decodeURIComponent(revokeMatch[1]), res); + + const deleteClientMatch = url.pathname.match(/^\/clients\/([^/]+)\/delete$/); + if (deleteClientMatch && req.method === "POST") + return await handleClientDelete(decodeURIComponent(deleteClientMatch[1]), res); + + res.writeHead(404, { "Content-Type": "text/plain" }); + res.end("not found"); + } catch (err) { + console.error(err); + // GET failures render inline; redirecting a failed GET back to itself would loop. + if (req.method !== "GET") { + const backTo = url.pathname.startsWith("/clients") ? "/clients" : "/identities"; + return redirect(res, `${backTo}?err=1&msg=${encodeURIComponent(err.message)}`); + } + res.writeHead(502, { "Content-Type": "text/html" }); + res.end(layout("Error", null, `
${escapeHtml(err.message)}
`, null)); + } +}); + +server.listen(PORT, () => console.log(`ory admin listening on :${PORT}`)); diff --git a/ory-kratos-setup-poc/docker-compose.yml b/ory-kratos-setup-poc/docker-compose.yml new file mode 100644 index 0000000000..9d0ef2464e --- /dev/null +++ b/ory-kratos-setup-poc/docker-compose.yml @@ -0,0 +1,140 @@ +version: "3.8" + +services: + postgres: + image: postgres:16 + container_name: kratos-postgres + restart: unless-stopped + environment: + POSTGRES_USER: kratos + POSTGRES_PASSWORD: kratos + POSTGRES_DB: kratos + volumes: + - kratos-postgres-data:/var/lib/postgresql/data + - ./postgres-init:/docker-entrypoint-initdb.d + ports: + - "5432:5432" + + kratos-migrate: + image: oryd/kratos:v1.3.1 + container_name: kratos-migrate + depends_on: + - postgres + environment: + - DSN=postgres://kratos:kratos@postgres:5432/kratos?sslmode=disable + volumes: + - ./kratos-config:/etc/config/kratos + command: -c /etc/config/kratos/kratos.yml migrate sql -e --yes + + kratos: + image: oryd/kratos:v1.3.1 + container_name: kratos + restart: unless-stopped + depends_on: + - postgres + - kratos-migrate + environment: + - DSN=postgres://kratos:kratos@postgres:5432/kratos?sslmode=disable + ports: + - "4433:4433" # public API + - "4434:4434" # admin API + volumes: + - ./kratos-config:/etc/config/kratos + command: serve -c /etc/config/kratos/kratos.yml --dev --watch-courier + + kratos-selfservice-ui: + image: oryd/kratos-selfservice-ui-node:v1.3.1 + container_name: kratos-selfservice-ui + restart: unless-stopped + depends_on: + - kratos + environment: + - PORT=4455 + - KRATOS_PUBLIC_URL=http://kratos:4433/ + - KRATOS_BROWSER_URL=http://127.0.0.1:4433/ + - COOKIE_SECRET=PLEASE-CHANGE-ME-I-AM-INSECURE + - CSRF_COOKIE_NAME=ory_csrf_ui + - CSRF_COOKIE_SECRET=PLEASE-CHANGE-ME-I-AM-INSECURE + ports: + - "4455:4455" + + hydra-migrate: + image: oryd/hydra:v2.3.0 + container_name: hydra-migrate + depends_on: + - postgres + environment: + - DSN=postgres://kratos:kratos@postgres:5432/hydra?sslmode=disable + volumes: + - ./hydra-config:/etc/config/hydra + command: migrate -c /etc/config/hydra/hydra.yml sql -e --yes + + hydra: + image: oryd/hydra:v2.3.0 + container_name: hydra + restart: unless-stopped + depends_on: + - postgres + - hydra-migrate + environment: + - DSN=postgres://kratos:kratos@postgres:5432/hydra?sslmode=disable + ports: + - "4444:4444" # public API (token/authorize endpoints) + - "4445:4445" # admin API (create OAuth2 clients, etc.) + volumes: + - ./hydra-config:/etc/config/hydra + command: serve all -c /etc/config/hydra/hydra.yml + + login-consent: + image: node:20-alpine + container_name: login-consent + restart: unless-stopped + depends_on: + - kratos + - hydra + environment: + - HYDRA_ADMIN_URL=http://hydra:4445 + - KRATOS_PUBLIC_URL=http://kratos:4433 + - KRATOS_ADMIN_URL=http://kratos:4434 + - PORT=4456 + volumes: + - ./login-consent:/app + working_dir: /app + command: node server.js + + admin: + image: node:20-alpine + container_name: ory-admin + restart: unless-stopped + depends_on: + - kratos + - hydra + environment: + - HYDRA_ADMIN_URL=http://hydra:4445 + - KRATOS_ADMIN_URL=http://kratos:4434 + - PORT=4457 + volumes: + - ./admin:/app + working_dir: /app + command: node server.js + + caddy: + image: caddy:2 + container_name: caddy + restart: unless-stopped + depends_on: + - hydra + - login-consent + - admin + ports: + - "443:443" + - "80:80" + volumes: + - ./Caddyfile:/etc/caddy/Caddyfile + - caddy-data:/data + - caddy-config:/config + +volumes: + kratos-postgres-data: + caddy-data: + caddy-config: diff --git a/ory-kratos-setup-poc/hydra-config/hydra.yml b/ory-kratos-setup-poc/hydra-config/hydra.yml new file mode 100644 index 0000000000..8f062c0e91 --- /dev/null +++ b/ory-kratos-setup-poc/hydra-config/hydra.yml @@ -0,0 +1,32 @@ +dsn: postgres://kratos:kratos@postgres:5432/hydra?sslmode=disable + +serve: + public: + cors: + enabled: true + +urls: + self: + issuer: https://auth.luminary.local/ + consent: https://login.luminary.local/consent + login: https://login.luminary.local/login + logout: https://login.luminary.local/logout + +strategies: + access_token: jwt + +secrets: + system: + - PLEASE-CHANGE-ME-I-AM-INSECURE + +oidc: + subject_identifiers: + supported_types: + - public + - pairwise + pairwise: + salt: PLEASE-CHANGE-ME-I-AM-INSECURE + +log: + level: debug + format: text diff --git a/ory-kratos-setup-poc/kratos-config/identity.schema.json b/ory-kratos-setup-poc/kratos-config/identity.schema.json new file mode 100644 index 0000000000..e7024e0e08 --- /dev/null +++ b/ory-kratos-setup-poc/kratos-config/identity.schema.json @@ -0,0 +1,38 @@ +{ + "$id": "https://schemas.ory.sh/presets/kratos/identity.email.schema.json", + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "Public User", + "type": "object", + "properties": { + "traits": { + "type": "object", + "properties": { + "email": { + "type": "string", + "format": "email", + "title": "E-Mail", + "minLength": 3, + "ory.sh/kratos": { + "credentials": { + "password": { + "identifier": true + } + }, + "verification": { + "via": "email" + }, + "recovery": { + "via": "email" + } + } + }, + "name": { + "type": "string", + "title": "Name" + } + }, + "required": ["email"], + "additionalProperties": false + } + } +} diff --git a/ory-kratos-setup-poc/kratos-config/kratos.yml b/ory-kratos-setup-poc/kratos-config/kratos.yml new file mode 100644 index 0000000000..758d84bc3c --- /dev/null +++ b/ory-kratos-setup-poc/kratos-config/kratos.yml @@ -0,0 +1,76 @@ +version: v1.3.1 + +dsn: postgres://kratos:kratos@postgres:5432/kratos?sslmode=disable + +serve: + public: + base_url: http://127.0.0.1:4433/ + cors: + enabled: true + admin: + base_url: http://127.0.0.1:4434/ + +selfservice: + default_browser_return_url: http://127.0.0.1:4455/ + allowed_return_urls: + - http://127.0.0.1:4455 + + methods: + password: + enabled: true + + flows: + error: + ui_url: http://127.0.0.1:4455/error + + settings: + ui_url: http://127.0.0.1:4455/settings + privileged_session_max_age: 15m + + recovery: + enabled: true + ui_url: http://127.0.0.1:4455/recovery + + verification: + enabled: true + ui_url: http://127.0.0.1:4455/verification + + logout: + after: + default_browser_return_url: http://127.0.0.1:4455/login + + login: + ui_url: http://127.0.0.1:4455/login + lifespan: 10m + + registration: + lifespan: 10m + ui_url: http://127.0.0.1:4455/registration + after: + password: + hooks: + - hook: session + +log: + level: debug + format: text + +secrets: + cookie: + - PLEASE-CHANGE-ME-I-AM-INSECURE + cipher: + - 32-LETTER-LONG-SECRET-NOT-SECURE + +ciphers: + algorithm: xchacha20-poly1305 + +hashers: + algorithm: bcrypt + bcrypt: + cost: 8 + +identity: + default_schema_id: default + schemas: + - id: default + url: file:///etc/config/kratos/identity.schema.json diff --git a/ory-kratos-setup-poc/login-consent/server.js b/ory-kratos-setup-poc/login-consent/server.js new file mode 100644 index 0000000000..0322f2cd2c --- /dev/null +++ b/ory-kratos-setup-poc/login-consent/server.js @@ -0,0 +1,194 @@ +import { createServer } from "node:http"; +import { errorPage, loginPage, registerPage } from "./views.js"; + +const HYDRA_ADMIN_URL = process.env.HYDRA_ADMIN_URL || "http://hydra:4445"; +const KRATOS_PUBLIC_URL = process.env.KRATOS_PUBLIC_URL || "http://kratos:4433"; +const KRATOS_ADMIN_URL = process.env.KRATOS_ADMIN_URL || "http://kratos:4434"; +const PORT = process.env.PORT || 4456; +const REMEMBER_FOR_SECONDS = 3600; + +async function readFormBody(req) { + const chunks = []; + for await (const chunk of req) chunks.push(chunk); + const params = new URLSearchParams(Buffer.concat(chunks).toString("utf8")); + return Object.fromEntries(params); +} + +async function hydraFetch(path, options) { + const res = await fetch(`${HYDRA_ADMIN_URL}${path}`, { + ...options, + headers: { "Content-Type": "application/json", ...(options?.headers || {}) }, + }); + const body = await res.json(); + if (!res.ok) throw new Error(`Hydra ${path} -> ${res.status}: ${JSON.stringify(body)}`); + return body; +} + +function redirect(res, location) { + res.writeHead(302, { Location: location }); + res.end(); +} + +async function handleLoginGet(url, res) { + const challenge = url.searchParams.get("login_challenge"); + if (!challenge) return badRequest(res, "missing login_challenge"); + + const loginRequest = await hydraFetch( + `/admin/oauth2/auth/requests/login?login_challenge=${encodeURIComponent(challenge)}`, + ); + + if (loginRequest.skip) { + const accept = await hydraFetch( + `/admin/oauth2/auth/requests/login/accept?login_challenge=${encodeURIComponent(challenge)}`, + { method: "PUT", body: JSON.stringify({ subject: loginRequest.subject }) }, + ); + return redirect(res, accept.redirect_to); + } + + res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" }); + res.end(loginPage(challenge)); +} + +async function acceptLoginAndRedirect(res, challenge, identityId) { + const accept = await hydraFetch( + `/admin/oauth2/auth/requests/login/accept?login_challenge=${encodeURIComponent(challenge)}`, + { + method: "PUT", + body: JSON.stringify({ + subject: identityId, + remember: true, + remember_for: REMEMBER_FOR_SECONDS, + }), + }, + ); + redirect(res, accept.redirect_to); +} + +async function handleLoginPost(req, url, res) { + const challenge = url.searchParams.get("login_challenge"); + if (!challenge) return badRequest(res, "missing login_challenge"); + + const { email, password } = await readFormBody(req); + + const flow = await (await fetch(`${KRATOS_PUBLIC_URL}/self-service/login/api`)).json(); + const submit = await fetch(`${KRATOS_PUBLIC_URL}/self-service/login?flow=${flow.id}`, { + method: "POST", + headers: { "Content-Type": "application/json", Accept: "application/json" }, + body: JSON.stringify({ method: "password", identifier: email, password }), + }); + + if (!submit.ok) { + res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" }); + return res.end(loginPage(challenge, "Invalid email or password.")); + } + + const { session } = await submit.json(); + await acceptLoginAndRedirect(res, challenge, session.identity.id); +} + +async function handleRegisterGet(url, res) { + const challenge = url.searchParams.get("login_challenge"); + if (!challenge) return badRequest(res, "missing login_challenge"); + res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" }); + res.end(registerPage(challenge)); +} + +async function handleRegisterPost(req, url, res) { + const challenge = url.searchParams.get("login_challenge"); + if (!challenge) return badRequest(res, "missing login_challenge"); + + const { email, password } = await readFormBody(req); + + const flow = await (await fetch(`${KRATOS_PUBLIC_URL}/self-service/registration/api`)).json(); + const submit = await fetch(`${KRATOS_PUBLIC_URL}/self-service/registration?flow=${flow.id}`, { + method: "POST", + headers: { "Content-Type": "application/json", Accept: "application/json" }, + body: JSON.stringify({ method: "password", password, traits: { email } }), + }); + + if (!submit.ok) { + const body = await submit.json().catch(() => null); + const message = body?.ui?.messages?.[0]?.text || "Could not create account. Try a different email or a stronger password."; + res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" }); + return res.end(registerPage(challenge, message)); + } + + const { identity } = await submit.json(); + await acceptLoginAndRedirect(res, challenge, identity.id); +} + +async function handleConsentGet(url, res) { + const challenge = url.searchParams.get("consent_challenge"); + if (!challenge) return badRequest(res, "missing consent_challenge"); + + const consentRequest = await hydraFetch( + `/admin/oauth2/auth/requests/consent?consent_challenge=${encodeURIComponent(challenge)}`, + ); + + const identity = await ( + await fetch(`${KRATOS_ADMIN_URL}/admin/identities/${consentRequest.subject}`) + ).json(); + + const claims = { + email: identity.traits?.email, + name: identity.traits?.name, + }; + + const accept = await hydraFetch( + `/admin/oauth2/auth/requests/consent/accept?consent_challenge=${encodeURIComponent(challenge)}`, + { + method: "PUT", + body: JSON.stringify({ + grant_scope: consentRequest.requested_scope, + grant_access_token_audience: consentRequest.requested_access_token_audience, + remember: true, + remember_for: REMEMBER_FOR_SECONDS, + session: { + // Luminary's AuthGuard verifies the bearer ACCESS token, not the ID token, + // so identity claims have to land on both or the API never sees them. + access_token: claims, + id_token: claims, + }, + }), + }, + ); + redirect(res, accept.redirect_to); +} + +async function handleLogoutGet(url, res) { + const challenge = url.searchParams.get("logout_challenge"); + if (!challenge) return badRequest(res, "missing logout_challenge"); + + const accept = await hydraFetch( + `/admin/oauth2/auth/requests/logout/accept?logout_challenge=${encodeURIComponent(challenge)}`, + { method: "PUT", body: JSON.stringify({}) }, + ); + redirect(res, accept.redirect_to); +} + +function badRequest(res, message) { + res.writeHead(400, { "Content-Type": "text/html; charset=utf-8" }); + res.end(errorPage(message)); +} + +const server = createServer(async (req, res) => { + const url = new URL(req.url, `http://${req.headers.host}`); + try { + if (url.pathname === "/login" && req.method === "GET") return await handleLoginGet(url, res); + if (url.pathname === "/login" && req.method === "POST") return await handleLoginPost(req, url, res); + if (url.pathname === "/register" && req.method === "GET") return await handleRegisterGet(url, res); + if (url.pathname === "/register" && req.method === "POST") return await handleRegisterPost(req, url, res); + if (url.pathname === "/consent" && req.method === "GET") return await handleConsentGet(url, res); + if (url.pathname === "/logout" && req.method === "GET") return await handleLogoutGet(url, res); + res.writeHead(404, { "Content-Type": "text/html; charset=utf-8" }); + res.end(errorPage("That page does not exist.")); + } catch (err) { + // The reason goes to the log, not to the browser: an exception message from + // Kratos or Hydra says more about the deployment than the user needs. + console.error(err); + res.writeHead(500, { "Content-Type": "text/html; charset=utf-8" }); + res.end(errorPage("The sign-in service could not complete your request.")); + } +}); + +server.listen(PORT, () => console.log(`login-consent bridge listening on :${PORT}`)); diff --git a/ory-kratos-setup-poc/login-consent/views.js b/ory-kratos-setup-poc/login-consent/views.js new file mode 100644 index 0000000000..014e7358a9 --- /dev/null +++ b/ory-kratos-setup-poc/login-consent/views.js @@ -0,0 +1,277 @@ +// Pages the user actually sees. Kept apart from server.js so they can be +// rendered — and looked at — without Kratos, Hydra or a browser session. + +/** + * Everything interpolated into these pages is escaped. Error text reaches here + * from Kratos' flow messages, which can carry values a user typed. + */ +export function escapeHtml(value) { + return String(value ?? "") + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """) + .replace(/'/g, "'"); +} + +// The wordmark from the app, with the text set to currentColor so it survives +// dark mode. The amber mark is the brand colour and stays as it is. +const LOGO = ``; + +const CSS = ` +:root { + color-scheme: light dark; + --page: #fafafa; + --card: #ffffff; + --card-border: #f4f4f5; + --text: #18181b; + --muted: #71717a; + --field-border: #d4d4d8; + --field-bg: #ffffff; + --accent: #eab308; + --accent-hover: #facc15; + --accent-text: #422006; + --ring: rgba(234, 179, 8, 0.4); + --danger: #b91c1c; + --danger-bg: #fef2f2; + --danger-border: #fecaca; + --shadow: 0 1px 3px rgba(0, 0, 0, 0.08), 0 1px 2px rgba(0, 0, 0, 0.04); +} +@media (prefers-color-scheme: dark) { + :root { + --page: #0f172a; + --card: #1e293b; + --card-border: #1e293b; + --text: #f1f5f9; + --muted: #94a3b8; + --field-border: #475569; + --field-bg: #334155; + --danger: #fca5a5; + --danger-bg: rgba(220, 38, 38, 0.12); + --danger-border: rgba(220, 38, 38, 0.35); + --shadow: 0 1px 3px rgba(0, 0, 0, 0.4); + } +} + +* { box-sizing: border-box; } + +body { + margin: 0; + min-height: 100vh; + display: flex; + align-items: center; + justify-content: center; + padding: 24px 16px; + background: var(--page); + color: var(--text); + font-family: "Inter var", Inter, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; + font-size: 15px; + line-height: 1.5; + -webkit-font-smoothing: antialiased; +} + +main { + width: 100%; + max-width: 400px; + background: var(--card); + border: 1px solid var(--card-border); + border-radius: 12px; + box-shadow: var(--shadow); + padding: 28px 26px; +} + +.logo { display: block; margin-bottom: 22px; color: var(--text); } +h1 { font-size: 1.3rem; font-weight: 600; margin: 0; letter-spacing: -0.01em; } +.sub { color: var(--muted); font-size: 0.9rem; margin: 6px 0 0; } + +form { margin-top: 22px; display: flex; flex-direction: column; gap: 16px; } +.field { display: flex; flex-direction: column; gap: 6px; } +label { font-size: 0.85rem; font-weight: 500; } + +input { + width: 100%; + padding: 11px 12px; + font: inherit; + color: var(--text); + background: var(--field-bg); + border: 1px solid var(--field-border); + border-radius: 8px; + transition: border-color 0.12s, box-shadow 0.12s; +} +input::placeholder { color: var(--muted); opacity: 0.75; } +input:focus { + outline: none; + border-color: var(--accent); + box-shadow: 0 0 0 3px var(--ring); +} +input[aria-invalid="true"] { border-color: var(--danger); } + +button { + width: 100%; + padding: 12px 16px; + font: inherit; + font-weight: 600; + color: var(--accent-text); + background: var(--accent); + border: 0; + border-radius: 8px; + cursor: pointer; + transition: background 0.12s; +} +button:hover { background: var(--accent-hover); } +button:focus-visible { outline: 2px solid var(--accent-text); outline-offset: 2px; } + +.alert { + display: flex; + gap: 9px; + align-items: flex-start; + margin-top: 18px; + padding: 10px 12px; + font-size: 0.87rem; + color: var(--danger); + background: var(--danger-bg); + border: 1px solid var(--danger-border); + border-radius: 8px; +} +.alert svg { flex: none; margin-top: 2px; } + +.foot { + margin-top: 22px; + padding-top: 18px; + border-top: 1px solid var(--card-border); + font-size: 0.87rem; + color: var(--muted); + text-align: center; +} +@media (prefers-color-scheme: dark) { .foot { border-top-color: rgba(255, 255, 255, 0.08); } } +.foot a { color: var(--text); font-weight: 500; } +.foot a:hover { text-decoration: none; } +.badge { + display: flex; + align-items: center; + justify-content: center; + width: 46px; + height: 46px; + margin-bottom: 18px; + border-radius: 50%; + color: var(--muted); + background: var(--field-bg); + border: 1px solid var(--card-border); +} +@media (prefers-color-scheme: dark) { .badge { border-color: rgba(255, 255, 255, 0.08); } } + +.note-box { + margin-top: 20px; + padding: 12px 14px; + font-size: 0.87rem; + color: var(--muted); + background: var(--field-bg); + border-radius: 8px; +} +@media (prefers-color-scheme: dark) { .note-box { background: rgba(255, 255, 255, 0.04); } } +.note-box strong { color: var(--text); font-weight: 500; } +`; + +const WARNING_ICON = ``; + +function alert(message) { + return message ? `` : ""; +} + +function page({ title, heading, subtitle, error, body, foot, badge }) { + return ` + + + + + +${escapeHtml(title)} + + + +
+ ${LOGO} + ${badge ? `` : ""} +

${escapeHtml(heading)}

+ ${subtitle ? `

${escapeHtml(subtitle)}

` : ""} + ${alert(error)} + ${body ?? ""} + ${foot ? `

${foot}

` : ""} +
+ +`; +} + +const action = (path, challenge) => `${path}?login_challenge=${encodeURIComponent(challenge)}`; + +export function loginPage(challenge, error) { + return page({ + title: "Sign in — Luminary", + heading: "Sign in", + subtitle: "Use the email address and password for your account.", + error, + body: `
+
+ + +
+
+ + +
+ +
`, + foot: `New here? Create an account`, + }); +} + +export function registerPage(challenge, error) { + return page({ + title: "Create an account — Luminary", + heading: "Create your account", + subtitle: "Your saved items and progress move with you, on every device.", + error, + body: `
+
+ + +
+
+ + +
+ +
`, + foot: `Already have an account? Sign in`, + }); +} + +const ERROR_ICON = ``; + +/** + * Anything that stops the flow before a form can be shown — an expired request, + * a missing challenge, a route that does not exist, a failure upstream. There is + * nothing to retry from here: the flow starts at the application, not at this + * service, so the page says where to go rather than offering a dead button. + */ +export function errorPage(message) { + return page({ + title: "Something went wrong — Luminary", + heading: "Something went wrong", + subtitle: message, + badge: ERROR_ICON, + body: `
+ Sign-in requests expire for your safety. Go back to Luminary and sign in again — it only takes a moment. +
`, + }); +} diff --git a/ory-kratos-setup-poc/postgres-init/001-create-hydra-db.sql b/ory-kratos-setup-poc/postgres-init/001-create-hydra-db.sql new file mode 100644 index 0000000000..1d40bbce52 --- /dev/null +++ b/ory-kratos-setup-poc/postgres-init/001-create-hydra-db.sql @@ -0,0 +1,2 @@ +CREATE DATABASE hydra; +GRANT ALL PRIVILEGES ON DATABASE hydra TO kratos; diff --git a/zitadel-setup-poc/.env.example b/zitadel-setup-poc/.env.example new file mode 100644 index 0000000000..53479de850 --- /dev/null +++ b/zitadel-setup-poc/.env.example @@ -0,0 +1,2 @@ +# Must be exactly 32 characters — Zitadel refuses to start otherwise. +ZITADEL_MASTERKEY=MasterkeyNeedsToHave32Characters diff --git a/zitadel-setup-poc/.gitignore b/zitadel-setup-poc/.gitignore new file mode 100644 index 0000000000..27d75acb46 --- /dev/null +++ b/zitadel-setup-poc/.gitignore @@ -0,0 +1,3 @@ +secrets/* +!secrets/.gitkeep +.env diff --git a/zitadel-setup-poc/Caddyfile b/zitadel-setup-poc/Caddyfile new file mode 100644 index 0000000000..52667a32f8 --- /dev/null +++ b/zitadel-setup-poc/Caddyfile @@ -0,0 +1,22 @@ +auth.luminary.local { + tls internal + + # The API fetches JWKS from a fixed `/.well-known/jwks.json` (see + # authIdentity.service.ts) while Zitadel publishes keys at /oauth/v2/keys. + # Mapping the paths here keeps the API's validator untouched. + handle /.well-known/jwks.json { + rewrite * /oauth/v2/keys + reverse_proxy h2c://zitadel:8080 + } + + # Login v2 runs as its own service, so this path leaves the monolith. + handle /ui/v2/login* { + reverse_proxy login:3000 + } + + # Zitadel serves gRPC alongside REST on one port, so the console needs h2c + # rather than a plain HTTP/1.1 upstream. + handle { + reverse_proxy h2c://zitadel:8080 + } +} diff --git a/zitadel-setup-poc/README.md b/zitadel-setup-poc/README.md new file mode 100644 index 0000000000..3e3ce5f381 --- /dev/null +++ b/zitadel-setup-poc/README.md @@ -0,0 +1,196 @@ +# Zitadel PoC + +A second proof of concept for replacing Auth0 as the identity provider for public +users, to be compared against `../ory-kratos-setup-poc`. Both aim at the same +target: an OIDC provider that the API accepts as an ordinary `AuthProvider` doc, +with no change to `api/` or `app/`. + +Nothing here is wired into the monorepo — it is a standalone Compose stack, same +as the Kratos PoC. + +## Status + +**Run and verified** against Docker on macOS with Zitadel v4.17.1. The stack +comes up clean, `seed.mjs` provisions the project and OIDC app, and +`verify-contract.mjs` reports 8 passed / 2 failed against a real RS256 JWT. + +Both failures are the two predicted below, and they are the same failure seen +twice — the issuer's missing trailing slash, once in the discovery document and +once in the `iss` claim of an issued token. Everything else in the contract +passes unchanged: the JWKS rewrite works, `alg` is `RS256`, the token's `kid` +resolves in the published JWKS, and `aud` and `azp` behave as the API expects. + +One thing the original stack got wrong: Zitadel v4 defaults to Login v2, which +ships as a separate `zitadel-login` container rather than inside the monolith. +Without it the instance still starts and the OIDC endpoints all answer, but every +login redirect — including the console's own sign-in — lands on `/ui/v2/login/*` +and returns 404. The container is now in the Compose file and Caddy routes that +path to it. + +## Why a second PoC + +The Kratos + Hydra stack is architecturally right: it makes the identity provider +conform to the contract the repo already has, rather than making the app conform +to the provider. This PoC keeps that property and asks a narrower question — how +much of the stack is essential? + +Ory ships no login UI and no admin console by design, so both are yours to write. +Zitadel ships both. It is not quite a single service — from v4 the login UI runs +as its own container — but it is still four images you configure rather than +seven plus code you maintain. + +| | Kratos + Hydra | Zitadel | +| --- | --- | --- | +| Long-running services | 7 | 4 | +| One-shot migration jobs | 2 | 0 (self-migrating) | +| Databases | 2 schemas | 1 | +| Bespoke runtime code you maintain | 708 lines (login/consent + admin) | 0 | +| Login UI | you write it | shipped | +| Admin console | you write it | shipped | +| Language / runtime | Go | Go | + +The 708 lines are `login-consent/server.js`, `login-consent/views.js` and +`admin/server.js` in the Kratos PoC — security-critical request paths (redirect +handling, consent grants, identity deletion) that would need CSRF, authentication +and an audit trail before shipping. Here the equivalent surface is `seed.mjs`, a +61-line one-shot setup script that never serves a request. + +## The contract being tested + +`api/src/auth/authIdentity.service.ts` imposes five constraints on any provider. +Hydra satisfies all five. Two need work for Zitadel, both confirmed against the +running stack rather than predicted: + +| Constraint | Hydra | Zitadel | +| --- | --- | --- | +| JWT access tokens, not opaque | `strategies.access_token: jwt` | app `accessTokenType: OIDC_TOKEN_TYPE_JWT` (set by `seed.mjs`) | +| `RS256` | yes | yes | +| `azp`/`client_id` matches `provider.clientId` | yes | yes | +| JWKS at `/.well-known/jwks.json` | published there natively | **published at `/oauth/v2/keys`** — the Caddyfile rewrite fixes this, verified serving 2 keys | +| `iss` exactly `https:///` | issuer configured with the trailing slash | **is `https://`, no trailing slash** — in both discovery and the `iss` claim | +| Audience accepted by the CMS form | URL-shaped API identifier | **numeric project/client ID** — rejected by the CMS's `isValidAudience`, though the API itself accepts it | + +A third Auth0-ism sits in the CMS rather than the API. The Auth Provider form +rejects the audience with "Audience must be an absolute URL": + +```ts +// cms/src/components/authProvider/FormModal.vue +function isValidAudience(value: string): boolean { + const url = new URL(trimmed); + return url.protocol === "http:" || url.protocol === "https:"; +} +``` + +Zitadel's audience is a numeric project or client ID (`387937637261901827`) and +no setting makes it a URL, so the form cannot be satisfied as written. This one +is only a form rule: `AuthProviderDto` validates `audience` with `@IsString()` +`@IsNotEmpty()` and nothing more, and `jsonwebtoken` compares `aud` as an opaque +string. URL audiences are an Auth0 convention for its API identifiers, not an +OIDC requirement — the spec makes `aud` a case-sensitive string, usually the +client ID. Relaxing `isValidAudience` to "non-empty, no whitespace" would admit +Zitadel and Keycloak without weakening anything the API relies on. + +The trailing slash is an Auth0-ism baked into the API: + +```ts +issuer: `https://${provider.domain}/`, +``` + +Auth0 uses a trailing slash; most standards-compliant issuers do not. Zitadel +offers no setting to add one — `ExternalDomain` has no path component — so the +fix has to land in the API. It is one of: + +1. Compare the issuer with the trailing slash optional in + `authIdentity.service.ts` — smallest change, unblocks Zitadel and others. +2. Add an optional explicit `issuer` field to `AuthProviderDto`, so the issuer + stops being derived from `domain`. Larger, but also the only option that fits + providers whose issuer carries a path (Keycloak's is + `https://host/realms/`) and would want a JWKS path of their own. + +Neither is done here — this PoC deliberately changes nothing in `api/`. + +## Running it + +`auth.luminary.local` must resolve locally, matching the Kratos PoC: + +```sh +echo "127.0.0.1 auth.luminary.local" | sudo tee -a /etc/hosts +``` + +Then: + +```sh +cp .env.example .env # masterkey must be exactly 32 characters +docker compose up -d +``` + +Caddy signs `auth.luminary.local` with its own internal CA, which Node does not +trust by default — the scripts fail with `UNABLE_TO_GET_ISSUER_CERT_LOCALLY` +until it is handed the root certificate. Export it once: + +```sh +docker compose cp caddy:/data/caddy/pki/authorities/local/root.crt ./secrets/caddy-root.crt +``` + +First-instance setup writes a machine-user token to `secrets/seed-pat.txt`, which +the seed script uses: + +```sh +NODE_EXTRA_CA_CERTS=./secrets/caddy-root.crt node seed.mjs +``` + +To reach the console in a browser without a warning, trust that same root in the +login keychain (macOS): + +```sh +sudo security add-trusted-cert -d -r trustRoot \ + -k /Library/Keychains/System.keychain ./secrets/caddy-root.crt +``` + +It prints the `domain`, `clientId` and `audience` for an `AuthProvider` doc, plus +the `urn:zitadel:iam:org:project:id::aud` scope the app must request +for the project to appear in the token's `aud` claim. + +- Console: `https://auth.luminary.local/ui/console` (`admin` / `Password1!`) +- Discovery: `https://auth.luminary.local/.well-known/openid-configuration` + +## Branding + +`brand.mjs` applies the Ory PoC's Luminary palette and logo to Zitadel's shipped +login screens via the org label policy, so the two PoCs can be compared on looks +as well as behaviour. It reads the tokens straight out of +`../ory-kratos-setup-poc/login-consent/views.js`: + +```sh +node --use-system-ca brand.mjs +``` + +This restyles the shipped screens; it does not replace their markup. Zitadel's +login is themed through the label policy (colours, logo, light/dark), with the +strings overridable through the custom-text API. Replacing the layout outright +means running your own app against Zitadel's Session API and pointing the +instance's `LoginV2.BaseURI` at it — at which point you are maintaining login +code again, which is the cost this PoC was trying to avoid. + +## Verifying + +```sh +export NODE_EXTRA_CA_CERTS=./secrets/caddy-root.crt +node verify-contract.mjs # discovery + JWKS constraints +node verify-contract.mjs --client-id=... --audience=... --token= # all of them +``` + +It re-implements the checks `authIdentity.service.ts` performs and reports each +as PASS/FAIL with the remediation, exiting non-zero on any failure. Get a token +from the app's network tab after a sign-in, or from an authorization-code flow +against the seeded client. + +## Not covered + +The same gaps flagged for the Kratos PoC apply and are not addressed here either: +no SMTP courier, so verification and recovery mail will not send; no rate limiting +on public registration; and no guest-vs-registered identity distinction — that +product decision matters more than the choice of provider. + +Secrets in this directory are placeholders. Postgres credentials, the Zitadel +masterkey and the admin password are all committed and must not be reused. diff --git a/zitadel-setup-poc/brand.mjs b/zitadel-setup-poc/brand.mjs new file mode 100644 index 0000000000..48de54d0b0 --- /dev/null +++ b/zitadel-setup-poc/brand.mjs @@ -0,0 +1,71 @@ +// Applies the Ory PoC's Luminary look to Zitadel's shipped login screens by +// setting the org label policy, reusing the same design tokens and logo so the +// two PoCs can be compared on appearance as well as behaviour. +import { readFileSync } from "node:fs"; + +const DOMAIN = process.env.ZITADEL_DOMAIN || "auth.luminary.local"; +const BASE = `https://${DOMAIN}`; +const pat = readFileSync(process.env.PAT_PATH || "./secrets/seed-pat.txt", "utf8").trim(); + +// Lifted from ory-kratos-setup-poc/login-consent/views.js so the palettes stay +// the same in both PoCs. +const THEME = { + primaryColor: "#eab308", + backgroundColor: "#fafafa", + fontColor: "#18181b", + warnColor: "#b91c1c", + primaryColorDark: "#facc15", + backgroundColorDark: "#0f172a", + fontColorDark: "#f1f5f9", + warnColorDark: "#fca5a5", + hideLoginNameSuffix: true, + disableWatermark: true, + themeMode: "THEME_MODE_AUTO", +}; + +const call = async (path, body, method = "POST") => { + const res = await fetch(`${BASE}${path}`, { + method, + headers: { Authorization: `Bearer ${pat}`, "Content-Type": "application/json" }, + body: body === undefined ? undefined : JSON.stringify(body), + }); + const json = await res.json().catch(() => null); + if (!res.ok) throw new Error(`${path} -> ${res.status}: ${JSON.stringify(json)}`); + return json; +}; + +// The org may not have its own policy yet, in which case it inherits the +// instance default and PUT reports 404. +try { + await call("/management/v1/policies/label", THEME, "PUT"); +} catch { + await call("/management/v1/policies/label", THEME, "POST"); +} + +// The logo is the same SVG the Ory login pages render. +const views = readFileSync("../ory-kratos-setup-poc/login-consent/views.js", "utf8"); +const svg = views.match(/const LOGO = `([\s\S]*?)`;/)?.[1]; + +async function uploadLogo(endpoint, body, type) { + const form = new FormData(); + form.append("file", new Blob([body], { type }), `logo.${type === "image/svg+xml" ? "svg" : "png"}`); + const res = await fetch(`${BASE}${endpoint}`, { + method: "POST", + headers: { Authorization: `Bearer ${pat}` }, + body: form, + }); + return { ok: res.ok, status: res.status, text: await res.text().catch(() => "") }; +} + +if (svg) { + for (const [label, endpoint] of [ + ["light", "/assets/v1/org/policy/label/logo"], + ["dark", "/assets/v1/org/policy/label/logo/dark"], + ]) { + const r = await uploadLogo(endpoint, svg, "image/svg+xml"); + console.log(`logo (${label}) -> ${r.ok ? "uploaded" : `skipped (${r.status}) ${r.text.slice(0, 120)}`}`); + } +} + +await call("/management/v1/policies/label/_activate", {}); +console.log("\nBranding applied and activated. Reload the login tab to see it."); diff --git a/zitadel-setup-poc/docker-compose.yml b/zitadel-setup-poc/docker-compose.yml new file mode 100644 index 0000000000..ac450863f2 --- /dev/null +++ b/zitadel-setup-poc/docker-compose.yml @@ -0,0 +1,77 @@ +services: + postgres: + image: postgres:17-alpine + container_name: zitadel-postgres + restart: unless-stopped + environment: + POSTGRES_USER: postgres + POSTGRES_PASSWORD: postgres + POSTGRES_DB: zitadel + volumes: + - zitadel-postgres-data:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U postgres -d zitadel"] + interval: 5s + timeout: 5s + retries: 20 + + zitadel: + image: ghcr.io/zitadel/zitadel:v4.17.1 + container_name: zitadel + restart: unless-stopped + depends_on: + postgres: + condition: service_healthy + # Caddy terminates TLS, so Zitadel serves plaintext behind it while still + # advertising https URLs via ExternalSecure. + command: > + start-from-init + --config /etc/zitadel/zitadel.yaml + --steps /etc/zitadel/steps.yaml + --masterkey ${ZITADEL_MASTERKEY:?set ZITADEL_MASTERKEY to exactly 32 characters} + --tlsMode external + volumes: + - ./zitadel-config:/etc/zitadel:ro + - ./secrets:/secrets + ports: + - "8080:8080" + + # Zitadel v4 defaults to Login v2, which ships as its own container rather than + # inside the monolith. Without it every login redirect and the console's own + # sign-in land on /ui/v2/login/* and 404. + login: + image: ghcr.io/zitadel/zitadel-login:v4.17.1 + container_name: zitadel-login + restart: unless-stopped + depends_on: + - zitadel + environment: + ZITADEL_API_URL: http://zitadel:8080 + # Zitadel resolves the instance from the Host header, so the in-network + # call has to claim the external domain to be recognised. + CUSTOM_REQUEST_HEADERS: Host:auth.luminary.local + # Written by first-instance setup; the entrypoint waits for it to appear. + ZITADEL_SERVICE_USER_TOKEN_FILE: /secrets/seed-pat.txt + NEXT_PUBLIC_BASE_PATH: /ui/v2/login + volumes: + - ./secrets:/secrets:ro + + caddy: + image: caddy:2 + container_name: zitadel-caddy + restart: unless-stopped + depends_on: + - zitadel + - login + ports: + - "443:443" + - "80:80" + volumes: + - ./Caddyfile:/etc/caddy/Caddyfile:ro + - caddy-data:/data + - caddy-config:/config + +volumes: + zitadel-postgres-data: + caddy-data: + caddy-config: diff --git a/zitadel-setup-poc/login-demo.mjs b/zitadel-setup-poc/login-demo.mjs new file mode 100644 index 0000000000..bab507609f --- /dev/null +++ b/zitadel-setup-poc/login-demo.mjs @@ -0,0 +1,76 @@ +// Drives a real PKCE authorization-code flow against the seeded SPA client and +// prints the resulting access token's claims, so the API's contract can be +// checked against a browser-issued token rather than a machine-user one. +import { createServer } from "node:http"; +import { createHash, randomBytes } from "node:crypto"; + +const DOMAIN = process.env.ZITADEL_DOMAIN || "auth.luminary.local"; +const BASE = `https://${DOMAIN}`; +const CLIENT_ID = process.argv[2]; +const PROJECT_ID = process.argv[3]; +const PORT = 4174; +const REDIRECT = `http://localhost:${PORT}/callback`; + +if (!CLIENT_ID || !PROJECT_ID) { + console.error("usage: node --use-system-ca login-demo.mjs "); + process.exit(1); +} + +const b64url = (b) => b.toString("base64").replace(/\+/g, "-").replace(/\//g, "_").replace(/=/g, ""); +const verifier = b64url(randomBytes(32)); +const challenge = b64url(createHash("sha256").update(verifier).digest()); + +const authUrl = `${BASE}/oauth/v2/authorize?` + new URLSearchParams({ + client_id: CLIENT_ID, + redirect_uri: REDIRECT, + response_type: "code", + scope: `openid profile email urn:zitadel:iam:org:project:id:${PROJECT_ID}:aud`, + code_challenge: challenge, + code_challenge_method: "S256", +}); + +const server = createServer(async (req, res) => { + const url = new URL(req.url, `http://localhost:${PORT}`); + if (!url.pathname.startsWith("/callback")) return res.writeHead(404).end(); + + const code = url.searchParams.get("code"); + if (!code) { + res.writeHead(400, { "Content-Type": "text/plain" }); + res.end(`No code: ${url.searchParams.get("error_description") || url.search}`); + return; + } + + const tokenRes = await fetch(`${BASE}/oauth/v2/token`, { + method: "POST", + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + body: new URLSearchParams({ + grant_type: "authorization_code", + code, + redirect_uri: REDIRECT, + client_id: CLIENT_ID, + code_verifier: verifier, + }), + }); + const tok = await tokenRes.json(); + + if (!tok.access_token) { + res.writeHead(500, { "Content-Type": "text/plain" }); + res.end(JSON.stringify(tok, null, 2)); + console.error("token exchange failed:", tok); + server.close(); + return; + } + + const claims = JSON.parse(Buffer.from(tok.access_token.split(".")[1], "base64url").toString()); + res.writeHead(200, { "Content-Type": "text/html" }); + res.end("

Signed in. Claims printed in the terminal.

"); + + console.log("\n=== access token claims ==="); + console.log(JSON.stringify(claims, null, 2)); + console.log("\n=== access token ===\n" + tok.access_token + "\n"); + server.close(); +}); + +server.listen(PORT, () => { + console.log(`Listening on ${REDIRECT}\n\nOpen this to sign in:\n\n${authUrl}\n`); +}); diff --git a/zitadel-setup-poc/secrets/.gitkeep b/zitadel-setup-poc/secrets/.gitkeep new file mode 100644 index 0000000000..e69de29bb2 diff --git a/zitadel-setup-poc/seed.mjs b/zitadel-setup-poc/seed.mjs new file mode 100644 index 0000000000..3e209120ca --- /dev/null +++ b/zitadel-setup-poc/seed.mjs @@ -0,0 +1,74 @@ +// Creates the project and OIDC app the Luminary app would use, then prints the +// AuthProvider field values to enter in the CMS. Run after the stack is up. +import { readFileSync } from "node:fs"; + +const DOMAIN = process.env.ZITADEL_DOMAIN || "auth.luminary.local"; +const BASE = `https://${DOMAIN}`; +const PAT_PATH = process.env.PAT_PATH || "./secrets/seed-pat.txt"; +const APP_ORIGIN = process.env.APP_ORIGIN || "http://localhost:4174"; + +let pat; +try { + pat = readFileSync(PAT_PATH, "utf8").trim(); +} catch { + console.error(`No PAT at ${PAT_PATH}. Start the stack first — first-instance setup writes it.`); + process.exit(1); +} + +async function api(path, body, method = "POST") { + const res = await fetch(`${BASE}${path}`, { + method, + headers: { Authorization: `Bearer ${pat}`, "Content-Type": "application/json" }, + body: JSON.stringify(body), + }); + const json = await res.json().catch(() => null); + if (!res.ok) throw new Error(`${path} -> ${res.status}: ${JSON.stringify(json)}`); + return json; +} + +// The Login v2 container authenticates as this same machine user, and its +// session calls are refused with AUTH-AWfge unless it holds IAM_LOGIN_CLIENT. +// IAM_OWNER from first-instance setup does not imply it. +const members = await api("/admin/v1/members/_search", {}); +const loginClient = members.result?.find((m) => m.preferredLoginName === "seed-bot"); +if (loginClient && !loginClient.roles.includes("IAM_LOGIN_CLIENT")) { + await api( + `/admin/v1/members/${loginClient.userId}`, + { roles: [...loginClient.roles, "IAM_LOGIN_CLIENT"] }, + "PUT", + ); +} + +const project = await api("/management/v1/projects", { name: "Luminary" }); +const projectId = project.id; + +const app = await api(`/management/v1/projects/${projectId}/apps/oidc`, { + name: "Luminary app", + redirectUris: [`${APP_ORIGIN}/callback`, `${APP_ORIGIN}/`], + postLogoutRedirectUris: [`${APP_ORIGIN}/`], + responseTypes: ["OIDC_RESPONSE_TYPE_CODE"], + grantTypes: ["OIDC_GRANT_TYPE_AUTHORIZATION_CODE", "OIDC_GRANT_TYPE_REFRESH_TOKEN"], + // A browser SPA holds no secret, so PKCE with no client auth — this is what + // oidc-client-ts in app/src/auth.ts already does. + appType: "OIDC_APP_TYPE_USER_AGENT", + authMethodType: "OIDC_AUTH_METHOD_TYPE_NONE", + // The API runs jwtService.verifyAsync, which an opaque token fails outright. + accessTokenType: "OIDC_TOKEN_TYPE_JWT", + accessTokenRoleAssertion: true, + devMode: true, +}); + +const audienceScope = `urn:zitadel:iam:org:project:id:${projectId}:aud`; + +console.log(` +Project ..... ${projectId} +Client ID ... ${app.clientId} + +AuthProvider doc (CMS → Auth providers): + domain ${DOMAIN} + clientId ${app.clientId} + audience ${app.clientId} + +The app must request this scope so the project lands in the token's aud claim: + ${audienceScope} +`); diff --git a/zitadel-setup-poc/verify-contract.mjs b/zitadel-setup-poc/verify-contract.mjs new file mode 100644 index 0000000000..82ef4889ae --- /dev/null +++ b/zitadel-setup-poc/verify-contract.mjs @@ -0,0 +1,129 @@ +// Checks a running Zitadel against the exact constraints +// api/src/auth/authIdentity.service.ts imposes on an AuthProvider, so the +// answer comes from the running stack rather than from reading docs. +// +// node verify-contract.mjs [--domain=...] [--client-id=...] [--audience=...] [--token=] +// +// Without --token the token-shaped checks are skipped; grab one from the app's +// network tab or an authorization-code flow to run them. + +const arg = (name, fallback) => { + const hit = process.argv.find((a) => a.startsWith(`--${name}=`)); + return hit ? hit.slice(name.length + 3) : fallback; +}; + +const domain = arg("domain", "auth.luminary.local"); +const clientId = arg("client-id", null); +const audience = arg("audience", clientId); +const token = arg("token", null); + +const results = []; +const record = (id, ok, detail, fix) => results.push({ id, ok, detail, fix }); +const skip = (id, detail) => results.push({ id, ok: null, detail }); + +const b64url = (s) => Buffer.from(s.replace(/-/g, "+").replace(/_/g, "/"), "base64").toString("utf8"); + +async function getJson(url) { + const res = await fetch(url); + if (!res.ok) throw new Error(`${res.status}`); + return res.json(); +} + +// 1 — discovery reachable, and the issuer it advertises +let discovery = null; +try { + discovery = await getJson(`https://${domain}/.well-known/openid-configuration`); + record("discovery", true, `reachable, issuer=${discovery.issuer}`); +} catch (e) { + record("discovery", false, `unreachable (${e.message})`, "Is the stack up and does /etc/hosts point auth.luminary.local at 127.0.0.1?"); +} + +// 2 — the API builds `https://${provider.domain}/` and jsonwebtoken compares it +// to the iss claim as an exact string. +if (discovery) { + const expected = `https://${domain}/`; + const ok = discovery.issuer === expected; + record( + "issuer", + ok, + `advertised "${discovery.issuer}" vs required "${expected}"`, + ok ? undefined : "Trailing-slash mismatch. The API hardcodes it for Auth0. Either accept both forms in authIdentity.service.ts or add an explicit issuer field to AuthProviderDto.", + ); +} + +// 3 — the API fetches this fixed path; the Caddyfile maps it onto /oauth/v2/keys +let jwks = null; +try { + jwks = await getJson(`https://${domain}/.well-known/jwks.json`); + const n = jwks.keys?.length ?? 0; + record("jwks", n > 0, `${n} key(s) at /.well-known/jwks.json`, n > 0 ? undefined : "Endpoint answered but published no keys."); +} catch (e) { + record("jwks", false, `not served (${e.message})`, "Zitadel publishes at /oauth/v2/keys; the Caddyfile rewrite should expose it here."); +} +if (discovery?.jwks_uri) { + record("jwks-native", true, `Zitadel's own jwks_uri is ${discovery.jwks_uri}`); +} + +// 4/5 — everything that can only be seen on a real token +if (!token) { + skip("token-format", "no --token supplied"); + skip("alg", "no --token supplied"); + skip("aud", "no --token supplied"); + skip("azp", "no --token supplied"); +} else { + const parts = token.split("."); + if (parts.length !== 3) { + record("token-format", false, "not a JWT (opaque token)", "Set the app's accessTokenType to OIDC_TOKEN_TYPE_JWT — an opaque token fails jwtService.verifyAsync outright."); + } else { + record("token-format", true, "three-segment JWT"); + const header = JSON.parse(b64url(parts[0])); + const payload = JSON.parse(b64url(parts[1])); + + // The API compares the iss claim itself, so the token is the real test — + // the discovery document only predicts what it will contain. + const expectedIss = `https://${domain}/`; + const issOk = payload.iss === expectedIss; + record( + "iss-claim", + issOk, + `iss="${payload.iss}" vs required "${expectedIss}"`, + issOk ? undefined : "jsonwebtoken compares iss exactly; see the issuer check above for the fix.", + ); + + const algOk = header.alg === "RS256"; + record("alg", algOk, `alg=${header.alg}`, algOk ? undefined : "The API allows only RS256."); + + if (jwks?.keys) { + const known = jwks.keys.some((k) => k.kid === header.kid); + record("kid", known, `kid=${header.kid} ${known ? "found" : "absent"} in JWKS`, known ? undefined : "Token was signed by a key the JWKS endpoint does not publish."); + } + + const auds = Array.isArray(payload.aud) ? payload.aud : [payload.aud].filter(Boolean); + const audOk = audience ? auds.includes(audience) : null; + if (audience) { + record("aud", audOk, `aud=[${auds.join(", ")}] expecting "${audience}"`, audOk ? undefined : "Request the urn:zitadel:iam:org:project:id::aud scope so the project lands in aud."); + } else { + skip("aud", "no --audience supplied"); + } + + const tokenClient = payload.azp ?? payload.client_id; + if (clientId) { + // The API only enforces this when the claim is present. + const azpOk = !tokenClient || tokenClient === clientId; + record("azp", azpOk, `azp/client_id=${tokenClient ?? "(absent)"} expecting "${clientId}"`, azpOk ? undefined : "Token was issued to a different client than the AuthProvider doc names."); + } else { + skip("azp", "no --client-id supplied"); + } + } +} + +const mark = (ok) => (ok === null ? "SKIP" : ok ? "PASS" : "FAIL"); +console.log("\nContract checks against api/src/auth/authIdentity.service.ts\n"); +for (const r of results) { + console.log(` ${mark(r.ok).padEnd(5)} ${r.id.padEnd(14)} ${r.detail}`); + if (r.fix) console.log(` ↳ ${r.fix}`); +} +const failed = results.filter((r) => r.ok === false).length; +const skipped = results.filter((r) => r.ok === null).length; +console.log(`\n${results.filter((r) => r.ok).length} passed, ${failed} failed, ${skipped} skipped\n`); +process.exit(failed > 0 ? 1 : 0); diff --git a/zitadel-setup-poc/zitadel-config/steps.yaml b/zitadel-setup-poc/zitadel-config/steps.yaml new file mode 100644 index 0000000000..e1bdbab976 --- /dev/null +++ b/zitadel-setup-poc/zitadel-config/steps.yaml @@ -0,0 +1,20 @@ +FirstInstance: + InstanceName: luminary-poc + # The seed script authenticates with this token, so first-instance setup is + # the only step that needs a human. + PatPath: /secrets/seed-pat.txt + Org: + Name: Luminary + Human: + UserName: admin + Password: Password1! + PasswordChangeRequired: false + Email: + Address: admin@luminary.local + Verified: true + Machine: + Machine: + Username: seed-bot + Name: Seed bot + Pat: + ExpirationDate: 2100-01-01T00:00:00Z diff --git a/zitadel-setup-poc/zitadel-config/zitadel.yaml b/zitadel-setup-poc/zitadel-config/zitadel.yaml new file mode 100644 index 0000000000..25a9f6a09f --- /dev/null +++ b/zitadel-setup-poc/zitadel-config/zitadel.yaml @@ -0,0 +1,27 @@ +Log: + Level: info + +# The issuer Zitadel advertises is derived from these three values, so they +# have to match the host the app and API actually reach it on. +ExternalDomain: auth.luminary.local +ExternalPort: 443 +ExternalSecure: true + +TLS: + Enabled: false + +Database: + postgres: + Host: postgres + Port: 5432 + Database: zitadel + MaxOpenConns: 20 + MaxIdleConns: 10 + User: + Username: zitadel + Password: zitadel + SSLMode: disable + Admin: + Username: postgres + Password: postgres + SSLMode: disable