${heading}
${text}
${title}
${subtitle}
${form}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 `
${text}
${subtitle}
${form}Данные ниже получены из JWT Authentik