Files
Authentik/frontend/server.js
2026-05-01 18:55:17 +03:00

738 lines
29 KiB
JavaScript
Raw 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",
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) => ({
"&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;"
}[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 `<!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;--bg:#050505;--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;box-shadow:0 0 40px rgba(212,175,55,.12)}
.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}
.decor-car{position:absolute;left:44px;right:44px;bottom:132px;height:150px;opacity:.55;pointer-events:none}
.decor-car:before{content:"";position:absolute;left:0;right:0;bottom:28px;height:96px;border-bottom:2px solid rgba(212,175,55,.55);border-radius:50% 50% 18% 18% / 70% 70% 20% 20%;filter:drop-shadow(0 0 18px rgba(212,175,55,.24))}
.decor-car:after{content:"";position:absolute;left:18%;right:24%;bottom:82px;height:52px;border-top:2px solid rgba(255,255,255,.23);border-radius:70% 60% 0 0}
.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}
@media(max-width:900px){.shell{grid-template-columns:1fr}.brand-side{min-height:390px}.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>
</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="decor-car"></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 />
<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 || "");
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, `<div class="alert error">${escapeHtml(e.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("Сервис авторизации запускается. Попробуйте позже.");
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, `<div class="alert error">${escapeHtml(e.message || "Неверный email или пароль.")}</div>`);
}
}
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 = `<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">${escapeHtml(initials(name, email))}</div>
<div><h1>${escapeHtml(name)}</h1><p>Данные ниже получены из JWT Authentik</p></div>
</div>
<div class="jwt-info">
<h2>JWT</h2>
<div class="jwt-grid">
<div class="jwt-item"><b>sub</b><span>${escapeHtml(claims.sub || "—")}</span></div>
<div class="jwt-item"><b>name</b><span>${escapeHtml(claims.name || "—")}</span></div>
<div class="jwt-item"><b>email</b><span>${escapeHtml(claims.email || "—")}</span></div>
<div class="jwt-item"><b>username</b><span>${escapeHtml(claims.preferred_username || claims.email || "—")}</span></div>
<div class="jwt-item"><b>iat</b><span>${escapeHtml(iat)}</span></div>
<div class="jwt-item"><b>exp</b><span>${escapeHtml(exp)}</span></div>
<div class="jwt-item"><b>iss</b><span>${escapeHtml(claims.iss || "—")}</span></div>
<div class="jwt-item"><b>aud</b><span>${escapeHtml(Array.isArray(claims.aud) ? claims.aud.join(", ") : claims.aud || "—")}</span></div>
</div>
<div class="jwt-item" style="margin-top:12px">
<b>full id_token</b>
<span>${escapeHtml(idToken)}</span>
</div>
</div>
</section>
</main>`;
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, `<div class="alert ok">Аккаунт создан. Теперь можно войти.</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 === "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}`);
});