const http = require("http"); const crypto = require("crypto"); const fs = require("fs"); const { URLSearchParams } = require("url"); const PORT = 3000; const env = { APP_URL: process.env.APP_URL || "http://localhost:3000", ZITADEL_URL: process.env.ZITADEL_URL || "http://host.docker.internal:8080", ZITADEL_PUBLIC_URL: process.env.ZITADEL_PUBLIC_URL || "http://localhost:8080", ZITADEL_PROJECT_NAME: process.env.ZITADEL_PROJECT_NAME || "Million Miles", OIDC_APP_NAME: process.env.OIDC_APP_NAME || "Million Miles Custom Frontend", OIDC_REDIRECT_URI: process.env.OIDC_REDIRECT_URI || "http://localhost:3000/auth/callback", OIDC_CLIENT_ID: process.env.OIDC_CLIENT_ID || "", OIDC_CLIENT_SECRET: process.env.OIDC_CLIENT_SECRET || "", OIDC_CLIENT_STATE_FILE: process.env.OIDC_CLIENT_STATE_FILE || "/zitadel-secrets/oidc-client.json", ZITADEL_SERVICE_TOKEN_FILE: process.env.ZITADEL_SERVICE_TOKEN_FILE || "/zitadel-secrets/admin.pat", ZITADEL_LOGIN_CLIENT_TOKEN_FILE: process.env.ZITADEL_LOGIN_CLIENT_TOKEN_FILE || "/zitadel-secrets/login-client.pat", SESSION_SECRET: process.env.SESSION_SECRET || "dev-secret", AUTH_DEBUG: process.env.AUTH_DEBUG === "true" }; const persistedOidcClient = readOidcClientConfig(); const state = { projectId: null, clientId: env.OIDC_CLIENT_ID || persistedOidcClient.clientId, clientSecret: env.OIDC_CLIENT_SECRET || persistedOidcClient.clientSecret, setupTried: false }; function debug(...args) { if (env.AUTH_DEBUG) console.log("[zitadel-debug]", ...args); } function readTokenFile(path) { try { const value = fs.readFileSync(path, "utf8").trim(); return value || null; } catch { return null; } } function readOidcClientConfig() { try { const raw = fs.readFileSync(env.OIDC_CLIENT_STATE_FILE, "utf8"); const parsed = JSON.parse(raw); return { clientId: parsed.clientId || "", clientSecret: parsed.clientSecret || "" }; } catch { return { clientId: "", clientSecret: "" }; } } 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); } } function adminToken() { return readTokenFile(env.ZITADEL_SERVICE_TOKEN_FILE); } function loginClientToken() { return readTokenFile(env.ZITADEL_LOGIN_CLIENT_TOKEN_FILE) || adminToken(); } function escapeHtml(value) { return String(value ?? "").replace(/[&<>"']/g, s => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[s])); } function validatePassword(password) { if (password.length < 8) return "Пароль должен быть не короче 8 символов."; if (!/[A-Z]/.test(password)) return "Пароль должен содержать хотя бы одну заглавную букву."; if (!/[a-z]/.test(password)) return "Пароль должен содержать хотя бы одну строчную букву."; if (!/\d/.test(password)) return "Пароль должен содержать хотя бы одну цифру."; return null; } function b64url(input) { return Buffer.from(input).toString("base64url"); } function sign(value) { return crypto.createHmac("sha256", env.SESSION_SECRET).update(value).digest("base64url"); } function makeSigned(value) { const payload = b64url(JSON.stringify(value)); return `${payload}.${sign(payload)}`; } function readSigned(value) { if (!value || !value.includes(".")) return null; const [payload, mac] = value.split("."); if (sign(payload) !== mac) return null; try { return JSON.parse(Buffer.from(payload, "base64url").toString("utf8")); } catch { return null; } } function makeCookie(name, value, maxAge = 3600) { return `${name}=${value}; Path=/; HttpOnly; SameSite=Lax; Max-Age=${maxAge}`; } function clearCookie(name) { return `${name}=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0`; } 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)]; })); } function getSession(req) { return readSigned(parseCookies(req).mm_session); } async function parseBody(req) { let data = ""; for await (const chunk of req) data += chunk; 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 {}; } } 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: { Accept: "application/json", Authorization: `Bearer ${token}`, ...(options.headers || {}) } }); const text = await res.text(); let data = {}; 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); } return data; } async function waitForZitadel() { try { const res = await fetch(`${env.ZITADEL_URL}/debug/ready`); return res.ok; } catch { return false; } } async function setupOidcApp() { 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 { const search = await zFetch("/management/v1/projects/_search", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ 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); } if (!project) { const created = await zFetch("/management/v1/projects", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ name: env.ZITADEL_PROJECT_NAME, projectRoleAssertion: false, projectRoleCheck: false, 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" }, body: JSON.stringify({ name: env.OIDC_APP_NAME, redirectUris: [env.OIDC_REDIRECT_URI], responseTypes: ["OIDC_RESPONSE_TYPE_CODE"], grantTypes: ["OIDC_GRANT_TYPE_AUTHORIZATION_CODE"], appType: "OIDC_APP_TYPE_WEB", authMethodType: "OIDC_AUTH_METHOD_TYPE_BASIC", version: "OIDC_VERSION_1_0", devMode: true, accessTokenType: "OIDC_TOKEN_TYPE_JWT", idTokenUserinfoAssertion: true }) }, token); state.clientId = app.clientId; state.clientSecret = app.clientSecret; persistOidcClientConfig(state.clientId, state.clientSecret); debug("OIDC app created", { projectId, clientId: state.clientId }); } async function createUser({ fullName, email, password }) { const parts = fullName.trim().split(/\s+/); const givenName = parts[0] || fullName; const familyName = parts.slice(1).join(" ") || "User"; return await zFetch("/v2/users/human", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ username: email, profile: { givenName, familyName, displayName: fullName, preferredLanguage: "ru" }, email: { email, isVerified: true }, password: { password, changeRequired: false } }) }, adminToken()); } async function createAuthRequest(loginHint = "") { await setupOidcApp(); const clientId = state.clientId || env.OIDC_CLIENT_ID; if (!clientId) throw new Error("OIDC app не настроен. Укажите OIDC_CLIENT_ID/OIDC_CLIENT_SECRET в .env."); const stateValue = crypto.randomBytes(24).toString("base64url"); const verifier = crypto.randomBytes(48).toString("base64url"); const challenge = crypto.createHash("sha256").update(verifier).digest("base64url"); const params = new URLSearchParams({ client_id: clientId, redirect_uri: env.OIDC_REDIRECT_URI, response_type: "code", scope: "openid email profile", code_challenge: challenge, code_challenge_method: "S256", state: stateValue }); if (loginHint) params.set("login_hint", loginHint); return { authorizeUrl: `${env.ZITADEL_PUBLIC_URL}/oauth/v2/authorize?${params}`, stateValue, verifier }; } 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)); } } } 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, { 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; } 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 не завершил локальный вход."); } async function exchangeCode(code, verifier) { 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`, { method: "POST", headers: { "Content-Type": "application/x-www-form-urlencoded", Authorization: `Basic ${basic}` }, body: new URLSearchParams({ grant_type: "authorization_code", code, redirect_uri: env.OIDC_REDIRECT_URI, code_verifier: verifier }) }); const data = await res.json(); debug("token", res.status, data); if (!res.ok || !data.id_token) { throw new Error("ZITADEL не выдал id_token."); } return data.id_token; } function page(title, body) { return ` ${escapeHtml(title)} ${body} `; } function authLayout({ title, subtitle, heading, text, form }) { return `

