Files
ZITADEL/frontend/server.js
2026-05-01 19:09:58 +03:00

674 lines
30 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 => ({
"&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;"
}[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(/<p class="lgn-error-message">\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 `<!doctype html>
<html lang="ru">
<head>
<meta charset="utf-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1"/>
<title>${escapeHtml(title)}</title>
<style>
:root{--gold:#D4AF37;--gold-light:#F0D77D;--line:rgba(255,255,255,.11);--muted:rgba(255,255,255,.62);--muted2:rgba(255,255,255,.42)}
*{box-sizing:border-box}body{margin:0;min-height:100vh;background:#050505;color:#fff;font-family:Inter,Manrope,ui-sans-serif,system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI",Arial,sans-serif}a{color:inherit;text-decoration:none}button,input{font:inherit}
.screen{min-height:100vh;display:grid;place-items:center;padding:28px;background:radial-gradient(circle at 78% 12%,rgba(212,175,55,.16),transparent 27%),radial-gradient(circle at 8% 86%,rgba(18,44,90,.38),transparent 34%),linear-gradient(180deg,#111,#060606 58%,#030303)}
.shell{width:min(1080px,100%);min-height:700px;display:grid;grid-template-columns:1fr 455px;overflow:hidden;border:1px solid var(--line);border-radius:38px;background:rgba(255,255,255,.025);box-shadow:0 38px 120px rgba(0,0,0,.58)}
.brand-side{position:relative;padding:44px;display:flex;flex-direction:column;justify-content:space-between;background:radial-gradient(circle at 70% 24%,rgba(212,175,55,.18),transparent 34%),linear-gradient(145deg,rgba(255,255,255,.06),rgba(255,255,255,.015))}
.logo{display:flex;align-items:center;gap:14px}.logo-mark{width:46px;height:46px;border-radius:50%;display:grid;place-items:center;border:1px solid rgba(212,175,55,.38);background:linear-gradient(145deg,rgba(212,175,55,.28),rgba(255,255,255,.045));color:var(--gold);font-weight:800}.logo-title{display:block;font-size:15px;font-weight:800;letter-spacing:.22em;text-transform:uppercase}.logo-sub{display:block;margin-top:3px;font-size:12px;color:var(--muted2);letter-spacing:.12em}
.brand-copy h1{margin:0;max-width:560px;font-size:clamp(50px,6vw,80px);line-height:.9;letter-spacing:-.075em}.gold{color:var(--gold)}.brand-copy p{max-width:520px;margin:28px 0 0;color:var(--muted);font-size:18px;line-height:1.75}.brand-footer{display:flex;gap:12px;flex-wrap:wrap;color:var(--muted2);font-size:13px}.brand-footer span{min-width:118px;padding-top:12px;border-top:1px solid var(--line)}
.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}
@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}}
</style>
</head>
<body>${body}</body>
</html>`;
}
function authLayout({ title, subtitle, heading, text, form }) {
return `<main class="screen"><section class="shell"><div class="brand-side"><a class="logo" href="/login"><span class="logo-mark">MM</span><span><span class="logo-title">Million Miles</span><span class="logo-sub">Private Client Access</span></span></a><div class="brand-copy"><h1>${heading}</h1><p>${text}</p></div><div class="brand-footer"><span>Secure access</span><span>Private area</span><span>Premium service</span></div></div><div class="form-side"><div class="form-card"><h2>${title}</h2><p>${subtitle}</p>${form}</div></div></section></main>`;
}
function loginPage(req, res, msg = "", headers = {}) {
const form = `<form method="post" action="/login">${msg}<label class="label">Email</label><input class="input" name="email" type="email" autocomplete="email" placeholder="you@example.com" required /><label class="label">Пароль</label><input class="input" name="password" type="password" autocomplete="current-password" placeholder="Ваш пароль" required /><button class="btn" type="submit">Войти</button><div class="link-row">Нет аккаунта? <a href="/register">Зарегистрироваться</a></div></form>`;
send(res, 200, page("Вход | Million Miles", authLayout({ title: "Вход", subtitle: "Войдите в личный кабинет Million Miles.", heading: "Добро пожаловать", text: "Закрытый доступ для клиентов премиального сервиса.", form })), headers);
}
function registerPage(req, res, msg = "") {
const form = `<form method="post" action="/register">${msg}<label class="label">Полное имя</label><input class="input" name="fullName" autocomplete="name" placeholder="Ваше имя" required minlength="2" /><label class="label">Email</label><input class="input" name="email" type="email" autocomplete="email" placeholder="you@example.com" required /><label class="label">Пароль</label><input class="input" name="password" type="password" autocomplete="new-password" placeholder="Минимум 8 символов" minlength="8" required /><button class="btn" type="submit">Зарегистрироваться</button><div class="link-row">Уже есть аккаунт? <a href="/login">Войти</a></div></form>`;
send(res, 200, page("Регистрация | Million Miles", authLayout({ title: "Регистрация", subtitle: "Создайте личный кабинет Million Miles.", heading: `Персональный доступ <span class="gold">Million Miles</span>`, 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, `<div class="alert error">${escapeHtml(e.message || "Не удалось создать аккаунт.")}</div>`);
}
}
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, `<div class="alert error">${escapeHtml(e.message || "Неверный email или пароль.")}</div>`, {
"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, `<div class="alert error">${escapeHtml(e.message || "Не удалось начать вход.")}</div>`);
}
}
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, `<div class="alert error">${escapeHtml(e.message || "Вход не завершён.")}</div>`, {
"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 = `<main class="screen"><section class="dashboard-card"><div class="dashboard-top"><a class="logo" href="/dashboard"><span class="logo-mark">MM</span><span><span class="logo-title">Million Miles</span><span class="logo-sub">Private Client Access</span></span></a><form method="post" action="/logout"><button class="btn logout" type="submit">Выйти</button></form></div><div class="profile"><div class="avatar">${escapeHtml(initials(name, email))}</div><div><h1>${escapeHtml(name)}</h1><p>Данные ниже получены из JWT ZITADEL</p></div></div><div class="jwt-info"><h2>JWT</h2><div class="jwt-grid"><div class="jwt-item"><b>sub</b><span>${escapeHtml(claims.sub || "—")}</span></div><div class="jwt-item"><b>name</b><span>${escapeHtml(claims.name || "—")}</span></div><div class="jwt-item"><b>email</b><span>${escapeHtml(claims.email || "—")}</span></div><div class="jwt-item"><b>username</b><span>${escapeHtml(claims.preferred_username || claims.email || "—")}</span></div><div class="jwt-item"><b>iat</b><span>${escapeHtml(iat)}</span></div><div class="jwt-item"><b>exp</b><span>${escapeHtml(exp)}</span></div><div class="jwt-item"><b>iss</b><span>${escapeHtml(claims.iss || "—")}</span></div><div class="jwt-item"><b>aud</b><span>${escapeHtml(Array.isArray(claims.aud) ? claims.aud.join(", ") : claims.aud || "—")}</span></div></div><div class="jwt-raw"><b>Raw JWT from ZITADEL</b><pre>${escapeHtml(session.id_token || "—")}</pre></div></div></section></main>`;
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, `<div class="alert ok">Аккаунт создан. Теперь войдите через форму ZITADEL.</div>`, { "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}`);
});