${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", 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", COOKIE_SECURE: process.env.COOKIE_SECURE === "true", AUTH_DEBUG: process.env.AUTH_DEBUG === "true" }; const discoveryCache = { value: null, fetchedAt: 0 }; const jwksCache = { keys: null, fetchedAt: 0 }; function debug(...args) { if (env.AUTH_DEBUG) console.log("[authentik-debug]", ...args); } 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 randomString(bytes = 32) { return crypto.randomBytes(bytes).toString("base64url"); } function sha256base64url(value) { return crypto.createHash("sha256").update(value).digest("base64url"); } function makeCookie(name, value, maxAge = 3600) { const parts = [ `${name}=${value}`, "Path=/", "HttpOnly", "SameSite=Lax", `Max-Age=${maxAge}` ]; if (env.COOKIE_SECURE) parts.push("Secure"); return parts.join("; "); } function clearCookie(name) { const parts = [ `${name}=`, "Path=/", "HttpOnly", "SameSite=Lax", "Max-Age=0" ]; if (env.COOKIE_SECURE) parts.push("Secure"); return parts.join("; "); } function parseCookies(req) { const header = req.headers.cookie || ""; return Object.fromEntries( header .split(";") .map(value => value.trim()) .filter(Boolean) .map(value => { const index = value.indexOf("="); const rawName = index >= 0 ? value.slice(0, index) : value; const rawValue = index >= 0 ? value.slice(index + 1) : ""; try { return [rawName, decodeURIComponent(rawValue)]; } catch { return [rawName, rawValue]; } }) ); } async function parseBody(req) { let data = ""; for await (const chunk of req) data += chunk; return Object.fromEntries(new URLSearchParams(data)); } 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 index = pair.indexOf("="); if (index > 0) jar[pair.slice(0, index)] = pair.slice(index + 1); } } function cookieHeader(jar) { return Object.entries(jar).map(([key, value]) => `${key}=${value}`).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 index = 0; index < limit; index += 1) { 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 decodeJwtUnsafe(token) { if (!token || token.split(".").length !== 3) { throw new Error("Некорректный JWT."); } const [encodedHeader, encodedPayload, encodedSignature] = token.split("."); return { header: JSON.parse(Buffer.from(encodedHeader, "base64url").toString("utf8")), payload: JSON.parse(Buffer.from(encodedPayload, "base64url").toString("utf8")), signature: Buffer.from(encodedSignature, "base64url"), signingInput: `${encodedHeader}.${encodedPayload}` }; } function selectClaims(claims) { return { sub: claims.sub || null, email: claims.email || null, name: claims.name || claims.preferred_username || claims.email || null, preferred_username: claims.preferred_username || null, username: claims.preferred_username || claims.username || claims.email || null, iss: claims.iss || null, aud: claims.aud || null, iat: claims.iat || null, exp: claims.exp || null }; } 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(/ String(user.email || "").toLowerCase() === String(email).toLowerCase() || String(user.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(force = false) { const now = Date.now(); if (!force && discoveryCache.value && now - discoveryCache.fetchedAt < 5 * 60 * 1000) { return discoveryCache.value; } 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 минуты и попробуйте снова."); } const config = await res.json(); const discovery = { issuer: config.issuer, 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), jwks_uri: config.jwks_uri.replace(env.AUTHENTIK_PUBLIC_URL, env.AUTHENTIK_INTERNAL_URL) }; discoveryCache.value = discovery; discoveryCache.fetchedAt = now; return discovery; } async function getJwks(force = false) { const now = Date.now(); if (!force && jwksCache.keys && now - jwksCache.fetchedAt < 5 * 60 * 1000) { return jwksCache.keys; } const discovery = await discoverOidc(force); const res = await fetch(discovery.jwks_uri); if (!res.ok) throw new Error("Не удалось получить JWKS Authentik."); const data = await res.json(); jwksCache.keys = Array.isArray(data.keys) ? data.keys : []; jwksCache.fetchedAt = now; return jwksCache.keys; } function findVerificationKey(keys, header) { if (!Array.isArray(keys) || keys.length === 0) { throw new Error("JWKS Authentik пустой."); } const matchingKey = keys.find(key => (!header.kid || key.kid === header.kid) && (!key.use || key.use === "sig") && (!key.alg || key.alg === header.alg) ); if (matchingKey) return matchingKey; if (!header.kid && keys.length === 1) return keys[0]; throw new Error("Не удалось подобрать ключ для проверки JWT."); } async function validateIdToken(idToken) { const decoded = decodeJwtUnsafe(idToken); const { header, payload, signature, signingInput } = decoded; if (!header.alg || !/^RS(256|384|512)$/.test(header.alg)) { throw new Error("Неподдерживаемый алгоритм JWT Authentik."); } const algorithm = { RS256: "RSA-SHA256", RS384: "RSA-SHA384", RS512: "RSA-SHA512" }[header.alg]; const keys = await getJwks(); const jwk = findVerificationKey(keys, header); const publicKey = crypto.createPublicKey({ key: jwk, format: "jwk" }); const verifier = crypto.createVerify(algorithm); verifier.update(signingInput); verifier.end(); if (!verifier.verify(publicKey, signature)) { throw new Error("JWT Authentik не прошёл проверку подписи."); } const discovery = await discoverOidc(); const now = Math.floor(Date.now() / 1000); const audiences = Array.isArray(payload.aud) ? payload.aud : [payload.aud].filter(Boolean); if (payload.iss !== discovery.issuer) { throw new Error("JWT Authentik содержит неожиданный issuer."); } if (audiences.length > 0 && !audiences.includes(env.OIDC_CLIENT_ID)) { throw new Error("JWT Authentik содержит неожиданный audience."); } if (payload.nbf && now < payload.nbf) { throw new Error("JWT Authentik ещё не активен."); } if (payload.exp && now >= payload.exp) { throw new Error("JWT Authentik истёк."); } return payload; } async function getCurrentUser(req) { const { idToken, accessToken } = getTokenCookies(req); if (!idToken) return null; const claims = await validateIdToken(idToken); return { idToken, accessToken, claims, profile: selectClaims(claims) }; } 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) }; } 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) ); } let { res, challenge } = await getChallenge(); if (!res.ok) throw new Error("Сервис авторизации запускается. Попробуйте позже."); let passwordChecked = false; for (let step = 0; step < 12; step += 1) { 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 минуты и попробуйте снова."); } 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 getAuthentikTokensWithSession(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 || !tokens.access_token) { throw new Error("Не удалось получить токены от Authentik."); } return tokens; } function page(title, body, extra = "") { return `
${text}
${subtitle}
${form}Данные ниже получены из /me. Backend валидирует JWT Authentik из HttpOnly cookie.
${escapeHtml(JSON.stringify(profile, null, 2))}