diff --git a/README.md b/README.md
new file mode 100644
index 0000000..afef204
--- /dev/null
+++ b/README.md
@@ -0,0 +1,106 @@
+# ZITADEL POC
+
+POC с кастомными формами `login` и `register`, где ZITADEL используется только как backend IdP.
+
+- `/login` — кастомная форма входа
+- `/register` — кастомная форма регистрации
+- `/me` — JSON с claims из JWT, выданного ZITADEL
+- `/dashboard` — UI, который показывает данные из `/me`
+
+Приложение не использует hosted login UI ZITADEL для пользовательского входа:
+
+- не используется `/ui/login/loginname`
+- не используется `/ui/login/password`
+- не парсится hosted HTML, CSRF или `authRequestID` старого hosted login
+- браузер не уходит на hosted SSO-страницы ZITADEL при входе через `http://localhost:3001/login`
+
+Пароли и учетные записи хранятся в ZITADEL, не в нашем приложении.
+
+## Запуск
+
+```bash
+docker compose down -v
+docker compose up --build
+```
+
+Первый запуск ZITADEL может занять несколько минут.
+
+## Адреса
+
+Frontend:
+
+```txt
+http://localhost:3001
+```
+
+ZITADEL Console:
+
+```txt
+http://zitadel.localhost:8081
+```
+
+## Как работает вход
+
+1. Регистрация создает пользователя в ZITADEL через API:
+
+```txt
+POST /v2/users/human
+```
+
+2. Вход проверяет `email/password` через официальный Session API ZITADEL:
+
+```txt
+POST /v2/sessions
+PATCH /v2/sessions/{sessionId}
+```
+
+3. После успешной аутентификации backend завершает OIDC auth request и получает настоящий `id_token` / `access_token` от ZITADEL.
+
+4. `/me` валидирует и декодирует `id_token`, затем возвращает только claims:
+
+- `sub`
+- `email`
+- `name`
+- `preferred_username`
+- `iss`
+- `aud`
+- `iat`
+- `exp`
+
+5. `/dashboard` показывает именно JSON из `/me`, а не raw JWT.
+
+## Хранение токенов
+
+- токены не хранятся в `localStorage`
+- токены не хранятся в `sessionStorage`
+- используется `HttpOnly` cookie
+- используется `SameSite=Lax`
+- в production cookie должна быть `Secure=true`
+- в local dev `Secure=false`
+
+## Password Policy
+
+Локальная валидация и ZITADEL policy выровнены под тот же набор правил, что и в проекте Authentik:
+
+- минимум 8 символов
+- хотя бы одна заглавная буква
+- хотя бы одна строчная буква
+- хотя бы одна цифра
+
+## Logout
+
+```txt
+POST /logout
+```
+
+Этот endpoint очищает cookie приложения и закрывает ZITADEL session, если она есть.
+
+## Out Of Scope
+
+В рамках этого POC не реализованы:
+
+- sessions UI
+- consent UI
+- MFA devices UI
+- connected services
+- app passwords
diff --git a/frontend/server.js b/frontend/server.js
index f5709cb..ef9cdbc 100644
--- a/frontend/server.js
+++ b/frontend/server.js
@@ -1,7 +1,6 @@
-const http = require("http");
+const http = require("http");
const crypto = require("crypto");
const fs = require("fs");
-const { URLSearchParams } = require("url");
const PORT = 3000;
@@ -21,13 +20,23 @@ const env = {
AUTH_DEBUG: process.env.AUTH_DEBUG === "true"
};
+const COOKIE_SECURE = env.APP_URL.startsWith("https://") || process.env.NODE_ENV === "production";
+const PASSWORD_POLICY = {
+ minLength: "8",
+ hasUppercase: true,
+ hasLowercase: true,
+ hasNumber: true,
+ hasSymbol: false
+};
const persistedOidcClient = readOidcClientConfig();
-
const state = {
projectId: null,
clientId: env.OIDC_CLIENT_ID || persistedOidcClient.clientId,
clientSecret: env.OIDC_CLIENT_SECRET || persistedOidcClient.clientSecret,
- setupTried: false
+ setupTried: false,
+ configSyncTried: false,
+ jwks: null,
+ jwksFetchedAt: 0
};
function debug(...args) {
@@ -59,8 +68,8 @@ function readOidcClientConfig() {
function persistOidcClientConfig(clientId, clientSecret) {
try {
fs.writeFileSync(env.OIDC_CLIENT_STATE_FILE, JSON.stringify({ clientId, clientSecret }), "utf8");
- } catch (e) {
- debug("failed to persist OIDC client", e.message);
+ } catch (error) {
+ debug("failed to persist OIDC client", error.message);
}
}
@@ -72,10 +81,18 @@ function loginClientToken() {
return readTokenFile(env.ZITADEL_LOGIN_CLIENT_TOKEN_FILE) || adminToken();
}
+function stripTrailingSlash(value) {
+ return String(value || "").replace(/\/+$/, "");
+}
+
function escapeHtml(value) {
- return String(value ?? "").replace(/[&<>"']/g, s => ({
- "&": "&", "<": "<", ">": ">", '"': """, "'": "'"
- }[s]));
+ return String(value ?? "").replace(/[&<>"']/g, char => ({
+ "&": "&",
+ "<": "<",
+ ">": ">",
+ "\"": """,
+ "'": "'"
+ }[char]));
}
function validatePassword(password) {
@@ -94,6 +111,12 @@ function sign(value) {
return crypto.createHmac("sha256", env.SESSION_SECRET).update(value).digest("base64url");
}
+function signaturesMatch(left, right) {
+ const a = Buffer.from(String(left || ""));
+ const b = Buffer.from(String(right || ""));
+ return a.length === b.length && crypto.timingSafeEqual(a, b);
+}
+
function makeSigned(value) {
const payload = b64url(JSON.stringify(value));
return `${payload}.${sign(payload)}`;
@@ -102,7 +125,8 @@ function makeSigned(value) {
function readSigned(value) {
if (!value || !value.includes(".")) return null;
const [payload, mac] = value.split(".");
- if (sign(payload) !== mac) return null;
+ if (!signaturesMatch(sign(payload), mac)) return null;
+
try {
return JSON.parse(Buffer.from(payload, "base64url").toString("utf8"));
} catch {
@@ -111,23 +135,44 @@ function readSigned(value) {
}
function makeCookie(name, value, maxAge = 3600) {
- return `${name}=${value}; Path=/; HttpOnly; SameSite=Lax; Max-Age=${maxAge}`;
+ const secure = COOKIE_SECURE ? "; Secure" : "";
+ return `${name}=${value}; Path=/; HttpOnly; SameSite=Lax; Max-Age=${maxAge}${secure}`;
}
function clearCookie(name) {
- return `${name}=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0`;
+ const secure = COOKIE_SECURE ? "; Secure" : "";
+ return `${name}=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0${secure}`;
}
function parseCookies(req) {
const header = req.headers.cookie || "";
- return Object.fromEntries(header.split(";").map(v => v.trim()).filter(Boolean).map(v => {
- const i = v.indexOf("=");
- return [v.slice(0, i), v.slice(i + 1)];
- }));
+ return Object.fromEntries(
+ header
+ .split(";")
+ .map(value => value.trim())
+ .filter(Boolean)
+ .map(value => {
+ const index = value.indexOf("=");
+ if (index < 0) return [value, ""];
+ return [value.slice(0, index), value.slice(index + 1)];
+ })
+ );
+}
+
+function getSignedCookie(req, name) {
+ return readSigned(parseCookies(req)[name]);
}
function getSession(req) {
- return readSigned(parseCookies(req).mm_session);
+ return getSignedCookie(req, "mm_session");
+}
+
+function getPendingAuth(req) {
+ return getSignedCookie(req, "mm_auth");
+}
+
+function getFlash(req) {
+ return parseCookies(req).mm_flash || "";
}
async function parseBody(req) {
@@ -136,17 +181,15 @@ async function parseBody(req) {
return Object.fromEntries(new URLSearchParams(data));
}
-function decodeJwt(token) {
- try {
- const [, payload] = token.split(".");
- return JSON.parse(Buffer.from(payload, "base64url").toString("utf8"));
- } catch {
- return {};
- }
+function decodeJwtPart(token, index) {
+ const parts = String(token || "").split(".");
+ if (parts.length !== 3) throw new Error("Некорректный JWT.");
+ return JSON.parse(Buffer.from(parts[index], "base64url").toString("utf8"));
}
async function zFetch(path, options = {}, token = adminToken()) {
if (!token) throw new Error("ZITADEL PAT еще не создан. Подождите первый запуск ZITADEL.");
+
const res = await fetch(`${env.ZITADEL_URL}${path}`, {
...options,
headers: {
@@ -158,12 +201,17 @@ async function zFetch(path, options = {}, token = adminToken()) {
const text = await res.text();
let data = {};
- try { data = text ? JSON.parse(text) : {}; } catch { data = { raw: text }; }
+
+ try {
+ data = text ? JSON.parse(text) : {};
+ } catch {
+ data = { raw: text };
+ }
if (!res.ok) {
debug("zFetch failed", path, res.status, data);
- const msg = data.message || data.raw || `ZITADEL API error ${res.status}`;
- throw new Error(msg);
+ const message = data.message || data.raw || `ZITADEL API error ${res.status}`;
+ throw new Error(message);
}
return data;
@@ -178,17 +226,75 @@ async function waitForZitadel() {
}
}
-async function setupOidcApp() {
+async function ensurePasswordPolicy(token) {
+ const currentResponse = await zFetch("/management/v1/policies/password/complexity", {}, token);
+ const current = currentResponse.policy || {};
+ const alreadyMatches = String(current.minLength || "") === PASSWORD_POLICY.minLength
+ && current.hasUppercase === PASSWORD_POLICY.hasUppercase
+ && current.hasLowercase === PASSWORD_POLICY.hasLowercase
+ && current.hasNumber === PASSWORD_POLICY.hasNumber
+ && Boolean(current.hasSymbol) === PASSWORD_POLICY.hasSymbol;
+
+ if (alreadyMatches) return;
+
+ const method = currentResponse.isDefault ? "POST" : "PUT";
+ const body = JSON.stringify(PASSWORD_POLICY);
+
+ try {
+ await zFetch("/management/v1/policies/password/complexity", {
+ method,
+ headers: { "Content-Type": "application/json" },
+ body
+ }, token);
+ } catch (error) {
+ if (String(error.message || "").includes("NotChanged")) return;
+
+ if (method === "PUT") {
+ try {
+ await zFetch("/management/v1/policies/password/complexity", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body
+ }, token);
+ } catch (fallbackError) {
+ if (String(fallbackError.message || "").includes("already exists")) return;
+ throw fallbackError;
+ }
+ return;
+ }
+
+ throw error;
+ }
+}
+
+async function ensureLoginV2(token) {
+ const current = await zFetch("/v2/features/instance", {}, token);
+ const loginV2 = current.loginV2 || {};
+ const desiredBaseUri = stripTrailingSlash(env.APP_URL);
+ const currentBaseUri = stripTrailingSlash(loginV2.baseUri);
+
+ if (loginV2.required === true && currentBaseUri === desiredBaseUri) return;
+
+ await zFetch("/v2/features/instance", {
+ method: "PUT",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({
+ loginV2: {
+ required: true,
+ baseUri: desiredBaseUri
+ }
+ })
+ }, token);
+}
+
+async function setupOidcApp(token = adminToken()) {
if (state.clientId && state.clientSecret) return;
if (state.setupTried) return;
if (!(await waitForZitadel())) return;
-
- const token = adminToken();
if (!token) return;
state.setupTried = true;
- // Create project via V1 Management API.
let project = null;
try {
@@ -199,10 +305,9 @@ async function setupOidcApp() {
queries: [{ nameQuery: { name: env.ZITADEL_PROJECT_NAME, method: "TEXT_QUERY_METHOD_EQUALS" } }]
})
}, token);
-
- project = (search.result || search.projects || []).find(p => p.name === env.ZITADEL_PROJECT_NAME);
- } catch (e) {
- debug("project search failed", e.message);
+ project = (search.result || search.projects || []).find(item => item.name === env.ZITADEL_PROJECT_NAME);
+ } catch (error) {
+ debug("project search failed", error.message);
}
if (!project) {
@@ -216,16 +321,13 @@ async function setupOidcApp() {
hasProjectCheck: false
})
}, token);
-
project = { id: created.id || created.projectId };
}
const projectId = project.id || project.projectId;
state.projectId = projectId;
-
if (!projectId) throw new Error("Не удалось получить projectId ZITADEL.");
- // Create OIDC app. If it already exists, user can put client values in .env.
const app = await zFetch(`/management/v1/projects/${projectId}/apps/oidc`, {
method: "POST",
headers: { "Content-Type": "application/json" },
@@ -246,16 +348,30 @@ async function setupOidcApp() {
state.clientId = app.clientId;
state.clientSecret = app.clientSecret;
persistOidcClientConfig(state.clientId, state.clientSecret);
-
debug("OIDC app created", { projectId, clientId: state.clientId });
}
+async function syncZitadelConfig() {
+ if (state.configSyncTried) return;
+ if (!(await waitForZitadel())) return;
+
+ const token = adminToken();
+ if (!token) return;
+
+ state.configSyncTried = true;
+ await ensurePasswordPolicy(token);
+ await ensureLoginV2(token);
+ await setupOidcApp(token);
+}
+
async function createUser({ fullName, email, password }) {
+ await syncZitadelConfig();
+
const parts = fullName.trim().split(/\s+/);
const givenName = parts[0] || fullName;
const familyName = parts.slice(1).join(" ") || "User";
- return await zFetch("/v2/users/human", {
+ return zFetch("/v2/users/human", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
@@ -278,8 +394,41 @@ async function createUser({ fullName, email, password }) {
}, adminToken());
}
-async function createAuthRequest(loginHint = "") {
- await setupOidcApp();
+async function createPasswordSession(loginName, password) {
+ const token = loginClientToken();
+ const created = await zFetch("/v2/sessions", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({
+ checks: {
+ user: { loginName }
+ }
+ })
+ }, token);
+
+ const sessionId = created.sessionId;
+ let sessionToken = created.sessionToken;
+
+ if (!sessionId || !sessionToken) {
+ throw new Error("Не удалось создать session ZITADEL.");
+ }
+
+ const updated = await zFetch(`/v2/sessions/${encodeURIComponent(sessionId)}`, {
+ method: "PATCH",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({
+ checks: {
+ password: { password }
+ }
+ })
+ }, token);
+
+ sessionToken = updated.sessionToken || sessionToken;
+ return { sessionId, sessionToken };
+}
+
+async function createAuthRequest() {
+ await syncZitadelConfig();
const clientId = state.clientId || env.OIDC_CLIENT_ID;
if (!clientId) throw new Error("OIDC app не настроен. Укажите OIDC_CLIENT_ID/OIDC_CLIENT_SECRET в .env.");
@@ -288,7 +437,7 @@ async function createAuthRequest(loginHint = "") {
const verifier = crypto.randomBytes(48).toString("base64url");
const challenge = crypto.createHash("sha256").update(verifier).digest("base64url");
- const params = new URLSearchParams({
+ const url = `${env.ZITADEL_URL}/oauth/v2/authorize?${new URLSearchParams({
client_id: clientId,
redirect_uri: env.OIDC_REDIRECT_URI,
response_type: "code",
@@ -296,169 +445,54 @@ async function createAuthRequest(loginHint = "") {
code_challenge: challenge,
code_challenge_method: "S256",
state: stateValue
- });
+ })}`;
- if (loginHint) params.set("login_hint", loginHint);
+ const res = await fetch(url, { redirect: "manual" });
+ const location = res.headers.get("location") || "";
+ const redirectUrl = new URL(location, env.ZITADEL_PUBLIC_URL);
+ const authRequest = redirectUrl.searchParams.get("authRequest");
+ const hostedAuthRequest = redirectUrl.searchParams.get("authRequestID")
+ || redirectUrl.searchParams.get("auth_request")
+ || "";
- return {
- authorizeUrl: `${env.ZITADEL_PUBLIC_URL}/oauth/v2/authorize?${params}`,
- stateValue,
- verifier
- };
-}
+ debug("authorize start", res.status, location);
-function absoluteZitadelUrl(location) {
- return new URL(location, env.ZITADEL_URL).toString();
-}
-
-function updateCookieJar(jar, headers) {
- const setCookies = typeof headers.getSetCookie === "function"
- ? headers.getSetCookie()
- : (headers.get("set-cookie") ? [headers.get("set-cookie")] : []);
-
- for (const raw of setCookies) {
- const [pair] = String(raw || "").split(";");
- const index = pair.indexOf("=");
- if (index > 0) {
- jar.set(pair.slice(0, index), pair.slice(index + 1));
- }
+ if (!authRequest && hostedAuthRequest) {
+ throw new Error("ZITADEL вернул hosted login flow вместо custom login. Проверьте loginV2 и baseUri.");
}
+ if (!authRequest) throw new Error("ZITADEL не вернул authRequest.");
+
+ return { authRequest, stateValue, verifier };
}
-function cookieHeader(jar) {
- return Array.from(jar.entries()).map(([name, value]) => `${name}=${value}`).join("; ");
-}
-
-async function zBrowserFetch(url, jar, options = {}) {
- const headers = new Headers(options.headers || {});
- const cookies = cookieHeader(jar);
- if (cookies) headers.set("Cookie", cookies);
-
- const res = await fetch(url, {
- ...options,
- headers,
- redirect: "manual"
- });
-
- updateCookieJar(jar, res.headers);
- return res;
-}
-
-function extractInput(html, name) {
- const escaped = name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
- const match = html.match(new RegExp(`name="${escaped}"[^>]*value="([^"]*)"`, "i"));
- return match ? match[1] : "";
-}
-
-function extractErrorMessage(html) {
- const match = html.match(/
\s*([^<]+)\s*<\/p>/i);
- return match ? match[1].trim() : "";
-}
-
-function stripTags(value) {
- return String(value || "").replace(/<[^>]+>/g, " ").replace(/\s+/g, " ").trim();
-}
-
-async function submitHostedLogin(email, password) {
- const auth = await createAuthRequest(email);
- const jar = new Map();
-
- let res = await zBrowserFetch(auth.authorizeUrl, jar);
- let location = res.headers.get("location") || "";
- if (res.status !== 302 || !location) {
- throw new Error("ZITADEL не начал авторизацию.");
- }
-
- res = await zBrowserFetch(absoluteZitadelUrl(location), jar);
- let html = await res.text();
-
- let authRequestID = extractInput(html, "authRequestID");
- let csrf = extractInput(html, "gorilla.csrf.Token");
- if (!authRequestID || !csrf) {
- throw new Error(extractErrorMessage(html) || "ZITADEL не отдал форму логина.");
- }
-
- res = await zBrowserFetch(absoluteZitadelUrl("/ui/login/loginname"), jar, {
+async function finalizeAuthRequest(authRequest, session) {
+ const token = loginClientToken();
+ const response = await zFetch(`/v2/oidc/auth_requests/${encodeURIComponent(authRequest)}`, {
method: "POST",
- headers: { "Content-Type": "application/x-www-form-urlencoded" },
- body: new URLSearchParams({
- loginName: email,
- authRequestID,
- "gorilla.csrf.Token": csrf
- }).toString()
- });
- html = await res.text();
-
- csrf = extractInput(html, "gorilla.csrf.Token");
- authRequestID = extractInput(html, "authRequestID") || authRequestID;
- const normalizedLogin = extractInput(html, "loginName") || email;
-
- if (!csrf || !/action="\/ui\/login\/password"/i.test(html)) {
- throw new Error(extractErrorMessage(html) || "ZITADEL не отдал форму пароля.");
- }
-
- res = await zBrowserFetch(absoluteZitadelUrl("/ui/login/password"), jar, {
- method: "POST",
- headers: { "Content-Type": "application/x-www-form-urlencoded" },
- body: new URLSearchParams({
- password,
- loginName: normalizedLogin,
- authRequestID,
- "gorilla.csrf.Token": csrf
- }).toString()
- });
-
- for (let step = 0; step < 8; step += 1) {
- location = res.headers.get("location") || "";
-
- if (location) {
- const nextUrl = new URL(location, env.ZITADEL_PUBLIC_URL);
- if (nextUrl.origin + nextUrl.pathname === env.OIDC_REDIRECT_URI) {
- const code = nextUrl.searchParams.get("code");
- const returnedState = nextUrl.searchParams.get("state");
- if (!code || returnedState !== auth.stateValue) {
- throw new Error("Некорректный callback от ZITADEL.");
- }
-
- const idToken = await exchangeCode(code, auth.verifier);
- return idToken;
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({
+ session: {
+ sessionId: session.sessionId,
+ sessionToken: session.sessionToken
}
+ })
+ }, token);
- res = await zBrowserFetch(absoluteZitadelUrl(nextUrl.pathname + nextUrl.search), jar);
- continue;
- }
-
- html = await res.text();
- const errorMessage = extractErrorMessage(html);
- if (errorMessage) {
- throw new Error(stripTags(errorMessage));
- }
-
- if (/action="\/ui\/login\/mfa\/prompt"/i.test(html)) {
- csrf = extractInput(html, "gorilla.csrf.Token");
- authRequestID = extractInput(html, "authRequestID") || authRequestID;
- res = await zBrowserFetch(absoluteZitadelUrl("/ui/login/mfa/prompt"), jar, {
- method: "POST",
- headers: { "Content-Type": "application/x-www-form-urlencoded" },
- body: new URLSearchParams({
- skip: "true",
- authRequestID,
- "gorilla.csrf.Token": csrf
- }).toString()
- });
- continue;
- }
-
- throw new Error("ZITADEL не завершил локальный вход.");
- }
-
- throw new Error("ZITADEL не завершил локальный вход.");
+ if (!response.callbackUrl) throw new Error("ZITADEL не вернул callbackUrl.");
+ return response.callbackUrl;
}
-async function exchangeCode(code, verifier) {
+async function exchangeCode(callbackUrl, verifier, expectedState) {
+ const callback = new URL(callbackUrl, env.APP_URL);
+ const code = callback.searchParams.get("code");
+ const returnedState = callback.searchParams.get("state");
+
+ if (!code || returnedState !== expectedState) {
+ throw new Error("Некорректный callback от ZITADEL.");
+ }
+
const clientId = state.clientId || env.OIDC_CLIENT_ID;
const clientSecret = state.clientSecret || env.OIDC_CLIENT_SECRET;
-
const basic = Buffer.from(`${clientId}:${clientSecret}`).toString("base64");
const res = await fetch(`${env.ZITADEL_URL}/oauth/v2/token`, {
@@ -478,11 +512,125 @@ async function exchangeCode(code, verifier) {
const data = await res.json();
debug("token", res.status, data);
- if (!res.ok || !data.id_token) {
- throw new Error("ZITADEL не выдал id_token.");
+ if (!res.ok || !data.id_token) throw new Error("ZITADEL не выдал id_token.");
+
+ return {
+ idToken: data.id_token,
+ accessToken: data.access_token || ""
+ };
+}
+
+async function getJwks() {
+ const now = Date.now();
+ if (state.jwks && now - state.jwksFetchedAt < 5 * 60 * 1000) {
+ return state.jwks;
}
- return data.id_token;
+ const res = await fetch(`${env.ZITADEL_URL}/oauth/v2/keys`);
+ const data = await res.json();
+ if (!res.ok || !Array.isArray(data.keys)) {
+ throw new Error("Не удалось получить JWKS ZITADEL.");
+ }
+
+ state.jwks = data.keys;
+ state.jwksFetchedAt = now;
+ return state.jwks;
+}
+
+function extractPublicClaims(claims) {
+ const username = claims.preferred_username || claims.username || claims.email || "";
+
+ return {
+ sub: claims.sub || "",
+ email: claims.email || "",
+ name: claims.name || username,
+ preferred_username: claims.preferred_username || "",
+ username,
+ iss: claims.iss || "",
+ aud: Array.isArray(claims.aud) ? claims.aud : (claims.aud ? [claims.aud] : []),
+ iat: claims.iat || 0,
+ exp: claims.exp || 0
+ };
+}
+
+function formatTimestamp(timestamp) {
+ if (!timestamp) return "-";
+ return new Date(timestamp * 1000).toLocaleString("ru-RU");
+}
+
+function initials(name, email) {
+ const source = name || email || "MM";
+ return source
+ .split(/[ ._-]+/)
+ .filter(Boolean)
+ .slice(0, 2)
+ .map(part => part[0])
+ .join("")
+ .toUpperCase();
+}
+
+function jsonScript(value) {
+ return JSON.stringify(value).replace(/ item.kid === header.kid);
+ if (!jwk) throw new Error("Не найден публичный ключ для JWT.");
+
+ const algorithms = {
+ RS256: "RSA-SHA256",
+ RS384: "RSA-SHA384",
+ RS512: "RSA-SHA512"
+ };
+ const algorithm = algorithms[header.alg];
+ if (!algorithm) throw new Error(`Неподдерживаемый алгоритм JWT: ${header.alg}`);
+
+ const publicKey = crypto.createPublicKey({ key: jwk, format: "jwk" });
+ const payload = Buffer.from(`${parts[0]}.${parts[1]}`);
+ const verified = crypto.verify(algorithm, payload, publicKey, signature);
+ if (!verified) throw new Error("Подпись JWT не прошла проверку.");
+
+ return extractPublicClaims(claims);
+}
+
+async function closeZitadelSession(session) {
+ if (!session?.sessionId || !session?.sessionToken) return;
+
+ try {
+ await fetch(`${env.ZITADEL_URL}/v2/sessions/${encodeURIComponent(session.sessionId)}`, {
+ method: "DELETE",
+ headers: {
+ Accept: "application/json",
+ Authorization: `Bearer ${loginClientToken() || adminToken() || session.sessionToken}`,
+ "Content-Type": "application/json"
+ },
+ body: JSON.stringify({
+ sessionToken: session.sessionToken
+ })
+ });
+ } catch (error) {
+ debug("session close failed", error.message);
+ }
}
function page(title, body) {
@@ -503,8 +651,8 @@ function page(title, body) {
.form-side{padding:42px;display:grid;align-items:center;background:rgba(8,8,8,.78);backdrop-filter:blur(28px);border-left:1px solid var(--line)}.form-card h2{margin:0;font-size:36px;letter-spacing:-.045em}.form-card p{margin:12px 0 28px;color:var(--muted);line-height:1.65}.label{display:block;margin:18px 0 8px;color:rgba(255,255,255,.66);font-size:14px}.input{width:100%;border:1px solid var(--line);background:rgba(255,255,255,.045);border-radius:18px;padding:16px;color:#fff;outline:none;transition:.2s}.input::placeholder{color:rgba(255,255,255,.28)}.input:focus{border-color:rgba(212,175,55,.75);background:rgba(255,255,255,.065);box-shadow:0 0 0 4px rgba(212,175,55,.06)}
.btn{width:100%;min-height:52px;margin-top:24px;border:0;border-radius:999px;background:linear-gradient(135deg,var(--gold),var(--gold-light));color:#050505;font-weight:750;cursor:pointer;transition:.2s}.btn:hover{transform:translateY(-1px);box-shadow:0 20px 48px rgba(212,175,55,.22)}.link-row{margin-top:24px;text-align:center;color:var(--muted2);font-size:14px}.link-row a{color:var(--gold)}
.alert{margin:0 0 18px;padding:14px 16px;border:1px solid;border-radius:18px;font-size:14px;line-height:1.5}.alert.error{color:#fecaca;background:rgba(248,113,113,.09);border-color:rgba(248,113,113,.25)}.alert.ok{color:#bbf7d0;background:rgba(52,211,153,.08);border-color:rgba(52,211,153,.25)}
-.dashboard-card{width:min(980px,100%);padding:34px;border:1px solid var(--line);border-radius:36px;background:rgba(255,255,255,.04);box-shadow:0 38px 120px rgba(0,0,0,.58)}.dashboard-top{display:flex;align-items:center;justify-content:space-between;gap:20px}.logout{width:auto;min-height:46px;margin:0;padding:0 20px}.profile{margin-top:34px;display:flex;align-items:center;gap:22px}.avatar{width:82px;height:82px;border-radius:26px;display:grid;place-items:center;background:linear-gradient(145deg,var(--gold),var(--gold-light));color:#050505;font-size:30px;font-weight:800}.profile h1{margin:0;font-size:42px;letter-spacing:-.05em}.profile p{margin:8px 0 0;color:var(--muted)}
-.jwt-info{margin-top:24px;border:1px solid rgba(212,175,55,.22);border-radius:24px;background:rgba(212,175,55,.055);padding:20px}.jwt-info h2{margin:0 0 16px;font-size:22px;letter-spacing:-.035em}.jwt-grid{display:grid;grid-template-columns:repeat(2,1fr);gap:12px}.jwt-item{border:1px solid rgba(255,255,255,.09);background:rgba(0,0,0,.22);border-radius:18px;padding:14px}.jwt-item b{display:block;margin-bottom:8px;color:var(--muted2);font-size:11px;text-transform:uppercase;letter-spacing:.12em}.jwt-item span{color:rgba(255,255,255,.88);word-break:break-word;font-size:14px}.jwt-raw{margin-top:16px;border:1px solid rgba(255,255,255,.09);background:rgba(0,0,0,.24);border-radius:18px;padding:14px}.jwt-raw b{display:block;margin-bottom:8px;color:var(--muted2);font-size:11px;text-transform:uppercase;letter-spacing:.12em}.jwt-raw pre{margin:0;white-space:pre-wrap;word-break:break-all;color:rgba(255,255,255,.9);font-size:13px;line-height:1.6}
+.dashboard-card{width:min(980px,100%);padding:34px;border:1px solid var(--line);border-radius:36px;background:rgba(255,255,255,.04);box-shadow:0 38px 120px rgba(0,0,0,.58)}.dashboard-top{display:flex;align-items:center;justify-content:space-between;gap:20px}.logout{width:auto;min-height:46px;margin:0;padding:0 20px}.profile{margin-top:34px;display:flex;align-items:center;gap:22px}.avatar{width:82px;height:82px;border-radius:26px;display:grid;place-items:center;background:linear-gradient(145deg,var(--gold),var(--gold-light));color:#050505;font-size:30px;font-weight:800}.profile h1{margin:0;font-size:42px;letter-spacing:-.05em}.profile p{margin:8px 0 0;color:var(--muted);line-height:1.7}
+.jwt-info{margin-top:24px;border:1px solid rgba(212,175,55,.22);border-radius:24px;background:rgba(212,175,55,.055);padding:20px}.jwt-info h2{margin:0 0 16px;font-size:22px;letter-spacing:-.035em}.jwt-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:14px}.jwt-item{display:flex;flex-direction:column;gap:8px;padding:16px;border:1px solid rgba(255,255,255,.09);border-radius:18px;background:rgba(0,0,0,.18)}.jwt-item b{font-size:13px;letter-spacing:.06em;text-transform:uppercase;color:var(--muted2)}.jwt-item span{color:rgba(255,255,255,.94);word-break:break-word;line-height:1.6}.claims-json{margin-top:18px;display:flex;flex-direction:column;gap:12px}.claims-json b{font-size:15px}.claims-json pre{margin:0;border:1px solid rgba(255,255,255,.09);background:rgba(0,0,0,.24);border-radius:18px;padding:16px;white-space:pre-wrap;word-break:break-word;color:rgba(255,255,255,.9);font-size:14px;line-height:1.65}
@media(max-width:900px){.shell{grid-template-columns:1fr}.form-side{border-left:0;border-top:1px solid var(--line)}.jwt-grid{grid-template-columns:1fr}}
@@ -516,14 +664,48 @@ function authLayout({ title, subtitle, heading, text, form }) {
return ``;
}
-function loginPage(req, res, msg = "", headers = {}) {
- const form = `
`;
- send(res, 200, page("Вход | Million Miles", authLayout({ title: "Вход", subtitle: "Войдите в личный кабинет Million Miles.", heading: "Добро пожаловать", text: "Закрытый доступ для клиентов премиального сервиса.", form })), headers);
+function loginPath(authRequest = "") {
+ const suffix = authRequest ? `?${new URLSearchParams({ authRequest })}` : "";
+ return `/login${suffix}`;
}
-function registerPage(req, res, msg = "") {
- const form = ``;
- send(res, 200, page("Регистрация | Million Miles", authLayout({ title: "Регистрация", subtitle: "Создайте личный кабинет Million Miles.", heading: `Персональный доступ Million Miles`, text: "Создайте профиль для доступа в личный кабинет Million Miles.", form })));
+function registerPath(authRequest = "") {
+ const suffix = authRequest ? `?${new URLSearchParams({ authRequest })}` : "";
+ return `/register${suffix}`;
+}
+
+function loginPage(req, res, msg = "", headers = {}, explicitAuthRequest = "") {
+ const url = new URL(req.url, env.APP_URL);
+ const authRequest = explicitAuthRequest || url.searchParams.get("authRequest") || "";
+ const hiddenAuthRequest = authRequest
+ ? ``
+ : "";
+ const registerHref = registerPath(authRequest);
+ const form = ``;
+ send(res, 200, page("Вход | Million Miles", authLayout({
+ title: "Вход",
+ subtitle: "Войдите в личный кабинет Million Miles.",
+ heading: "Добро пожаловать",
+ text: "Кастомная форма входа. ZITADEL используется только как backend IdP.",
+ form
+ })), headers);
+}
+
+function registerPage(req, res, msg = "", explicitAuthRequest = "") {
+ const url = new URL(req.url, env.APP_URL);
+ const authRequest = explicitAuthRequest || url.searchParams.get("authRequest") || "";
+ const hiddenAuthRequest = authRequest
+ ? ``
+ : "";
+ const loginHref = loginPath(authRequest);
+ const form = ``;
+ send(res, 200, page("Регистрация | Million Miles", authLayout({
+ title: "Регистрация",
+ subtitle: "Создайте личный кабинет Million Miles.",
+ heading: `Персональный доступ Million Miles`,
+ text: "Пароли и учетные записи хранятся в ZITADEL, не в нашем приложении.",
+ form
+ })));
}
async function registerPost(req, res) {
@@ -531,15 +713,26 @@ async function registerPost(req, res) {
const email = String(data.email || "").trim().toLowerCase();
const fullName = String(data.fullName || "").trim();
const password = String(data.password || "");
+ const confirmPassword = String(data.confirmPassword || "");
+ const authRequest = String(data.authRequest || "").trim();
try {
- if (!fullName || !email || !password) throw new Error("Проверьте заполненные данные.");
+ if (!fullName || !email || !password || !confirmPassword) {
+ throw new Error("Проверьте заполненные данные.");
+ }
+ if (password !== confirmPassword) {
+ throw new Error("Пароли не совпадают.");
+ }
+
const passwordError = validatePassword(password);
if (passwordError) throw new Error(passwordError);
+
await createUser({ fullName, email, password });
- return redirect(res, "/login", { "Set-Cookie": makeCookie("mm_flash", "registered", 120) });
- } catch (e) {
- return registerPage(req, res, `${escapeHtml(e.message || "Не удалось создать аккаунт.")}
`);
+ return redirect(res, loginPath(authRequest), {
+ "Set-Cookie": makeCookie("mm_flash", "registered", 120)
+ });
+ } catch (error) {
+ return registerPage(req, res, `${escapeHtml(error.message || "Не удалось создать аккаунт.")}
`, authRequest);
}
}
@@ -547,85 +740,116 @@ async function loginPost(req, res) {
const data = await parseBody(req);
const email = String(data.email || "").trim().toLowerCase();
const password = String(data.password || "");
+ const authRequest = String(data.authRequest || "").trim();
try {
if (!email || !password) throw new Error("Введите email и пароль.");
- const idToken = await submitHostedLogin(email, password);
- const claims = decodeJwt(idToken);
- const signed = makeSigned({ id_token: idToken, claims });
- return redirect(res, "/dashboard", {
- "Set-Cookie": [
- makeCookie("mm_session", signed, 3600 * 8),
- clearCookie("mm_oidc")
- ]
- });
- } catch (e) {
- return loginPage(req, res, `${escapeHtml(e.message || "Неверный email или пароль.")}
`, {
- "Set-Cookie": clearCookie("mm_oidc")
- });
- }
-}
-async function authStart(req, res) {
- const url = new URL(req.url, env.APP_URL);
- const loginHint = String(url.searchParams.get("login_hint") || "").trim().toLowerCase();
+ const session = await createPasswordSession(email, password);
- try {
- const auth = await createAuthRequest(loginHint);
- const oidc = makeSigned({ state: auth.stateValue, verifier: auth.verifier });
- return redirect(res, auth.authorizeUrl, { "Set-Cookie": makeCookie("mm_oidc", oidc, 600) });
- } catch (e) {
- return loginPage(req, res, `${escapeHtml(e.message || "Не удалось начать вход.")}
`);
+ if (authRequest) {
+ const callbackUrl = await finalizeAuthRequest(authRequest, session);
+ return redirect(res, callbackUrl);
+ }
+
+ const auth = await createAuthRequest();
+ const callbackUrl = await finalizeAuthRequest(auth.authRequest, session);
+ return redirect(res, callbackUrl, {
+ "Set-Cookie": makeCookie("mm_auth", makeSigned({
+ verifier: auth.verifier,
+ stateValue: auth.stateValue,
+ sessionId: session.sessionId,
+ sessionToken: session.sessionToken
+ }), 300)
+ });
+ } catch (error) {
+ return loginPage(req, res, `${escapeHtml(error.message || "Неверный email или пароль.")}
`, {}, authRequest);
}
}
async function authCallback(req, res) {
- const url = new URL(req.url, env.APP_URL);
- const code = url.searchParams.get("code");
- const returnedState = url.searchParams.get("state");
- const error = url.searchParams.get("error");
- const cookies = parseCookies(req);
- const oidc = readSigned(cookies.mm_oidc);
+ const pendingAuth = getPendingAuth(req);
+ if (!pendingAuth?.verifier || !pendingAuth?.stateValue) {
+ return redirect(res, "/login", {
+ "Set-Cookie": clearCookie("mm_auth")
+ });
+ }
try {
- if (error) throw new Error(url.searchParams.get("error_description") || error);
- if (!oidc || !oidc.state || !oidc.verifier) throw new Error("Сессия входа истекла. Попробуйте снова.");
- if (!code || returnedState !== oidc.state) throw new Error("Некорректный callback от ZITADEL.");
+ const callbackUrl = new URL(req.url, env.APP_URL).toString();
+ const tokens = await exchangeCode(callbackUrl, pendingAuth.verifier, pendingAuth.stateValue);
+ await validateIdToken(tokens.idToken);
- const idToken = await exchangeCode(code, oidc.verifier);
- const claims = decodeJwt(idToken);
- const signed = makeSigned({ id_token: idToken, claims });
+ const signedSession = makeSigned({
+ idToken: tokens.idToken,
+ accessToken: tokens.accessToken,
+ sessionId: pendingAuth.sessionId || "",
+ sessionToken: pendingAuth.sessionToken || ""
+ });
return redirect(res, "/dashboard", {
"Set-Cookie": [
- makeCookie("mm_session", signed, 3600 * 8),
- clearCookie("mm_oidc")
+ makeCookie("mm_session", signedSession, 3600 * 8),
+ clearCookie("mm_auth")
]
});
- } catch (e) {
- return loginPage(req, res, `${escapeHtml(e.message || "Вход не завершён.")}
`, {
- "Set-Cookie": clearCookie("mm_oidc")
+ } catch {
+ return redirect(res, "/login", {
+ "Set-Cookie": clearCookie("mm_auth")
});
}
}
-function initials(name, email) {
- const source = name || email || "MM";
- return source.split(/[ ._-]+/).filter(Boolean).slice(0, 2).map(x => x[0]).join("").toUpperCase();
+async function dashboard(req, res) {
+ const session = getSession(req);
+ if (!session?.idToken) return redirect(res, "/login");
+
+ let profile;
+ try {
+ profile = await validateIdToken(session.idToken);
+ } catch {
+ return redirect(res, "/login", {
+ "Set-Cookie": clearCookie("mm_session")
+ });
+ }
+
+ const body = `${escapeHtml(initials(profile.name, profile.email))}
${escapeHtml(profile.name || "Клиент")}
Данные ниже получены из /me. Backend валидирует JWT ZITADEL из HttpOnly cookie.
Claims
sub${escapeHtml(profile.sub || "-")}
name${escapeHtml(profile.name || "-")}
email${escapeHtml(profile.email || "-")}
username${escapeHtml(profile.username || profile.preferred_username || "-")}
iat${escapeHtml(formatTimestamp(profile.iat))}
exp${escapeHtml(formatTimestamp(profile.exp))}
iss${escapeHtml(profile.iss || "-")}
aud${escapeHtml(Array.isArray(profile.aud) ? profile.aud.join(", ") : profile.aud || "-")}
/me JSON${escapeHtml(JSON.stringify(profile, null, 2))} `;
+
+ const script = ``;
+
+ send(res, 200, page("Dashboard | Million Miles", body + script));
}
-function dashboard(req, res) {
+async function me(req, res) {
const session = getSession(req);
- if (!session) return redirect(res, "/login");
+ if (!session?.idToken) {
+ return sendJson(res, 401, { error: "unauthorized" });
+ }
- const claims = session.claims || {};
- const name = claims.name || claims.preferred_username || claims.email || "Клиент";
- const email = claims.email || "—";
- const iat = claims.iat ? new Date(claims.iat * 1000).toLocaleString("ru-RU") : "—";
- const exp = claims.exp ? new Date(claims.exp * 1000).toLocaleString("ru-RU") : "—";
+ try {
+ const claims = await validateIdToken(session.idToken);
+ return sendJson(res, 200, claims);
+ } catch (error) {
+ return sendJson(res, 401, { error: error.message || "invalid_token" }, {
+ "Set-Cookie": clearCookie("mm_session")
+ });
+ }
+}
- const body = `${escapeHtml(initials(name, email))}
${escapeHtml(name)}
Данные ниже получены из JWT ZITADEL
JWT
sub${escapeHtml(claims.sub || "—")}
name${escapeHtml(claims.name || "—")}
email${escapeHtml(claims.email || "—")}
username${escapeHtml(claims.preferred_username || claims.email || "—")}
iat${escapeHtml(iat)}
exp${escapeHtml(exp)}
iss${escapeHtml(claims.iss || "—")}
aud${escapeHtml(Array.isArray(claims.aud) ? claims.aud.join(", ") : claims.aud || "—")}
Raw JWT from ZITADEL${escapeHtml(session.id_token || "—")} `;
- send(res, 200, page("Dashboard | Million Miles", body));
+async function logout(req, res) {
+ const session = getSession(req);
+ await closeZitadelSession(session);
+ redirect(res, "/login", {
+ "Set-Cookie": [clearCookie("mm_session"), clearCookie("mm_flash"), clearCookie("mm_auth")]
+ });
}
function redirect(res, location, headers = {}) {
@@ -638,8 +862,9 @@ function send(res, status, content, headers = {}) {
res.end(content);
}
-function logout(req, res) {
- redirect(res, "/login", { "Set-Cookie": clearCookie("mm_session") });
+function sendJson(res, status, value, headers = {}) {
+ res.writeHead(status, { "Content-Type": "application/json; charset=utf-8", ...headers });
+ res.end(JSON.stringify(value));
}
http.createServer(async (req, res) => {
@@ -647,27 +872,56 @@ http.createServer(async (req, res) => {
if (req.method === "GET" && url.pathname === "/") return redirect(res, "/login");
if (req.method === "GET" && url.pathname === "/login") {
- const cookies = parseCookies(req);
- if (cookies.mm_flash === "registered") {
- return loginPage(req, res, `Аккаунт создан. Теперь войдите через форму ZITADEL.
`, { "Set-Cookie": clearCookie("mm_flash") });
+ if (getSession(req)) return redirect(res, "/dashboard");
+
+ const authRequest = url.searchParams.get("authRequest") || "";
+ if (!authRequest) {
+ try {
+ const auth = await createAuthRequest();
+ const authCookie = makeCookie("mm_auth", makeSigned({
+ verifier: auth.verifier,
+ stateValue: auth.stateValue
+ }), 300);
+
+ if (getFlash(req) === "registered") {
+ return loginPage(req, res, `Аккаунт создан. Теперь можно войти.
`, {
+ "Set-Cookie": [authCookie, clearCookie("mm_flash")]
+ }, auth.authRequest);
+ }
+
+ return loginPage(req, res, "", {
+ "Set-Cookie": authCookie
+ }, auth.authRequest);
+ } catch (error) {
+ return loginPage(req, res, `${escapeHtml(error.message || "Не удалось подготовить вход.")}
`);
+ }
}
+
+ if (getFlash(req) === "registered") {
+ return loginPage(req, res, `Аккаунт создан. Теперь можно войти.
`, {
+ "Set-Cookie": clearCookie("mm_flash")
+ });
+ }
+
return loginPage(req, res);
}
+
if (req.method === "POST" && url.pathname === "/login") return loginPost(req, res);
- if (req.method === "GET" && url.pathname === "/auth/start") return authStart(req, res);
if (req.method === "GET" && url.pathname === "/auth/callback") return authCallback(req, res);
- if (req.method === "GET" && url.pathname === "/register") return registerPage(req, res);
+ if (req.method === "GET" && url.pathname === "/register") {
+ if (getSession(req)) return redirect(res, "/dashboard");
+ return registerPage(req, res);
+ }
if (req.method === "POST" && url.pathname === "/register") return registerPost(req, res);
if (req.method === "GET" && url.pathname === "/dashboard") return dashboard(req, res);
+ if (req.method === "GET" && url.pathname === "/me") return me(req, res);
if (req.method === "POST" && url.pathname === "/logout") return logout(req, res);
if (req.method === "GET" && url.pathname === "/health") {
- res.writeHead(200, { "Content-Type": "application/json" });
- return res.end(JSON.stringify({ ok: true }));
+ return sendJson(res, 200, { ok: true });
}
redirect(res, "/login");
}).listen(PORT, () => {
- setupOidcApp().catch(e => debug("startup setup failed", e.message));
+ syncZitadelConfig().catch(error => debug("startup setup failed", error.message));
console.log(`Million Miles ZITADEL POC on http://localhost:${PORT}`);
});
-