${heading}

${text}

${title}

${subtitle}

${form}
`; } function loginPage(req, res, msg = "", headers = {}) { const form = `
${msg}
`; send(res, 200, page("Вход | Million Miles", authLayout({ title: "Вход", subtitle: "Войдите в личный кабинет Million Miles.", heading: "Добро пожаловать", text: "Закрытый доступ для клиентов премиального сервиса.", form })), headers); } function registerPage(req, res, msg = "") { const form = `
${msg}
`; send(res, 200, page("Регистрация | Million Miles", authLayout({ title: "Регистрация", subtitle: "Создайте личный кабинет Million Miles.", heading: `Персональный доступ Million Miles`, text: "Создайте профиль для доступа в личный кабинет Million Miles.", form }))); } async function registerPost(req, res) { const data = await parseBody(req); const email = String(data.email || "").trim().toLowerCase(); const fullName = String(data.fullName || "").trim(); const password = String(data.password || ""); try { if (!fullName || !email || !password) 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 || "Не удалось создать аккаунт.")}
`); } } async function loginPost(req, res) { const data = await parseBody(req); const email = String(data.email || "").trim().toLowerCase(); const password = String(data.password || ""); 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(); 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 || "Не удалось начать вход.")}
`); } } 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); 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 idToken = await exchangeCode(code, oidc.verifier); 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 || "Вход не завершён.")}
`, { "Set-Cookie": clearCookie("mm_oidc") }); } } function initials(name, email) { const source = name || email || "MM"; return source.split(/[ ._-]+/).filter(Boolean).slice(0, 2).map(x => x[0]).join("").toUpperCase(); } function dashboard(req, res) { const session = getSession(req); if (!session) return redirect(res, "/login"); 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") : "—"; 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)); } function redirect(res, location, headers = {}) { res.writeHead(302, { Location: location, ...headers }); res.end(); } function send(res, status, content, headers = {}) { res.writeHead(status, { "Content-Type": "text/html; charset=utf-8", ...headers }); res.end(content); } function logout(req, res) { redirect(res, "/login", { "Set-Cookie": clearCookie("mm_session") }); } http.createServer(async (req, res) => { const url = new URL(req.url, env.APP_URL); 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") }); } 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 === "POST" && url.pathname === "/register") return registerPost(req, res); if (req.method === "GET" && url.pathname === "/dashboard") return dashboard(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 })); } redirect(res, "/login"); }).listen(PORT, () => { setupOidcApp().catch(e => debug("startup setup failed", e.message)); console.log(`Million Miles ZITADEL POC on http://localhost:${PORT}`); });