const http = require("http"); const crypto = require("crypto"); const { URLSearchParams } = require("url"); const PORT = 3000; const env = { APP_URL: process.env.APP_URL || "http://localhost:3000", SESSION_SECRET: process.env.SESSION_SECRET || "change-me-session-secret", AUTHENTIK_PUBLIC_URL: process.env.AUTHENTIK_PUBLIC_URL || "http://localhost:9000", AUTHENTIK_INTERNAL_URL: process.env.AUTHENTIK_INTERNAL_URL || "http://host.docker.internal:9000", AUTHENTIK_API_TOKEN: process.env.AUTHENTIK_API_TOKEN || "change-me-api-token", AUTHENTIK_LOGIN_FLOW: process.env.AUTHENTIK_LOGIN_FLOW || "mm-custom-authentication-flow", OIDC_CLIENT_ID: process.env.OIDC_CLIENT_ID || "mm-custom-frontend", OIDC_CLIENT_SECRET: process.env.OIDC_CLIENT_SECRET || "change-me-oidc-client-secret", OIDC_ISSUER: process.env.OIDC_ISSUER || "http://localhost:9000/application/o/mm-custom-frontend/", OIDC_REDIRECT_URI: process.env.OIDC_REDIRECT_URI || "http://localhost:3000/auth/callback", AUTH_DEBUG: process.env.AUTH_DEBUG === "true" }; function debug(...args) { if (env.AUTH_DEBUG) console.log("[auth-debug]", ...args); } function escapeHtml(value) { return String(value ?? "").replace(/[&<>"']/g, (s) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[s])); } function b64url(input) { return Buffer.from(input).toString("base64url"); } function randomString(bytes = 32) { return crypto.randomBytes(bytes).toString("base64url"); } function sha256base64url(value) { return crypto.createHash("sha256").update(value).digest("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 collectSetCookies(headers) { if (typeof headers.getSetCookie === "function") return headers.getSetCookie(); const value = headers.get("set-cookie"); return value ? [value] : []; } function mergeCookies(jar, setCookies) { for (const raw of setCookies) { const pair = String(raw).split(";")[0]; const idx = pair.indexOf("="); if (idx > 0) jar[pair.slice(0, idx)] = pair.slice(idx + 1); } } function cookieHeader(jar) { return Object.entries(jar).map(([k, v]) => `${k}=${v}`).join("; "); } async function fetchWithJar(url, options = {}, jar = {}) { const headers = { ...(options.headers || {}) }; const cookie = cookieHeader(jar); if (cookie) headers.Cookie = cookie; const res = await fetch(url, { ...options, headers, redirect: "manual" }); mergeCookies(jar, collectSetCookies(res.headers)); return res; } async function followRedirectsWithJar(res, jar, limit = 8) { let current = res; for (let i = 0; i < limit; i++) { const location = current.headers.get("location"); if (!location || current.status < 300 || current.status >= 400) return current; current = await fetchWithJar(new URL(location, env.AUTHENTIK_INTERNAL_URL).toString(), { method: "GET", headers: { Accept: "text/html,application/xhtml+xml,application/json" } }, jar); } return current; } async function safeJson(res) { const text = await res.text(); if (!text) return {}; try { return JSON.parse(text); } catch { return { raw: text }; } } function decodeJwt(token) { try { const [, payload] = token.split("."); return JSON.parse(Buffer.from(payload, "base64url").toString("utf8")); } catch { return {}; } } 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)); } async function authentikReady() { try { const res = await fetch(`${env.AUTHENTIK_INTERNAL_URL}/api/v3/root/config/`, { headers: { Authorization: `Bearer ${env.AUTHENTIK_API_TOKEN}` } }); return res.status < 500; } catch { return false; } } async function apiGet(path) { const res = await fetch(`${env.AUTHENTIK_INTERNAL_URL}${path}`, { headers: { Authorization: `Bearer ${env.AUTHENTIK_API_TOKEN}` } }); if (!res.ok) throw new Error(`API GET ${path} failed: ${res.status}`); return await res.json(); } async function apiPost(path, body) { const res = await fetch(`${env.AUTHENTIK_INTERNAL_URL}${path}`, { method: "POST", headers: { Authorization: `Bearer ${env.AUTHENTIK_API_TOKEN}`, "Content-Type": "application/json" }, body: JSON.stringify(body) }); const text = await res.text(); if (!res.ok) { debug("apiPost failed", path, res.status, text); throw new Error(`API POST ${path} failed: ${res.status}`); } return text ? JSON.parse(text) : {}; } async function findAuthentikUser(email) { const res = await fetch(`${env.AUTHENTIK_INTERNAL_URL}/api/v3/core/users/?search=${encodeURIComponent(email)}`, { headers: { Authorization: `Bearer ${env.AUTHENTIK_API_TOKEN}` } }); if (!res.ok) return null; const data = await res.json(); const users = data.results || data; return Array.isArray(users) ? users.find(u => String(u.email || "").toLowerCase() === String(email).toLowerCase() || String(u.username || "").toLowerCase() === String(email).toLowerCase() ) : null; } async function createAuthentikUser({ fullName, email, password }) { const existing = await findAuthentikUser(email); if (existing) throw new Error("Пользователь с таким email уже существует."); const createRes = await fetch(`${env.AUTHENTIK_INTERNAL_URL}/api/v3/core/users/`, { method: "POST", headers: { Authorization: `Bearer ${env.AUTHENTIK_API_TOKEN}`, "Content-Type": "application/json" }, body: JSON.stringify({ username: String(email).toLowerCase(), name: fullName, email, is_active: true, type: "internal" }) }); const createText = await createRes.text(); if (!createRes.ok) { debug("create user failed", createRes.status, createText); throw new Error("Не удалось создать аккаунт. Попробуйте позже."); } const user = JSON.parse(createText); const userId = user.pk || user.id; const passRes = await fetch(`${env.AUTHENTIK_INTERNAL_URL}/api/v3/core/users/${userId}/set_password/`, { method: "POST", headers: { Authorization: `Bearer ${env.AUTHENTIK_API_TOKEN}`, "Content-Type": "application/json" }, body: JSON.stringify({ password }) }); const passText = await passRes.text(); if (!passRes.ok) { debug("set password failed", passRes.status, passText); throw new Error("Аккаунт создан, но пароль не был сохранён."); } return user; } async function discoverOidc() { const publicDiscovery = `${env.OIDC_ISSUER.replace(/\/$/, "")}/.well-known/openid-configuration`; const internalDiscovery = publicDiscovery.replace(env.AUTHENTIK_PUBLIC_URL, env.AUTHENTIK_INTERNAL_URL); const res = await fetch(internalDiscovery); if (!res.ok) throw new Error("OIDC provider ещё не применился. Подождите 1-2 минуты или запустите docker compose down -v && docker compose up --build."); const config = await res.json(); return { authorization_endpoint: config.authorization_endpoint.replace(env.AUTHENTIK_PUBLIC_URL, env.AUTHENTIK_INTERNAL_URL), public_authorization_endpoint: config.authorization_endpoint, token_endpoint: config.token_endpoint.replace(env.AUTHENTIK_PUBLIC_URL, env.AUTHENTIK_INTERNAL_URL) }; } // Best-effort OIDC setup. Authentik APIs can differ by version, so README has manual fallback. async function ensureOidcProvider() { if (ensureOidcProvider.done) return; try { await discoverOidc(); ensureOidcProvider.done = true; } catch (e) { debug("OIDC provider not ready yet", e.message); } } ensureOidcProvider.done = false; function isSuccessChallenge(res, challenge) { const component = challenge.component || ""; return ( res.status === 302 || component === "xak-flow-redirect" || component === "ak-flow-redirect" || component === "ak-stage-redirect" || challenge.type === "redirect" || Boolean(challenge.redirect) ); } async function authentikPasswordLogin(email, password) { const jar = {}; const flow = env.AUTHENTIK_LOGIN_FLOW; const instanceUrl = `${env.AUTHENTIK_INTERNAL_URL}/api/v3/flows/instances/${flow}/execute/?next=/`; const executorUrl = `${env.AUTHENTIK_INTERNAL_URL}/api/v3/flows/executor/${flow}/?query=${encodeURIComponent("next=/")}`; await fetchWithJar(instanceUrl, { method: "GET", headers: { Accept: "application/json" } }, jar); async function getChallenge() { const res = await fetchWithJar(executorUrl, { method: "GET", headers: { Accept: "application/json" } }, jar); return { res, challenge: await safeJson(res) }; } async function postChallenge(component, payload = {}) { const res = await fetchWithJar(executorUrl, { method: "POST", headers: { "Content-Type": "application/json", Accept: "application/json" }, body: JSON.stringify({ component, ...payload }) }, jar); return { res, challenge: await safeJson(res) }; } let { res, challenge } = await getChallenge(); if (!res.ok) throw new Error("Сервис авторизации запускается. Попробуйте позже."); let passwordChecked = false; for (let step = 0; step < 12; step++) { const component = challenge.component || ""; debug("auth step", step, component, res.status, challenge); if (component === "ak-stage-access-denied") { throw new Error("Неверный email или пароль."); } if (isSuccessChallenge(res, challenge)) { if (passwordChecked) { await followRedirectsWithJar(res, jar); return jar; } throw new Error("OIDC provider ещё не применился. Подождите 1-2 минуты или запустите docker compose down -v && docker compose up --build."); } if (component === "ak-stage-identification") { const payload = { uid_field: email }; const hasPasswordOnIdentification = challenge.password_fields === true || (Array.isArray(challenge.password_fields) && challenge.password_fields.length > 0); if (hasPasswordOnIdentification) { payload.password = password; passwordChecked = true; } ({ res, challenge } = await postChallenge("ak-stage-identification", payload)); continue; } if (component === "ak-stage-password") { passwordChecked = true; ({ res, challenge } = await postChallenge("ak-stage-password", { password })); continue; } if (component === "ak-stage-user-login") { if (!passwordChecked) throw new Error("Authentik flow не запросил пароль."); ({ res, challenge } = await postChallenge("ak-stage-user-login")); continue; } if (component === "ak-stage-consent") { if (!passwordChecked) throw new Error("Authentik flow не запросил пароль."); ({ res, challenge } = await postChallenge("ak-stage-consent", { consent: true })); continue; } if (component === "ak-stage-authenticator-validate") { throw new Error("Для пользователя требуется дополнительная проверка."); } if (passwordChecked && res.ok && !component) { await followRedirectsWithJar(res, jar); return jar; } throw new Error("Не удалось завершить вход. Проверь custom flow Authentik."); } throw new Error("Неверный email или пароль."); } async function getAuthentikIdTokenWithSession(jar) { const config = await discoverOidc(); const state = randomString(24); const verifier = randomString(48); const challenge = sha256base64url(verifier); const authorizeUrl = `${config.authorization_endpoint}?${new URLSearchParams({ response_type: "code", client_id: env.OIDC_CLIENT_ID, redirect_uri: env.OIDC_REDIRECT_URI, scope: "openid profile email", state, code_challenge: challenge, code_challenge_method: "S256", prompt: "none" }).toString()}`; const authRes = await fetchWithJar(authorizeUrl, { method: "GET", headers: { Accept: "text/html,application/xhtml+xml,application/json" } }, jar); const location = authRes.headers.get("location"); debug("authorize status", authRes.status, "location", location); if (!location) { throw new Error("Authentik не вернул authorization code. Проверь OIDC Provider."); } const redirectUrl = new URL(location, env.APP_URL); const code = redirectUrl.searchParams.get("code"); const returnedState = redirectUrl.searchParams.get("state"); if (!code || returnedState !== state) { throw new Error("Не удалось получить authorization code от Authentik."); } const tokenRes = await fetch(config.token_endpoint, { method: "POST", headers: { "Content-Type": "application/x-www-form-urlencoded" }, body: new URLSearchParams({ grant_type: "authorization_code", code, redirect_uri: env.OIDC_REDIRECT_URI, client_id: env.OIDC_CLIENT_ID, client_secret: env.OIDC_CLIENT_SECRET, code_verifier: verifier }) }); const tokens = await tokenRes.json(); debug("token response", tokenRes.status, tokens); if (!tokenRes.ok || !tokens.id_token) { throw new Error("Не удалось получить JWT от Authentik."); } return tokens.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.length < 8) throw new Error("Проверьте заполненные данные."); if (!(await authentikReady())) throw new Error("Сервис авторизации запускается. Попробуйте позже."); await createAuthentikUser({ 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 и пароль."); if (!(await authentikReady())) throw new Error("Сервис авторизации запускается. Попробуйте позже."); await ensureOidcProvider(); const jar = await authentikPasswordLogin(email, password); const idToken = await getAuthentikIdTokenWithSession(jar); const claims = decodeJwt(idToken); const session = makeSigned({ id_token: idToken, claims, sub: claims.sub, name: claims.name || claims.preferred_username || claims.email || email, email: claims.email || email, username: claims.preferred_username || claims.email || email, iss: claims.iss, aud: claims.aud, iat: claims.iat, exp: claims.exp }); return redirect(res, "/dashboard", { "Set-Cookie": makeCookie("mm_session", session, 3600 * 8) }); } catch (e) { debug("login failed", e); return loginPage(req, res, `
${escapeHtml(e.message || "Неверный email или пароль.")}
`); } } 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 = session.name || "Клиент"; const email = session.email || "—"; const idToken = session.id_token || "—"; 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 Authentik

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 || "—")}
full id_token ${escapeHtml(idToken)}
`; 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, `
Аккаунт создан. Теперь можно войти.
`, { "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 === "/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, () => { ensureOidcProvider().catch(e => debug("startup oidc setup failed", e.message)); console.log(`Million Miles Authentik 100% POC on http://localhost:${PORT}`); });