commit 6b91afe3004bf751b5044bc8fe84640fc36f1148 Author: Codex Date: Fri May 1 19:09:58 2026 +0300 zitadel auth diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..5edf973 --- /dev/null +++ b/.env.example @@ -0,0 +1,23 @@ +POSTGRES_PASSWORD=change-me +ZITADEL_MASTERKEY=replace-with-32-char-masterkey +ZITADEL_ADMIN_USERNAME=admin +ZITADEL_ADMIN_EMAIL=admin@example.com +ZITADEL_ADMIN_PASSWORD=change-me + +APP_URL=http://localhost:3001 +ZITADEL_URL=http://zitadel.localhost:8081 +ZITADEL_PUBLIC_URL=http://zitadel.localhost:8081 + +# Эти значения frontend пытается создать автоматически через Management API. +ZITADEL_PROJECT_NAME=Million Miles +OIDC_APP_NAME=Million Miles Custom Frontend +OIDC_REDIRECT_URI=http://localhost:3001/auth/callback +OIDC_CLIENT_ID= +OIDC_CLIENT_SECRET= + +# PAT создаётся на первом старте ZITADEL и монтируется в frontend как файл. +ZITADEL_SERVICE_TOKEN_FILE=/zitadel-secrets/admin.pat +ZITADEL_LOGIN_CLIENT_TOKEN_FILE=/zitadel-secrets/login-client.pat + +SESSION_SECRET=change-me +AUTH_DEBUG=false diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..be022c3 --- /dev/null +++ b/.gitignore @@ -0,0 +1,9 @@ +.env +.env.local +.env.*.local +zitadel-secrets/ +*.pat +oidc-client.json +.DS_Store +Thumbs.db +proxy/ diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..a1e988d --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,86 @@ +services: + db: + image: postgres:17-alpine + restart: unless-stopped + environment: + PGUSER: postgres + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD} + POSTGRES_DB: zitadel + healthcheck: + test: ["CMD-SHELL", "pg_isready -d zitadel -U postgres"] + interval: 10s + timeout: 30s + retries: 10 + start_period: 20s + volumes: + - postgres_data:/var/lib/postgresql/data + + zitadel: + image: ghcr.io/zitadel/zitadel:v4.13.0 + user: "0:0" + restart: unless-stopped + command: 'start-from-init --masterkey "${ZITADEL_MASTERKEY}" --tlsMode disabled' + environment: + ZITADEL_DATABASE_POSTGRES_DSN: postgresql://postgres:${POSTGRES_PASSWORD}@db:5432/zitadel?sslmode=disable + + ZITADEL_EXTERNALSECURE: false + ZITADEL_EXTERNALPORT: 8081 + ZITADEL_EXTERNALDOMAIN: zitadel.localhost + ZITADEL_TLS_ENABLED: false + + ZITADEL_FIRSTINSTANCE_ORG_HUMAN_PASSWORDCHANGEREQUIRED: false + ZITADEL_FIRSTINSTANCE_ORG_HUMAN_USERNAME: ${ZITADEL_ADMIN_USERNAME} + ZITADEL_FIRSTINSTANCE_ORG_HUMAN_EMAIL_ADDRESS: ${ZITADEL_ADMIN_EMAIL} + ZITADEL_FIRSTINSTANCE_ORG_HUMAN_EMAIL_VERIFIED: true + ZITADEL_FIRSTINSTANCE_ORG_HUMAN_PASSWORD: ${ZITADEL_ADMIN_PASSWORD} + + # PAT для API provision/user registration. + ZITADEL_FIRSTINSTANCE_PATPATH: /zitadel-secrets/admin.pat + ZITADEL_FIRSTINSTANCE_ORG_MACHINE_MACHINE_USERNAME: mm-admin-machine + ZITADEL_FIRSTINSTANCE_ORG_MACHINE_MACHINE_NAME: Million Miles Admin Machine + ZITADEL_FIRSTINSTANCE_ORG_MACHINE_PAT_EXPIRATIONDATE: '2029-01-01T00:00:00Z' + + # PAT для Session API / Auth Request finalize. + ZITADEL_FIRSTINSTANCE_LOGINCLIENTPATPATH: /zitadel-secrets/login-client.pat + ZITADEL_FIRSTINSTANCE_ORG_LOGINCLIENT_MACHINE_USERNAME: mm-login-client + ZITADEL_FIRSTINSTANCE_ORG_LOGINCLIENT_MACHINE_NAME: Million Miles Login Client + ZITADEL_FIRSTINSTANCE_ORG_LOGINCLIENT_PAT_EXPIRATIONDATE: '2029-01-01T00:00:00Z' + depends_on: + db: + condition: service_healthy + expose: + - "8080" + volumes: + - zitadel_data:/app/data + - zitadel_secrets:/zitadel-secrets + + frontend: + build: ./frontend + restart: unless-stopped + environment: + APP_URL: ${APP_URL} + ZITADEL_URL: ${ZITADEL_URL} + ZITADEL_PUBLIC_URL: ${ZITADEL_PUBLIC_URL} + ZITADEL_PROJECT_NAME: ${ZITADEL_PROJECT_NAME} + OIDC_APP_NAME: ${OIDC_APP_NAME} + OIDC_REDIRECT_URI: ${OIDC_REDIRECT_URI} + OIDC_CLIENT_ID: ${OIDC_CLIENT_ID} + OIDC_CLIENT_SECRET: ${OIDC_CLIENT_SECRET} + ZITADEL_SERVICE_TOKEN_FILE: ${ZITADEL_SERVICE_TOKEN_FILE} + ZITADEL_LOGIN_CLIENT_TOKEN_FILE: ${ZITADEL_LOGIN_CLIENT_TOKEN_FILE} + SESSION_SECRET: ${SESSION_SECRET} + AUTH_DEBUG: ${AUTH_DEBUG} + expose: + - "3000" + depends_on: + - zitadel + extra_hosts: + - "host.docker.internal:host-gateway" + - "zitadel.localhost:host-gateway" + volumes: + - zitadel_secrets:/zitadel-secrets + +volumes: + postgres_data: + zitadel_data: + zitadel_secrets: diff --git a/frontend/Dockerfile b/frontend/Dockerfile new file mode 100644 index 0000000..8899568 --- /dev/null +++ b/frontend/Dockerfile @@ -0,0 +1,5 @@ +FROM node:20-alpine +WORKDIR /app +COPY server.js ./server.js +EXPOSE 3000 +CMD ["node", "server.js"] diff --git a/frontend/server.js b/frontend/server.js new file mode 100644 index 0000000..f5709cb --- /dev/null +++ b/frontend/server.js @@ -0,0 +1,673 @@ +const http = require("http"); +const crypto = require("crypto"); +const fs = require("fs"); +const { URLSearchParams } = require("url"); + +const PORT = 3000; + +const env = { + APP_URL: process.env.APP_URL || "http://localhost:3000", + ZITADEL_URL: process.env.ZITADEL_URL || "http://host.docker.internal:8080", + ZITADEL_PUBLIC_URL: process.env.ZITADEL_PUBLIC_URL || "http://localhost:8080", + ZITADEL_PROJECT_NAME: process.env.ZITADEL_PROJECT_NAME || "Million Miles", + OIDC_APP_NAME: process.env.OIDC_APP_NAME || "Million Miles Custom Frontend", + OIDC_REDIRECT_URI: process.env.OIDC_REDIRECT_URI || "http://localhost:3000/auth/callback", + OIDC_CLIENT_ID: process.env.OIDC_CLIENT_ID || "", + OIDC_CLIENT_SECRET: process.env.OIDC_CLIENT_SECRET || "", + OIDC_CLIENT_STATE_FILE: process.env.OIDC_CLIENT_STATE_FILE || "/zitadel-secrets/oidc-client.json", + ZITADEL_SERVICE_TOKEN_FILE: process.env.ZITADEL_SERVICE_TOKEN_FILE || "/zitadel-secrets/admin.pat", + ZITADEL_LOGIN_CLIENT_TOKEN_FILE: process.env.ZITADEL_LOGIN_CLIENT_TOKEN_FILE || "/zitadel-secrets/login-client.pat", + SESSION_SECRET: process.env.SESSION_SECRET || "dev-secret", + AUTH_DEBUG: process.env.AUTH_DEBUG === "true" +}; + +const persistedOidcClient = readOidcClientConfig(); + +const state = { + projectId: null, + clientId: env.OIDC_CLIENT_ID || persistedOidcClient.clientId, + clientSecret: env.OIDC_CLIENT_SECRET || persistedOidcClient.clientSecret, + setupTried: false +}; + +function debug(...args) { + if (env.AUTH_DEBUG) console.log("[zitadel-debug]", ...args); +} + +function readTokenFile(path) { + try { + const value = fs.readFileSync(path, "utf8").trim(); + return value || null; + } catch { + return null; + } +} + +function readOidcClientConfig() { + try { + const raw = fs.readFileSync(env.OIDC_CLIENT_STATE_FILE, "utf8"); + const parsed = JSON.parse(raw); + return { + clientId: parsed.clientId || "", + clientSecret: parsed.clientSecret || "" + }; + } catch { + return { clientId: "", clientSecret: "" }; + } +} + +function persistOidcClientConfig(clientId, clientSecret) { + try { + fs.writeFileSync(env.OIDC_CLIENT_STATE_FILE, JSON.stringify({ clientId, clientSecret }), "utf8"); + } catch (e) { + debug("failed to persist OIDC client", e.message); + } +} + +function adminToken() { + return readTokenFile(env.ZITADEL_SERVICE_TOKEN_FILE); +} + +function loginClientToken() { + return readTokenFile(env.ZITADEL_LOGIN_CLIENT_TOKEN_FILE) || adminToken(); +} + +function escapeHtml(value) { + return String(value ?? "").replace(/[&<>"']/g, s => ({ + "&": "&", "<": "<", ">": ">", '"': """, "'": "'" + }[s])); +} + +function validatePassword(password) { + if (password.length < 8) return "Пароль должен быть не короче 8 символов."; + if (!/[A-Z]/.test(password)) return "Пароль должен содержать хотя бы одну заглавную букву."; + if (!/[a-z]/.test(password)) return "Пароль должен содержать хотя бы одну строчную букву."; + if (!/\d/.test(password)) return "Пароль должен содержать хотя бы одну цифру."; + return null; +} + +function b64url(input) { + return Buffer.from(input).toString("base64url"); +} + +function sign(value) { + return crypto.createHmac("sha256", env.SESSION_SECRET).update(value).digest("base64url"); +} + +function makeSigned(value) { + const payload = b64url(JSON.stringify(value)); + return `${payload}.${sign(payload)}`; +} + +function readSigned(value) { + if (!value || !value.includes(".")) return null; + const [payload, mac] = value.split("."); + if (sign(payload) !== mac) return null; + try { + return JSON.parse(Buffer.from(payload, "base64url").toString("utf8")); + } catch { + return null; + } +} + +function makeCookie(name, value, maxAge = 3600) { + return `${name}=${value}; Path=/; HttpOnly; SameSite=Lax; Max-Age=${maxAge}`; +} + +function clearCookie(name) { + return `${name}=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0`; +} + +function parseCookies(req) { + const header = req.headers.cookie || ""; + return Object.fromEntries(header.split(";").map(v => v.trim()).filter(Boolean).map(v => { + const i = v.indexOf("="); + return [v.slice(0, i), v.slice(i + 1)]; + })); +} + +function getSession(req) { + return readSigned(parseCookies(req).mm_session); +} + +async function parseBody(req) { + let data = ""; + for await (const chunk of req) data += chunk; + return Object.fromEntries(new URLSearchParams(data)); +} + +function decodeJwt(token) { + try { + const [, payload] = token.split("."); + return JSON.parse(Buffer.from(payload, "base64url").toString("utf8")); + } catch { + return {}; + } +} + +async function zFetch(path, options = {}, token = adminToken()) { + if (!token) throw new Error("ZITADEL PAT еще не создан. Подождите первый запуск ZITADEL."); + const res = await fetch(`${env.ZITADEL_URL}${path}`, { + ...options, + headers: { + Accept: "application/json", + Authorization: `Bearer ${token}`, + ...(options.headers || {}) + } + }); + + const text = await res.text(); + let data = {}; + try { data = text ? JSON.parse(text) : {}; } catch { data = { raw: text }; } + + if (!res.ok) { + debug("zFetch failed", path, res.status, data); + const msg = data.message || data.raw || `ZITADEL API error ${res.status}`; + throw new Error(msg); + } + + return data; +} + +async function waitForZitadel() { + try { + const res = await fetch(`${env.ZITADEL_URL}/debug/ready`); + return res.ok; + } catch { + return false; + } +} + +async function setupOidcApp() { + if (state.clientId && state.clientSecret) return; + if (state.setupTried) return; + if (!(await waitForZitadel())) return; + + const token = adminToken(); + if (!token) return; + + state.setupTried = true; + + // Create project via V1 Management API. + let project = null; + + try { + const search = await zFetch("/management/v1/projects/_search", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + queries: [{ nameQuery: { name: env.ZITADEL_PROJECT_NAME, method: "TEXT_QUERY_METHOD_EQUALS" } }] + }) + }, token); + + project = (search.result || search.projects || []).find(p => p.name === env.ZITADEL_PROJECT_NAME); + } catch (e) { + debug("project search failed", e.message); + } + + if (!project) { + const created = await zFetch("/management/v1/projects", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + name: env.ZITADEL_PROJECT_NAME, + projectRoleAssertion: false, + projectRoleCheck: false, + hasProjectCheck: false + }) + }, token); + + project = { id: created.id || created.projectId }; + } + + const projectId = project.id || project.projectId; + state.projectId = projectId; + + if (!projectId) throw new Error("Не удалось получить projectId ZITADEL."); + + // Create OIDC app. If it already exists, user can put client values in .env. + const app = await zFetch(`/management/v1/projects/${projectId}/apps/oidc`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + name: env.OIDC_APP_NAME, + redirectUris: [env.OIDC_REDIRECT_URI], + responseTypes: ["OIDC_RESPONSE_TYPE_CODE"], + grantTypes: ["OIDC_GRANT_TYPE_AUTHORIZATION_CODE"], + appType: "OIDC_APP_TYPE_WEB", + authMethodType: "OIDC_AUTH_METHOD_TYPE_BASIC", + version: "OIDC_VERSION_1_0", + devMode: true, + accessTokenType: "OIDC_TOKEN_TYPE_JWT", + idTokenUserinfoAssertion: true + }) + }, token); + + state.clientId = app.clientId; + state.clientSecret = app.clientSecret; + persistOidcClientConfig(state.clientId, state.clientSecret); + + debug("OIDC app created", { projectId, clientId: state.clientId }); +} + +async function createUser({ fullName, email, password }) { + const parts = fullName.trim().split(/\s+/); + const givenName = parts[0] || fullName; + const familyName = parts.slice(1).join(" ") || "User"; + + return await zFetch("/v2/users/human", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + username: email, + profile: { + givenName, + familyName, + displayName: fullName, + preferredLanguage: "ru" + }, + email: { + email, + isVerified: true + }, + password: { + password, + changeRequired: false + } + }) + }, adminToken()); +} + +async function createAuthRequest(loginHint = "") { + await setupOidcApp(); + + const clientId = state.clientId || env.OIDC_CLIENT_ID; + if (!clientId) throw new Error("OIDC app не настроен. Укажите OIDC_CLIENT_ID/OIDC_CLIENT_SECRET в .env."); + + const stateValue = crypto.randomBytes(24).toString("base64url"); + const verifier = crypto.randomBytes(48).toString("base64url"); + const challenge = crypto.createHash("sha256").update(verifier).digest("base64url"); + + const params = new URLSearchParams({ + client_id: clientId, + redirect_uri: env.OIDC_REDIRECT_URI, + response_type: "code", + scope: "openid email profile", + code_challenge: challenge, + code_challenge_method: "S256", + state: stateValue + }); + + if (loginHint) params.set("login_hint", loginHint); + + return { + authorizeUrl: `${env.ZITADEL_PUBLIC_URL}/oauth/v2/authorize?${params}`, + stateValue, + verifier + }; +} + +function absoluteZitadelUrl(location) { + return new URL(location, env.ZITADEL_URL).toString(); +} + +function updateCookieJar(jar, headers) { + const setCookies = typeof headers.getSetCookie === "function" + ? headers.getSetCookie() + : (headers.get("set-cookie") ? [headers.get("set-cookie")] : []); + + for (const raw of setCookies) { + const [pair] = String(raw || "").split(";"); + const index = pair.indexOf("="); + if (index > 0) { + jar.set(pair.slice(0, index), pair.slice(index + 1)); + } + } +} + +function cookieHeader(jar) { + return Array.from(jar.entries()).map(([name, value]) => `${name}=${value}`).join("; "); +} + +async function zBrowserFetch(url, jar, options = {}) { + const headers = new Headers(options.headers || {}); + const cookies = cookieHeader(jar); + if (cookies) headers.set("Cookie", cookies); + + const res = await fetch(url, { + ...options, + headers, + redirect: "manual" + }); + + updateCookieJar(jar, res.headers); + return res; +} + +function extractInput(html, name) { + const escaped = name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + const match = html.match(new RegExp(`name="${escaped}"[^>]*value="([^"]*)"`, "i")); + return match ? match[1] : ""; +} + +function extractErrorMessage(html) { + const match = html.match(/

