/
nasya
/
SafeDrop
Обзор
Документация
Войти
/
nasya
/
SafeDrop
Код
Запросы
0
Задачи
Вики
Пакеты
0
Релизы
0
CI/CD
Аналитика
Безопасность
master
apps/auth-ui/src/app.ts
1 716 строк
58 KB
nasya
Initial commit
24 май 2026, 16:12
24 май 2026, 16:12
16bc032
Код
Авторство
О чём код?
import { createPrismaClient, ensureUserProfile, type PrismaClient } from "@safedrop/db"; import { healthResponseJsonSchema, optionalEnv } from "@safedrop/shared"; import Fastify, { type FastifyBaseLogger, type FastifyInstance, type FastifyReply, type FastifyRequest } from "fastify"; export type AuthUiOptions = { authPublicUrl?: string; fetchImpl?: typeof fetch; hydraAdminUrl?: string; hydraClientId?: string; logger?: boolean | FastifyBaseLogger; kratosFlowFetcher?: KratosFlowFetcher; kratosInternalUrl?: string; hydraPublicUrl?: string; profileSync?: ProfileSync; webPublicUrl?: string; }; type FlowKind = "login" | "registration"; type KratosFlowFetcher = (kind: FlowKind, flowId: string, cookieHeader?: string) => Promise<KratosFlow>; type KratosUiMessage = { id?: number; text?: string; type?: "error" | "info" | "success" | string; }; type KratosUiNode = { group?: string; attributes?: { disabled?: boolean; href?: string; name?: string; node_type?: string; required?: boolean; type?: string; value?: unknown; }; meta?: { label?: { text?: string; }; }; messages?: KratosUiMessage[]; }; type KratosFlow = { id: string; return_to?: string; ui: { action: string; method: string; messages?: KratosUiMessage[]; nodes: KratosUiNode[]; }; }; type KratosSession = { id?: string; identity: { id: string; traits?: { email?: string; name?: string | { first?: string; last?: string }; }; }; }; type ProfileSync = { close?: () => Promise<void>; sync: (session: KratosSession) => Promise<void>; }; type HydraRedirect = { redirect_to: string; }; type KratosLogoutFlow = { logout_url: string; }; type HydraConsentRequest = { client?: { client_id?: string; }; requested_access_token_audience?: string[]; requested_scope?: string[]; }; function page(title: string, body: string) { return `<!doctype html> <html lang="ru"> <head> <meta charset="utf-8" /> <meta name="viewport" content="width=device-width, initial-scale=1" /> <title>${title}</title> <style> body { font-family: Inter, system-ui, sans-serif; margin: 0; background: #f7f8fb; color: #172033; } main { max-width: 480px; margin: 56px auto; padding: 32px; background: white; border: 1px solid #dfe3ec; border-radius: 8px; box-shadow: 0 18px 48px rgba(23, 32, 51, 0.08); } h1 { margin: 0; font-size: 28px; line-height: 1.15; text-align: center; } p { margin: 0; } form { display: grid; gap: 16px; margin-top: 24px; } label { display: grid; gap: 6px; font-weight: 600; } input { border: 1px solid #c7cedd; border-radius: 6px; font: inherit; padding: 10px 12px; } a, button { color: #1457d9; } button { border: 1px solid #1457d9; border-radius: 6px; background: #ffffff; cursor: pointer; font: inherit; padding: 10px 14px; width: fit-content; } .auth-form button[type="submit"] { width: 100%; background: #1457d9; color: white; font-weight: 700; } .provider-divider { display: flex; align-items: center; gap: 12px; margin: 24px 0 14px; color: #6c7890; font-size: 12px; text-transform: uppercase; letter-spacing: 0.04em; } .provider-divider::before, .provider-divider::after { content: ""; height: 1px; flex: 1; background: #dfe3ec; } .provider-actions, .provider-form { display: grid; gap: 10px; } .provider-form { margin: 0; } .provider-button { display: flex; width: 100%; min-height: 42px; align-items: center; justify-content: center; gap: 10px; border: 1px solid #c7cedd; border-radius: 6px; background: #ffffff; color: #172033; cursor: pointer; font: inherit; font-weight: 700; text-decoration: none; } .provider-button:hover { border-color: #1457d9; color: #1457d9; } .provider-button[disabled], .provider-button-disabled { border-color: #dfe3ec; background: #f2f4f8; color: #8a94a6; cursor: not-allowed; } .provider-icon { display: inline-flex; width: 22px; height: 22px; flex: 0 0 auto; align-items: center; justify-content: center; border-radius: 999px; font-size: 12px; font-weight: 800; line-height: 1; } .provider-icon-google { border: 1px solid #dfe3ec; background: #ffffff; color: #1a73e8; } .provider-icon-yandex { background: #fc3f1d; color: #ffffff; } .provider-icon-telegram { background: #229ed9; color: #ffffff; } .provider-icon-default { background: #e8edf7; color: #52617a; } .auth-switch { margin-top: 20px; color: #52617a; font-size: 14px; text-align: center; } .auth-switch a { font-weight: 700; } .secondary-form { margin-top: 12px; } .secondary-button { border-color: transparent; padding-left: 0; background: transparent; color: #52617a; } .secondary-button:hover { color: #1457d9; } .flow-messages { display: grid; gap: 8px; margin-top: 18px; } .message { border-radius: 6px; padding: 10px 12px; font-size: 14px; line-height: 1.45; } .message-error { border: 1px solid #ef9a9a; background: #fff5f5; color: #8a1f1f; } .message-info { border: 1px solid #b9c7f7; background: #f3f6ff; color: #1d397d; } .message-success { border: 1px solid #9dd9b6; background: #f0fff6; color: #146339; } .field-messages { display: grid; gap: 4px; margin-top: -2px; font-weight: 400; } .field-messages .message { padding: 0; border: 0; background: transparent; } .error { border: 1px solid #ef9a9a; background: #fff5f5; border-radius: 6px; color: #8a1f1f; padding: 12px; } .actions { display: flex; gap: 12px; flex-wrap: wrap; margin-top: 24px; } </style> </head> <body><main>${body}</main></body> </html>`; } function escapeHtml(value: unknown): string { return String(value ?? "") .replaceAll("&", "&") .replaceAll("<", "<") .replaceAll(">", ">") .replaceAll('"', """) .replaceAll("'", "'"); } function normalizeText(value: unknown): string { return String(value ?? "").trim(); } function translateKratosText(value: unknown): string { const text = normalizeText(value); const normalized = text.toLowerCase(); const dictionary: Record<string, string> = { back: "Назад", continue: "Продолжить", email: "Электронная почта", identifier: "Электронная почта", login: "Войти", name: "Имя", password: "Пароль", register: "Зарегистрироваться", submit: "Продолжить", username: "Имя пользователя", }; if (dictionary[normalized]) { return dictionary[normalized]; } if (normalized.includes("sign in") || normalized.includes("log in")) { return "Войти"; } if (normalized.includes("sign up") || normalized.includes("create account")) { return "Создать аккаунт"; } if (normalized.includes("invalid") && (normalized.includes("password") || normalized.includes("credentials"))) { return "Неверный email или пароль."; } if (normalized.includes("password") && normalized.includes("required")) { return "Введите пароль."; } if ((normalized.includes("email") || normalized.includes("identifier")) && normalized.includes("required")) { return "Введите email."; } return text; } function selfServicePathToPagePath(pathname: string): string { if (pathname === "/self-service/login" || pathname === "/self-service/login/browser") { return "/login"; } if (pathname === "/self-service/registration" || pathname === "/self-service/registration/browser") { return "/registration"; } if (pathname === "/self-service/settings" || pathname === "/self-service/settings/browser") { return "/settings"; } if (pathname.startsWith("/self-service/methods/")) { return pathname.replace(/^\/self-service\/methods\/?/, "/auth/methods/"); } return pathname.replace(/^\/self-service\/?/, "/auth/"); } function rewriteSelfServiceUrl(value: string, authPublicUrl: string): string { const url = new URL(value, authPublicUrl); if (url.searchParams.has("redirect_uri")) { const redirectUri = url.searchParams.get("redirect_uri"); if (redirectUri?.includes("/self-service/")) { url.searchParams.set("redirect_uri", rewriteSelfServiceUrl(redirectUri, authPublicUrl)); } } if (!url.pathname.startsWith("/self-service/")) { return url.toString(); } const pathname = selfServicePathToPagePath(url.pathname); return new URL(`${pathname}${url.search}${url.hash}`, authPublicUrl).toString(); } function renderBooleanAttribute(name: string, enabled?: boolean): string { return enabled ? ` ${name}` : ""; } function messageClass(message: KratosUiMessage): string { if (message.type === "success") { return "message-success"; } if (message.type === "info") { return "message-info"; } return "message-error"; } function renderMessages(messages?: KratosUiMessage[], className = "flow-messages"): string { if (!messages?.length) { return ""; } const renderedMessages = messages .filter((message) => message.text) .map( (message) => `<p class="message ${messageClass(message)}" ${ message.type === "error" || !message.type ? 'role="alert"' : "" }>${escapeHtml(translateKratosText(message.text))}</p>`, ) .join("\n"); return renderedMessages ? `<div class="${className}">${renderedMessages}</div>` : ""; } function renderInputNode(node: KratosUiNode): string { const attributes = node.attributes ?? {}; const inputType = attributes.type ?? "text"; const name = attributes.name; const value = attributes.value === undefined ? "" : ` value="${escapeHtml(attributes.value)}"`; const common = `type="${escapeHtml(inputType)}"${name ? ` name="${escapeHtml(name)}"` : ""}${value}${renderBooleanAttribute( "required", attributes.required, )}${renderBooleanAttribute("disabled", attributes.disabled)}`; if (inputType === "hidden") { return `<input ${common} />`; } if (inputType === "submit") { const label = translateKratosText(node.meta?.label?.text ?? attributes.value ?? "Продолжить"); return `<button ${name ? `name="${escapeHtml(name)}"` : ""}${value} type="submit">${escapeHtml(label)}</button>`; } const label = translateKratosText(node.meta?.label?.text ?? name ?? "Поле"); const messages = renderMessages(node.messages, "field-messages"); return `<label><span>${escapeHtml(label)}</span><input ${common} />${messages}</label>`; } function isOidcNode(node: KratosUiNode): boolean { const attributes = node.attributes ?? {}; return node.group === "oidc" || (attributes.node_type === "a" && Boolean(attributes.href)); } function isAnchorNode(node: KratosUiNode): boolean { const attributes = node.attributes ?? {}; return attributes.node_type === "a" && Boolean(attributes.href); } function isHiddenNode(node: KratosUiNode): boolean { const attributes = node.attributes ?? {}; return attributes.type === "hidden"; } function isBackNode(node: KratosUiNode): boolean { const attributes = node.attributes ?? {}; const text = `${node.meta?.label?.text ?? ""} ${attributes.value ?? ""}`.toLowerCase(); return text.includes("back") || text.includes("назад"); } function isTemporarilyDisabledProvider(node: KratosUiNode): boolean { const attributes = node.attributes ?? {}; const providerText = `${node.meta?.label?.text ?? ""} ${attributes.value ?? ""} ${attributes.href ?? ""}`.toLowerCase(); return ( providerText.includes("google") || providerText.includes("yandex") || providerText.includes("яндекс") || providerText.includes("telegram") || providerText.includes("телеграм") ); } function providerKind(node: KratosUiNode): "google" | "telegram" | "yandex" | "default" { const attributes = node.attributes ?? {}; const providerText = `${node.meta?.label?.text ?? ""} ${attributes.value ?? ""} ${attributes.href ?? ""}`.toLowerCase(); if (providerText.includes("google")) { return "google"; } if (providerText.includes("yandex") || providerText.includes("яндекс")) { return "yandex"; } if (providerText.includes("telegram") || providerText.includes("телеграм")) { return "telegram"; } return "default"; } function renderProviderLabel(node: KratosUiNode, label: string): string { const kind = providerKind(node); const iconText: Record<ReturnType<typeof providerKind>, string> = { default: "ID", google: "G", telegram: "T", yandex: "Я", }; return `<span class="provider-icon provider-icon-${kind}" aria-hidden="true">${escapeHtml( iconText[kind], )}</span><span>${escapeHtml(label)}</span>`; } function renderKratosNode(node: KratosUiNode, authPublicUrl: string): string { const attributes = node.attributes ?? {}; if (attributes.node_type === "a" && attributes.href) { const label = translateKratosText(node.meta?.label?.text ?? attributes.href); return `<a href="${escapeHtml(rewriteSelfServiceUrl(attributes.href, authPublicUrl))}">${escapeHtml(label)}</a>`; } return renderInputNode(node); } function renderProviderNode(node: KratosUiNode, authPublicUrl: string): string { const attributes = node.attributes ?? {}; const label = translateKratosText(node.meta?.label?.text ?? attributes.href ?? "Войти через провайдера"); const providerKey = String(attributes.value ?? label).toLowerCase(); if (isTemporarilyDisabledProvider(node)) { return `<button class="provider-button provider-button-disabled" data-provider="${escapeHtml( providerKey, )}" type="button" disabled>${renderProviderLabel(node, label)}</button>`; } if (isAnchorNode(node)) { const href = attributes.href ? rewriteSelfServiceUrl(attributes.href, authPublicUrl) : "#"; return `<a class="provider-button" href="${escapeHtml(href)}">${renderProviderLabel(node, label)}</a>`; } const name = attributes.name ? ` name="${escapeHtml(attributes.name)}"` : ""; const value = attributes.value === undefined ? "" : ` value="${escapeHtml(attributes.value)}"`; return `<button class="provider-button"${name}${value} type="submit">${renderProviderLabel(node, label)}</button>`; } function renderSecondaryActionNode(node: KratosUiNode, authPublicUrl: string): string { const attributes = node.attributes ?? {}; if (isAnchorNode(node)) { return renderKratosNode(node, authPublicUrl); } const label = translateKratosText(node.meta?.label?.text ?? attributes.value ?? "Назад"); const name = attributes.name ? ` name="${escapeHtml(attributes.name)}"` : ""; const value = attributes.value === undefined ? "" : ` value="${escapeHtml(attributes.value)}"`; return `<button class="secondary-button"${name}${value} type="submit" formnovalidate>${escapeHtml(label)}</button>`; } function hasRegistrationNameNode(nodes: KratosUiNode[]): boolean { return nodes.some((node) => { const name = node.attributes?.name?.toLowerCase(); return name === "traits.name" || name === "traits[name]" || name === "name"; }); } function renderRegistrationNameFallback(kind: FlowKind, nodes: KratosUiNode[]): string { if (kind !== "registration" || hasRegistrationNameNode(nodes)) { return ""; } return '<label><span>Имя</span><input type="text" name="traits.name" autocomplete="name" /></label>'; } function flowReturnTo(flow: KratosFlow, authPublicUrl: string): string | null { if (!flow.return_to) { return null; } return rewriteSelfServiceUrl(flow.return_to, authPublicUrl); } function switchHref(kind: FlowKind, flow: KratosFlow, authPublicUrl: string): string { const returnTo = flowReturnTo(flow, authPublicUrl); if (kind === "login") { const registrationUrl = new URL("/registration", authPublicUrl); if (returnTo) { registrationUrl.searchParams.set("return_to", returnTo); } return `${registrationUrl.pathname}${registrationUrl.search}`; } if (returnTo) { const url = new URL(returnTo, authPublicUrl); if (url.origin === authPublicUrl && url.pathname === "/login") { return `${url.pathname}${url.search}`; } } return "/login"; } function renderKratosFlow(kind: FlowKind, flow: KratosFlow, authPublicUrl: string): string { const title = kind === "login" ? "Вход" : "Регистрация"; const oidcNodes = flow.ui.nodes.filter(isOidcNode); const secondaryActionNodes = flow.ui.nodes.filter((node) => !isOidcNode(node) && isBackNode(node)); const passwordNodes = flow.ui.nodes.filter((node) => !isOidcNode(node) && !isBackNode(node)); const hiddenNodes = passwordNodes.filter(isHiddenNode).map(renderInputNode).join("\n"); const nodes = passwordNodes.map((node) => renderKratosNode(node, authPublicUrl)).join("\n"); const anchorProviders = oidcNodes .filter(isAnchorNode) .map((node) => renderProviderNode(node, authPublicUrl)) .join("\n"); const submitProviders = oidcNodes.filter((node) => !isAnchorNode(node)); const method = flow.ui.method || "POST"; const action = rewriteSelfServiceUrl(flow.ui.action, authPublicUrl); const submitProviderForm = submitProviders.length ? `<form class="provider-form" action="${escapeHtml(action)}" method="${escapeHtml(method)}"> ${hiddenNodes} ${submitProviders.map((node) => renderProviderNode(node, authPublicUrl)).join("\n")} </form>` : ""; const providers = [anchorProviders, submitProviderForm].filter(Boolean).join("\n"); const secondaryActionBlock = secondaryActionNodes.length ? `<form class="secondary-form" action="${escapeHtml(action)}" method="${escapeHtml(method)}" novalidate> ${hiddenNodes} ${secondaryActionNodes.map((node) => renderSecondaryActionNode(node, authPublicUrl)).join("\n")} </form>` : ""; const registrationNameFallback = renderRegistrationNameFallback(kind, passwordNodes); const flowMessages = renderMessages(flow.ui.messages); const providerBlock = kind === "login" && providers ? `<div class="provider-divider">или</div> <div class="provider-actions" aria-label="Вход через провайдера"> ${providers} </div>` : ""; const switchBlock = kind === "login" ? `<p class="auth-switch">Нет аккаунта? <a href="${escapeHtml( switchHref(kind, flow, authPublicUrl), )}">Создать аккаунт</a></p>` : `<p class="auth-switch">Уже есть аккаунт? <a href="${escapeHtml( switchHref(kind, flow, authPublicUrl), )}">Войти</a></p>`; return page( `SafeDrop: ${title}`, `<h1>${title}</h1> ${flowMessages} <form class="auth-form" action="${escapeHtml(action)}" method="${escapeHtml(method)}"> ${registrationNameFallback} ${nodes} </form> ${secondaryActionBlock} ${providerBlock} ${switchBlock}`, ); } async function fetchKratosFlow(kratosInternalUrl: string, kind: FlowKind, flowId: string, cookieHeader?: string) { const flowUrl = new URL(`/self-service/${kind}/flows`, kratosInternalUrl); flowUrl.searchParams.set("id", flowId); const headers = new Headers({ accept: "application/json" }); if (cookieHeader) { headers.set("cookie", cookieHeader); } const response = await fetch(flowUrl, { headers }); if (!response.ok) { throw new Error(`Kratos ${kind} flow request failed: ${response.status}`); } return (await response.json()) as KratosFlow; } function getSetCookieHeaders(headers: Headers): string[] { const headersWithSetCookie = headers as Headers & { getSetCookie?: () => string[] }; const cookies = headersWithSetCookie.getSetCookie?.(); if (cookies?.length) { return cookies; } const cookie = headers.get("set-cookie"); return cookie ? [cookie] : []; } function getProxyBody(request: FastifyRequest): string | undefined { if (request.method === "GET" || request.method === "HEAD") { return undefined; } if (typeof request.body === "string") { return request.body; } if (request.body === undefined) { return undefined; } return JSON.stringify(request.body); } function getProxyHeaders(request: FastifyRequest): Record<string, string> { const headers: Record<string, string> = { accept: "text/html, application/xhtml+xml, application/json;q=0.8, */*;q=0.5", }; if (request.headers.cookie) { headers.cookie = request.headers.cookie; } if (request.headers["content-type"]) { headers["content-type"] = request.headers["content-type"]; } if (request.headers["user-agent"]) { headers["user-agent"] = request.headers["user-agent"]; } return headers; } async function renderOrRedirectKratosBrowserFlow( kind: FlowKind, response: Response, reply: FastifyReply, authPublicUrl: string, ) { for (const cookie of getSetCookieHeaders(response.headers)) { reply.header("set-cookie", cookie); } const location = response.headers.get("location"); if (location) { return reply.code(response.status).header("location", rewriteSelfServiceUrl(location, authPublicUrl)).send(); } const contentType = response.headers.get("content-type") ?? ""; const body = await response.text(); if (contentType.includes("application/json")) { try { const flow = JSON.parse(body) as KratosFlow; reply.code(200).type("text/html"); return renderKratosFlow(kind, flow, authPublicUrl); } catch (_error) { reply.code(502).type("text/html"); return errorPage("Ошибка входа", "Не удалось открыть форму входа. Попробуйте обновить страницу."); } } if (contentType) { reply.header("content-type", contentType); } reply.code(response.status); return body; } async function startKratosBrowserFlow( request: FastifyRequest, reply: FastifyReply, kind: FlowKind, authPublicUrl: string, kratosInternalUrl: string, fetchImpl: typeof fetch, returnTo?: string, ) { const targetUrl = new URL(`/self-service/${kind}/browser`, kratosInternalUrl); if (returnTo) { targetUrl.searchParams.set("return_to", returnTo); } const response = await fetchImpl(targetUrl, { headers: getProxyHeaders(request), method: "GET", redirect: "manual", }); return renderOrRedirectKratosBrowserFlow(kind, response, reply, authPublicUrl); } async function startKratosLogout( request: FastifyRequest, reply: FastifyReply, authPublicUrl: string, kratosInternalUrl: string, webPublicUrl: string, fetchImpl: typeof fetch, ) { const sourceUrl = new URL(request.url, authPublicUrl); const returnTo = sourceUrl.searchParams.get("return_to") ?? `${webPublicUrl}/`; if (!request.headers.cookie) { return reply.code(303).header("location", returnTo).send(); } const flowUrl = new URL("/self-service/logout/browser", kratosInternalUrl); flowUrl.searchParams.set("return_to", returnTo); const flowResponse = await fetchImpl(flowUrl, { headers: { ...getProxyHeaders(request), accept: "application/json" }, method: "GET", redirect: "manual", }); if (flowResponse.status === 401 || flowResponse.status === 403) { return reply.code(303).header("location", returnTo).send(); } if (!flowResponse.ok) { reply.code(400).type("text/html"); return errorPage("Ошибка выхода", "Не удалось выйти из аккаунта. Попробуйте обновить страницу."); } const flow = (await flowResponse.json()) as KratosLogoutFlow; if (!flow.logout_url) { reply.code(400).type("text/html"); return errorPage("Ошибка выхода", "Не удалось выйти из аккаунта. Попробуйте обновить страницу."); } const logoutUrl = new URL(flow.logout_url, kratosInternalUrl); const internalLogoutUrl = new URL(`${logoutUrl.pathname}${logoutUrl.search}${logoutUrl.hash}`, kratosInternalUrl); const logoutResponse = await fetchImpl(internalLogoutUrl, { headers: getProxyHeaders(request), method: "GET", redirect: "manual", }); for (const cookie of getSetCookieHeaders(logoutResponse.headers)) { reply.header("set-cookie", cookie); } const location = logoutResponse.headers.get("location") ?? returnTo; return reply.code(303).header("location", rewriteSelfServiceUrl(location, authPublicUrl)).send(); } function authMethodsPathToSelfServicePath(pathname: string): string { return pathname.replace(/^\/auth\/methods\/?/, "/self-service/methods/"); } async function proxyKratosRequest( request: FastifyRequest, reply: FastifyReply, authPublicUrl: string, kratosInternalUrl: string, targetPathname: string, flowKind?: FlowKind, ) { const sourceUrl = new URL(request.url, authPublicUrl); const targetUrl = new URL(`${targetPathname}${sourceUrl.search}`, kratosInternalUrl); const response = await fetch(targetUrl, { body: getProxyBody(request), headers: getProxyHeaders(request), method: request.method, redirect: "manual", }); if (flowKind) { return renderOrRedirectKratosBrowserFlow(flowKind, response, reply, authPublicUrl); } for (const cookie of getSetCookieHeaders(response.headers)) { reply.header("set-cookie", cookie); } const location = response.headers.get("location"); if (location) { reply.header("location", rewriteSelfServiceUrl(location, authPublicUrl)); } const contentType = response.headers.get("content-type"); if (contentType) { reply.header("content-type", contentType); } reply.code(response.status); return response.text(); } function errorPage(title: string, message: string) { return page( title, `<h1>${escapeHtml(title)}</h1> <div class="error">${escapeHtml(message)}</div> <div class="actions"><a href="/login">Начать вход заново</a></div>`, ); } function hydraAdminUrlFor(pathname: string, hydraAdminUrl: string, searchParams: Record<string, string>) { const url = new URL(pathname, hydraAdminUrl); for (const [name, value] of Object.entries(searchParams)) { url.searchParams.set(name, value); } return url; } async function fetchKratosSession( kratosInternalUrl: string, cookieHeader: string | undefined, fetchImpl: typeof fetch, ): Promise<KratosSession | null> { if (!cookieHeader) { return null; } const response = await fetchImpl(new URL("/sessions/whoami", kratosInternalUrl), { headers: { accept: "application/json", cookie: cookieHeader, }, }); if (response.status === 401 || response.status === 403) { return null; } if (!response.ok) { throw new Error(`Kratos session request failed: ${response.status}`); } return (await response.json()) as KratosSession; } async function startKratosLoginForHydra( request: FastifyRequest, reply: FastifyReply, authPublicUrl: string, kratosInternalUrl: string, loginChallenge: string, fetchImpl: typeof fetch, ) { const returnTo = new URL("/login", authPublicUrl); returnTo.searchParams.set("login_challenge", loginChallenge); return startKratosBrowserFlow( request, reply, "login", authPublicUrl, kratosInternalUrl, fetchImpl, returnTo.toString(), ); } async function acceptHydraLogin( hydraAdminUrl: string, loginChallenge: string, session: KratosSession, fetchImpl: typeof fetch, ): Promise<HydraRedirect> { const requestUrl = hydraAdminUrlFor("/admin/oauth2/auth/requests/login", hydraAdminUrl, { login_challenge: loginChallenge, }); const loginRequest = await fetchImpl(requestUrl, { headers: { accept: "application/json" } }); if (!loginRequest.ok) { throw new Error(`Hydra login request failed: ${loginRequest.status}`); } const acceptUrl = hydraAdminUrlFor("/admin/oauth2/auth/requests/login/accept", hydraAdminUrl, { login_challenge: loginChallenge, }); const response = await fetchImpl(acceptUrl, { body: JSON.stringify({ context: { kratos_identity_id: session.identity.id }, remember: true, remember_for: 3600, subject: session.identity.id, }), headers: { "content-type": "application/json" }, method: "PUT", }); if (!response.ok) { throw new Error(`Hydra login accept failed: ${response.status}`); } return (await response.json()) as HydraRedirect; } function identityDisplayName(session: KratosSession): string | undefined { const name = session.identity.traits?.name; if (typeof name === "string") { return name; } if (name && (name.first || name.last)) { return [name.first, name.last].filter(Boolean).join(" "); } return session.identity.traits?.email; } function createPrismaProfileSync(prisma: PrismaClient = createPrismaClient()): ProfileSync { return { async close() { await prisma.$disconnect(); }, async sync(session) { await ensureUserProfile(prisma, session.identity.id, { displayName: identityDisplayName(session), email: session.identity.traits?.email, }); }, }; } async function acceptHydraConsent( hydraAdminUrl: string, consentChallenge: string, hydraClientId: string, session: KratosSession, fetchImpl: typeof fetch, ): Promise<HydraRedirect> { const requestUrl = hydraAdminUrlFor("/admin/oauth2/auth/requests/consent", hydraAdminUrl, { consent_challenge: consentChallenge, }); const request = await fetchImpl(requestUrl, { headers: { accept: "application/json" } }); if (!request.ok) { throw new Error(`Hydra consent request failed: ${request.status}`); } const consent = (await request.json()) as HydraConsentRequest; if (consent.client?.client_id !== hydraClientId) { throw new Error("Hydra consent request belongs to an unknown client"); } const grantScope = (consent.requested_scope ?? []).filter((scope) => ["openid", "offline_access"].includes(scope)); const acceptUrl = hydraAdminUrlFor("/admin/oauth2/auth/requests/consent/accept", hydraAdminUrl, { consent_challenge: consentChallenge, }); const response = await fetchImpl(acceptUrl, { body: JSON.stringify({ grant_access_token_audience: consent.requested_access_token_audience ?? [], grant_scope: grantScope, remember: true, remember_for: 3600, session: { id_token: { email: session.identity.traits?.email, name: identityDisplayName(session), ory_identity_id: session.identity.id, }, }, }), headers: { "content-type": "application/json" }, method: "PUT", }); if (!response.ok) { throw new Error(`Hydra consent accept failed: ${response.status}`); } return (await response.json()) as HydraRedirect; } export function buildApp(options: AuthUiOptions = {}): FastifyInstance { const authPublicUrl = options.authPublicUrl ?? optionalEnv("AUTH_PUBLIC_URL", optionalEnv("PUBLIC_AUTH_URL", "http://localhost:3004")); const fetchImpl = options.fetchImpl ?? fetch; const hydraAdminUrl = options.hydraAdminUrl ?? optionalEnv("HYDRA_ADMIN_URL", "http://hydra:4445"); const hydraClientId = options.hydraClientId ?? optionalEnv("PUBLIC_HYDRA_CLIENT_ID", optionalEnv("HYDRA_CLIENT_ID", "safedrop-web")); const kratosInternalUrl = options.kratosInternalUrl ?? optionalEnv("KRATOS_INTERNAL_URL", "http://kratos:4433"); const hydraPublicUrl = options.hydraPublicUrl ?? optionalEnv("HYDRA_PUBLIC_URL", "http://localhost:4444"); const webPublicUrl = options.webPublicUrl ?? optionalEnv("WEB_PUBLIC_URL", "http://localhost:5173"); const kratosFlowFetcher = options.kratosFlowFetcher ?? ((kind, flowId, cookieHeader) => fetchKratosFlow(kratosInternalUrl, kind, flowId, cookieHeader)); const profileSync = options.profileSync ?? createPrismaProfileSync(); const app = Fastify({ logger: options.logger ?? true }); app.addHook("onClose", async () => { await profileSync.close?.(); }); app.addContentTypeParser("application/x-www-form-urlencoded", { parseAs: "string" }, (_request, body, done) => { done(null, body); }); app.get("/health", { schema: { response: { 200: healthResponseJsonSchema } } }, async () => ({ authPublicUrl, hydraClientId, hydraPublicUrl, kratosInternalUrl, service: "auth-ui", status: "ok", })); app.get("/", async (_request, reply) => { reply.type("text/html"); return page( "SafeDrop Auth", `<h1>SafeDrop Auth</h1> <p>Этот сервис держит UI-точки для Ory Kratos/Hydra.</p> <div class="actions"> <a href="/login">Вход</a> <a href="/registration">Регистрация</a> <a href="/settings">Настройки / TOTP</a> <a href="${webPublicUrl}">SafeDrop web</a> </div>`, ); }); app.get("/settings", async (_request, reply) => { return proxyKratosRequest(_request, reply, authPublicUrl, kratosInternalUrl, "/self-service/settings/browser"); }); app.get("/logout", async (request, reply) => { return startKratosLogout(request, reply, authPublicUrl, kratosInternalUrl, webPublicUrl, fetchImpl); }); app.all("/auth/methods/*", async (request, reply) => proxyKratosRequest( request, reply, authPublicUrl, kratosInternalUrl, authMethodsPathToSelfServicePath(new URL(request.url, authPublicUrl).pathname), ), ); app.all("/login", async (request, reply) => { const query = request.query as { flow?: string; login_challenge?: string }; const flowId = query.flow; const loginChallenge = query.login_challenge; if (request.method === "GET" && loginChallenge) { try { const session = await fetchKratosSession(kratosInternalUrl, request.headers.cookie, fetchImpl); if (!session) { return startKratosLoginForHydra(request, reply, authPublicUrl, kratosInternalUrl, loginChallenge, fetchImpl); } await profileSync.sync(session); const accepted = await acceptHydraLogin(hydraAdminUrl, loginChallenge, session, fetchImpl); return reply.code(303).header("location", accepted.redirect_to).send(); } catch (error) { request.log.error({ error }, "Hydra login flow failed"); reply.code(400).type("text/html"); return errorPage("Ошибка входа", "Не удалось завершить вход. Начните вход заново."); } } if (request.method === "GET" && !flowId) { return startKratosBrowserFlow(request, reply, "login", authPublicUrl, kratosInternalUrl, fetchImpl); } if (request.method !== "GET") { return proxyKratosRequest(request, reply, authPublicUrl, kratosInternalUrl, "/self-service/login", "login"); } if (!flowId) { return startKratosBrowserFlow(request, reply, "login", authPublicUrl, kratosInternalUrl, fetchImpl); } try { const flow = await kratosFlowFetcher("login", flowId, request.headers.cookie); reply.type("text/html"); return renderKratosFlow("login", flow, authPublicUrl); } catch (_error) { reply.code(400).type("text/html"); return page( "SafeDrop: ошибка входа", `<h1>Вход</h1> <div class="error">Не удалось открыть форму входа. Вероятно, форма устарела или была создана в другой сессии.</div> <div class="actions"><a href="/login">Начать вход заново</a></div>`, ); } }); app.all("/registration", async (request, reply) => { const query = request.query as { flow?: string; return_to?: string }; const flowId = query.flow; const returnTo = query.return_to; if (request.method === "GET" && !flowId) { return startKratosBrowserFlow( request, reply, "registration", authPublicUrl, kratosInternalUrl, fetchImpl, returnTo, ); } if (request.method !== "GET") { return proxyKratosRequest( request, reply, authPublicUrl, kratosInternalUrl, "/self-service/registration", "registration", ); } if (!flowId) { return startKratosBrowserFlow( request, reply, "registration", authPublicUrl, kratosInternalUrl, fetchImpl, returnTo, ); } try { const flow = await kratosFlowFetcher("registration", flowId, request.headers.cookie); reply.type("text/html"); return renderKratosFlow("registration", flow, authPublicUrl); } catch (_error) { reply.code(400).type("text/html"); return page( "SafeDrop: ошибка регистрации", `<h1>Регистрация</h1> <div class="error">Не удалось открыть форму регистрации. Вероятно, форма устарела или была создана в другой сессии.</div> <div class="actions"><a href="/registration">Начать регистрацию заново</a></div>`, ); } }); app.get("/consent", async (request, reply) => { const query = request.query as { consent_challenge?: string }; const consentChallenge = query.consent_challenge; if (!consentChallenge) { reply.code(400).type("text/html"); return errorPage("Ошибка доступа", "Сессия входа устарела. Начните вход заново."); } try { const session = await fetchKratosSession(kratosInternalUrl, request.headers.cookie, fetchImpl); if (!session) { reply.code(401).type("text/html"); return errorPage("Требуется вход", "Сессия входа не найдена. Начните вход заново."); } await profileSync.sync(session); const accepted = await acceptHydraConsent(hydraAdminUrl, consentChallenge, hydraClientId, session, fetchImpl); return reply.code(303).header("location", accepted.redirect_to).send(); } catch (error) { request.log.error({ error }, "Hydra consent flow failed"); reply.code(400).type("text/html"); return errorPage("Ошибка доступа", "Не удалось завершить вход. Начните вход заново."); } }); return app; } if (import.meta.rstest) { const { describe, expect, it } = import.meta.rstest; describe("auth-ui Kratos routes", () => { it("starts login and registration browser flows when flow id is absent", async () => { const previousFetch = globalThis.fetch; globalThis.fetch = async (input) => { const target = String(input); const flow = target.includes("/registration/") ? "registration-flow" : "login-flow"; const kind = target.includes("/registration/") ? "registration" : "login"; return new Response("", { headers: { location: `http://kratos:4433/self-service/${kind}?flow=${flow}`, "set-cookie": "csrf_token=value; Path=/; HttpOnly", }, status: 303, }); }; try { const app = buildApp({ authPublicUrl: "http://auth.local", logger: false }); const login = await app.inject({ method: "GET", url: "/login" }); const registration = await app.inject({ method: "GET", url: "/registration" }); expect(login.statusCode).toBe(303); expect(login.headers.location).toBe("http://auth.local/login?flow=login-flow"); expect(registration.statusCode).toBe(303); expect(registration.headers.location).toBe("http://auth.local/registration?flow=registration-flow"); await app.close(); } finally { globalThis.fetch = previousFetch; } }); it("renders a Kratos login flow instead of redirecting again", async () => { const app = buildApp({ authPublicUrl: "http://auth.local", kratosFlowFetcher: async () => ({ id: "flow-1", return_to: "http://auth.local/login?login_challenge=login-1", ui: { action: "http://kratos:4433/self-service/login?flow=flow-1", method: "POST", messages: [{ text: "Неверный email или пароль.", type: "error" }], nodes: [ { attributes: { name: "csrf_token", node_type: "input", type: "hidden", value: "token" } }, { attributes: { name: "identifier", node_type: "input", required: true, type: "text" }, meta: { label: { text: "Email" } }, messages: [{ text: "Проверьте email.", type: "error" }], }, { attributes: { name: "password", node_type: "input", required: true, type: "password" }, meta: { label: { text: "Пароль" } }, }, { attributes: { name: "method", node_type: "input", type: "submit", value: "password" }, meta: { label: { text: "Войти" } }, }, { attributes: { name: "method", node_type: "input", type: "submit", value: "back" }, meta: { label: { text: "Back" } }, }, { attributes: { href: "http://kratos:4433/self-service/methods/oidc/auth/google?redirect_uri=http%3A%2F%2Fkratos%3A4433%2Fself-service%2Fmethods%2Foidc%2Fcallback%2Fgoogle", node_type: "a", }, meta: { label: { text: "Google" } }, }, { attributes: { name: "provider", node_type: "input", type: "submit", value: "yandex" }, group: "oidc", meta: { label: { text: "Yandex" } }, }, { attributes: { href: "http://kratos:4433/self-service/methods/oidc/auth/telegram", node_type: "a", }, group: "oidc", meta: { label: { text: "Telegram" } }, }, ], }, }), logger: false, }); const response = await app.inject({ method: "GET", url: "/login?flow=flow-1" }); expect(response.statusCode).toBe(200); expect(response.body).toContain('action="http://auth.local/login?flow=flow-1"'); expect(response.body).toContain('class="auth-form"'); expect(response.body).toContain('name="csrf_token"'); expect(response.body).toContain("Электронная почта"); expect(response.body).toContain("Пароль"); expect(response.body).toContain("Неверный email или пароль."); expect(response.body).toContain("Проверьте email."); expect(response.body).toContain('class="flow-messages"'); expect(response.body).toContain('class="field-messages"'); expect(response.body).toContain('class="secondary-form"'); expect(response.body).toContain('type="submit" formnovalidate>Назад'); expect(response.body).toContain('class="provider-actions"'); expect(response.body).toContain('class="provider-form"'); expect(response.body).toContain("provider-icon-google"); expect(response.body).toContain("provider-icon-yandex"); expect(response.body).toContain("provider-icon-telegram"); expect(response.body).toContain('data-provider="google" type="button" disabled'); expect(response.body).toContain('data-provider="yandex" type="button" disabled'); expect(response.body).toContain('data-provider="telegram" type="button" disabled'); expect(response.body).toContain( '<a href="/registration?return_to=http%3A%2F%2Fauth.local%2Flogin%3Flogin_challenge%3Dlogin-1">Создать аккаунт</a>', ); expect(response.body).not.toContain("http://auth.local/auth/methods/oidc/auth/google"); expect(response.body).not.toContain("http://auth.local/auth/methods/oidc/auth/telegram"); expect(response.body.indexOf('type="submit">Войти')).toBeLessThan( response.body.indexOf('class="provider-button provider-button-disabled"'), ); await app.close(); }); it("starts registration with preserved Hydra return_to", async () => { const previousFetch = globalThis.fetch; globalThis.fetch = async (input) => { const target = String(input); expect(target).toContain("/self-service/registration/browser"); expect(new URL(target).searchParams.get("return_to")).toBe("http://auth.local/login?login_challenge=login-1"); return new Response("", { headers: { location: "http://kratos:4433/self-service/registration?flow=registration-hydra", }, status: 303, }); }; try { const app = buildApp({ authPublicUrl: "http://auth.local", kratosInternalUrl: "http://kratos:4433", logger: false, }); const response = await app.inject({ method: "GET", url: "/registration?return_to=http%3A%2F%2Fauth.local%2Flogin%3Flogin_challenge%3Dlogin-1", }); expect(response.statusCode).toBe(303); expect(response.headers.location).toBe("http://auth.local/registration?flow=registration-hydra"); await app.close(); } finally { globalThis.fetch = previousFetch; } }); it("renders HTML when Kratos browser flow returns JSON", async () => { const previousFetch = globalThis.fetch; globalThis.fetch = async () => new Response( JSON.stringify({ id: "json-flow", ui: { action: "http://kratos:4433/self-service/login?flow=json-flow", method: "POST", nodes: [ { attributes: { name: "identifier", node_type: "input", required: true, type: "text" }, meta: { label: { text: "Email" } }, }, { attributes: { name: "password", node_type: "input", required: true, type: "password" }, meta: { label: { text: "Пароль" } }, }, { attributes: { name: "method", node_type: "input", type: "submit", value: "password" }, meta: { label: { text: "Войти" } }, }, ], }, }), { headers: { "content-type": "application/json", "set-cookie": "csrf_token=value; Path=/; HttpOnly", }, status: 200, }, ); try { const app = buildApp({ authPublicUrl: "http://auth.local", logger: false }); const response = await app.inject({ method: "GET", url: "/login" }); expect(response.statusCode).toBe(200); expect(response.headers["content-type"]).toContain("text/html"); expect(response.body).toContain("<form"); expect(response.body).toContain('class="auth-form"'); expect(response.body).toContain('action="http://auth.local/login?flow=json-flow"'); expect(response.body).toContain("Электронная почта"); expect(response.body).toContain("Пароль"); expect(response.body).not.toContain('"ui"'); await app.close(); } finally { globalThis.fetch = previousFetch; } }); it("renders registration with name, email, and password fields", async () => { const app = buildApp({ authPublicUrl: "http://auth.local", kratosFlowFetcher: async () => ({ id: "registration-flow", ui: { action: "http://kratos:4433/self-service/registration?flow=registration-flow", method: "POST", nodes: [ { attributes: { name: "csrf_token", node_type: "input", type: "hidden", value: "token" } }, { attributes: { name: "traits.email", node_type: "input", required: true, type: "email" }, meta: { label: { text: "Email" } }, }, { attributes: { name: "password", node_type: "input", required: true, type: "password" }, meta: { label: { text: "Password" } }, }, { attributes: { name: "method", node_type: "input", type: "submit", value: "password" }, meta: { label: { text: "Register" } }, }, { attributes: { href: "http://kratos:4433/self-service/methods/oidc/auth/google", node_type: "a", }, group: "oidc", meta: { label: { text: "Google" } }, }, ], }, }), logger: false, }); const response = await app.inject({ method: "GET", url: "/registration?flow=registration-flow" }); expect(response.statusCode).toBe(200); expect(response.body).toContain("<h1>Регистрация</h1>"); expect(response.body).toContain('name="traits.name"'); expect(response.body).toContain("Имя"); expect(response.body).toContain("Электронная почта"); expect(response.body).toContain("Пароль"); expect(response.body).not.toContain("Создайте аккаунт через email"); expect(response.body).not.toContain('class="provider-actions"'); expect(response.body).not.toContain("http://auth.local/auth/methods/oidc/auth/google"); expect(response.body).toContain(">Зарегистрироваться</button>"); await app.close(); }); it("renders validation errors when Kratos submit returns a JSON flow", async () => { const previousFetch = globalThis.fetch; globalThis.fetch = async () => new Response( JSON.stringify({ id: "submit-flow", ui: { action: "http://kratos:4433/self-service/login?flow=submit-flow", method: "POST", messages: [{ text: "Пароль не принят.", type: "error" }], nodes: [ { attributes: { name: "identifier", node_type: "input", required: true, type: "text" }, meta: { label: { text: "Email" } }, messages: [{ text: "Введите корректный email.", type: "error" }], }, { attributes: { name: "password", node_type: "input", required: true, type: "password" }, meta: { label: { text: "Пароль" } }, }, { attributes: { name: "method", node_type: "input", type: "submit", value: "password" }, meta: { label: { text: "Войти" } }, }, ], }, }), { headers: { "content-type": "application/json", "set-cookie": "csrf_token=value; Path=/; HttpOnly", }, status: 400, }, ); try { const app = buildApp({ authPublicUrl: "http://auth.local", kratosInternalUrl: "http://kratos:4433", logger: false, }); const response = await app.inject({ body: "identifier=alice%40example.com&password=bad&method=password", headers: { "content-type": "application/x-www-form-urlencoded" }, method: "POST", url: "/login?flow=submit-flow", }); expect(response.statusCode).toBe(200); expect(response.headers["content-type"]).toContain("text/html"); expect(response.body).toContain("Пароль не принят."); expect(response.body).toContain("Введите корректный email."); expect(response.body).toContain('class="flow-messages"'); expect(response.body).toContain('class="field-messages"'); expect(response.body).not.toContain('"ui"'); await app.close(); } finally { globalThis.fetch = previousFetch; } }); it("proxies settings flow through the public settings route", async () => { const previousFetch = globalThis.fetch; globalThis.fetch = async () => new Response("", { headers: { location: "http://kratos:4433/self-service/settings?flow=settings-1", "set-cookie": "csrf_token=value; Path=/; HttpOnly", }, status: 303, }); try { const app = buildApp({ authPublicUrl: "http://auth.local", logger: false }); const response = await app.inject({ method: "GET", url: "/settings" }); expect(response.statusCode).toBe(303); expect(response.headers.location).toBe("http://auth.local/settings?flow=settings-1"); expect(response.headers["set-cookie"]).toBeDefined(); await app.close(); } finally { globalThis.fetch = previousFetch; } }); it("submits login forms through the public login route", async () => { const previousFetch = globalThis.fetch; globalThis.fetch = async (input) => { expect(String(input)).toBe("http://kratos:4433/self-service/login?flow=flow-1"); return new Response("", { headers: { location: "http://localhost:5173/", "set-cookie": "csrf_token=value; Path=/; HttpOnly", }, status: 303, }); }; try { const app = buildApp({ authPublicUrl: "http://auth.local", kratosInternalUrl: "http://kratos:4433", logger: false, }); const response = await app.inject({ body: "identifier=alice%40example.com&password=secret&method=password", headers: { "content-type": "application/x-www-form-urlencoded" }, method: "POST", url: "/login?flow=flow-1", }); expect(response.statusCode).toBe(303); expect(response.headers.location).toBe("http://localhost:5173/"); expect(response.headers["set-cookie"]).toBeDefined(); await app.close(); } finally { globalThis.fetch = previousFetch; } }); it("logs out through Kratos browser logout flow and forwards cleared cookie", async () => { const calls: string[] = []; const fetchImpl = (async (input) => { const target = String(input); calls.push(target); if (target.includes("/self-service/logout/browser")) { expect(new URL(target).searchParams.get("return_to")).toBe("http://web.local/"); return new Response(JSON.stringify({ logout_url: "http://kratos:4433/self-service/logout?token=logout-1" }), { headers: { "content-type": "application/json" }, }); } expect(target).toBe("http://kratos:4433/self-service/logout?token=logout-1"); return new Response("", { headers: { location: "http://web.local/", "set-cookie": "ory_session=; Path=/; Max-Age=0; HttpOnly", }, status: 303, }); }) as typeof fetch; const app = buildApp({ authPublicUrl: "http://auth.local", fetchImpl, kratosInternalUrl: "http://kratos:4433", logger: false, webPublicUrl: "http://web.local", }); const response = await app.inject({ headers: { cookie: "ory_session=value" }, method: "GET", url: "/logout", }); expect(response.statusCode).toBe(303); expect(response.headers.location).toBe("http://web.local/"); expect(String(response.headers["set-cookie"])).toContain("Max-Age=0"); expect(calls).toHaveLength(2); await app.close(); }); it("starts Kratos login with return_to when Hydra login has no Kratos session", async () => { const calls: string[] = []; const fetchImpl = (async (input: string | URL | Request) => { const target = String(input); calls.push(target); if (target.includes("/sessions/whoami")) { return new Response("{}", { status: 401 }); } expect(target).toContain("/self-service/login/browser"); expect(new URL(target).searchParams.get("return_to")).toBe("http://auth.local/login?login_challenge=login-1"); return new Response("", { headers: { location: "http://kratos:4433/self-service/login?flow=flow-hydra", "set-cookie": "csrf_token=value; Path=/; HttpOnly", }, status: 303, }); }) as typeof fetch; const app = buildApp({ authPublicUrl: "http://auth.local", fetchImpl, kratosInternalUrl: "http://kratos:4433", logger: false, }); const response = await app.inject({ headers: { cookie: "ory_session=expired" }, method: "GET", url: "/login?login_challenge=login-1", }); expect(response.statusCode).toBe(303); expect(response.headers.location).toBe("http://auth.local/login?flow=flow-hydra"); expect(response.headers["set-cookie"]).toBeDefined(); expect(calls).toHaveLength(2); await app.close(); }); it("accepts Hydra login when Kratos session exists", async () => { const calls: Array<{ body?: string; method?: string; url: string }> = []; const syncedProfiles: string[] = []; const fetchImpl = (async (input: string | URL | Request, init?: RequestInit) => { const target = String(input); calls.push({ body: init?.body as string | undefined, method: init?.method, url: target }); if (target.includes("/sessions/whoami")) { return new Response( JSON.stringify({ identity: { id: "kratos-alice", traits: { email: "alice@example.com" } } }), { headers: { "content-type": "application/json" } }, ); } if (target.includes("/admin/oauth2/auth/requests/login/accept")) { expect(init?.method).toBe("PUT"); expect(JSON.parse(init?.body as string).subject).toBe("kratos-alice"); return new Response(JSON.stringify({ redirect_to: "http://localhost:4444/oauth2/auth/complete" }), { headers: { "content-type": "application/json" }, }); } return new Response(JSON.stringify({ challenge: "login-1" }), { headers: { "content-type": "application/json" }, }); }) as typeof fetch; const app = buildApp({ authPublicUrl: "http://auth.local", fetchImpl, logger: false, profileSync: { sync: async (session) => { syncedProfiles.push(`${session.identity.id}:${session.identity.traits?.email ?? ""}`); }, }, }); const response = await app.inject({ headers: { cookie: "ory_session=value" }, method: "GET", url: "/login?login_challenge=login-1", }); expect(response.statusCode).toBe(303); expect(response.headers.location).toBe("http://localhost:4444/oauth2/auth/complete"); expect(syncedProfiles).toEqual(["kratos-alice:alice@example.com"]); expect(calls.some((call) => call.url.includes("/admin/oauth2/auth/requests/login/accept"))).toBe(true); await app.close(); }); it("returns a readable error page when Hydra login accept fails", async () => { const fetchImpl = (async (input: string | URL | Request) => { const target = String(input); if (target.includes("/sessions/whoami")) { return new Response(JSON.stringify({ identity: { id: "kratos-alice" } }), { headers: { "content-type": "application/json" }, }); } if (target.includes("/admin/oauth2/auth/requests/login/accept")) { return new Response("hydra failed", { status: 500 }); } return new Response(JSON.stringify({ challenge: "login-1" }), { headers: { "content-type": "application/json" }, }); }) as typeof fetch; const app = buildApp({ authPublicUrl: "http://auth.local", fetchImpl, logger: false, profileSync: { sync: async () => {} }, }); const response = await app.inject({ headers: { cookie: "ory_session=value" }, method: "GET", url: "/login?login_challenge=login-1", }); expect(response.statusCode).toBe(400); expect(response.headers["content-type"]).toContain("text/html"); expect(response.body).toContain("Не удалось завершить вход"); expect(response.body).toContain("/login"); expect(response.body).not.toContain("oauth2/auth/complete"); await app.close(); }); it("accepts Hydra consent for the SafeDrop public client", async () => { const fetchImpl = (async (input: string | URL | Request, init?: RequestInit) => { const target = String(input); if (target.includes("/sessions/whoami")) { return new Response( JSON.stringify({ identity: { id: "kratos-alice", traits: { email: "alice@example.com" } } }), { headers: { "content-type": "application/json" } }, ); } if (target.includes("/admin/oauth2/auth/requests/consent/accept")) { const body = JSON.parse(init?.body as string); expect(body.grant_scope).toEqual(["openid", "offline_access"]); expect(body.session.id_token.ory_identity_id).toBe("kratos-alice"); return new Response(JSON.stringify({ redirect_to: "http://localhost:4444/oauth2/consent/complete" }), { headers: { "content-type": "application/json" }, }); } return new Response( JSON.stringify({ client: { client_id: "safedrop-web" }, requested_scope: ["openid", "offline_access", "email"], }), { headers: { "content-type": "application/json" } }, ); }) as typeof fetch; const app = buildApp({ authPublicUrl: "http://auth.local", fetchImpl, logger: false, profileSync: { sync: async () => {} }, }); const response = await app.inject({ headers: { cookie: "ory_session=value" }, method: "GET", url: "/consent?consent_challenge=consent-1", }); expect(response.statusCode).toBe(303); expect(response.headers.location).toBe("http://localhost:4444/oauth2/consent/complete"); await app.close(); }); it("rejects consent requests without challenge without redirecting", async () => { const app = buildApp({ authPublicUrl: "http://auth.local", logger: false }); const response = await app.inject({ method: "GET", url: "/consent" }); expect(response.statusCode).toBe(400); expect(response.headers["content-type"]).toContain("text/html"); expect(response.body).toContain("Сессия входа устарела"); await app.close(); }); }); } export async function start() { const port = Number(optionalEnv("PORT", "3004")); const app = buildApp(); await app.listen({ host: "0.0.0.0", port }); }