${heading}
${text}
${title}
${subtitle}
${form}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 => ({ "&": "&", "<": "<", ">": ">", "\"": """, "'": "'" }[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(/ 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 `
${text}
${subtitle}
${form}Данные ниже получены из /me. Backend валидирует JWT ZITADEL из HttpOnly cookie.
${escapeHtml(JSON.stringify(profile, null, 2))}