\s*([^<]+)\s*<\/p>/i); + return match ? match[1].trim() : ""; +} + +function stripTags(value) { + return String(value || "").replace(/<[^>]+>/g, " ").replace(/\s+/g, " ").trim(); +} + +async function submitHostedLogin(email, password) { + const auth = await createAuthRequest(email); + const jar = new Map(); + + let res = await zBrowserFetch(auth.authorizeUrl, jar); + let location = res.headers.get("location") || ""; + if (res.status !== 302 || !location) { + throw new Error("ZITADEL не начал авторизацию."); + } + + res = await zBrowserFetch(absoluteZitadelUrl(location), jar); + let html = await res.text(); + + let authRequestID = extractInput(html, "authRequestID"); + let csrf = extractInput(html, "gorilla.csrf.Token"); + if (!authRequestID || !csrf) { + throw new Error(extractErrorMessage(html) || "ZITADEL не отдал форму логина."); + } + + res = await zBrowserFetch(absoluteZitadelUrl("/ui/login/loginname"), jar, { + method: "POST", + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + body: new URLSearchParams({ + loginName: email, + authRequestID, + "gorilla.csrf.Token": csrf + }).toString() + }); + html = await res.text(); + + csrf = extractInput(html, "gorilla.csrf.Token"); + authRequestID = extractInput(html, "authRequestID") || authRequestID; + const normalizedLogin = extractInput(html, "loginName") || email; + + if (!csrf || !/action="\/ui\/login\/password"/i.test(html)) { + throw new Error(extractErrorMessage(html) || "ZITADEL не отдал форму пароля."); + } + + res = await zBrowserFetch(absoluteZitadelUrl("/ui/login/password"), jar, { + method: "POST", + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + body: new URLSearchParams({ + password, + loginName: normalizedLogin, + authRequestID, + "gorilla.csrf.Token": csrf + }).toString() + }); + + for (let step = 0; step < 8; step += 1) { + location = res.headers.get("location") || ""; + + if (location) { + const nextUrl = new URL(location, env.ZITADEL_PUBLIC_URL); + if (nextUrl.origin + nextUrl.pathname === env.OIDC_REDIRECT_URI) { + const code = nextUrl.searchParams.get("code"); + const returnedState = nextUrl.searchParams.get("state"); + if (!code || returnedState !== auth.stateValue) { + throw new Error("Некорректный callback от ZITADEL."); + } + + const idToken = await exchangeCode(code, auth.verifier); + return idToken; + } + + res = await zBrowserFetch(absoluteZitadelUrl(nextUrl.pathname + nextUrl.search), jar); + continue; + } + + html = await res.text(); + const errorMessage = extractErrorMessage(html); + if (errorMessage) { + throw new Error(stripTags(errorMessage)); + } + + if (/action="\/ui\/login\/mfa\/prompt"/i.test(html)) { + csrf = extractInput(html, "gorilla.csrf.Token"); + authRequestID = extractInput(html, "authRequestID") || authRequestID; + res = await zBrowserFetch(absoluteZitadelUrl("/ui/login/mfa/prompt"), jar, { + method: "POST", + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + body: new URLSearchParams({ + skip: "true", + authRequestID, + "gorilla.csrf.Token": csrf + }).toString() + }); + continue; + } + + throw new Error("ZITADEL не завершил локальный вход."); + } + + throw new Error("ZITADEL не завершил локальный вход."); +} + +async function exchangeCode(code, verifier) { + const clientId = state.clientId || env.OIDC_CLIENT_ID; + const clientSecret = state.clientSecret || env.OIDC_CLIENT_SECRET; + + const basic = Buffer.from(`${clientId}:${clientSecret}`).toString("base64"); + + const res = await fetch(`${env.ZITADEL_URL}/oauth/v2/token`, { + method: "POST", + headers: { + "Content-Type": "application/x-www-form-urlencoded", + Authorization: `Basic ${basic}` + }, + body: new URLSearchParams({ + grant_type: "authorization_code", + code, + redirect_uri: env.OIDC_REDIRECT_URI, + code_verifier: verifier + }) + }); + + const data = await res.json(); + debug("token", res.status, data); + + if (!res.ok || !data.id_token) { + throw new Error("ZITADEL не выдал id_token."); + } + + return data.id_token; +} + +function page(title, body) { + return ` + + + + +${escapeHtml(title)} + + +${body} +`; +} + +function authLayout({ title, subtitle, heading, text, form }) { + return `

${heading}

${text}

${title}

${subtitle}

${form}
`; +} + +function loginPage(req, res, msg = "", headers = {}) { + const form = `
${msg}
`; + send(res, 200, page("Вход | Million Miles", authLayout({ title: "Вход", subtitle: "Войдите в личный кабинет Million Miles.", heading: "Добро пожаловать", text: "Закрытый доступ для клиентов премиального сервиса.", form })), headers); +} + +function registerPage(req, res, msg = "") { + const form = `
${msg}
`; + send(res, 200, page("Регистрация | Million Miles", authLayout({ title: "Регистрация", subtitle: "Создайте личный кабинет Million Miles.", heading: `Персональный доступ Million Miles`, text: "Создайте профиль для доступа в личный кабинет Million Miles.", form }))); +} + +async function registerPost(req, res) { + const data = await parseBody(req); + const email = String(data.email || "").trim().toLowerCase(); + const fullName = String(data.fullName || "").trim(); + const password = String(data.password || ""); + + try { + if (!fullName || !email || !password) throw new Error("Проверьте заполненные данные."); + const passwordError = validatePassword(password); + if (passwordError) throw new Error(passwordError); + await createUser({ fullName, email, password }); + return redirect(res, "/login", { "Set-Cookie": makeCookie("mm_flash", "registered", 120) }); + } catch (e) { + return registerPage(req, res, `
${escapeHtml(e.message || "Не удалось создать аккаунт.")}
`); + } +} + +async function loginPost(req, res) { + const data = await parseBody(req); + const email = String(data.email || "").trim().toLowerCase(); + const password = String(data.password || ""); + + try { + if (!email || !password) throw new Error("Введите email и пароль."); + const idToken = await submitHostedLogin(email, password); + const claims = decodeJwt(idToken); + const signed = makeSigned({ id_token: idToken, claims }); + return redirect(res, "/dashboard", { + "Set-Cookie": [ + makeCookie("mm_session", signed, 3600 * 8), + clearCookie("mm_oidc") + ] + }); + } catch (e) { + return loginPage(req, res, `
${escapeHtml(e.message || "Неверный email или пароль.")}
`, { + "Set-Cookie": clearCookie("mm_oidc") + }); + } +} + +async function authStart(req, res) { + const url = new URL(req.url, env.APP_URL); + const loginHint = String(url.searchParams.get("login_hint") || "").trim().toLowerCase(); + + try { + const auth = await createAuthRequest(loginHint); + const oidc = makeSigned({ state: auth.stateValue, verifier: auth.verifier }); + return redirect(res, auth.authorizeUrl, { "Set-Cookie": makeCookie("mm_oidc", oidc, 600) }); + } catch (e) { + return loginPage(req, res, `
${escapeHtml(e.message || "Не удалось начать вход.")}
`); + } +} + +async function authCallback(req, res) { + const url = new URL(req.url, env.APP_URL); + const code = url.searchParams.get("code"); + const returnedState = url.searchParams.get("state"); + const error = url.searchParams.get("error"); + const cookies = parseCookies(req); + const oidc = readSigned(cookies.mm_oidc); + + try { + if (error) throw new Error(url.searchParams.get("error_description") || error); + if (!oidc || !oidc.state || !oidc.verifier) throw new Error("Сессия входа истекла. Попробуйте снова."); + if (!code || returnedState !== oidc.state) throw new Error("Некорректный callback от ZITADEL."); + + const idToken = await exchangeCode(code, oidc.verifier); + const claims = decodeJwt(idToken); + const signed = makeSigned({ id_token: idToken, claims }); + + return redirect(res, "/dashboard", { + "Set-Cookie": [ + makeCookie("mm_session", signed, 3600 * 8), + clearCookie("mm_oidc") + ] + }); + } catch (e) { + return loginPage(req, res, `
${escapeHtml(e.message || "Вход не завершён.")}
`, { + "Set-Cookie": clearCookie("mm_oidc") + }); + } +} + +function initials(name, email) { + const source = name || email || "MM"; + return source.split(/[ ._-]+/).filter(Boolean).slice(0, 2).map(x => x[0]).join("").toUpperCase(); +} + +function dashboard(req, res) { + const session = getSession(req); + if (!session) return redirect(res, "/login"); + + const claims = session.claims || {}; + const name = claims.name || claims.preferred_username || claims.email || "Клиент"; + const email = claims.email || "—"; + const iat = claims.iat ? new Date(claims.iat * 1000).toLocaleString("ru-RU") : "—"; + const exp = claims.exp ? new Date(claims.exp * 1000).toLocaleString("ru-RU") : "—"; + + const body = `
${escapeHtml(initials(name, email))}

${escapeHtml(name)}

Данные ниже получены из JWT ZITADEL

JWT

sub${escapeHtml(claims.sub || "—")}
name${escapeHtml(claims.name || "—")}
email${escapeHtml(claims.email || "—")}
username${escapeHtml(claims.preferred_username || claims.email || "—")}
iat${escapeHtml(iat)}
exp${escapeHtml(exp)}
iss${escapeHtml(claims.iss || "—")}
aud${escapeHtml(Array.isArray(claims.aud) ? claims.aud.join(", ") : claims.aud || "—")}
Raw JWT from ZITADEL
${escapeHtml(session.id_token || "—")}
`; + send(res, 200, page("Dashboard | Million Miles", body)); +} + +function redirect(res, location, headers = {}) { + res.writeHead(302, { Location: location, ...headers }); + res.end(); +} + +function send(res, status, content, headers = {}) { + res.writeHead(status, { "Content-Type": "text/html; charset=utf-8", ...headers }); + res.end(content); +} + +function logout(req, res) { + redirect(res, "/login", { "Set-Cookie": clearCookie("mm_session") }); +} + +http.createServer(async (req, res) => { + const url = new URL(req.url, env.APP_URL); + + if (req.method === "GET" && url.pathname === "/") return redirect(res, "/login"); + if (req.method === "GET" && url.pathname === "/login") { + const cookies = parseCookies(req); + if (cookies.mm_flash === "registered") { + return loginPage(req, res, `
Аккаунт создан. Теперь войдите через форму ZITADEL.
`, { "Set-Cookie": clearCookie("mm_flash") }); + } + return loginPage(req, res); + } + if (req.method === "POST" && url.pathname === "/login") return loginPost(req, res); + if (req.method === "GET" && url.pathname === "/auth/start") return authStart(req, res); + if (req.method === "GET" && url.pathname === "/auth/callback") return authCallback(req, res); + if (req.method === "GET" && url.pathname === "/register") return registerPage(req, res); + if (req.method === "POST" && url.pathname === "/register") return registerPost(req, res); + if (req.method === "GET" && url.pathname === "/dashboard") return dashboard(req, res); + if (req.method === "POST" && url.pathname === "/logout") return logout(req, res); + if (req.method === "GET" && url.pathname === "/health") { + res.writeHead(200, { "Content-Type": "application/json" }); + return res.end(JSON.stringify({ ok: true })); + } + + redirect(res, "/login"); +}).listen(PORT, () => { + setupOidcApp().catch(e => debug("startup setup failed", e.message)); + console.log(`Million Miles ZITADEL POC on http://localhost:${PORT}`); +}); +