commit 51495deece1a014179fa7f9d44996a9d636a6e7d Author: Local User Date: Fri May 1 18:55:17 2026 +0300 Authentik auth diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..f1ea8aa --- /dev/null +++ b/.env.example @@ -0,0 +1,26 @@ +AUTHENTIK_IMAGE=docker.io/authentik/server +AUTHENTIK_TAG=2026.2.2 + +PG_DB=authentik +PG_USER=authentik +PG_PASS=change-me-postgres-password + +AUTHENTIK_SECRET_KEY=change-me-authentik-secret-key +AUTHENTIK_BOOTSTRAP_EMAIL=admin@example.local +AUTHENTIK_BOOTSTRAP_PASSWORD=change-me-admin-password +AUTHENTIK_BOOTSTRAP_TOKEN=change-me-bootstrap-token + +APP_URL=http://localhost:3000 +SESSION_SECRET=change-me-session-secret + +AUTHENTIK_PUBLIC_URL=http://localhost:9000 +AUTHENTIK_INTERNAL_URL=http://host.docker.internal:9000 +AUTHENTIK_API_TOKEN=change-me-api-token + +AUTHENTIK_LOGIN_FLOW=mm-custom-authentication-flow + +OIDC_CLIENT_ID=mm-custom-frontend +OIDC_CLIENT_SECRET=change-me-oidc-client-secret +OIDC_ISSUER=http://localhost:9000/application/o/mm-custom-frontend/ +OIDC_REDIRECT_URI=http://localhost:3000/auth/callback +AUTH_DEBUG=false diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..0d2c726 --- /dev/null +++ b/.gitignore @@ -0,0 +1,16 @@ +.env +.env.* +!.env.example +authentik-data/ +frontend/node_modules/ +node_modules/ +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +.DS_Store +Thumbs.db +.idea/ +.vscode/ + diff --git a/README.md b/README.md new file mode 100644 index 0000000..fd17b21 --- /dev/null +++ b/README.md @@ -0,0 +1,130 @@ +# Million Miles Auth + +Локальный POC с `Authentik` и кастомным frontend: + +- регистрация пользователя на своей форме +- вход пользователя на своей форме +- Authentik используется как backend авторизации +- после входа frontend показывает данные из `id_token` + +## Что в проекте + +- `docker-compose.yml` поднимает `postgresql`, `redis`, `authentik`, `authentik-worker` и `frontend` +- `authentik/blueprints/mm-custom-authentication-flow.yaml` создаёт flow, provider и application +- `frontend/server.js` содержит UI и логику регистрации/входа + +## Порты + +- frontend: `http://localhost:3000` +- Authentik admin: `http://localhost:9000` + +## Быстрый старт + + +```bash +docker compose up --build +``` + +Если нужно полностью пересобрать проект с чистыми данными: + +```bash +docker compose down -v +docker compose up --build +``` + +Первый запуск Authentik может занять несколько минут. Нужно дождаться, пока `authentik-worker` применит blueprint. + +## Удобный локальный `.env` для теста + +Для быстрого ручного тестирования можно использовать такие локальные значения в `.env`: + +```env +AUTHENTIK_IMAGE=docker.io/authentik/server +AUTHENTIK_TAG=2026.2.2 + +PG_DB=authentik +PG_USER=authentik +PG_PASS=authentik + +AUTHENTIK_SECRET_KEY=change-me-local-secret +AUTHENTIK_BOOTSTRAP_EMAIL=admin@millionmiles.local +AUTHENTIK_BOOTSTRAP_PASSWORD=admin123456 +AUTHENTIK_BOOTSTRAP_TOKEN=local-bootstrap-token-change-me + +APP_URL=http://localhost:3000 +SESSION_SECRET=local-session-secret-change-me + +AUTHENTIK_PUBLIC_URL=http://localhost:9000 +AUTHENTIK_INTERNAL_URL=http://host.docker.internal:9000 +AUTHENTIK_API_TOKEN=local-bootstrap-token-change-me + +AUTHENTIK_LOGIN_FLOW=mm-custom-authentication-flow + +OIDC_CLIENT_ID=mm-custom-frontend +OIDC_CLIENT_SECRET=mm-custom-secret +OIDC_ISSUER=http://localhost:9000/application/o/mm-custom-frontend/ +OIDC_REDIRECT_URI=http://localhost:3000/auth/callback +AUTH_DEBUG=false +``` + +## Как зайти в админку Authentik + + +```txt +http://localhost:9000 +``` + +Логин администратора берётся из `.env`: + +- email: значение `AUTHENTIK_BOOTSTRAP_EMAIL` +- password: значение `AUTHENTIK_BOOTSTRAP_PASSWORD` + +Или + +```txt +admin@millionmiles.local +admin123456 +``` + +## Что создаётся автоматически + +Blueprint создаёт: + +```txt +Flow: Million Miles Custom Authentication +Slug: mm-custom-authentication-flow + +OAuth2/OIDC Provider: Million Miles Custom Frontend +Application slug: mm-custom-frontend +Client ID: mm-custom-frontend +Client Secret: mm-custom-secret +Redirect URI: http://localhost:3000/auth/callback +``` + +## Как быстро всё протестировать + +1. Открой `http://localhost:3000/register` +2. Зарегистрируй нового пользователя +3. После регистрации тебя перекинет на `/login` +4. Войди с только что созданными `email/password` +5. После успешного входа откроется `/dashboard` +6. На dashboard увидишь данные пользователя из JWT `id_token` + +## Что проверить в админке + + +```txt +Applications -> Applications -> Million Miles +Applications -> Providers -> Million Miles Custom Frontend +Flows and Stages -> Flows -> Million Miles Custom Authentication +Directory -> Users +``` + +## Если OIDC provider ещё не применился + +Если frontend пишет, что провайдер ещё не готов, проверь worker: + +```bash +docker compose logs -f authentik-worker +``` + diff --git a/authentik/blueprints/mm-custom-authentication-flow.yaml b/authentik/blueprints/mm-custom-authentication-flow.yaml new file mode 100644 index 0000000..c967f4f --- /dev/null +++ b/authentik/blueprints/mm-custom-authentication-flow.yaml @@ -0,0 +1,100 @@ +version: 1 +metadata: + name: Million Miles Authentik POC +entries: + - model: authentik_flows.flow + id: mm-custom-authentication-flow + identifiers: + slug: mm-custom-authentication-flow + attrs: + name: Million Miles Custom Authentication + title: Million Miles + designation: authentication + authentication: none + layout: stacked + denied_action: message_continue + + - model: authentik_stages_password.passwordstage + id: mm-custom-authentication-password + identifiers: + name: mm-custom-authentication-password + attrs: + backends: + - authentik.core.auth.InbuiltBackend + failed_attempts_before_cancel: 5 + allow_show_password: true + + - model: authentik_stages_identification.identificationstage + id: mm-custom-authentication-identification + identifiers: + name: mm-custom-authentication-identification + attrs: + user_fields: + - email + - username + case_insensitive_matching: true + show_matched_user: false + password_stage: !KeyOf mm-custom-authentication-password + + - model: authentik_stages_user_login.userloginstage + id: mm-custom-authentication-login + identifiers: + name: mm-custom-authentication-login + attrs: + session_duration: hours=8 + + - model: authentik_flows.flowstagebinding + identifiers: + target: !KeyOf mm-custom-authentication-flow + stage: !KeyOf mm-custom-authentication-identification + order: 10 + attrs: + re_evaluate_policies: true + invalid_response_action: retry + policy_engine_mode: all + + - model: authentik_flows.flowstagebinding + identifiers: + target: !KeyOf mm-custom-authentication-flow + stage: !KeyOf mm-custom-authentication-login + order: 20 + attrs: + re_evaluate_policies: true + invalid_response_action: retry + policy_engine_mode: all + + - model: authentik_providers_oauth2.oauth2provider + id: mm-custom-frontend-provider + identifiers: + name: Million Miles Custom Frontend + attrs: + name: Million Miles Custom Frontend + authorization_flow: !Find [authentik_flows.flow, [slug, default-provider-authorization-implicit-consent]] + invalidation_flow: !Find [authentik_flows.flow, [slug, default-provider-invalidation-flow]] + client_type: confidential + client_id: mm-custom-frontend + client_secret: mm-custom-secret + access_code_validity: minutes=10 + access_token_validity: minutes=5 + refresh_token_validity: days=30 + include_claims_in_id_token: true + issuer_mode: per_provider + sub_mode: hashed_user_id + signing_key: !Find [authentik_crypto.certificatekeypair, [name, authentik Self-signed Certificate]] + redirect_uris: + - url: http://localhost:3000/auth/callback + matching_mode: strict + property_mappings: + - !Find [authentik_providers_oauth2.scopemapping, [scope_name, openid]] + - !Find [authentik_providers_oauth2.scopemapping, [scope_name, email]] + - !Find [authentik_providers_oauth2.scopemapping, [scope_name, profile]] + + - model: authentik_core.application + id: mm-custom-frontend-application + identifiers: + slug: mm-custom-frontend + attrs: + name: Million Miles + slug: mm-custom-frontend + provider: !KeyOf mm-custom-frontend-provider + policy_engine_mode: any diff --git a/custom-templates/.gitkeep b/custom-templates/.gitkeep new file mode 100644 index 0000000..2d3e562 --- /dev/null +++ b/custom-templates/.gitkeep @@ -0,0 +1 @@ +Place custom Authentik templates here. diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..52bb4f1 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,111 @@ +services: + postgresql: + image: docker.io/library/postgres:16-alpine + restart: unless-stopped + environment: + POSTGRES_DB: ${PG_DB:-authentik} + POSTGRES_USER: ${PG_USER:-authentik} + POSTGRES_PASSWORD: ${PG_PASS:-authentik} + healthcheck: + test: ["CMD-SHELL", "pg_isready -d $${POSTGRES_DB} -U $${POSTGRES_USER}"] + interval: 10s + timeout: 5s + retries: 12 + start_period: 20s + volumes: + - database:/var/lib/postgresql/data + + redis: + image: docker.io/library/redis:7-alpine + restart: unless-stopped + command: redis-server --save "" --appendonly no + healthcheck: + test: ["CMD-SHELL", "redis-cli ping | grep PONG"] + interval: 10s + timeout: 3s + retries: 12 + start_period: 10s + + authentik: + image: ${AUTHENTIK_IMAGE:-docker.io/authentik/server}:${AUTHENTIK_TAG:-2026.2.2} + restart: unless-stopped + command: server + environment: + AUTHENTIK_SECRET_KEY: ${AUTHENTIK_SECRET_KEY} + AUTHENTIK_POSTGRESQL__HOST: postgresql + AUTHENTIK_POSTGRESQL__NAME: ${PG_DB:-authentik} + AUTHENTIK_POSTGRESQL__USER: ${PG_USER:-authentik} + AUTHENTIK_POSTGRESQL__PASSWORD: ${PG_PASS:-authentik} + AUTHENTIK_REDIS__HOST: redis + AUTHENTIK_BOOTSTRAP_EMAIL: ${AUTHENTIK_BOOTSTRAP_EMAIL} + AUTHENTIK_BOOTSTRAP_PASSWORD: ${AUTHENTIK_BOOTSTRAP_PASSWORD} + AUTHENTIK_BOOTSTRAP_TOKEN: ${AUTHENTIK_BOOTSTRAP_TOKEN} + AUTHENTIK_ERROR_REPORTING__ENABLED: "false" + AUTHENTIK_LOG_LEVEL: info + ports: + - "127.0.0.1:9000:9000" + depends_on: + postgresql: + condition: service_healthy + redis: + condition: service_healthy + volumes: + - ./authentik-data:/data + - ./custom-templates:/templates + - ./authentik/blueprints:/blueprints/custom + + authentik-worker: + image: ${AUTHENTIK_IMAGE:-docker.io/authentik/server}:${AUTHENTIK_TAG:-2026.2.2} + restart: unless-stopped + command: worker + user: root + environment: + AUTHENTIK_SECRET_KEY: ${AUTHENTIK_SECRET_KEY} + AUTHENTIK_POSTGRESQL__HOST: postgresql + AUTHENTIK_POSTGRESQL__NAME: ${PG_DB:-authentik} + AUTHENTIK_POSTGRESQL__USER: ${PG_USER:-authentik} + AUTHENTIK_POSTGRESQL__PASSWORD: ${PG_PASS:-authentik} + AUTHENTIK_REDIS__HOST: redis + AUTHENTIK_BOOTSTRAP_EMAIL: ${AUTHENTIK_BOOTSTRAP_EMAIL} + AUTHENTIK_BOOTSTRAP_PASSWORD: ${AUTHENTIK_BOOTSTRAP_PASSWORD} + AUTHENTIK_BOOTSTRAP_TOKEN: ${AUTHENTIK_BOOTSTRAP_TOKEN} + AUTHENTIK_ERROR_REPORTING__ENABLED: "false" + AUTHENTIK_LOG_LEVEL: info + depends_on: + postgresql: + condition: service_healthy + redis: + condition: service_healthy + volumes: + - /var/run/docker.sock:/var/run/docker.sock + - ./authentik-data:/data + - ./custom-templates:/templates + - ./authentik/blueprints:/blueprints/custom + + frontend: + build: ./frontend + restart: unless-stopped + environment: + APP_URL: ${APP_URL} + SESSION_SECRET: ${SESSION_SECRET} + AUTHENTIK_PUBLIC_URL: ${AUTHENTIK_PUBLIC_URL} + AUTHENTIK_INTERNAL_URL: ${AUTHENTIK_INTERNAL_URL} + AUTHENTIK_API_TOKEN: ${AUTHENTIK_API_TOKEN} + AUTHENTIK_LOGIN_FLOW: ${AUTHENTIK_LOGIN_FLOW} + OIDC_CLIENT_ID: ${OIDC_CLIENT_ID} + OIDC_CLIENT_SECRET: ${OIDC_CLIENT_SECRET} + OIDC_ISSUER: ${OIDC_ISSUER} + OIDC_REDIRECT_URI: ${OIDC_REDIRECT_URI} + AUTH_DEBUG: ${AUTH_DEBUG} + ports: + - "127.0.0.1:3000:3000" + depends_on: + authentik: + condition: service_started + authentik-worker: + condition: service_started + extra_hosts: + - "host.docker.internal:host-gateway" + +volumes: + database: diff --git a/frontend/.dockerignore b/frontend/.dockerignore new file mode 100644 index 0000000..e5f06a4 --- /dev/null +++ b/frontend/.dockerignore @@ -0,0 +1,8 @@ +.git +.gitignore +.env +node_modules +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* diff --git a/frontend/Dockerfile b/frontend/Dockerfile new file mode 100644 index 0000000..d1f759e --- /dev/null +++ b/frontend/Dockerfile @@ -0,0 +1,7 @@ +FROM node:20-alpine +WORKDIR /app +ENV NODE_ENV=production +COPY server.js ./server.js +USER node +EXPOSE 3000 +CMD ["node", "server.js"] diff --git a/frontend/server.js b/frontend/server.js new file mode 100644 index 0000000..a65bbea --- /dev/null +++ b/frontend/server.js @@ -0,0 +1,737 @@ +const http = require("http"); +const crypto = require("crypto"); +const { URLSearchParams } = require("url"); + +const PORT = 3000; + +const env = { + APP_URL: process.env.APP_URL || "http://localhost:3000", + SESSION_SECRET: process.env.SESSION_SECRET || "change-me-session-secret", + AUTHENTIK_PUBLIC_URL: process.env.AUTHENTIK_PUBLIC_URL || "http://localhost:9000", + AUTHENTIK_INTERNAL_URL: process.env.AUTHENTIK_INTERNAL_URL || "http://host.docker.internal:9000", + AUTHENTIK_API_TOKEN: process.env.AUTHENTIK_API_TOKEN || "change-me-api-token", + AUTHENTIK_LOGIN_FLOW: process.env.AUTHENTIK_LOGIN_FLOW || "mm-custom-authentication-flow", + OIDC_CLIENT_ID: process.env.OIDC_CLIENT_ID || "mm-custom-frontend", + OIDC_CLIENT_SECRET: process.env.OIDC_CLIENT_SECRET || "change-me-oidc-client-secret", + OIDC_ISSUER: process.env.OIDC_ISSUER || "http://localhost:9000/application/o/mm-custom-frontend/", + OIDC_REDIRECT_URI: process.env.OIDC_REDIRECT_URI || "http://localhost:3000/auth/callback", + AUTH_DEBUG: process.env.AUTH_DEBUG === "true" +}; + +function debug(...args) { + if (env.AUTH_DEBUG) console.log("[auth-debug]", ...args); +} + +function escapeHtml(value) { + return String(value ?? "").replace(/[&<>"']/g, (s) => ({ + "&": "&", "<": "<", ">": ">", '"': """, "'": "'" + }[s])); +} + +function b64url(input) { + return Buffer.from(input).toString("base64url"); +} + +function randomString(bytes = 32) { + return crypto.randomBytes(bytes).toString("base64url"); +} + +function sha256base64url(value) { + return crypto.createHash("sha256").update(value).digest("base64url"); +} + +function sign(value) { + return crypto.createHmac("sha256", env.SESSION_SECRET).update(value).digest("base64url"); +} + +function makeSigned(value) { + const payload = b64url(JSON.stringify(value)); + return `${payload}.${sign(payload)}`; +} + +function readSigned(value) { + if (!value || !value.includes(".")) return null; + const [payload, mac] = value.split("."); + if (sign(payload) !== mac) return null; + try { + return JSON.parse(Buffer.from(payload, "base64url").toString("utf8")); + } catch { + return null; + } +} + +function makeCookie(name, value, maxAge = 3600) { + return `${name}=${value}; Path=/; HttpOnly; SameSite=Lax; Max-Age=${maxAge}`; +} + +function clearCookie(name) { + return `${name}=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0`; +} + +function parseCookies(req) { + const header = req.headers.cookie || ""; + return Object.fromEntries( + header + .split(";") + .map(v => v.trim()) + .filter(Boolean) + .map(v => { + const i = v.indexOf("="); + return [v.slice(0, i), v.slice(i + 1)]; + }) + ); +} + +function collectSetCookies(headers) { + if (typeof headers.getSetCookie === "function") return headers.getSetCookie(); + const value = headers.get("set-cookie"); + return value ? [value] : []; +} + +function mergeCookies(jar, setCookies) { + for (const raw of setCookies) { + const pair = String(raw).split(";")[0]; + const idx = pair.indexOf("="); + if (idx > 0) jar[pair.slice(0, idx)] = pair.slice(idx + 1); + } +} + +function cookieHeader(jar) { + return Object.entries(jar).map(([k, v]) => `${k}=${v}`).join("; "); +} + +async function fetchWithJar(url, options = {}, jar = {}) { + const headers = { ...(options.headers || {}) }; + const cookie = cookieHeader(jar); + if (cookie) headers.Cookie = cookie; + + const res = await fetch(url, { + ...options, + headers, + redirect: "manual" + }); + + mergeCookies(jar, collectSetCookies(res.headers)); + return res; +} + +async function followRedirectsWithJar(res, jar, limit = 8) { + let current = res; + + for (let i = 0; i < limit; i++) { + const location = current.headers.get("location"); + if (!location || current.status < 300 || current.status >= 400) return current; + + current = await fetchWithJar(new URL(location, env.AUTHENTIK_INTERNAL_URL).toString(), { + method: "GET", + headers: { Accept: "text/html,application/xhtml+xml,application/json" } + }, jar); + } + + return current; +} + +async function safeJson(res) { + const text = await res.text(); + if (!text) return {}; + try { + return JSON.parse(text); + } catch { + return { raw: text }; + } +} + +function decodeJwt(token) { + try { + const [, payload] = token.split("."); + return JSON.parse(Buffer.from(payload, "base64url").toString("utf8")); + } catch { + return {}; + } +} + +function getSession(req) { + return readSigned(parseCookies(req).mm_session); +} + +async function parseBody(req) { + let data = ""; + for await (const chunk of req) data += chunk; + return Object.fromEntries(new URLSearchParams(data)); +} + +async function authentikReady() { + try { + const res = await fetch(`${env.AUTHENTIK_INTERNAL_URL}/api/v3/root/config/`, { + headers: { Authorization: `Bearer ${env.AUTHENTIK_API_TOKEN}` } + }); + return res.status < 500; + } catch { + return false; + } +} + +async function apiGet(path) { + const res = await fetch(`${env.AUTHENTIK_INTERNAL_URL}${path}`, { + headers: { Authorization: `Bearer ${env.AUTHENTIK_API_TOKEN}` } + }); + if (!res.ok) throw new Error(`API GET ${path} failed: ${res.status}`); + return await res.json(); +} + +async function apiPost(path, body) { + const res = await fetch(`${env.AUTHENTIK_INTERNAL_URL}${path}`, { + method: "POST", + headers: { + Authorization: `Bearer ${env.AUTHENTIK_API_TOKEN}`, + "Content-Type": "application/json" + }, + body: JSON.stringify(body) + }); + const text = await res.text(); + if (!res.ok) { + debug("apiPost failed", path, res.status, text); + throw new Error(`API POST ${path} failed: ${res.status}`); + } + return text ? JSON.parse(text) : {}; +} + +async function findAuthentikUser(email) { + const res = await fetch(`${env.AUTHENTIK_INTERNAL_URL}/api/v3/core/users/?search=${encodeURIComponent(email)}`, { + headers: { Authorization: `Bearer ${env.AUTHENTIK_API_TOKEN}` } + }); + + if (!res.ok) return null; + + const data = await res.json(); + const users = data.results || data; + + return Array.isArray(users) + ? users.find(u => + String(u.email || "").toLowerCase() === String(email).toLowerCase() || + String(u.username || "").toLowerCase() === String(email).toLowerCase() + ) + : null; +} + +async function createAuthentikUser({ fullName, email, password }) { + const existing = await findAuthentikUser(email); + if (existing) throw new Error("Пользователь с таким email уже существует."); + + const createRes = await fetch(`${env.AUTHENTIK_INTERNAL_URL}/api/v3/core/users/`, { + method: "POST", + headers: { + Authorization: `Bearer ${env.AUTHENTIK_API_TOKEN}`, + "Content-Type": "application/json" + }, + body: JSON.stringify({ + username: String(email).toLowerCase(), + name: fullName, + email, + is_active: true, + type: "internal" + }) + }); + + const createText = await createRes.text(); + if (!createRes.ok) { + debug("create user failed", createRes.status, createText); + throw new Error("Не удалось создать аккаунт. Попробуйте позже."); + } + + const user = JSON.parse(createText); + const userId = user.pk || user.id; + + const passRes = await fetch(`${env.AUTHENTIK_INTERNAL_URL}/api/v3/core/users/${userId}/set_password/`, { + method: "POST", + headers: { + Authorization: `Bearer ${env.AUTHENTIK_API_TOKEN}`, + "Content-Type": "application/json" + }, + body: JSON.stringify({ password }) + }); + + const passText = await passRes.text(); + if (!passRes.ok) { + debug("set password failed", passRes.status, passText); + throw new Error("Аккаунт создан, но пароль не был сохранён."); + } + + return user; +} + +async function discoverOidc() { + const publicDiscovery = `${env.OIDC_ISSUER.replace(/\/$/, "")}/.well-known/openid-configuration`; + const internalDiscovery = publicDiscovery.replace(env.AUTHENTIK_PUBLIC_URL, env.AUTHENTIK_INTERNAL_URL); + const res = await fetch(internalDiscovery); + if (!res.ok) throw new Error("OIDC provider ещё не применился. Подождите 1-2 минуты или запустите docker compose down -v && docker compose up --build."); + const config = await res.json(); + + return { + authorization_endpoint: config.authorization_endpoint.replace(env.AUTHENTIK_PUBLIC_URL, env.AUTHENTIK_INTERNAL_URL), + public_authorization_endpoint: config.authorization_endpoint, + token_endpoint: config.token_endpoint.replace(env.AUTHENTIK_PUBLIC_URL, env.AUTHENTIK_INTERNAL_URL) + }; +} + +// Best-effort OIDC setup. Authentik APIs can differ by version, so README has manual fallback. + + +async function ensureOidcProvider() { + if (ensureOidcProvider.done) return; + try { + await discoverOidc(); + ensureOidcProvider.done = true; + } catch (e) { + debug("OIDC provider not ready yet", e.message); + } +} +ensureOidcProvider.done = false; + +function isSuccessChallenge(res, challenge) { + const component = challenge.component || ""; + return ( + res.status === 302 || + component === "xak-flow-redirect" || + component === "ak-flow-redirect" || + component === "ak-stage-redirect" || + challenge.type === "redirect" || + Boolean(challenge.redirect) + ); +} + +async function authentikPasswordLogin(email, password) { + const jar = {}; + const flow = env.AUTHENTIK_LOGIN_FLOW; + const instanceUrl = `${env.AUTHENTIK_INTERNAL_URL}/api/v3/flows/instances/${flow}/execute/?next=/`; + const executorUrl = `${env.AUTHENTIK_INTERNAL_URL}/api/v3/flows/executor/${flow}/?query=${encodeURIComponent("next=/")}`; + + await fetchWithJar(instanceUrl, { + method: "GET", + headers: { Accept: "application/json" } + }, jar); + + async function getChallenge() { + const res = await fetchWithJar(executorUrl, { + method: "GET", + headers: { Accept: "application/json" } + }, jar); + return { res, challenge: await safeJson(res) }; + } + + async function postChallenge(component, payload = {}) { + const res = await fetchWithJar(executorUrl, { + method: "POST", + headers: { "Content-Type": "application/json", Accept: "application/json" }, + body: JSON.stringify({ component, ...payload }) + }, jar); + return { res, challenge: await safeJson(res) }; + } + + let { res, challenge } = await getChallenge(); + if (!res.ok) throw new Error("Сервис авторизации запускается. Попробуйте позже."); + + let passwordChecked = false; + + for (let step = 0; step < 12; step++) { + const component = challenge.component || ""; + debug("auth step", step, component, res.status, challenge); + + if (component === "ak-stage-access-denied") { + throw new Error("Неверный email или пароль."); + } + + if (isSuccessChallenge(res, challenge)) { + if (passwordChecked) { + await followRedirectsWithJar(res, jar); + return jar; + } + throw new Error("OIDC provider ещё не применился. Подождите 1-2 минуты или запустите docker compose down -v && docker compose up --build."); + } + + if (component === "ak-stage-identification") { + const payload = { uid_field: email }; + const hasPasswordOnIdentification = + challenge.password_fields === true || + (Array.isArray(challenge.password_fields) && challenge.password_fields.length > 0); + + if (hasPasswordOnIdentification) { + payload.password = password; + passwordChecked = true; + } + + ({ res, challenge } = await postChallenge("ak-stage-identification", payload)); + continue; + } + + if (component === "ak-stage-password") { + passwordChecked = true; + ({ res, challenge } = await postChallenge("ak-stage-password", { password })); + continue; + } + + if (component === "ak-stage-user-login") { + if (!passwordChecked) throw new Error("Authentik flow не запросил пароль."); + ({ res, challenge } = await postChallenge("ak-stage-user-login")); + continue; + } + + if (component === "ak-stage-consent") { + if (!passwordChecked) throw new Error("Authentik flow не запросил пароль."); + ({ res, challenge } = await postChallenge("ak-stage-consent", { consent: true })); + continue; + } + + if (component === "ak-stage-authenticator-validate") { + throw new Error("Для пользователя требуется дополнительная проверка."); + } + + if (passwordChecked && res.ok && !component) { + await followRedirectsWithJar(res, jar); + return jar; + } + + throw new Error("Не удалось завершить вход. Проверь custom flow Authentik."); + } + + throw new Error("Неверный email или пароль."); +} + +async function getAuthentikIdTokenWithSession(jar) { + const config = await discoverOidc(); + + const state = randomString(24); + const verifier = randomString(48); + const challenge = sha256base64url(verifier); + + const authorizeUrl = `${config.authorization_endpoint}?${new URLSearchParams({ + response_type: "code", + client_id: env.OIDC_CLIENT_ID, + redirect_uri: env.OIDC_REDIRECT_URI, + scope: "openid profile email", + state, + code_challenge: challenge, + code_challenge_method: "S256", + prompt: "none" + }).toString()}`; + + const authRes = await fetchWithJar(authorizeUrl, { + method: "GET", + headers: { Accept: "text/html,application/xhtml+xml,application/json" } + }, jar); + + const location = authRes.headers.get("location"); + debug("authorize status", authRes.status, "location", location); + + if (!location) { + throw new Error("Authentik не вернул authorization code. Проверь OIDC Provider."); + } + + const redirectUrl = new URL(location, env.APP_URL); + const code = redirectUrl.searchParams.get("code"); + const returnedState = redirectUrl.searchParams.get("state"); + + if (!code || returnedState !== state) { + throw new Error("Не удалось получить authorization code от Authentik."); + } + + const tokenRes = await fetch(config.token_endpoint, { + method: "POST", + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + body: new URLSearchParams({ + grant_type: "authorization_code", + code, + redirect_uri: env.OIDC_REDIRECT_URI, + client_id: env.OIDC_CLIENT_ID, + client_secret: env.OIDC_CLIENT_SECRET, + code_verifier: verifier + }) + }); + + const tokens = await tokenRes.json(); + debug("token response", tokenRes.status, tokens); + + if (!tokenRes.ok || !tokens.id_token) { + throw new Error("Не удалось получить JWT от Authentik."); + } + + return tokens.id_token; +} + +function page(title, body) { + return ` + + + + +${escapeHtml(title)} + + +${body} +`; +} + +function authLayout({ title, subtitle, heading, text, form }) { + return `
+
+
+ +

