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 ``;
}
function loginPage(req, res, msg = "", headers = {}) {
const form = ``;
send(res, 200, page("Вход | Million Miles", authLayout({ title: "Вход", subtitle: "Войдите в личный кабинет Million Miles.", heading: "Добро пожаловать", text: "Закрытый доступ для клиентов премиального сервиса.", form })), headers);
}
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 })));
}
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}`);
});