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

928 lines
38 KiB
JavaScript
Raw Permalink 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 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 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,
configSyncTried: false,
jwks: null,
jwksFetchedAt: 0
};
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 (error) {
debug("failed to persist OIDC client", error.message);
}
}
function adminToken() {
return readTokenFile(env.ZITADEL_SERVICE_TOKEN_FILE);
}
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, char => ({
"&": "&amp;",
"<": "&lt;",
">": "&gt;",
"\"": "&quot;",
"'": "&#39;"
}[char]));
}
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 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)}`;
}
function readSigned(value) {
if (!value || !value.includes(".")) return null;
const [payload, mac] = value.split(".");
if (!signaturesMatch(sign(payload), mac)) return null;
try {
return JSON.parse(Buffer.from(payload, "base64url").toString("utf8"));
} catch {
return null;
}
}
function makeCookie(name, value, maxAge = 3600) {
const secure = COOKIE_SECURE ? "; Secure" : "";
return `${name}=${value}; Path=/; HttpOnly; SameSite=Lax; Max-Age=${maxAge}${secure}`;
}
function clearCookie(name) {
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(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 getSignedCookie(req, "mm_session");
}
function getPendingAuth(req) {
return getSignedCookie(req, "mm_auth");
}
function getFlash(req) {
return parseCookies(req).mm_flash || "";
}
async function parseBody(req) {
let data = "";
for await (const chunk of req) data += chunk;
return Object.fromEntries(new URLSearchParams(data));
}
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: {
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 message = data.message || data.raw || `ZITADEL API error ${res.status}`;
throw new Error(message);
}
return data;
}
async function waitForZitadel() {
try {
const res = await fetch(`${env.ZITADEL_URL}/debug/ready`);
return res.ok;
} catch {
return false;
}
}
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;
if (!token) return;
state.setupTried = true;
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(item => item.name === env.ZITADEL_PROJECT_NAME);
} catch (error) {
debug("project search failed", error.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.");
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 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 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 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.");
const stateValue = crypto.randomBytes(24).toString("base64url");
const verifier = crypto.randomBytes(48).toString("base64url");
const challenge = crypto.createHash("sha256").update(verifier).digest("base64url");
const url = `${env.ZITADEL_URL}/oauth/v2/authorize?${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
})}`;
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")
|| "";
debug("authorize start", res.status, location);
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 };
}
async function finalizeAuthRequest(authRequest, session) {
const token = loginClientToken();
const response = await zFetch(`/v2/oidc/auth_requests/${encodeURIComponent(authRequest)}`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
session: {
sessionId: session.sessionId,
sessionToken: session.sessionToken
}
})
}, token);
if (!response.callbackUrl) throw new Error("ZITADEL не вернул callbackUrl.");
return response.callbackUrl;
}
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`, {
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 {
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;
}
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(/</g, "\\u003c");
}
async function validateIdToken(token) {
const header = decodeJwtPart(token, 0);
const claims = decodeJwtPart(token, 1);
const parts = String(token || "").split(".");
const signature = Buffer.from(parts[2], "base64url");
const issuer = stripTrailingSlash(env.ZITADEL_PUBLIC_URL);
const audience = state.clientId || env.OIDC_CLIENT_ID;
if (!claims.exp || claims.exp * 1000 <= Date.now()) {
throw new Error("Сессия истекла.");
}
if (stripTrailingSlash(claims.iss) !== issuer) {
throw new Error("Некорректный issuer токена.");
}
const aud = Array.isArray(claims.aud) ? claims.aud : [claims.aud].filter(Boolean);
if (audience && !aud.includes(audience)) {
throw new Error("Некорректная audience токена.");
}
const keys = await getJwks();
const jwk = keys.find(item => 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) {
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);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}}
</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 loginPath(authRequest = "") {
const suffix = authRequest ? `?${new URLSearchParams({ authRequest })}` : "";
return `/login${suffix}`;
}
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
? `<input type="hidden" name="authRequest" value="${escapeHtml(authRequest)}" />`
: "";
const registerHref = registerPath(authRequest);
const form = `<form method="post" action="/login">${msg}${hiddenAuthRequest}<label class="label">Email</label><input class="input" name="email" type="email" autocomplete="email" placeholder="you@example.com" required /><label class="label">Password</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="${escapeHtml(registerHref)}">Зарегистрироваться</a></div></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
? `<input type="hidden" name="authRequest" value="${escapeHtml(authRequest)}" />`
: "";
const loginHref = loginPath(authRequest);
const form = `<form method="post" action="/register">${msg}${hiddenAuthRequest}<label class="label">Full name</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">Password</label><input class="input" name="password" type="password" autocomplete="new-password" placeholder="Минимум 8 символов" minlength="8" required /><label class="label">Confirm password</label><input class="input" name="confirmPassword" type="password" autocomplete="new-password" placeholder="Повторите пароль" minlength="8" required /><button class="btn" type="submit">Зарегистрироваться</button><div class="link-row">Уже есть аккаунт? <a href="${escapeHtml(loginHref)}">Войти</a></div></form>`;
send(res, 200, page("Регистрация | Million Miles", authLayout({
title: "Регистрация",
subtitle: "Создайте личный кабинет Million Miles.",
heading: `Персональный доступ <span class="gold">Million Miles</span>`,
text: "Пароли и учетные записи хранятся в ZITADEL, не в нашем приложении.",
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 || "");
const confirmPassword = String(data.confirmPassword || "");
const authRequest = String(data.authRequest || "").trim();
try {
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, loginPath(authRequest), {
"Set-Cookie": makeCookie("mm_flash", "registered", 120)
});
} catch (error) {
return registerPage(req, res, `<div class="alert error">${escapeHtml(error.message || "Не удалось создать аккаунт.")}</div>`, authRequest);
}
}
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 session = await createPasswordSession(email, password);
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, `<div class="alert error">${escapeHtml(error.message || "Неверный email или пароль.")}</div>`, {}, authRequest);
}
}
async function authCallback(req, res) {
const pendingAuth = getPendingAuth(req);
if (!pendingAuth?.verifier || !pendingAuth?.stateValue) {
return redirect(res, "/login", {
"Set-Cookie": clearCookie("mm_auth")
});
}
try {
const callbackUrl = new URL(req.url, env.APP_URL).toString();
const tokens = await exchangeCode(callbackUrl, pendingAuth.verifier, pendingAuth.stateValue);
await validateIdToken(tokens.idToken);
const signedSession = makeSigned({
idToken: tokens.idToken,
accessToken: tokens.accessToken,
sessionId: pendingAuth.sessionId || "",
sessionToken: pendingAuth.sessionToken || ""
});
return redirect(res, "/dashboard", {
"Set-Cookie": [
makeCookie("mm_session", signedSession, 3600 * 8),
clearCookie("mm_auth")
]
});
} catch {
return redirect(res, "/login", {
"Set-Cookie": clearCookie("mm_auth")
});
}
}
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 = `<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" id="profile-avatar">${escapeHtml(initials(profile.name, profile.email))}</div><div><h1 id="profile-name">${escapeHtml(profile.name || "Клиент")}</h1><p>Данные ниже получены из /me. Backend валидирует JWT ZITADEL из HttpOnly cookie.</p></div></div><div class="jwt-info"><h2>Claims</h2><div class="jwt-grid"><div class="jwt-item"><b>sub</b><span id="claim-sub">${escapeHtml(profile.sub || "-")}</span></div><div class="jwt-item"><b>name</b><span id="claim-name">${escapeHtml(profile.name || "-")}</span></div><div class="jwt-item"><b>email</b><span id="claim-email">${escapeHtml(profile.email || "-")}</span></div><div class="jwt-item"><b>username</b><span id="claim-username">${escapeHtml(profile.username || profile.preferred_username || "-")}</span></div><div class="jwt-item"><b>iat</b><span id="claim-iat">${escapeHtml(formatTimestamp(profile.iat))}</span></div><div class="jwt-item"><b>exp</b><span id="claim-exp">${escapeHtml(formatTimestamp(profile.exp))}</span></div><div class="jwt-item"><b>iss</b><span id="claim-iss">${escapeHtml(profile.iss || "-")}</span></div><div class="jwt-item"><b>aud</b><span id="claim-aud">${escapeHtml(Array.isArray(profile.aud) ? profile.aud.join(", ") : profile.aud || "-")}</span></div></div><div class="claims-json"><b>/me JSON</b><pre id="me-json">${escapeHtml(JSON.stringify(profile, null, 2))}</pre></div></div></section></main>`;
const script = `<script id="me-bootstrap" type="application/json">${jsonScript(profile)}</script><script>
const claimIds=["sub","name","email","username","iat","exp","iss","aud"];
const initial=JSON.parse(document.getElementById("me-bootstrap").textContent||"{}");
function formatValue(key,value){if(value==null||value==="")return "-";if(key==="iat"||key==="exp"){const date=new Date(Number(value)*1000);return Number.isNaN(date.getTime())?"-":date.toLocaleString("ru-RU");}if(Array.isArray(value))return value.join(", ");return String(value);}
function avatarInitials(name,email){const source=name||email||"MM";return source.split(/[ ._-]+/).filter(Boolean).slice(0,2).map(part=>part[0]).join("").toUpperCase();}
function render(data){document.getElementById("profile-name").textContent=data.name||data.username||data.preferred_username||data.email||"Клиент";document.getElementById("profile-avatar").textContent=avatarInitials(data.name,data.email);for(const key of claimIds){const node=document.getElementById("claim-"+key);if(node)node.textContent=formatValue(key,data[key]);}document.getElementById("me-json").textContent=JSON.stringify(data,null,2);}
render(initial);
fetch("/me",{headers:{Accept:"application/json"}}).then(async response=>{if(response.status===401){window.location="/login";return null;}if(!response.ok){const text=await response.text();throw new Error(text||"Failed to load /me");}return response.json();}).then(data=>{if(data)render(data);}).catch(()=>{window.location="/login";});
</script>`;
send(res, 200, page("Dashboard | Million Miles", body + script));
}
async function me(req, res) {
const session = getSession(req);
if (!session?.idToken) {
return sendJson(res, 401, { error: "unauthorized" });
}
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")
});
}
}
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 = {}) {
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 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) => {
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") {
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, `<div class="alert ok">Аккаунт создан. Теперь можно войти.</div>`, {
"Set-Cookie": [authCookie, clearCookie("mm_flash")]
}, auth.authRequest);
}
return loginPage(req, res, "", {
"Set-Cookie": authCookie
}, auth.authRequest);
} catch (error) {
return loginPage(req, res, `<div class="alert error">${escapeHtml(error.message || "Не удалось подготовить вход.")}</div>`);
}
}
if (getFlash(req) === "registered") {
return loginPage(req, res, `<div class="alert ok">Аккаунт создан. Теперь можно войти.</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/callback") return authCallback(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") {
return sendJson(res, 200, { ok: true });
}
redirect(res, "/login");
}).listen(PORT, () => {
syncZitadelConfig().catch(error => debug("startup setup failed", error.message));
console.log(`Million Miles ZITADEL POC on http://localhost:${PORT}`);
});