From 46de87e213609bade103c1e162068e7c538d090c Mon Sep 17 00:00:00 2001 From: Denozordec Date: Tue, 13 Oct 2026 16:09:27 +0700 Subject: [PATCH] =?UTF-8?q?fix(auth):=20=D0=B2=D0=BE=D1=81=D1=81=D1=82?= =?UTF-8?q?=D0=B0=D0=BD=D0=B0=D0=B2=D0=BB=D0=B8=D0=B2=D0=B0=D1=82=D1=8C=20?= =?UTF-8?q?=D1=80=D0=B0=D0=B7=D0=B4=D0=B5=D0=BB=20=D0=B8=20=D1=84=D0=B8?= =?UTF-8?q?=D0=BB=D1=8C=D1=82=D1=80=D1=8B=20=D0=BF=D0=BE=D1=81=D0=BB=D0=B5?= =?UTF-8?q?=20=D0=B2=D1=85=D0=BE=D0=B4=D0=B0=20Portal?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/auth/callback/page.tsx | 15 ++++++-- docs/ux-ui-external-acceptance.md | 22 ++++++++++-- docs/ux-ui-plan.md | 6 ++-- docs/ux-ui-progress.md | 20 ++++++++++- docs/ux-ui-remaining-acceptance.md | 24 +++++++++++-- docs/ux-ui-stage-12.md | 20 ++++++++++- docs/ux-ui-test-server.md | 22 ++++++++++-- .../portal199-deployment-2026-10-13.json | 14 ++++++++ .../acceptance/portal199-e2e-2026-10-13.json | 14 ++++++++ .../portal199-live-ui-2026-10-13.json | 36 +++++++++++++++++++ .../portal199-return-to-2026-10-13.json | 32 +++++++++++++++++ lib/auth-return-path.test.ts | 9 +++++ lib/auth-return-path.ts | 22 ++++++++++++ lib/auth.ts | 8 +++-- tests/e2e/auth-return-path.spec.ts | 28 +++++++++++++++ 15 files changed, 275 insertions(+), 17 deletions(-) create mode 100644 docs/ux-ui/acceptance/portal199-deployment-2026-10-13.json create mode 100644 docs/ux-ui/acceptance/portal199-e2e-2026-10-13.json create mode 100644 docs/ux-ui/acceptance/portal199-live-ui-2026-10-13.json create mode 100644 docs/ux-ui/acceptance/portal199-return-to-2026-10-13.json create mode 100644 lib/auth-return-path.test.ts create mode 100644 lib/auth-return-path.ts create mode 100644 tests/e2e/auth-return-path.spec.ts diff --git a/app/auth/callback/page.tsx b/app/auth/callback/page.tsx index 2e339e19..49aa4872 100644 --- a/app/auth/callback/page.tsx +++ b/app/auth/callback/page.tsx @@ -5,15 +5,18 @@ import { useRouter } from "next/navigation" import { clearPortalHandoffFlag, clearToken, + can, ensureAuthConfig, firstAllowedPath, getClaims, getToken, parseHashToken, + permissionForPath, redirectToPortalLogin, redirectToPortalLoginInteractive, setToken, } from "@/lib/auth" +import { permittedAuthReturnPath } from "@/lib/auth-return-path" export default function AuthCallbackPage() { const router = useRouter() @@ -28,6 +31,10 @@ export default function AuthCallbackPage() { const params = new URLSearchParams(window.location.search) const error = params.get("error") + const returnPath = () => permittedAuthReturnPath(params.get("next"), pathname => { + const permission = permissionForPath(pathname) + return !permission || can(permission) + }, firstAllowedPath()) if (error === "sso_loop" || error === "jwt_rejected") { redirectToPortalLoginInteractive() return @@ -62,7 +69,7 @@ export default function AuthCallbackPage() { /* ignore network — proceed */ } - const next = firstAllowedPath() + const next = returnPath() if (next === "/access-denied") { router.replace("/access-denied") return @@ -77,11 +84,13 @@ export default function AuthCallbackPage() { router.replace("/access-denied") return } - router.replace(firstAllowedPath()) + router.replace(returnPath()) return } - const ok = redirectToPortalLogin(`${window.location.origin}/auth/callback`) + const callback = new URL(`${window.location.origin}/auth/callback`) + if (params.get("next")) callback.searchParams.set("next", params.get("next")!) + const ok = redirectToPortalLogin(callback.toString()) if (!ok) redirectToPortalLoginInteractive() })() diff --git a/docs/ux-ui-external-acceptance.md b/docs/ux-ui-external-acceptance.md index aadcd3a6..8beda6b5 100644 --- a/docs/ux-ui-external-acceptance.md +++ b/docs/ux-ui-external-acceptance.md @@ -1,6 +1,6 @@ # UX/UI: протокол внешней приёмки 12.5 -Актуальное продолжение 2026-10-13: блок198 — actual expired JWT401, reissue role changes, logout boundary и live expired recovery ПК/телефон приняты. Первый внешний return_to и browser logout passed; остальные два return_to ограничены подтверждённым429 Portal. Runtime ux-peak196. Следующий199 — сохранить исходный раздел/параметры после SSO;12.5 открыта. +Актуальное продолжение 2026-10-13: завершён199 — SSO сохраняет исходный разрешённый раздел/query; unit/typecheck/ESLint,12 E2E и2 live expired recovery passed. Frontend ux-portal199/backend ux-peak196, Portal1.3.1. Оставшиеся return_to199 приняты после коррекции тестового URL. Следующий200 — crash/lock-loss traffic collector; диагностическая граница цельного Telegram197 сохраняется.12.5 открыта. IPsec78: реальный PSK responder established, mode-config198.18.78.10 и online=true подтверждены API; точные собственные объекты удалены. Сертификатный/DN online, late failure и history restore остаются открытыми. [Отчёт](ux-ui/acceptance/ipsec-mode-config-2026-10-09.json), [журнал](ux-ui-progress.md). @@ -696,7 +696,7 @@ flowchart LR ## 12.5.77 — основной блок IPsec (2026-10-09) -Актуальное продолжение 2026-10-13: блок198 — actual expired JWT401, reissue role changes, logout boundary и live expired recovery ПК/телефон приняты. Первый внешний return_to и browser logout passed; остальные два return_to ограничены подтверждённым429 Portal. Runtime ux-peak196. Следующий199 — сохранить исходный раздел/параметры после SSO;12.5 открыта. +Актуальное продолжение 2026-10-13: завершён199 — SSO сохраняет исходный разрешённый раздел/query; unit/typecheck/ESLint,12 E2E и2 live expired recovery passed. Frontend ux-portal199/backend ux-peak196, Portal1.3.1. Оставшиеся return_to199 приняты после коррекции тестового URL. Следующий200 — crash/lock-loss traffic collector; диагностическая граница цельного Telegram197 сохраняется.12.5 открыта. Подробный объём, протоколы, схема и ограничения: [12.5.77 в журнале](ux-ui-progress.md). @@ -2414,3 +2414,21 @@ Live UI1440/320 с настоящим expired JWT: MM очистил mmapp_token Протоколы: [actual lifecycle API](ux-ui/acceptance/portal198-lifecycle-2026-10-13.json), [live UI и границы429](ux-ui/acceptance/portal198-live-ui-2026-10-13.json). Следующий199: восстановить исходный same-origin раздел и query после SSO с проверкой permissions и запретом внешних/auth callback redirect. Затем профильные E2E/deploy и два оставшихся Portal return_to.12.5 открыта. + +## 12.5.199 — восстановление раздела и параметров после SSO (2026-10-13) + +Из198 подтверждено: expired session на /servers попадала после login в /dashboard. redirectToPortalLogin теперь добавляет исходный pathname+query в next callback URL. AuthCallbackPage после valid claims выбирает permittedAuthReturnPath и проверяет существующее permissionForPath/can; недопустимый путь или недостаточные права используют firstAllowedPath. При повторном handoff next сохраняется. JWT fragment не включается в сохранённый путь. + +Новый pure helper отклоняет внешние/protocol-relative URL, backslash/control/space, слишком длинный путь, auth/api/access-denied loop, нормализованный dot traversal вauth и percent-encoded pathname. Query, включая percent-encoded filter values, сохраняется; hash удаляется. Консервативное ограничение касается pathname: приложение использует обычные фиксированные пути/ID; percent-encoded pathname не восстанавливается, выбирается fallback. Это не расширение существующей карты permissions и не замена backend RBAC. + +Unit unsafe/normalization/query/permissions passed, frontend TypeScript и scoped ESLint passed.12 unique E2E/24971ms/0unexpected:10 query/denied/external/auth-loop/handoff cases1440/320 и2 runtime Portal links. Это fixtures, не реальные подписи Portal. Отдельно production frontend ux-portal199 собран и развёрнут,527 source files совпали перед build. Backend ux-peak196/Portal1.3.1/AUTH_REQUIRED=true сохранены.2 live UI с настоящим naturally expired JWT и login Portal1440/320 восстановили /servers?server=42&tab=details, fragment удалён, errors/overflow нет. CHR/native config и backend не менялись, новых Telegram sends нет. + +Оставшиеся return_to198 проверены адресно: http://localhost:3001/auth/callback и http://localhost.evil.invalid:3000/auth/callback не вызывают external navigation, Portal остаётся /admin. Browser logout очищает storage. Предыдущий lookalike URL в драйвере был синтаксически ошибочным (порт3000.evil.invalid), поэтому login form отсутствовала; это исправлено в тесте. Подтверждённый429198 сохраняется в историческом протоколе, но не объявляется причиной этого отдельного malformed-URL сбоя. Повторные full cycles прекращены; реальные endpoints не mock. Доверенные allowlist origins сохранены. + +Протоколы: [E2E12](ux-ui/acceptance/portal199-e2e-2026-10-13.json), [deployment](ux-ui/acceptance/portal199-deployment-2026-10-13.json), [actual expired recovery2](ux-ui/acceptance/portal199-live-ui-2026-10-13.json), [remaining return_to](ux-ui/acceptance/portal199-return-to-2026-10-13.json). + +| Референс | Схема | +| --- | --- | +| [ReUI App Shell12](https://reui.io/preview/base/app-shell-12?ref=mcp) | Раздел + фильтры → expired session → Portal → callback с проверкой прав → тот же раздел + фильтры | + +Следующий200: owned PostgreSQL/child-process crash и advisory-lock loss traffic collector. Цельный непрерывный Telegram live197 остаётся отдельно с исходным failed evidence; успешные chunk tests не отменяют его. Logout не отозвал offlineJWT доexp, publicIPv6/clientOS/independentWAN/scale остаются внешними ограничениями.12.5 открыта. diff --git a/docs/ux-ui-plan.md b/docs/ux-ui-plan.md index 26c7b018..084d2665 100644 --- a/docs/ux-ui-plan.md +++ b/docs/ux-ui-plan.md @@ -1,12 +1,12 @@ # План реализации UX/UI по пятичасовым окнам -Актуальное продолжение 2026-10-13: блок198 — actual expired JWT401, reissue role changes, logout boundary и live expired recovery ПК/телефон приняты. Первый внешний return_to и browser logout passed; остальные два return_to ограничены подтверждённым429 Portal. Runtime ux-peak196. Следующий199 — сохранить исходный раздел/параметры после SSO;12.5 открыта. +Актуальное продолжение 2026-10-13: завершён199 — SSO сохраняет исходный разрешённый раздел/query; unit/typecheck/ESLint,12 E2E и2 live expired recovery passed. Frontend ux-portal199/backend ux-peak196, Portal1.3.1. Оставшиеся return_to199 приняты после коррекции тестового URL. Следующий200 — crash/lock-loss traffic collector; диагностическая граница цельного Telegram197 сохраняется.12.5 открыта. Общий план проекта MikrotikManager. Основан на согласованном плане из задачи; точка продолжения обновлена 2026-10-13. Источник фактического прогресса, проверок и ограничений — [журнал реализации](ux-ui-progress.md). Этот документ задаёт объём и критерии завершения, журнал фиксирует результат. ## Актуальная точка продолжения — 2026-10-13 -Актуальное продолжение 2026-10-13: блок198 — actual expired JWT401, reissue role changes, logout boundary и live expired recovery ПК/телефон приняты. Первый внешний return_to и browser logout passed; остальные два return_to ограничены подтверждённым429 Portal. Runtime ux-peak196. Следующий199 — сохранить исходный раздел/параметры после SSO;12.5 открыта. +Актуальное продолжение 2026-10-13: завершён199 — SSO сохраняет исходный разрешённый раздел/query; unit/typecheck/ESLint,12 E2E и2 live expired recovery passed. Frontend ux-portal199/backend ux-peak196, Portal1.3.1. Оставшиеся return_to199 приняты после коррекции тестового URL. Следующий200 — crash/lock-loss traffic collector; диагностическая граница цельного Telegram197 сохраняется.12.5 открыта. Этапы0–11 завершены в локальных границах журнала. История выполненных внешних блоков и доказательства сохранены в [журнале](ux-ui-stage-12.md) и [протоколе](ux-ui-external-acceptance.md). Реальная Telegram доставка, Portal SSO, multi-instance и оставшиеся профильные сетевые ограничения открыты. @@ -371,6 +371,6 @@ flowchart LR ## 12.5.77 — основной блок IPsec (2026-10-09) -Актуальное продолжение 2026-10-13: блок198 — actual expired JWT401, reissue role changes, logout boundary и live expired recovery ПК/телефон приняты. Первый внешний return_to и browser logout passed; остальные два return_to ограничены подтверждённым429 Portal. Runtime ux-peak196. Следующий199 — сохранить исходный раздел/параметры после SSO;12.5 открыта. +Актуальное продолжение 2026-10-13: завершён199 — SSO сохраняет исходный разрешённый раздел/query; unit/typecheck/ESLint,12 E2E и2 live expired recovery passed. Frontend ux-portal199/backend ux-peak196, Portal1.3.1. Оставшиеся return_to199 приняты после коррекции тестового URL. Следующий200 — crash/lock-loss traffic collector; диагностическая граница цельного Telegram197 сохраняется.12.5 открыта. Подробный объём, протоколы, схема и ограничения: [12.5.77 в журнале](ux-ui-progress.md). diff --git a/docs/ux-ui-progress.md b/docs/ux-ui-progress.md index b56ce00c..583e67e6 100644 --- a/docs/ux-ui-progress.md +++ b/docs/ux-ui-progress.md @@ -1,6 +1,6 @@ # UX/UI: журнал реализации -Актуальное продолжение 2026-10-13: блок198 — actual expired JWT401, reissue role changes, logout boundary и live expired recovery ПК/телефон приняты. Первый внешний return_to и browser logout passed; остальные два return_to ограничены подтверждённым429 Portal. Runtime ux-peak196. Следующий199 — сохранить исходный раздел/параметры после SSO;12.5 открыта. +Актуальное продолжение 2026-10-13: завершён199 — SSO сохраняет исходный разрешённый раздел/query; unit/typecheck/ESLint,12 E2E и2 live expired recovery passed. Frontend ux-portal199/backend ux-peak196, Portal1.3.1. Оставшиеся return_to199 приняты после коррекции тестового URL. Следующий200 — crash/lock-loss traffic collector; диагностическая граница цельного Telegram197 сохраняется.12.5 открыта. Общий план: [UX/UI по этапам и пятичасовым окнам](ux-ui-plan.md). План задаёт объём, этот журнал фиксирует выполненные блоки и ограничения. Записи расположены от новых к старым; подробная история этапа12 также сохранена в [ux-ui-stage-12.md](ux-ui-stage-12.md). @@ -38,6 +38,24 @@ | 11Б | Оповещения, сбор данных, интеграции, релизы и настройки | Завершён в границах локальной приёмки 2026-10-03; ограничения и evidence в11Б.7 | | 12 | Итоговая доступность, адаптивность, производительность и регрессия | Локальная приёмка завершена в границах12.3.83; внешняя12.5 в работе, выполненные блоки1–196 и ограничения ниже. Runtime backend/frontend ux-peak196. | +## 12.5.199 — восстановление раздела и параметров после SSO (2026-10-13) + +Из198 подтверждено: expired session на /servers попадала после login в /dashboard. redirectToPortalLogin теперь добавляет исходный pathname+query в next callback URL. AuthCallbackPage после valid claims выбирает permittedAuthReturnPath и проверяет существующее permissionForPath/can; недопустимый путь или недостаточные права используют firstAllowedPath. При повторном handoff next сохраняется. JWT fragment не включается в сохранённый путь. + +Новый pure helper отклоняет внешние/protocol-relative URL, backslash/control/space, слишком длинный путь, auth/api/access-denied loop, нормализованный dot traversal вauth и percent-encoded pathname. Query, включая percent-encoded filter values, сохраняется; hash удаляется. Консервативное ограничение касается pathname: приложение использует обычные фиксированные пути/ID; percent-encoded pathname не восстанавливается, выбирается fallback. Это не расширение существующей карты permissions и не замена backend RBAC. + +Unit unsafe/normalization/query/permissions passed, frontend TypeScript и scoped ESLint passed.12 unique E2E/24971ms/0unexpected:10 query/denied/external/auth-loop/handoff cases1440/320 и2 runtime Portal links. Это fixtures, не реальные подписи Portal. Отдельно production frontend ux-portal199 собран и развёрнут,527 source files совпали перед build. Backend ux-peak196/Portal1.3.1/AUTH_REQUIRED=true сохранены.2 live UI с настоящим naturally expired JWT и login Portal1440/320 восстановили /servers?server=42&tab=details, fragment удалён, errors/overflow нет. CHR/native config и backend не менялись, новых Telegram sends нет. + +Оставшиеся return_to198 проверены адресно: http://localhost:3001/auth/callback и http://localhost.evil.invalid:3000/auth/callback не вызывают external navigation, Portal остаётся /admin. Browser logout очищает storage. Предыдущий lookalike URL в драйвере был синтаксически ошибочным (порт3000.evil.invalid), поэтому login form отсутствовала; это исправлено в тесте. Подтверждённый429198 сохраняется в историческом протоколе, но не объявляется причиной этого отдельного malformed-URL сбоя. Повторные full cycles прекращены; реальные endpoints не mock. Доверенные allowlist origins сохранены. + +Протоколы: [E2E12](ux-ui/acceptance/portal199-e2e-2026-10-13.json), [deployment](ux-ui/acceptance/portal199-deployment-2026-10-13.json), [actual expired recovery2](ux-ui/acceptance/portal199-live-ui-2026-10-13.json), [remaining return_to](ux-ui/acceptance/portal199-return-to-2026-10-13.json). + +| Референс | Схема | +| --- | --- | +| [ReUI App Shell12](https://reui.io/preview/base/app-shell-12?ref=mcp) | Раздел + фильтры → expired session → Portal → callback с проверкой прав → тот же раздел + фильтры | + +Следующий200: owned PostgreSQL/child-process crash и advisory-lock loss traffic collector. Цельный непрерывный Telegram live197 остаётся отдельно с исходным failed evidence; успешные chunk tests не отменяют его. Logout не отозвал offlineJWT доexp, publicIPv6/clientOS/independentWAN/scale остаются внешними ограничениями.12.5 открыта. + ## 12.5.198 — срок действия Portal JWT, права и выход (2026-10-13) Использован настоящий JWT, выданный Portal в195 и естественно истёкший к проверке: MM GET servers401, Portal me401 и reissue401. Токен не редактировался и TTL не менялся. На собственном временном reader через документированный PUT /api/v1/admin/users/:id/access удалён доступmm: прежний JWT по-прежнему200 доexp, reissue200 выдаёт текущие claims и новый MM JWT403. Возвращённый доступ и новый reissue восстановили200. Первый неверно адресованный PATCH в диагностическом драйвере был исправлен на PUT/access; изменение source продукта не требовалось. User fixture удалён204. diff --git a/docs/ux-ui-remaining-acceptance.md b/docs/ux-ui-remaining-acceptance.md index 3382f132..0375679f 100644 --- a/docs/ux-ui-remaining-acceptance.md +++ b/docs/ux-ui-remaining-acceptance.md @@ -1,8 +1,8 @@ # Оставшаяся внешняя приёмка — 2026-10-13 -Актуальное продолжение 2026-10-13: блок198 — actual expired JWT401, reissue role changes, logout boundary и live expired recovery ПК/телефон приняты. Первый внешний return_to и browser logout passed; остальные два return_to ограничены подтверждённым429 Portal. Runtime ux-peak196. Следующий199 — сохранить исходный раздел/параметры после SSO;12.5 открыта. +Актуальное продолжение 2026-10-13: завершён199 — SSO сохраняет исходный разрешённый раздел/query; unit/typecheck/ESLint,12 E2E и2 live expired recovery passed. Frontend ux-portal199/backend ux-peak196, Portal1.3.1. Оставшиеся return_to199 приняты после коррекции тестового URL. Следующий200 — crash/lock-loss traffic collector; диагностическая граница цельного Telegram197 сохраняется.12.5 открыта. -Это актуальная матрица после 12.5.198. Исторические записи сохраняют версии и ограничения на момент выполнения; поздние проверки ниже уточняют их. Этап 12.5 открыт. Успех в двух CHR и одном backend не означает приёмку всех конфигураций, интеграций или полной регрессии. +Это актуальная матрица после 12.5.199. Исторические записи сохраняют версии и ограничения на момент выполнения; поздние проверки ниже уточняют их. Этап 12.5 открыт. Успех в двух CHR и одном backend не означает приёмку всех конфигураций, интеграций или полной регрессии. Стенд: Debian13/Compose, PostgreSQL18.6, два CHR RouterOS7.24.5. Backend/frontend `ux-peak196`; production lab с195 использует AUTH_REQUIRED=true и Portal1.3.1. Подписанные JWT/RBAC проверялись в изолированных процессах. Все собственные объекты112 удалены, исходные13 коллекций×2 (включая logging/certificates/files/routes) проверены; пароли не менялись. @@ -26,7 +26,7 @@ ## Следующая последовательность — 2026-10-13 -1. **199 — SSO context:** сохранить исходный разрешённый раздел/query после login, запретить внешние/auth callback redirect. Затем scoped deploy и два return_to, ограниченные429 в198. +1. **200 — crash/lock-loss collector:** собственная ограниченная БД и процессы, без остановки production PG или native CHR. Telegram197 whole-live failure остаётся отдельной диагностической границей. 2. **Профильный контроль:** для новых изменений выполнять затронутые SQL/API/UI проверки. Общая регрессия192 выполнена; не повторять её без изменений общей основы или нового основания. 3. **Внешние зависимости:** Portal issuer/login/revoke, Telegram bot/chat/разрешённое сообщение, клиентские ОС, независимые WAN/public IPv6 и scale/multi-instance — отдельные группы по таблице выше. Ограничения текущего стенда не превращать в обещание приёмки всех конфигураций. @@ -1330,3 +1330,21 @@ Live UI1440/320 с настоящим expired JWT: MM очистил mmapp_token Протоколы: [actual lifecycle API](ux-ui/acceptance/portal198-lifecycle-2026-10-13.json), [live UI и границы429](ux-ui/acceptance/portal198-live-ui-2026-10-13.json). Следующий199: восстановить исходный same-origin раздел и query после SSO с проверкой permissions и запретом внешних/auth callback redirect. Затем профильные E2E/deploy и два оставшихся Portal return_to.12.5 открыта. + +## 12.5.199 — восстановление раздела и параметров после SSO (2026-10-13) + +Из198 подтверждено: expired session на /servers попадала после login в /dashboard. redirectToPortalLogin теперь добавляет исходный pathname+query в next callback URL. AuthCallbackPage после valid claims выбирает permittedAuthReturnPath и проверяет существующее permissionForPath/can; недопустимый путь или недостаточные права используют firstAllowedPath. При повторном handoff next сохраняется. JWT fragment не включается в сохранённый путь. + +Новый pure helper отклоняет внешние/protocol-relative URL, backslash/control/space, слишком длинный путь, auth/api/access-denied loop, нормализованный dot traversal вauth и percent-encoded pathname. Query, включая percent-encoded filter values, сохраняется; hash удаляется. Консервативное ограничение касается pathname: приложение использует обычные фиксированные пути/ID; percent-encoded pathname не восстанавливается, выбирается fallback. Это не расширение существующей карты permissions и не замена backend RBAC. + +Unit unsafe/normalization/query/permissions passed, frontend TypeScript и scoped ESLint passed.12 unique E2E/24971ms/0unexpected:10 query/denied/external/auth-loop/handoff cases1440/320 и2 runtime Portal links. Это fixtures, не реальные подписи Portal. Отдельно production frontend ux-portal199 собран и развёрнут,527 source files совпали перед build. Backend ux-peak196/Portal1.3.1/AUTH_REQUIRED=true сохранены.2 live UI с настоящим naturally expired JWT и login Portal1440/320 восстановили /servers?server=42&tab=details, fragment удалён, errors/overflow нет. CHR/native config и backend не менялись, новых Telegram sends нет. + +Оставшиеся return_to198 проверены адресно: http://localhost:3001/auth/callback и http://localhost.evil.invalid:3000/auth/callback не вызывают external navigation, Portal остаётся /admin. Browser logout очищает storage. Предыдущий lookalike URL в драйвере был синтаксически ошибочным (порт3000.evil.invalid), поэтому login form отсутствовала; это исправлено в тесте. Подтверждённый429198 сохраняется в историческом протоколе, но не объявляется причиной этого отдельного malformed-URL сбоя. Повторные full cycles прекращены; реальные endpoints не mock. Доверенные allowlist origins сохранены. + +Протоколы: [E2E12](ux-ui/acceptance/portal199-e2e-2026-10-13.json), [deployment](ux-ui/acceptance/portal199-deployment-2026-10-13.json), [actual expired recovery2](ux-ui/acceptance/portal199-live-ui-2026-10-13.json), [remaining return_to](ux-ui/acceptance/portal199-return-to-2026-10-13.json). + +| Референс | Схема | +| --- | --- | +| [ReUI App Shell12](https://reui.io/preview/base/app-shell-12?ref=mcp) | Раздел + фильтры → expired session → Portal → callback с проверкой прав → тот же раздел + фильтры | + +Следующий200: owned PostgreSQL/child-process crash и advisory-lock loss traffic collector. Цельный непрерывный Telegram live197 остаётся отдельно с исходным failed evidence; успешные chunk tests не отменяют его. Logout не отозвал offlineJWT доexp, publicIPv6/clientOS/independentWAN/scale остаются внешними ограничениями.12.5 открыта. diff --git a/docs/ux-ui-stage-12.md b/docs/ux-ui-stage-12.md index e76d20a3..3bcd8edc 100644 --- a/docs/ux-ui-stage-12.md +++ b/docs/ux-ui-stage-12.md @@ -1,6 +1,6 @@ # Этап 12: итоговая приёмка UX/UI -Актуальное продолжение 2026-10-13: блок198 — actual expired JWT401, reissue role changes, logout boundary и live expired recovery ПК/телефон приняты. Первый внешний return_to и browser logout passed; остальные два return_to ограничены подтверждённым429 Portal. Runtime ux-peak196. Следующий199 — сохранить исходный раздел/параметры после SSO;12.5 открыта. +Актуальное продолжение 2026-10-13: завершён199 — SSO сохраняет исходный разрешённый раздел/query; unit/typecheck/ESLint,12 E2E и2 live expired recovery passed. Frontend ux-portal199/backend ux-peak196, Portal1.3.1. Оставшиеся return_to199 приняты после коррекции тестового URL. Следующий200 — crash/lock-loss traffic collector; диагностическая граница цельного Telegram197 сохраняется.12.5 открыта. План: [ux-ui-plan.md](ux-ui-plan.md). Фактические результаты: [ux-ui-progress.md](ux-ui-progress.md). Подготовлен после сводных проверок 11Б; завершение каждого блока требует локального коммита без push. @@ -3719,3 +3719,21 @@ Live UI1440/320 с настоящим expired JWT: MM очистил mmapp_token Протоколы: [actual lifecycle API](ux-ui/acceptance/portal198-lifecycle-2026-10-13.json), [live UI и границы429](ux-ui/acceptance/portal198-live-ui-2026-10-13.json). Следующий199: восстановить исходный same-origin раздел и query после SSO с проверкой permissions и запретом внешних/auth callback redirect. Затем профильные E2E/deploy и два оставшихся Portal return_to.12.5 открыта. + +## 12.5.199 — восстановление раздела и параметров после SSO (2026-10-13) + +Из198 подтверждено: expired session на /servers попадала после login в /dashboard. redirectToPortalLogin теперь добавляет исходный pathname+query в next callback URL. AuthCallbackPage после valid claims выбирает permittedAuthReturnPath и проверяет существующее permissionForPath/can; недопустимый путь или недостаточные права используют firstAllowedPath. При повторном handoff next сохраняется. JWT fragment не включается в сохранённый путь. + +Новый pure helper отклоняет внешние/protocol-relative URL, backslash/control/space, слишком длинный путь, auth/api/access-denied loop, нормализованный dot traversal вauth и percent-encoded pathname. Query, включая percent-encoded filter values, сохраняется; hash удаляется. Консервативное ограничение касается pathname: приложение использует обычные фиксированные пути/ID; percent-encoded pathname не восстанавливается, выбирается fallback. Это не расширение существующей карты permissions и не замена backend RBAC. + +Unit unsafe/normalization/query/permissions passed, frontend TypeScript и scoped ESLint passed.12 unique E2E/24971ms/0unexpected:10 query/denied/external/auth-loop/handoff cases1440/320 и2 runtime Portal links. Это fixtures, не реальные подписи Portal. Отдельно production frontend ux-portal199 собран и развёрнут,527 source files совпали перед build. Backend ux-peak196/Portal1.3.1/AUTH_REQUIRED=true сохранены.2 live UI с настоящим naturally expired JWT и login Portal1440/320 восстановили /servers?server=42&tab=details, fragment удалён, errors/overflow нет. CHR/native config и backend не менялись, новых Telegram sends нет. + +Оставшиеся return_to198 проверены адресно: http://localhost:3001/auth/callback и http://localhost.evil.invalid:3000/auth/callback не вызывают external navigation, Portal остаётся /admin. Browser logout очищает storage. Предыдущий lookalike URL в драйвере был синтаксически ошибочным (порт3000.evil.invalid), поэтому login form отсутствовала; это исправлено в тесте. Подтверждённый429198 сохраняется в историческом протоколе, но не объявляется причиной этого отдельного malformed-URL сбоя. Повторные full cycles прекращены; реальные endpoints не mock. Доверенные allowlist origins сохранены. + +Протоколы: [E2E12](ux-ui/acceptance/portal199-e2e-2026-10-13.json), [deployment](ux-ui/acceptance/portal199-deployment-2026-10-13.json), [actual expired recovery2](ux-ui/acceptance/portal199-live-ui-2026-10-13.json), [remaining return_to](ux-ui/acceptance/portal199-return-to-2026-10-13.json). + +| Референс | Схема | +| --- | --- | +| [ReUI App Shell12](https://reui.io/preview/base/app-shell-12?ref=mcp) | Раздел + фильтры → expired session → Portal → callback с проверкой прав → тот же раздел + фильтры | + +Следующий200: owned PostgreSQL/child-process crash и advisory-lock loss traffic collector. Цельный непрерывный Telegram live197 остаётся отдельно с исходным failed evidence; успешные chunk tests не отменяют его. Logout не отозвал offlineJWT доexp, publicIPv6/clientOS/independentWAN/scale остаются внешними ограничениями.12.5 открыта. diff --git a/docs/ux-ui-test-server.md b/docs/ux-ui-test-server.md index 9aab4350..b1220d27 100644 --- a/docs/ux-ui-test-server.md +++ b/docs/ux-ui-test-server.md @@ -1,6 +1,6 @@ # Тестовый сервер UX/UI — 2026-10-10 -Актуальное продолжение 2026-10-13: блок198 — actual expired JWT401, reissue role changes, logout boundary и live expired recovery ПК/телефон приняты. Первый внешний return_to и browser logout passed; остальные два return_to ограничены подтверждённым429 Portal. Runtime ux-peak196. Следующий199 — сохранить исходный раздел/параметры после SSO;12.5 открыта. +Актуальное продолжение 2026-10-13: завершён199 — SSO сохраняет исходный разрешённый раздел/query; unit/typecheck/ESLint,12 E2E и2 live expired recovery passed. Frontend ux-portal199/backend ux-peak196, Portal1.3.1. Оставшиеся return_to199 приняты после коррекции тестового URL. Следующий200 — crash/lock-loss traffic collector; диагностическая граница цельного Telegram197 сохраняется.12.5 открыта. | Параметр | Проверенное состояние | | --- | --- | @@ -708,7 +708,7 @@ flowchart LR ## 12.5.77 — основной блок IPsec (2026-10-09) -Актуальное продолжение 2026-10-13: блок198 — actual expired JWT401, reissue role changes, logout boundary и live expired recovery ПК/телефон приняты. Первый внешний return_to и browser logout passed; остальные два return_to ограничены подтверждённым429 Portal. Runtime ux-peak196. Следующий199 — сохранить исходный раздел/параметры после SSO;12.5 открыта. +Актуальное продолжение 2026-10-13: завершён199 — SSO сохраняет исходный разрешённый раздел/query; unit/typecheck/ESLint,12 E2E и2 live expired recovery passed. Frontend ux-portal199/backend ux-peak196, Portal1.3.1. Оставшиеся return_to199 приняты после коррекции тестового URL. Следующий200 — crash/lock-loss traffic collector; диагностическая граница цельного Telegram197 сохраняется.12.5 открыта. Подробный объём, протоколы, схема и ограничения: [12.5.77 в журнале](ux-ui-progress.md). @@ -2145,3 +2145,21 @@ Live UI1440/320 с настоящим expired JWT: MM очистил mmapp_token Протоколы: [actual lifecycle API](ux-ui/acceptance/portal198-lifecycle-2026-10-13.json), [live UI и границы429](ux-ui/acceptance/portal198-live-ui-2026-10-13.json). Следующий199: восстановить исходный same-origin раздел и query после SSO с проверкой permissions и запретом внешних/auth callback redirect. Затем профильные E2E/deploy и два оставшихся Portal return_to.12.5 открыта. + +## 12.5.199 — восстановление раздела и параметров после SSO (2026-10-13) + +Из198 подтверждено: expired session на /servers попадала после login в /dashboard. redirectToPortalLogin теперь добавляет исходный pathname+query в next callback URL. AuthCallbackPage после valid claims выбирает permittedAuthReturnPath и проверяет существующее permissionForPath/can; недопустимый путь или недостаточные права используют firstAllowedPath. При повторном handoff next сохраняется. JWT fragment не включается в сохранённый путь. + +Новый pure helper отклоняет внешние/protocol-relative URL, backslash/control/space, слишком длинный путь, auth/api/access-denied loop, нормализованный dot traversal вauth и percent-encoded pathname. Query, включая percent-encoded filter values, сохраняется; hash удаляется. Консервативное ограничение касается pathname: приложение использует обычные фиксированные пути/ID; percent-encoded pathname не восстанавливается, выбирается fallback. Это не расширение существующей карты permissions и не замена backend RBAC. + +Unit unsafe/normalization/query/permissions passed, frontend TypeScript и scoped ESLint passed.12 unique E2E/24971ms/0unexpected:10 query/denied/external/auth-loop/handoff cases1440/320 и2 runtime Portal links. Это fixtures, не реальные подписи Portal. Отдельно production frontend ux-portal199 собран и развёрнут,527 source files совпали перед build. Backend ux-peak196/Portal1.3.1/AUTH_REQUIRED=true сохранены.2 live UI с настоящим naturally expired JWT и login Portal1440/320 восстановили /servers?server=42&tab=details, fragment удалён, errors/overflow нет. CHR/native config и backend не менялись, новых Telegram sends нет. + +Оставшиеся return_to198 проверены адресно: http://localhost:3001/auth/callback и http://localhost.evil.invalid:3000/auth/callback не вызывают external navigation, Portal остаётся /admin. Browser logout очищает storage. Предыдущий lookalike URL в драйвере был синтаксически ошибочным (порт3000.evil.invalid), поэтому login form отсутствовала; это исправлено в тесте. Подтверждённый429198 сохраняется в историческом протоколе, но не объявляется причиной этого отдельного malformed-URL сбоя. Повторные full cycles прекращены; реальные endpoints не mock. Доверенные allowlist origins сохранены. + +Протоколы: [E2E12](ux-ui/acceptance/portal199-e2e-2026-10-13.json), [deployment](ux-ui/acceptance/portal199-deployment-2026-10-13.json), [actual expired recovery2](ux-ui/acceptance/portal199-live-ui-2026-10-13.json), [remaining return_to](ux-ui/acceptance/portal199-return-to-2026-10-13.json). + +| Референс | Схема | +| --- | --- | +| [ReUI App Shell12](https://reui.io/preview/base/app-shell-12?ref=mcp) | Раздел + фильтры → expired session → Portal → callback с проверкой прав → тот же раздел + фильтры | + +Следующий200: owned PostgreSQL/child-process crash и advisory-lock loss traffic collector. Цельный непрерывный Telegram live197 остаётся отдельно с исходным failed evidence; успешные chunk tests не отменяют его. Logout не отозвал offlineJWT доexp, publicIPv6/clientOS/independentWAN/scale остаются внешними ограничениями.12.5 открыта. diff --git a/docs/ux-ui/acceptance/portal199-deployment-2026-10-13.json b/docs/ux-ui/acceptance/portal199-deployment-2026-10-13.json new file mode 100644 index 00000000..cd208c59 --- /dev/null +++ b/docs/ux-ui/acceptance/portal199-deployment-2026-10-13.json @@ -0,0 +1,14 @@ +{ + "date": "2026-10-13", + "block": "12.5.199", + "sourceFilesMatched": 527, + "sourceHashAlgorithm": "sha256 raw bytes before Docker build", + "productionBuildPassed": true, + "frontendImage": "mmapp-test/frontend:ux-portal199", + "frontendImageId": "sha256:d3041db1ab0443211bc65bbe819438b9c9647763421fb7fe0dddae786c943cbe4", + "backendImage": "mmapp-test/backend:ux-peak196", + "rollbackFrontendTag": "ux-peak196", + "portalVersion": "1.3.1", + "authRequired": true, + "scope": "Only frontend image tag changed; actual expired session SSO UI tested separately" +} diff --git a/docs/ux-ui/acceptance/portal199-e2e-2026-10-13.json b/docs/ux-ui/acceptance/portal199-e2e-2026-10-13.json new file mode 100644 index 00000000..a0a933c7 --- /dev/null +++ b/docs/ux-ui/acceptance/portal199-e2e-2026-10-13.json @@ -0,0 +1,14 @@ +{ + "date": "2026-10-13", + "block": "12.5.199", + "stats": { + "startTime": "2026-10-13T09:01:20.154Z", + "duration": 24970.957000000002, + "expected": 12, + "skipped": 0, + "unexpected": 0, + "flaky": 0 + }, + "errors": [], + "scope": "10 callback/query/permissions/unsafe target/handoff fixtures + 2 runtime link fixtures; not real Portal" +} diff --git a/docs/ux-ui/acceptance/portal199-live-ui-2026-10-13.json b/docs/ux-ui/acceptance/portal199-live-ui-2026-10-13.json new file mode 100644 index 00000000..da85fcd8 --- /dev/null +++ b/docs/ux-ui/acceptance/portal199-live-ui-2026-10-13.json @@ -0,0 +1,36 @@ +{ + "date": "2026-10-13", + "block": "12.5.199", + "cases": [ + { + "name": "expired token recovery", + "width": 1440, + "expiredMmTokenCleared": true, + "portalLogin": true, + "recoveredActualSso": true, + "landingPath": "/servers", + "originalPathRestored": true, + "noFragment": true, + "noOverflow": true, + "pageErrors": 0, + "restoredSearch": "?server=42&tab=details" + }, + { + "name": "expired token recovery", + "width": 320, + "expiredMmTokenCleared": true, + "portalLogin": true, + "recoveredActualSso": true, + "landingPath": "/servers", + "originalPathRestored": true, + "noFragment": true, + "noOverflow": true, + "pageErrors": 0, + "restoredSearch": "?server=42&tab=details" + } + ], + "realRuntime": true, + "apiMocks": false, + "blockedUnexpectedNavigationForSafety": true, + "serverWrites": 0 +} diff --git a/docs/ux-ui/acceptance/portal199-return-to-2026-10-13.json b/docs/ux-ui/acceptance/portal199-return-to-2026-10-13.json new file mode 100644 index 00000000..ca179b63 --- /dev/null +++ b/docs/ux-ui/acceptance/portal199-return-to-2026-10-13.json @@ -0,0 +1,32 @@ +{ + "date": "2026-10-13", + "block": "12.5.199", + "cases": [ + { + "name": "disallowed return_to", + "target": "http://localhost:3001/auth/callback", + "externalNavigationAttempts": 0, + "portalStayed": true, + "apiMocks": false + }, + { + "name": "disallowed return_to", + "target": "http://localhost.evil.invalid:3000/auth/callback", + "externalNavigationAttempts": 0, + "portalStayed": true, + "apiMocks": false + }, + { + "name": "Portal browser logout", + "loginVisible": true, + "tokenStorageCleared": true + } + ], + "realRuntime": true, + "apiMocks": false, + "blockedUnexpectedNavigationForSafety": true, + "serverWrites": 0, + "remaining": [], + "complete": true, + "limitation": "Previous lookalike target used an invalid port and could not render login; corrected syntactically valid hostname tested separately." +} diff --git a/lib/auth-return-path.test.ts b/lib/auth-return-path.test.ts new file mode 100644 index 00000000..894285ad --- /dev/null +++ b/lib/auth-return-path.test.ts @@ -0,0 +1,9 @@ +import assert from 'node:assert/strict' +import {safeAuthReturnPath,permittedAuthReturnPath} from './auth-return-path' +for(const input of [null,undefined,'','https://outside.invalid','//outside.invalid','/\\outside.invalid','/auth/callback?next=/servers','/api/servers','/access-denied','/a/../auth/callback','/%2f%2foutside','/%5coutside','/%61uth/callback','/%73ettings','/servers\n','/servers?'+ 'a'.repeat(2048)])assert.equal(safeAuthReturnPath(input),null,String(input)) +assert.equal(safeAuthReturnPath('/servers?server=42&tab=details#private-token'),'/servers?server=42&tab=details') +assert.equal(safeAuthReturnPath('/traffic?period=24h&name=%D0%A2%D0%B5%D1%81%D1%82'),'/traffic?period=24h&name=%D0%A2%D0%B5%D1%81%D1%82') +assert.equal(permittedAuthReturnPath('/servers?server=42',p=>p==='/servers','/dashboard'),'/servers?server=42') +assert.equal(permittedAuthReturnPath('/settings',p=>p==='/servers','/servers'),'/servers') +assert.equal(permittedAuthReturnPath('//outside.invalid',()=>true,'/servers'),'/servers') +console.log('auth return path: unsafe targets, normalized auth loops, query preservation and permission fallback passed') diff --git a/lib/auth-return-path.ts b/lib/auth-return-path.ts new file mode 100644 index 00000000..c4fb3d8c --- /dev/null +++ b/lib/auth-return-path.ts @@ -0,0 +1,22 @@ +/** Accept an application path, never an external URL or another auth handoff. */ +export function safeAuthReturnPath(value: string | null | undefined): string | null { + if (!value || value.length > 2048 || !value.startsWith("/") || value.startsWith("//")) return null + if (/[\\\u0000-\u0020\u007f]/.test(value)) return null + try { + const url = new URL(value, "https://mmapp.invalid") + if (url.origin !== "https://mmapp.invalid" || /%/.test(url.pathname)) return null + if (/^\/(auth|api|access-denied)(\/|$)/.test(url.pathname)) return null + return url.pathname + url.search + } catch { + return null + } +} + +export function permittedAuthReturnPath( + value: string | null | undefined, + allowed: (pathname: string) => boolean, + fallback: string, +): string { + const path = safeAuthReturnPath(value) + return path && allowed(new URL(path, "https://mmapp.invalid").pathname) ? path : fallback +} diff --git a/lib/auth.ts b/lib/auth.ts index 3a06a96c..fa405b30 100644 --- a/lib/auth.ts +++ b/lib/auth.ts @@ -1,4 +1,5 @@ /** Portal JWT storage + claims helpers for MikrotikManager. */ +import { safeAuthReturnPath } from "./auth-return-path" const TOKEN_KEY = "mmapp_token" const HANDOFF_KEY = "mmapp_auth_401_handoff" @@ -125,9 +126,12 @@ export function redirectToPortalLogin(returnTo?: string): boolean { return false } markPortalHandoff() - const callback = returnTo ?? `${window.location.origin}/auth/callback` + const callback = new URL(returnTo ?? `${window.location.origin}/auth/callback`) + const next = safeAuthReturnPath(callback.searchParams.get("next")) ?? + safeAuthReturnPath(window.location.pathname + window.location.search) + if (next) callback.searchParams.set("next", next) const url = new URL(authPortalUrl()) - url.searchParams.set("return_to", callback) + url.searchParams.set("return_to", callback.toString()) window.location.assign(url.toString()) return true } diff --git a/tests/e2e/auth-return-path.spec.ts b/tests/e2e/auth-return-path.spec.ts new file mode 100644 index 00000000..6a97e3fe --- /dev/null +++ b/tests/e2e/auth-return-path.spec.ts @@ -0,0 +1,28 @@ +import {test,expect} from '@playwright/test' +const token=(permissions:string[])=>[Buffer.from('{}').toString('base64url'),Buffer.from(JSON.stringify({sub:'own199',name:'Own199',email:'own199@example.test',apps:['mm'],permissions,exp:Math.floor(Date.now()/1000)+600})).toString('base64url'),'fixture'].join('.') +for(const width of [1440,320]){ + for(const [name,next,permissions,expected]of [ + ['query','/servers?server=42&tab=details',['mm:servers:read'],'/servers?server=42&tab=details'], + ['denied','/settings',['mm:servers:read'],'/servers'], + ['external','//outside.invalid',['mm:servers:read'],'/servers'], + ['loop','/auth/callback?next=/servers',['mm:servers:read'],'/servers'], + ] as const)test(`SSO return ${name} ${width}`,async({page})=>{ + await page.setViewportSize({width,height:1000}) + await page.route('**/api/**',r=>{const path=new URL(r.request().url()).pathname;return r.fulfill({json:path==='/api/auth/config'?{required:true,portal_url:'https://portal.example.test'}:path==='/api/servers'?[]:path==='/api/v1/app-switcher'?{menuLabel:'Приложения',apps:[]}:{}})}) + await page.goto('/auth/callback?next='+encodeURIComponent(next)+'#access_token='+token([...permissions])) + await expect.poll(()=>new URL(page.url()).pathname+new URL(page.url()).search).toBe(expected) + expect(new URL(page.url()).hash).toBe('') + await expect(page.getByRole('button',{name:'Добавить сервер',exact:true})).toBeVisible() + }) + test(`SSO handoff captures query ${width}`,async({page})=>{ + await page.setViewportSize({width,height:1000}) + await page.route('**/api/auth/config',r=>r.fulfill({json:{required:true,portal_url:'https://portal.example.test/'}})) + await page.route('https://portal.example.test/**',r=>r.fulfill({contentType:'text/html',body:'

Own Portal fixture

'})) + await page.goto('/servers?server=42&tab=details') + await expect.poll(()=>new URL(page.url()).hostname).toBe('portal.example.test') + const callback=new URL(new URL(page.url()).searchParams.get('return_to')!) + expect(callback.origin).toBe('http://127.0.0.1:3100') + expect(callback.pathname).toBe('/auth/callback') + expect(callback.searchParams.get('next')).toBe('/servers?server=42&tab=details') + }) +}