Files
Authentik/frontend/server.js
2026-05-01 19:37:40 +03:00

865 lines
35 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 => ({
"&": "&amp;",
"<": "&lt;",
">": "&gt;",
'"': "&quot;",
"'": "&#39;"
}[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(/</g, "\\u003c");
}
function tokenMaxAge(claims) {
const now = Math.floor(Date.now() / 1000);
const maxAge = Number(claims.exp || 0) - now;
return Math.max(60, maxAge || 3600);
}
function getTokenCookies(req) {
const cookies = parseCookies(req);
return {
idToken: cookies.mm_id_token || "",
accessToken: cookies.mm_access_token || ""
};
}
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 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(user =>
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 `<!doctype html>
<html lang="ru">
<head>
<meta charset="utf-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1"/>
<title>${escapeHtml(title)}</title>
<style>
:root{--gold:#D4AF37;--gold-light:#F0D77D;--line:rgba(255,255,255,.11);--muted:rgba(255,255,255,.62);--muted2:rgba(255,255,255,.42)}
*{box-sizing:border-box}body{margin:0;min-height:100vh;background:#050505;color:#fff;font-family:Inter,Manrope,ui-sans-serif,system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI",Arial,sans-serif}a{color:inherit;text-decoration:none}button,input{font:inherit}
.screen{min-height:100vh;display:grid;place-items:center;padding:28px;background:radial-gradient(circle at 78% 12%,rgba(212,175,55,.16),transparent 27%),radial-gradient(circle at 8% 86%,rgba(18,44,90,.38),transparent 34%),linear-gradient(180deg,#111,#060606 58%,#030303)}
.shell{width:min(1080px,100%);min-height:700px;display:grid;grid-template-columns:1fr 455px;overflow:hidden;border:1px solid var(--line);border-radius:38px;background:rgba(255,255,255,.025);box-shadow:0 38px 120px rgba(0,0,0,.58)}
.brand-side{position:relative;padding:44px;display:flex;flex-direction:column;justify-content:space-between;background:radial-gradient(circle at 70% 24%,rgba(212,175,55,.18),transparent 34%),linear-gradient(145deg,rgba(255,255,255,.06),rgba(255,255,255,.015))}
.logo{display:flex;align-items:center;gap:14px}.logo-mark{width:46px;height:46px;border-radius:50%;display:grid;place-items:center;border:1px solid rgba(212,175,55,.38);background:linear-gradient(145deg,rgba(212,175,55,.28),rgba(255,255,255,.045));color:var(--gold);font-weight:800}.logo-title{display:block;font-size:15px;font-weight:800;letter-spacing:.22em;text-transform:uppercase}.logo-sub{display:block;margin-top:3px;font-size:12px;color:var(--muted2);letter-spacing:.12em}
.brand-copy h1{margin:0;max-width:560px;font-size:clamp(50px,6vw,80px);line-height:.9;letter-spacing:-.075em}.gold{color:var(--gold)}.brand-copy p{max-width:520px;margin:28px 0 0;color:var(--muted);font-size:18px;line-height:1.75}.brand-footer{display:flex;gap:12px;flex-wrap:wrap;color:var(--muted2);font-size:13px}.brand-footer span{min-width:118px;padding-top:12px;border-top:1px solid var(--line)}
.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}
.claims-json{margin-top:16px;border:1px solid rgba(255,255,255,.09);background:rgba(0,0,0,.24);border-radius:18px;padding:14px}.claims-json b{display:block;margin-bottom:8px;color:var(--muted2);font-size:11px;text-transform:uppercase;letter-spacing:.12em}.claims-json pre{margin:0;white-space:pre-wrap;word-break:break-word;color:rgba(255,255,255,.9);font-size:13px;line-height:1.6}
@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}}
@media(max-width:620px){.screen{padding:14px}.brand-side,.form-side,.dashboard-card{padding:24px}.dashboard-top,.profile{align-items:flex-start;flex-direction:column}}
</style>
${extra}
</head>
<body>${body}</body>
</html>`;
}
function authLayout({ title, subtitle, heading, text, form }) {
return `<main class="screen"><section class="shell"><div class="brand-side"><a class="logo" href="/login"><span class="logo-mark">MM</span><span><span class="logo-title">Million Miles</span><span class="logo-sub">Private Client Access</span></span></a><div class="brand-copy"><h1>${heading}</h1><p>${text}</p></div><div class="brand-footer"><span>Secure access</span><span>Private area</span><span>Premium service</span></div></div><div class="form-side"><div class="form-card"><h2>${title}</h2><p>${subtitle}</p>${form}</div></div></section></main>`;
}
function loginPage(req, res, msg = "", headers = {}) {
const form = `<form method="post" action="/login">${msg}<label class="label">Email</label><input class="input" name="email" type="email" autocomplete="email" placeholder="you@example.com" required /><label class="label">Пароль</label><input class="input" name="password" type="password" autocomplete="current-password" placeholder="Ваш пароль" required /><button class="btn" type="submit">Войти</button><div class="link-row">Нет аккаунта? <a href="/register">Зарегистрироваться</a></div></form>`;
send(res, 200, page("Вход | Million Miles", authLayout({ title: "Вход", subtitle: "Войдите в личный кабинет Million Miles.", heading: "Добро пожаловать", text: "Закрытый доступ для клиентов премиального сервиса.", form })), headers);
}
function registerPage(req, res, msg = "") {
const form = `<form method="post" action="/register">${msg}<label class="label">Полное имя</label><input class="input" name="fullName" autocomplete="name" placeholder="Ваше имя" required minlength="2" /><label class="label">Email</label><input class="input" name="email" type="email" autocomplete="email" placeholder="you@example.com" required /><label class="label">Пароль</label><input class="input" name="password" type="password" autocomplete="new-password" placeholder="Минимум 8 символов" minlength="8" required /><label class="label">Подтвердите пароль</label><input class="input" name="confirmPassword" type="password" autocomplete="new-password" placeholder="Повторите пароль" minlength="8" required /><button class="btn" type="submit">Зарегистрироваться</button><div class="link-row">Уже есть аккаунт? <a href="/login">Войти</a></div></form>`;
send(res, 200, page("Регистрация | Million Miles", authLayout({ title: "Регистрация", subtitle: "Создайте личный кабинет Million Miles.", heading: `Персональный доступ <span class="gold">Million Miles</span>`, 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 || "");
const confirmPassword = String(data.confirmPassword || "");
try {
if (!fullName || !email || !password || !confirmPassword) {
throw new Error("Проверьте заполненные данные.");
}
if (password !== confirmPassword) {
throw new Error("Пароли не совпадают.");
}
const passwordError = validatePassword(password);
if (passwordError) throw new Error(passwordError);
if (!(await authentikReady())) {
throw new Error("Сервис авторизации запускается. Попробуйте позже.");
}
await createAuthentikUser({ fullName, email, password });
return redirect(res, "/login", {
"Set-Cookie": makeCookie("mm_flash", "registered", 120)
});
} catch (error) {
return registerPage(req, res, `<div class="alert error">${escapeHtml(error.message || "Не удалось создать аккаунт.")}</div>`);
}
}
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("Сервис авторизации запускается. Попробуйте позже.");
}
const jar = await authentikPasswordLogin(email, password);
const tokens = await getAuthentikTokensWithSession(jar);
const claims = await validateIdToken(tokens.id_token);
const maxAge = tokenMaxAge(claims);
return redirect(res, "/dashboard", {
"Set-Cookie": [
makeCookie("mm_id_token", encodeURIComponent(tokens.id_token), maxAge),
makeCookie("mm_access_token", encodeURIComponent(tokens.access_token), maxAge)
]
});
} catch (error) {
debug("login failed", error);
return loginPage(req, res, `<div class="alert error">${escapeHtml(error.message || "Неверный email или пароль.")}</div>`);
}
}
async function me(req, res) {
try {
const user = await getCurrentUser(req);
if (!user) {
res.writeHead(401, { "Content-Type": "application/json" });
return res.end(JSON.stringify({ error: "Unauthorized" }));
}
res.writeHead(200, { "Content-Type": "application/json; charset=utf-8" });
return res.end(JSON.stringify(user.profile));
} catch (error) {
res.writeHead(401, {
"Content-Type": "application/json; charset=utf-8",
"Set-Cookie": [
clearCookie("mm_id_token"),
clearCookie("mm_access_token")
]
});
return res.end(JSON.stringify({ error: error.message || "Unauthorized" }));
}
}
async function dashboard(req, res) {
let user;
try {
user = await getCurrentUser(req);
} catch (error) {
return redirect(res, "/login", {
"Set-Cookie": [
clearCookie("mm_id_token"),
clearCookie("mm_access_token")
]
});
}
if (!user) return redirect(res, "/login");
const profile = user.profile;
const body = `<main class="screen"><section class="dashboard-card"><div class="dashboard-top"><a class="logo" href="/dashboard"><span class="logo-mark">MM</span><span><span class="logo-title">Million Miles</span><span class="logo-sub">Private Client Access</span></span></a><form method="post" action="/logout"><button class="btn logout" type="submit">Выйти</button></form></div><div class="profile"><div class="avatar" id="profile-avatar">${escapeHtml(initials(profile.name, profile.email))}</div><div><h1 id="profile-name">${escapeHtml(profile.name || "Клиент")}</h1><p>Данные ниже получены из /me. Backend валидирует JWT Authentik из HttpOnly cookie.</p></div></div><div class="jwt-info"><h2>Claims</h2><div class="jwt-grid"><div class="jwt-item"><b>sub</b><span id="claim-sub">${escapeHtml(profile.sub || "—")}</span></div><div class="jwt-item"><b>name</b><span id="claim-name">${escapeHtml(profile.name || "—")}</span></div><div class="jwt-item"><b>email</b><span id="claim-email">${escapeHtml(profile.email || "—")}</span></div><div class="jwt-item"><b>username</b><span id="claim-username">${escapeHtml(profile.username || "—")}</span></div><div class="jwt-item"><b>iat</b><span id="claim-iat">${escapeHtml(formatTimestamp(profile.iat))}</span></div><div class="jwt-item"><b>exp</b><span id="claim-exp">${escapeHtml(formatTimestamp(profile.exp))}</span></div><div class="jwt-item"><b>iss</b><span id="claim-iss">${escapeHtml(profile.iss || "—")}</span></div><div class="jwt-item"><b>aud</b><span id="claim-aud">${escapeHtml(Array.isArray(profile.aud) ? profile.aud.join(", ") : profile.aud || "—")}</span></div></div><div class="claims-json"><b>/me JSON</b><pre id="me-json">${escapeHtml(JSON.stringify(profile, null, 2))}</pre></div></div></section></main>`;
const script = `<script id="me-bootstrap" type="application/json">${jsonScript(profile)}</script><script>
const claimIds = ["sub", "name", "email", "username", "iat", "exp", "iss", "aud"];
const initial = JSON.parse(document.getElementById("me-bootstrap").textContent || "{}");
function formatValue(key, value) {
if (value == null || value === "") return "—";
if (key === "iat" || key === "exp") {
const date = new Date(Number(value) * 1000);
return Number.isNaN(date.getTime()) ? "—" : date.toLocaleString("ru-RU");
}
if (Array.isArray(value)) return value.join(", ");
return String(value);
}
function initials(name, email) {
const source = name || email || "MM";
return source.split(/[ ._-]+/).filter(Boolean).slice(0, 2).map(part => part[0]).join("").toUpperCase();
}
function render(data) {
document.getElementById("profile-name").textContent = data.name || data.username || data.email || "Клиент";
document.getElementById("profile-avatar").textContent = initials(data.name, data.email);
for (const key of claimIds) {
const node = document.getElementById("claim-" + key);
if (node) node.textContent = formatValue(key, data[key]);
}
document.getElementById("me-json").textContent = JSON.stringify(data, null, 2);
}
render(initial);
fetch("/me", { headers: { Accept: "application/json" } })
.then(async response => {
if (response.status === 401) {
window.location.href = "/login";
return null;
}
if (!response.ok) {
throw new Error("Не удалось получить /me.");
}
return response.json();
})
.then(data => {
if (data) render(data);
})
.catch(error => {
console.error(error);
});
</script>`;
send(res, 200, page("Dashboard | Million Miles", body, script));
}
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_id_token"),
clearCookie("mm_access_token")
]
});
}
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, `<div class="alert ok">Аккаунт создан. Теперь войдите через форму Authentik.</div>`, {
"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 === "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 }));
}
redirect(res, "/login");
}).listen(PORT, () => {
discoverOidc().catch(error => debug("startup oidc setup failed", error.message));
console.log(`Million Miles Authentik POC on http://localhost:${PORT}`);
});