diff --git a/README.md b/README.md new file mode 100644 index 0000000..afef204 --- /dev/null +++ b/README.md @@ -0,0 +1,106 @@ +# ZITADEL POC + +POC с кастомными формами `login` и `register`, где ZITADEL используется только как backend IdP. + +- `/login` — кастомная форма входа +- `/register` — кастомная форма регистрации +- `/me` — JSON с claims из JWT, выданного ZITADEL +- `/dashboard` — UI, который показывает данные из `/me` + +Приложение не использует hosted login UI ZITADEL для пользовательского входа: + +- не используется `/ui/login/loginname` +- не используется `/ui/login/password` +- не парсится hosted HTML, CSRF или `authRequestID` старого hosted login +- браузер не уходит на hosted SSO-страницы ZITADEL при входе через `http://localhost:3001/login` + +Пароли и учетные записи хранятся в ZITADEL, не в нашем приложении. + +## Запуск + +```bash +docker compose down -v +docker compose up --build +``` + +Первый запуск ZITADEL может занять несколько минут. + +## Адреса + +Frontend: + +```txt +http://localhost:3001 +``` + +ZITADEL Console: + +```txt +http://zitadel.localhost:8081 +``` + +## Как работает вход + +1. Регистрация создает пользователя в ZITADEL через API: + +```txt +POST /v2/users/human +``` + +2. Вход проверяет `email/password` через официальный Session API ZITADEL: + +```txt +POST /v2/sessions +PATCH /v2/sessions/{sessionId} +``` + +3. После успешной аутентификации backend завершает OIDC auth request и получает настоящий `id_token` / `access_token` от ZITADEL. + +4. `/me` валидирует и декодирует `id_token`, затем возвращает только claims: + +- `sub` +- `email` +- `name` +- `preferred_username` +- `iss` +- `aud` +- `iat` +- `exp` + +5. `/dashboard` показывает именно JSON из `/me`, а не raw JWT. + +## Хранение токенов + +- токены не хранятся в `localStorage` +- токены не хранятся в `sessionStorage` +- используется `HttpOnly` cookie +- используется `SameSite=Lax` +- в production cookie должна быть `Secure=true` +- в local dev `Secure=false` + +## Password Policy + +Локальная валидация и ZITADEL policy выровнены под тот же набор правил, что и в проекте Authentik: + +- минимум 8 символов +- хотя бы одна заглавная буква +- хотя бы одна строчная буква +- хотя бы одна цифра + +## Logout + +```txt +POST /logout +``` + +Этот endpoint очищает cookie приложения и закрывает ZITADEL session, если она есть. + +## Out Of Scope + +В рамках этого POC не реализованы: + +- sessions UI +- consent UI +- MFA devices UI +- connected services +- app passwords diff --git a/frontend/server.js b/frontend/server.js index f5709cb..ef9cdbc 100644 --- a/frontend/server.js +++ b/frontend/server.js @@ -1,7 +1,6 @@ -const http = require("http"); +const http = require("http"); const crypto = require("crypto"); const fs = require("fs"); -const { URLSearchParams } = require("url"); const PORT = 3000; @@ -21,13 +20,23 @@ const env = { 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 + setupTried: false, + configSyncTried: false, + jwks: null, + jwksFetchedAt: 0 }; function debug(...args) { @@ -59,8 +68,8 @@ function readOidcClientConfig() { 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); + } catch (error) { + debug("failed to persist OIDC client", error.message); } } @@ -72,10 +81,18 @@ 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, s => ({ - "&": "&", "<": "<", ">": ">", '"': """, "'": "'" - }[s])); + return String(value ?? "").replace(/[&<>"']/g, char => ({ + "&": "&", + "<": "<", + ">": ">", + "\"": """, + "'": "'" + }[char])); } function validatePassword(password) { @@ -94,6 +111,12 @@ 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)}`; @@ -102,7 +125,8 @@ function makeSigned(value) { function readSigned(value) { if (!value || !value.includes(".")) return null; const [payload, mac] = value.split("."); - if (sign(payload) !== mac) return null; + if (!signaturesMatch(sign(payload), mac)) return null; + try { return JSON.parse(Buffer.from(payload, "base64url").toString("utf8")); } catch { @@ -111,23 +135,44 @@ function readSigned(value) { } function makeCookie(name, value, maxAge = 3600) { - return `${name}=${value}; Path=/; HttpOnly; SameSite=Lax; Max-Age=${maxAge}`; + const secure = COOKIE_SECURE ? "; Secure" : ""; + return `${name}=${value}; Path=/; HttpOnly; SameSite=Lax; Max-Age=${maxAge}${secure}`; } function clearCookie(name) { - return `${name}=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0`; + 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(v => v.trim()).filter(Boolean).map(v => { - const i = v.indexOf("="); - return [v.slice(0, i), v.slice(i + 1)]; - })); + 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 readSigned(parseCookies(req).mm_session); + 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) { @@ -136,17 +181,15 @@ async function parseBody(req) { 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 {}; - } +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: { @@ -158,12 +201,17 @@ async function zFetch(path, options = {}, token = adminToken()) { const text = await res.text(); let data = {}; - try { data = text ? JSON.parse(text) : {}; } catch { data = { raw: text }; } + + 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); + const message = data.message || data.raw || `ZITADEL API error ${res.status}`; + throw new Error(message); } return data; @@ -178,17 +226,75 @@ async function waitForZitadel() { } } -async function setupOidcApp() { +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; - - const token = adminToken(); if (!token) return; state.setupTried = true; - // Create project via V1 Management API. let project = null; try { @@ -199,10 +305,9 @@ async function setupOidcApp() { 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); + project = (search.result || search.projects || []).find(item => item.name === env.ZITADEL_PROJECT_NAME); + } catch (error) { + debug("project search failed", error.message); } if (!project) { @@ -216,16 +321,13 @@ async function setupOidcApp() { 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" }, @@ -246,16 +348,30 @@ async function setupOidcApp() { 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 await zFetch("/v2/users/human", { + return zFetch("/v2/users/human", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ @@ -278,8 +394,41 @@ async function createUser({ fullName, email, password }) { }, adminToken()); } -async function createAuthRequest(loginHint = "") { - await setupOidcApp(); +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."); @@ -288,7 +437,7 @@ async function createAuthRequest(loginHint = "") { const verifier = crypto.randomBytes(48).toString("base64url"); const challenge = crypto.createHash("sha256").update(verifier).digest("base64url"); - const params = new URLSearchParams({ + const url = `${env.ZITADEL_URL}/oauth/v2/authorize?${new URLSearchParams({ client_id: clientId, redirect_uri: env.OIDC_REDIRECT_URI, response_type: "code", @@ -296,169 +445,54 @@ async function createAuthRequest(loginHint = "") { code_challenge: challenge, code_challenge_method: "S256", state: stateValue - }); + })}`; - if (loginHint) params.set("login_hint", loginHint); + 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") + || ""; - return { - authorizeUrl: `${env.ZITADEL_PUBLIC_URL}/oauth/v2/authorize?${params}`, - stateValue, - verifier - }; -} + debug("authorize start", res.status, location); -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)); - } + 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 }; } -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, { +async function finalizeAuthRequest(authRequest, session) { + const token = loginClientToken(); + const response = await zFetch(`/v2/oidc/auth_requests/${encodeURIComponent(authRequest)}`, { 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; + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + session: { + sessionId: session.sessionId, + sessionToken: session.sessionToken } + }) + }, token); - 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 не завершил локальный вход."); + if (!response.callbackUrl) throw new Error("ZITADEL не вернул callbackUrl."); + return response.callbackUrl; } -async function exchangeCode(code, verifier) { +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`, { @@ -478,11 +512,125 @@ async function exchangeCode(code, verifier) { const data = await res.json(); debug("token", res.status, data); - if (!res.ok || !data.id_token) { - throw new Error("ZITADEL не выдал id_token."); + 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; } - return data.id_token; + 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) { @@ -503,8 +651,8 @@ function page(title, body) { .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} +.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}} @@ -516,14 +664,48 @@ 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 loginPath(authRequest = "") { + const suffix = authRequest ? `?${new URLSearchParams({ authRequest })}` : ""; + return `/login${suffix}`; } -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 }))); +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 + ? `` + : ""; + const registerHref = registerPath(authRequest); + const form = `
${msg}${hiddenAuthRequest}
`; + 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 + ? `` + : ""; + const loginHref = loginPath(authRequest); + const form = `
${msg}${hiddenAuthRequest}
`; + send(res, 200, page("Регистрация | Million Miles", authLayout({ + title: "Регистрация", + subtitle: "Создайте личный кабинет Million Miles.", + heading: `Персональный доступ Million Miles`, + text: "Пароли и учетные записи хранятся в ZITADEL, не в нашем приложении.", + form + }))); } async function registerPost(req, res) { @@ -531,15 +713,26 @@ async function registerPost(req, res) { 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) throw new Error("Проверьте заполненные данные."); + 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, "/login", { "Set-Cookie": makeCookie("mm_flash", "registered", 120) }); - } catch (e) { - return registerPage(req, res, `
${escapeHtml(e.message || "Не удалось создать аккаунт.")}
`); + return redirect(res, loginPath(authRequest), { + "Set-Cookie": makeCookie("mm_flash", "registered", 120) + }); + } catch (error) { + return registerPage(req, res, `
${escapeHtml(error.message || "Не удалось создать аккаунт.")}
`, authRequest); } } @@ -547,85 +740,116 @@ 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 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(); + const session = await createPasswordSession(email, password); - 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 || "Не удалось начать вход.")}
`); + 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, `
${escapeHtml(error.message || "Неверный email или пароль.")}
`, {}, authRequest); } } 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); + const pendingAuth = getPendingAuth(req); + if (!pendingAuth?.verifier || !pendingAuth?.stateValue) { + return redirect(res, "/login", { + "Set-Cookie": clearCookie("mm_auth") + }); + } 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 callbackUrl = new URL(req.url, env.APP_URL).toString(); + const tokens = await exchangeCode(callbackUrl, pendingAuth.verifier, pendingAuth.stateValue); + await validateIdToken(tokens.idToken); - const idToken = await exchangeCode(code, oidc.verifier); - const claims = decodeJwt(idToken); - const signed = makeSigned({ id_token: idToken, claims }); + const signedSession = makeSigned({ + idToken: tokens.idToken, + accessToken: tokens.accessToken, + sessionId: pendingAuth.sessionId || "", + sessionToken: pendingAuth.sessionToken || "" + }); return redirect(res, "/dashboard", { "Set-Cookie": [ - makeCookie("mm_session", signed, 3600 * 8), - clearCookie("mm_oidc") + makeCookie("mm_session", signedSession, 3600 * 8), + clearCookie("mm_auth") ] }); - } catch (e) { - return loginPage(req, res, `
${escapeHtml(e.message || "Вход не завершён.")}
`, { - "Set-Cookie": clearCookie("mm_oidc") + } catch { + return redirect(res, "/login", { + "Set-Cookie": clearCookie("mm_auth") }); } } -function initials(name, email) { - const source = name || email || "MM"; - return source.split(/[ ._-]+/).filter(Boolean).slice(0, 2).map(x => x[0]).join("").toUpperCase(); +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 = `
${escapeHtml(initials(profile.name, profile.email))}

${escapeHtml(profile.name || "Клиент")}

Данные ниже получены из /me. Backend валидирует JWT ZITADEL из HttpOnly cookie.

Claims

sub${escapeHtml(profile.sub || "-")}
name${escapeHtml(profile.name || "-")}
email${escapeHtml(profile.email || "-")}
username${escapeHtml(profile.username || profile.preferred_username || "-")}
iat${escapeHtml(formatTimestamp(profile.iat))}
exp${escapeHtml(formatTimestamp(profile.exp))}
iss${escapeHtml(profile.iss || "-")}
aud${escapeHtml(Array.isArray(profile.aud) ? profile.aud.join(", ") : profile.aud || "-")}
/me JSON
${escapeHtml(JSON.stringify(profile, null, 2))}
`; + + const script = ``; + + send(res, 200, page("Dashboard | Million Miles", body + script)); } -function dashboard(req, res) { +async function me(req, res) { const session = getSession(req); - if (!session) return redirect(res, "/login"); + if (!session?.idToken) { + return sendJson(res, 401, { error: "unauthorized" }); + } - 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") : "—"; + 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") + }); + } +} - 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)); +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 = {}) { @@ -638,8 +862,9 @@ function send(res, status, content, headers = {}) { res.end(content); } -function logout(req, res) { - redirect(res, "/login", { "Set-Cookie": clearCookie("mm_session") }); +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) => { @@ -647,27 +872,56 @@ http.createServer(async (req, res) => { 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") }); + 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, `
Аккаунт создан. Теперь можно войти.
`, { + "Set-Cookie": [authCookie, clearCookie("mm_flash")] + }, auth.authRequest); + } + + return loginPage(req, res, "", { + "Set-Cookie": authCookie + }, auth.authRequest); + } catch (error) { + return loginPage(req, res, `
${escapeHtml(error.message || "Не удалось подготовить вход.")}
`); + } } + + if (getFlash(req) === "registered") { + return loginPage(req, res, `
Аккаунт создан. Теперь можно войти.
`, { + "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 === "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") { - res.writeHead(200, { "Content-Type": "application/json" }); - return res.end(JSON.stringify({ ok: true })); + return sendJson(res, 200, { ok: true }); } redirect(res, "/login"); }).listen(PORT, () => { - setupOidcApp().catch(e => debug("startup setup failed", e.message)); + syncZitadelConfig().catch(error => debug("startup setup failed", error.message)); console.log(`Million Miles ZITADEL POC on http://localhost:${PORT}`); }); -