928 lines
38 KiB
JavaScript
928 lines
38 KiB
JavaScript
const http = require("http");
|
||
const crypto = require("crypto");
|
||
const fs = require("fs");
|
||
|
||
const PORT = 3000;
|
||
|
||
const env = {
|
||
APP_URL: process.env.APP_URL || "http://localhost:3000",
|
||
ZITADEL_URL: process.env.ZITADEL_URL || "http://host.docker.internal:8080",
|
||
ZITADEL_PUBLIC_URL: process.env.ZITADEL_PUBLIC_URL || "http://localhost:8080",
|
||
ZITADEL_PROJECT_NAME: process.env.ZITADEL_PROJECT_NAME || "Million Miles",
|
||
OIDC_APP_NAME: process.env.OIDC_APP_NAME || "Million Miles Custom Frontend",
|
||
OIDC_REDIRECT_URI: process.env.OIDC_REDIRECT_URI || "http://localhost:3000/auth/callback",
|
||
OIDC_CLIENT_ID: process.env.OIDC_CLIENT_ID || "",
|
||
OIDC_CLIENT_SECRET: process.env.OIDC_CLIENT_SECRET || "",
|
||
OIDC_CLIENT_STATE_FILE: process.env.OIDC_CLIENT_STATE_FILE || "/zitadel-secrets/oidc-client.json",
|
||
ZITADEL_SERVICE_TOKEN_FILE: process.env.ZITADEL_SERVICE_TOKEN_FILE || "/zitadel-secrets/admin.pat",
|
||
ZITADEL_LOGIN_CLIENT_TOKEN_FILE: process.env.ZITADEL_LOGIN_CLIENT_TOKEN_FILE || "/zitadel-secrets/login-client.pat",
|
||
SESSION_SECRET: process.env.SESSION_SECRET || "dev-secret",
|
||
AUTH_DEBUG: process.env.AUTH_DEBUG === "true"
|
||
};
|
||
|
||
const COOKIE_SECURE = env.APP_URL.startsWith("https://") || process.env.NODE_ENV === "production";
|
||
const PASSWORD_POLICY = {
|
||
minLength: "8",
|
||
hasUppercase: true,
|
||
hasLowercase: true,
|
||
hasNumber: true,
|
||
hasSymbol: false
|
||
};
|
||
const persistedOidcClient = readOidcClientConfig();
|
||
const state = {
|
||
projectId: null,
|
||
clientId: env.OIDC_CLIENT_ID || persistedOidcClient.clientId,
|
||
clientSecret: env.OIDC_CLIENT_SECRET || persistedOidcClient.clientSecret,
|
||
setupTried: false,
|
||
configSyncTried: false,
|
||
jwks: null,
|
||
jwksFetchedAt: 0
|
||
};
|
||
|
||
function debug(...args) {
|
||
if (env.AUTH_DEBUG) console.log("[zitadel-debug]", ...args);
|
||
}
|
||
|
||
function readTokenFile(path) {
|
||
try {
|
||
const value = fs.readFileSync(path, "utf8").trim();
|
||
return value || null;
|
||
} catch {
|
||
return null;
|
||
}
|
||
}
|
||
|
||
function readOidcClientConfig() {
|
||
try {
|
||
const raw = fs.readFileSync(env.OIDC_CLIENT_STATE_FILE, "utf8");
|
||
const parsed = JSON.parse(raw);
|
||
return {
|
||
clientId: parsed.clientId || "",
|
||
clientSecret: parsed.clientSecret || ""
|
||
};
|
||
} catch {
|
||
return { clientId: "", clientSecret: "" };
|
||
}
|
||
}
|
||
|
||
function persistOidcClientConfig(clientId, clientSecret) {
|
||
try {
|
||
fs.writeFileSync(env.OIDC_CLIENT_STATE_FILE, JSON.stringify({ clientId, clientSecret }), "utf8");
|
||
} catch (error) {
|
||
debug("failed to persist OIDC client", error.message);
|
||
}
|
||
}
|
||
|
||
function adminToken() {
|
||
return readTokenFile(env.ZITADEL_SERVICE_TOKEN_FILE);
|
||
}
|
||
|
||
function loginClientToken() {
|
||
return readTokenFile(env.ZITADEL_LOGIN_CLIENT_TOKEN_FILE) || adminToken();
|
||
}
|
||
|
||
function stripTrailingSlash(value) {
|
||
return String(value || "").replace(/\/+$/, "");
|
||
}
|
||
|
||
function escapeHtml(value) {
|
||
return String(value ?? "").replace(/[&<>"']/g, char => ({
|
||
"&": "&",
|
||
"<": "<",
|
||
">": ">",
|
||
"\"": """,
|
||
"'": "'"
|
||
}[char]));
|
||
}
|
||
|
||
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 b64url(input) {
|
||
return Buffer.from(input).toString("base64url");
|
||
}
|
||
|
||
function sign(value) {
|
||
return crypto.createHmac("sha256", env.SESSION_SECRET).update(value).digest("base64url");
|
||
}
|
||
|
||
function signaturesMatch(left, right) {
|
||
const a = Buffer.from(String(left || ""));
|
||
const b = Buffer.from(String(right || ""));
|
||
return a.length === b.length && crypto.timingSafeEqual(a, b);
|
||
}
|
||
|
||
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 (!signaturesMatch(sign(payload), mac)) return null;
|
||
|
||
try {
|
||
return JSON.parse(Buffer.from(payload, "base64url").toString("utf8"));
|
||
} catch {
|
||
return null;
|
||
}
|
||
}
|
||
|
||
function makeCookie(name, value, maxAge = 3600) {
|
||
const secure = COOKIE_SECURE ? "; Secure" : "";
|
||
return `${name}=${value}; Path=/; HttpOnly; SameSite=Lax; Max-Age=${maxAge}${secure}`;
|
||
}
|
||
|
||
function clearCookie(name) {
|
||
const secure = COOKIE_SECURE ? "; Secure" : "";
|
||
return `${name}=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0${secure}`;
|
||
}
|
||
|
||
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("=");
|
||
if (index < 0) return [value, ""];
|
||
return [value.slice(0, index), value.slice(index + 1)];
|
||
})
|
||
);
|
||
}
|
||
|
||
function getSignedCookie(req, name) {
|
||
return readSigned(parseCookies(req)[name]);
|
||
}
|
||
|
||
function getSession(req) {
|
||
return getSignedCookie(req, "mm_session");
|
||
}
|
||
|
||
function getPendingAuth(req) {
|
||
return getSignedCookie(req, "mm_auth");
|
||
}
|
||
|
||
function getFlash(req) {
|
||
return parseCookies(req).mm_flash || "";
|
||
}
|
||
|
||
async function parseBody(req) {
|
||
let data = "";
|
||
for await (const chunk of req) data += chunk;
|
||
return Object.fromEntries(new URLSearchParams(data));
|
||
}
|
||
|
||
function decodeJwtPart(token, index) {
|
||
const parts = String(token || "").split(".");
|
||
if (parts.length !== 3) throw new Error("Некорректный JWT.");
|
||
return JSON.parse(Buffer.from(parts[index], "base64url").toString("utf8"));
|
||
}
|
||
|
||
async function zFetch(path, options = {}, token = adminToken()) {
|
||
if (!token) throw new Error("ZITADEL PAT еще не создан. Подождите первый запуск ZITADEL.");
|
||
|
||
const res = await fetch(`${env.ZITADEL_URL}${path}`, {
|
||
...options,
|
||
headers: {
|
||
Accept: "application/json",
|
||
Authorization: `Bearer ${token}`,
|
||
...(options.headers || {})
|
||
}
|
||
});
|
||
|
||
const text = await res.text();
|
||
let data = {};
|
||
|
||
try {
|
||
data = text ? JSON.parse(text) : {};
|
||
} catch {
|
||
data = { raw: text };
|
||
}
|
||
|
||
if (!res.ok) {
|
||
debug("zFetch failed", path, res.status, data);
|
||
const message = data.message || data.raw || `ZITADEL API error ${res.status}`;
|
||
throw new Error(message);
|
||
}
|
||
|
||
return data;
|
||
}
|
||
|
||
async function waitForZitadel() {
|
||
try {
|
||
const res = await fetch(`${env.ZITADEL_URL}/debug/ready`);
|
||
return res.ok;
|
||
} catch {
|
||
return false;
|
||
}
|
||
}
|
||
|
||
async function ensurePasswordPolicy(token) {
|
||
const currentResponse = await zFetch("/management/v1/policies/password/complexity", {}, token);
|
||
const current = currentResponse.policy || {};
|
||
const alreadyMatches = String(current.minLength || "") === PASSWORD_POLICY.minLength
|
||
&& current.hasUppercase === PASSWORD_POLICY.hasUppercase
|
||
&& current.hasLowercase === PASSWORD_POLICY.hasLowercase
|
||
&& current.hasNumber === PASSWORD_POLICY.hasNumber
|
||
&& Boolean(current.hasSymbol) === PASSWORD_POLICY.hasSymbol;
|
||
|
||
if (alreadyMatches) return;
|
||
|
||
const method = currentResponse.isDefault ? "POST" : "PUT";
|
||
const body = JSON.stringify(PASSWORD_POLICY);
|
||
|
||
try {
|
||
await zFetch("/management/v1/policies/password/complexity", {
|
||
method,
|
||
headers: { "Content-Type": "application/json" },
|
||
body
|
||
}, token);
|
||
} catch (error) {
|
||
if (String(error.message || "").includes("NotChanged")) return;
|
||
|
||
if (method === "PUT") {
|
||
try {
|
||
await zFetch("/management/v1/policies/password/complexity", {
|
||
method: "POST",
|
||
headers: { "Content-Type": "application/json" },
|
||
body
|
||
}, token);
|
||
} catch (fallbackError) {
|
||
if (String(fallbackError.message || "").includes("already exists")) return;
|
||
throw fallbackError;
|
||
}
|
||
return;
|
||
}
|
||
|
||
throw error;
|
||
}
|
||
}
|
||
|
||
async function ensureLoginV2(token) {
|
||
const current = await zFetch("/v2/features/instance", {}, token);
|
||
const loginV2 = current.loginV2 || {};
|
||
const desiredBaseUri = stripTrailingSlash(env.APP_URL);
|
||
const currentBaseUri = stripTrailingSlash(loginV2.baseUri);
|
||
|
||
if (loginV2.required === true && currentBaseUri === desiredBaseUri) return;
|
||
|
||
await zFetch("/v2/features/instance", {
|
||
method: "PUT",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify({
|
||
loginV2: {
|
||
required: true,
|
||
baseUri: desiredBaseUri
|
||
}
|
||
})
|
||
}, token);
|
||
}
|
||
|
||
async function setupOidcApp(token = adminToken()) {
|
||
if (state.clientId && state.clientSecret) return;
|
||
if (state.setupTried) return;
|
||
if (!(await waitForZitadel())) return;
|
||
if (!token) return;
|
||
|
||
state.setupTried = true;
|
||
|
||
let project = null;
|
||
|
||
try {
|
||
const search = await zFetch("/management/v1/projects/_search", {
|
||
method: "POST",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify({
|
||
queries: [{ nameQuery: { name: env.ZITADEL_PROJECT_NAME, method: "TEXT_QUERY_METHOD_EQUALS" } }]
|
||
})
|
||
}, token);
|
||
project = (search.result || search.projects || []).find(item => item.name === env.ZITADEL_PROJECT_NAME);
|
||
} catch (error) {
|
||
debug("project search failed", error.message);
|
||
}
|
||
|
||
if (!project) {
|
||
const created = await zFetch("/management/v1/projects", {
|
||
method: "POST",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify({
|
||
name: env.ZITADEL_PROJECT_NAME,
|
||
projectRoleAssertion: false,
|
||
projectRoleCheck: false,
|
||
hasProjectCheck: false
|
||
})
|
||
}, token);
|
||
project = { id: created.id || created.projectId };
|
||
}
|
||
|
||
const projectId = project.id || project.projectId;
|
||
state.projectId = projectId;
|
||
if (!projectId) throw new Error("Не удалось получить projectId ZITADEL.");
|
||
|
||
const app = await zFetch(`/management/v1/projects/${projectId}/apps/oidc`, {
|
||
method: "POST",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify({
|
||
name: env.OIDC_APP_NAME,
|
||
redirectUris: [env.OIDC_REDIRECT_URI],
|
||
responseTypes: ["OIDC_RESPONSE_TYPE_CODE"],
|
||
grantTypes: ["OIDC_GRANT_TYPE_AUTHORIZATION_CODE"],
|
||
appType: "OIDC_APP_TYPE_WEB",
|
||
authMethodType: "OIDC_AUTH_METHOD_TYPE_BASIC",
|
||
version: "OIDC_VERSION_1_0",
|
||
devMode: true,
|
||
accessTokenType: "OIDC_TOKEN_TYPE_JWT",
|
||
idTokenUserinfoAssertion: true
|
||
})
|
||
}, token);
|
||
|
||
state.clientId = app.clientId;
|
||
state.clientSecret = app.clientSecret;
|
||
persistOidcClientConfig(state.clientId, state.clientSecret);
|
||
debug("OIDC app created", { projectId, clientId: state.clientId });
|
||
}
|
||
|
||
async function syncZitadelConfig() {
|
||
if (state.configSyncTried) return;
|
||
if (!(await waitForZitadel())) return;
|
||
|
||
const token = adminToken();
|
||
if (!token) return;
|
||
|
||
state.configSyncTried = true;
|
||
await ensurePasswordPolicy(token);
|
||
await ensureLoginV2(token);
|
||
await setupOidcApp(token);
|
||
}
|
||
|
||
async function createUser({ fullName, email, password }) {
|
||
await syncZitadelConfig();
|
||
|
||
const parts = fullName.trim().split(/\s+/);
|
||
const givenName = parts[0] || fullName;
|
||
const familyName = parts.slice(1).join(" ") || "User";
|
||
|
||
return zFetch("/v2/users/human", {
|
||
method: "POST",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify({
|
||
username: email,
|
||
profile: {
|
||
givenName,
|
||
familyName,
|
||
displayName: fullName,
|
||
preferredLanguage: "ru"
|
||
},
|
||
email: {
|
||
email,
|
||
isVerified: true
|
||
},
|
||
password: {
|
||
password,
|
||
changeRequired: false
|
||
}
|
||
})
|
||
}, adminToken());
|
||
}
|
||
|
||
async function createPasswordSession(loginName, password) {
|
||
const token = loginClientToken();
|
||
const created = await zFetch("/v2/sessions", {
|
||
method: "POST",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify({
|
||
checks: {
|
||
user: { loginName }
|
||
}
|
||
})
|
||
}, token);
|
||
|
||
const sessionId = created.sessionId;
|
||
let sessionToken = created.sessionToken;
|
||
|
||
if (!sessionId || !sessionToken) {
|
||
throw new Error("Не удалось создать session ZITADEL.");
|
||
}
|
||
|
||
const updated = await zFetch(`/v2/sessions/${encodeURIComponent(sessionId)}`, {
|
||
method: "PATCH",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify({
|
||
checks: {
|
||
password: { password }
|
||
}
|
||
})
|
||
}, token);
|
||
|
||
sessionToken = updated.sessionToken || sessionToken;
|
||
return { sessionId, sessionToken };
|
||
}
|
||
|
||
async function createAuthRequest() {
|
||
await syncZitadelConfig();
|
||
|
||
const clientId = state.clientId || env.OIDC_CLIENT_ID;
|
||
if (!clientId) throw new Error("OIDC app не настроен. Укажите OIDC_CLIENT_ID/OIDC_CLIENT_SECRET в .env.");
|
||
|
||
const stateValue = crypto.randomBytes(24).toString("base64url");
|
||
const verifier = crypto.randomBytes(48).toString("base64url");
|
||
const challenge = crypto.createHash("sha256").update(verifier).digest("base64url");
|
||
|
||
const url = `${env.ZITADEL_URL}/oauth/v2/authorize?${new URLSearchParams({
|
||
client_id: clientId,
|
||
redirect_uri: env.OIDC_REDIRECT_URI,
|
||
response_type: "code",
|
||
scope: "openid email profile",
|
||
code_challenge: challenge,
|
||
code_challenge_method: "S256",
|
||
state: stateValue
|
||
})}`;
|
||
|
||
const res = await fetch(url, { redirect: "manual" });
|
||
const location = res.headers.get("location") || "";
|
||
const redirectUrl = new URL(location, env.ZITADEL_PUBLIC_URL);
|
||
const authRequest = redirectUrl.searchParams.get("authRequest");
|
||
const hostedAuthRequest = redirectUrl.searchParams.get("authRequestID")
|
||
|| redirectUrl.searchParams.get("auth_request")
|
||
|| "";
|
||
|
||
debug("authorize start", res.status, location);
|
||
|
||
if (!authRequest && hostedAuthRequest) {
|
||
throw new Error("ZITADEL вернул hosted login flow вместо custom login. Проверьте loginV2 и baseUri.");
|
||
}
|
||
if (!authRequest) throw new Error("ZITADEL не вернул authRequest.");
|
||
|
||
return { authRequest, stateValue, verifier };
|
||
}
|
||
|
||
async function finalizeAuthRequest(authRequest, session) {
|
||
const token = loginClientToken();
|
||
const response = await zFetch(`/v2/oidc/auth_requests/${encodeURIComponent(authRequest)}`, {
|
||
method: "POST",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify({
|
||
session: {
|
||
sessionId: session.sessionId,
|
||
sessionToken: session.sessionToken
|
||
}
|
||
})
|
||
}, token);
|
||
|
||
if (!response.callbackUrl) throw new Error("ZITADEL не вернул callbackUrl.");
|
||
return response.callbackUrl;
|
||
}
|
||
|
||
async function exchangeCode(callbackUrl, verifier, expectedState) {
|
||
const callback = new URL(callbackUrl, env.APP_URL);
|
||
const code = callback.searchParams.get("code");
|
||
const returnedState = callback.searchParams.get("state");
|
||
|
||
if (!code || returnedState !== expectedState) {
|
||
throw new Error("Некорректный callback от ZITADEL.");
|
||
}
|
||
|
||
const clientId = state.clientId || env.OIDC_CLIENT_ID;
|
||
const clientSecret = state.clientSecret || env.OIDC_CLIENT_SECRET;
|
||
const basic = Buffer.from(`${clientId}:${clientSecret}`).toString("base64");
|
||
|
||
const res = await fetch(`${env.ZITADEL_URL}/oauth/v2/token`, {
|
||
method: "POST",
|
||
headers: {
|
||
"Content-Type": "application/x-www-form-urlencoded",
|
||
Authorization: `Basic ${basic}`
|
||
},
|
||
body: new URLSearchParams({
|
||
grant_type: "authorization_code",
|
||
code,
|
||
redirect_uri: env.OIDC_REDIRECT_URI,
|
||
code_verifier: verifier
|
||
})
|
||
});
|
||
|
||
const data = await res.json();
|
||
debug("token", res.status, data);
|
||
|
||
if (!res.ok || !data.id_token) throw new Error("ZITADEL не выдал id_token.");
|
||
|
||
return {
|
||
idToken: data.id_token,
|
||
accessToken: data.access_token || ""
|
||
};
|
||
}
|
||
|
||
async function getJwks() {
|
||
const now = Date.now();
|
||
if (state.jwks && now - state.jwksFetchedAt < 5 * 60 * 1000) {
|
||
return state.jwks;
|
||
}
|
||
|
||
const res = await fetch(`${env.ZITADEL_URL}/oauth/v2/keys`);
|
||
const data = await res.json();
|
||
if (!res.ok || !Array.isArray(data.keys)) {
|
||
throw new Error("Не удалось получить JWKS ZITADEL.");
|
||
}
|
||
|
||
state.jwks = data.keys;
|
||
state.jwksFetchedAt = now;
|
||
return state.jwks;
|
||
}
|
||
|
||
function extractPublicClaims(claims) {
|
||
const username = claims.preferred_username || claims.username || claims.email || "";
|
||
|
||
return {
|
||
sub: claims.sub || "",
|
||
email: claims.email || "",
|
||
name: claims.name || username,
|
||
preferred_username: claims.preferred_username || "",
|
||
username,
|
||
iss: claims.iss || "",
|
||
aud: Array.isArray(claims.aud) ? claims.aud : (claims.aud ? [claims.aud] : []),
|
||
iat: claims.iat || 0,
|
||
exp: claims.exp || 0
|
||
};
|
||
}
|
||
|
||
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");
|
||
}
|
||
|
||
async function validateIdToken(token) {
|
||
const header = decodeJwtPart(token, 0);
|
||
const claims = decodeJwtPart(token, 1);
|
||
const parts = String(token || "").split(".");
|
||
const signature = Buffer.from(parts[2], "base64url");
|
||
const issuer = stripTrailingSlash(env.ZITADEL_PUBLIC_URL);
|
||
const audience = state.clientId || env.OIDC_CLIENT_ID;
|
||
|
||
if (!claims.exp || claims.exp * 1000 <= Date.now()) {
|
||
throw new Error("Сессия истекла.");
|
||
}
|
||
if (stripTrailingSlash(claims.iss) !== issuer) {
|
||
throw new Error("Некорректный issuer токена.");
|
||
}
|
||
|
||
const aud = Array.isArray(claims.aud) ? claims.aud : [claims.aud].filter(Boolean);
|
||
if (audience && !aud.includes(audience)) {
|
||
throw new Error("Некорректная audience токена.");
|
||
}
|
||
|
||
const keys = await getJwks();
|
||
const jwk = keys.find(item => item.kid === header.kid);
|
||
if (!jwk) throw new Error("Не найден публичный ключ для JWT.");
|
||
|
||
const algorithms = {
|
||
RS256: "RSA-SHA256",
|
||
RS384: "RSA-SHA384",
|
||
RS512: "RSA-SHA512"
|
||
};
|
||
const algorithm = algorithms[header.alg];
|
||
if (!algorithm) throw new Error(`Неподдерживаемый алгоритм JWT: ${header.alg}`);
|
||
|
||
const publicKey = crypto.createPublicKey({ key: jwk, format: "jwk" });
|
||
const payload = Buffer.from(`${parts[0]}.${parts[1]}`);
|
||
const verified = crypto.verify(algorithm, payload, publicKey, signature);
|
||
if (!verified) throw new Error("Подпись JWT не прошла проверку.");
|
||
|
||
return extractPublicClaims(claims);
|
||
}
|
||
|
||
async function closeZitadelSession(session) {
|
||
if (!session?.sessionId || !session?.sessionToken) return;
|
||
|
||
try {
|
||
await fetch(`${env.ZITADEL_URL}/v2/sessions/${encodeURIComponent(session.sessionId)}`, {
|
||
method: "DELETE",
|
||
headers: {
|
||
Accept: "application/json",
|
||
Authorization: `Bearer ${loginClientToken() || adminToken() || session.sessionToken}`,
|
||
"Content-Type": "application/json"
|
||
},
|
||
body: JSON.stringify({
|
||
sessionToken: session.sessionToken
|
||
})
|
||
});
|
||
} catch (error) {
|
||
debug("session close failed", error.message);
|
||
}
|
||
}
|
||
|
||
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;--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);line-height:1.7}
|
||
.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,minmax(0,1fr));gap:14px}.jwt-item{display:flex;flex-direction:column;gap:8px;padding:16px;border:1px solid rgba(255,255,255,.09);border-radius:18px;background:rgba(0,0,0,.18)}.jwt-item b{font-size:13px;letter-spacing:.06em;text-transform:uppercase;color:var(--muted2)}.jwt-item span{color:rgba(255,255,255,.94);word-break:break-word;line-height:1.6}.claims-json{margin-top:18px;display:flex;flex-direction:column;gap:12px}.claims-json b{font-size:15px}.claims-json pre{margin:0;border:1px solid rgba(255,255,255,.09);background:rgba(0,0,0,.24);border-radius:18px;padding:16px;white-space:pre-wrap;word-break:break-word;color:rgba(255,255,255,.9);font-size:14px;line-height:1.65}
|
||
@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}}
|
||
</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="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 loginPath(authRequest = "") {
|
||
const suffix = authRequest ? `?${new URLSearchParams({ authRequest })}` : "";
|
||
return `/login${suffix}`;
|
||
}
|
||
|
||
function registerPath(authRequest = "") {
|
||
const suffix = authRequest ? `?${new URLSearchParams({ authRequest })}` : "";
|
||
return `/register${suffix}`;
|
||
}
|
||
|
||
function loginPage(req, res, msg = "", headers = {}, explicitAuthRequest = "") {
|
||
const url = new URL(req.url, env.APP_URL);
|
||
const authRequest = explicitAuthRequest || url.searchParams.get("authRequest") || "";
|
||
const hiddenAuthRequest = authRequest
|
||
? `<input type="hidden" name="authRequest" value="${escapeHtml(authRequest)}" />`
|
||
: "";
|
||
const registerHref = registerPath(authRequest);
|
||
const form = `<form method="post" action="/login">${msg}${hiddenAuthRequest}<label class="label">Email</label><input class="input" name="email" type="email" autocomplete="email" placeholder="you@example.com" required /><label class="label">Password</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="${escapeHtml(registerHref)}">Зарегистрироваться</a></div></form>`;
|
||
send(res, 200, page("Вход | Million Miles", authLayout({
|
||
title: "Вход",
|
||
subtitle: "Войдите в личный кабинет Million Miles.",
|
||
heading: "Добро пожаловать",
|
||
text: "Кастомная форма входа. ZITADEL используется только как backend IdP.",
|
||
form
|
||
})), headers);
|
||
}
|
||
|
||
function registerPage(req, res, msg = "", explicitAuthRequest = "") {
|
||
const url = new URL(req.url, env.APP_URL);
|
||
const authRequest = explicitAuthRequest || url.searchParams.get("authRequest") || "";
|
||
const hiddenAuthRequest = authRequest
|
||
? `<input type="hidden" name="authRequest" value="${escapeHtml(authRequest)}" />`
|
||
: "";
|
||
const loginHref = loginPath(authRequest);
|
||
const form = `<form method="post" action="/register">${msg}${hiddenAuthRequest}<label class="label">Full name</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">Password</label><input class="input" name="password" type="password" autocomplete="new-password" placeholder="Минимум 8 символов" minlength="8" required /><label class="label">Confirm password</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="${escapeHtml(loginHref)}">Войти</a></div></form>`;
|
||
send(res, 200, page("Регистрация | Million Miles", authLayout({
|
||
title: "Регистрация",
|
||
subtitle: "Создайте личный кабинет Million Miles.",
|
||
heading: `Персональный доступ <span class="gold">Million Miles</span>`,
|
||
text: "Пароли и учетные записи хранятся в ZITADEL, не в нашем приложении.",
|
||
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 || "");
|
||
const authRequest = String(data.authRequest || "").trim();
|
||
|
||
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);
|
||
|
||
await createUser({ fullName, email, password });
|
||
return redirect(res, loginPath(authRequest), {
|
||
"Set-Cookie": makeCookie("mm_flash", "registered", 120)
|
||
});
|
||
} catch (error) {
|
||
return registerPage(req, res, `<div class="alert error">${escapeHtml(error.message || "Не удалось создать аккаунт.")}</div>`, authRequest);
|
||
}
|
||
}
|
||
|
||
async function loginPost(req, res) {
|
||
const data = await parseBody(req);
|
||
const email = String(data.email || "").trim().toLowerCase();
|
||
const password = String(data.password || "");
|
||
const authRequest = String(data.authRequest || "").trim();
|
||
|
||
try {
|
||
if (!email || !password) throw new Error("Введите email и пароль.");
|
||
|
||
const session = await createPasswordSession(email, password);
|
||
|
||
if (authRequest) {
|
||
const callbackUrl = await finalizeAuthRequest(authRequest, session);
|
||
return redirect(res, callbackUrl);
|
||
}
|
||
|
||
const auth = await createAuthRequest();
|
||
const callbackUrl = await finalizeAuthRequest(auth.authRequest, session);
|
||
return redirect(res, callbackUrl, {
|
||
"Set-Cookie": makeCookie("mm_auth", makeSigned({
|
||
verifier: auth.verifier,
|
||
stateValue: auth.stateValue,
|
||
sessionId: session.sessionId,
|
||
sessionToken: session.sessionToken
|
||
}), 300)
|
||
});
|
||
} catch (error) {
|
||
return loginPage(req, res, `<div class="alert error">${escapeHtml(error.message || "Неверный email или пароль.")}</div>`, {}, authRequest);
|
||
}
|
||
}
|
||
|
||
async function authCallback(req, res) {
|
||
const pendingAuth = getPendingAuth(req);
|
||
if (!pendingAuth?.verifier || !pendingAuth?.stateValue) {
|
||
return redirect(res, "/login", {
|
||
"Set-Cookie": clearCookie("mm_auth")
|
||
});
|
||
}
|
||
|
||
try {
|
||
const callbackUrl = new URL(req.url, env.APP_URL).toString();
|
||
const tokens = await exchangeCode(callbackUrl, pendingAuth.verifier, pendingAuth.stateValue);
|
||
await validateIdToken(tokens.idToken);
|
||
|
||
const signedSession = makeSigned({
|
||
idToken: tokens.idToken,
|
||
accessToken: tokens.accessToken,
|
||
sessionId: pendingAuth.sessionId || "",
|
||
sessionToken: pendingAuth.sessionToken || ""
|
||
});
|
||
|
||
return redirect(res, "/dashboard", {
|
||
"Set-Cookie": [
|
||
makeCookie("mm_session", signedSession, 3600 * 8),
|
||
clearCookie("mm_auth")
|
||
]
|
||
});
|
||
} catch {
|
||
return redirect(res, "/login", {
|
||
"Set-Cookie": clearCookie("mm_auth")
|
||
});
|
||
}
|
||
}
|
||
|
||
async function dashboard(req, res) {
|
||
const session = getSession(req);
|
||
if (!session?.idToken) return redirect(res, "/login");
|
||
|
||
let profile;
|
||
try {
|
||
profile = await validateIdToken(session.idToken);
|
||
} catch {
|
||
return redirect(res, "/login", {
|
||
"Set-Cookie": clearCookie("mm_session")
|
||
});
|
||
}
|
||
|
||
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 ZITADEL из 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 || profile.preferred_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 avatarInitials(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.preferred_username||data.email||"Клиент";document.getElementById("profile-avatar").textContent=avatarInitials(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="/login";return null;}if(!response.ok){const text=await response.text();throw new Error(text||"Failed to load /me");}return response.json();}).then(data=>{if(data)render(data);}).catch(()=>{window.location="/login";});
|
||
</script>`;
|
||
|
||
send(res, 200, page("Dashboard | Million Miles", body + script));
|
||
}
|
||
|
||
async function me(req, res) {
|
||
const session = getSession(req);
|
||
if (!session?.idToken) {
|
||
return sendJson(res, 401, { error: "unauthorized" });
|
||
}
|
||
|
||
try {
|
||
const claims = await validateIdToken(session.idToken);
|
||
return sendJson(res, 200, claims);
|
||
} catch (error) {
|
||
return sendJson(res, 401, { error: error.message || "invalid_token" }, {
|
||
"Set-Cookie": clearCookie("mm_session")
|
||
});
|
||
}
|
||
}
|
||
|
||
async function logout(req, res) {
|
||
const session = getSession(req);
|
||
await closeZitadelSession(session);
|
||
redirect(res, "/login", {
|
||
"Set-Cookie": [clearCookie("mm_session"), clearCookie("mm_flash"), clearCookie("mm_auth")]
|
||
});
|
||
}
|
||
|
||
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 sendJson(res, status, value, headers = {}) {
|
||
res.writeHead(status, { "Content-Type": "application/json; charset=utf-8", ...headers });
|
||
res.end(JSON.stringify(value));
|
||
}
|
||
|
||
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") {
|
||
if (getSession(req)) return redirect(res, "/dashboard");
|
||
|
||
const authRequest = url.searchParams.get("authRequest") || "";
|
||
if (!authRequest) {
|
||
try {
|
||
const auth = await createAuthRequest();
|
||
const authCookie = makeCookie("mm_auth", makeSigned({
|
||
verifier: auth.verifier,
|
||
stateValue: auth.stateValue
|
||
}), 300);
|
||
|
||
if (getFlash(req) === "registered") {
|
||
return loginPage(req, res, `<div class="alert ok">Аккаунт создан. Теперь можно войти.</div>`, {
|
||
"Set-Cookie": [authCookie, clearCookie("mm_flash")]
|
||
}, auth.authRequest);
|
||
}
|
||
|
||
return loginPage(req, res, "", {
|
||
"Set-Cookie": authCookie
|
||
}, auth.authRequest);
|
||
} catch (error) {
|
||
return loginPage(req, res, `<div class="alert error">${escapeHtml(error.message || "Не удалось подготовить вход.")}</div>`);
|
||
}
|
||
}
|
||
|
||
if (getFlash(req) === "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 === "/auth/callback") return authCallback(req, res);
|
||
if (req.method === "GET" && url.pathname === "/register") {
|
||
if (getSession(req)) return redirect(res, "/dashboard");
|
||
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") {
|
||
return sendJson(res, 200, { ok: true });
|
||
}
|
||
|
||
redirect(res, "/login");
|
||
}).listen(PORT, () => {
|
||
syncZitadelConfig().catch(error => debug("startup setup failed", error.message));
|
||
console.log(`Million Miles ZITADEL POC on http://localhost:${PORT}`);
|
||
});
|