${heading}

${text}

+
+ +
+

${title}

${subtitle}

${form}
+
+
`; +} + +function loginPage(req, res, msg = "", headers = {}) { + const form = `
+ ${msg} + + + + + + +
`; + + send(res, 200, page("Вход | Million Miles", authLayout({ + title: "Вход", + subtitle: "Войдите в личный кабинет Million Miles.", + heading: `Добро пожаловать`, + text: "Закрытый доступ для клиентов премиального сервиса.", + form + })), headers); +} + +function registerPage(req, res, msg = "") { + const form = `
+ ${msg} + + + + + + + + +
`; + + send(res, 200, page("Регистрация | Million Miles", authLayout({ + title: "Регистрация", + subtitle: "Создайте личный кабинет Million Miles.", + heading: `Персональный доступ Million Miles`, + 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, `
${escapeHtml(e.message || "Не удалось создать аккаунт.")}
`); + } +} + +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, `
${escapeHtml(e.message || "Неверный email или пароль.")}
`); + } +} + +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 = `
+
+ + +
+
${escapeHtml(initials(name, email))}
+

${escapeHtml(name)}

Данные ниже получены из JWT Authentik

+
+ +
+

JWT

+
+
sub${escapeHtml(claims.sub || "—")}
+
name${escapeHtml(claims.name || "—")}
+
email${escapeHtml(claims.email || "—")}
+
username${escapeHtml(claims.preferred_username || claims.email || "—")}
+
iat${escapeHtml(iat)}
+
exp${escapeHtml(exp)}
+
iss${escapeHtml(claims.iss || "—")}
+
aud${escapeHtml(Array.isArray(claims.aud) ? claims.aud.join(", ") : claims.aud || "—")}
+
+
+ full id_token + ${escapeHtml(idToken)} +
+
+
+
`; + + 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, `
Аккаунт создан. Теперь можно войти.
`, { + "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}`); +});