From a4ece3d344b6ca884e35b5003b7a17f80634c2d4 Mon Sep 17 00:00:00 2001 From: Denozordec Date: Sat, 18 Jul 2026 18:36:21 +0700 Subject: [PATCH] refactor(auth): streamline logout route and update route tree for logout integration --- apps/api/src/routes/auth.ts | 28 ++++++++++++---------------- apps/web/src/routeTree.gen.ts | 24 ++++++++++++++++++++++-- apps/web/src/routes/logout.tsx | 22 ++++++++++++++++++++++ docs/integrate-cfdm.md | 7 ++++++- docs/integrate-vps-tracker.md | 7 +++++++ 5 files changed, 69 insertions(+), 19 deletions(-) create mode 100644 apps/web/src/routes/logout.tsx diff --git a/apps/api/src/routes/auth.ts b/apps/api/src/routes/auth.ts index 2993c57..fe97254 100644 --- a/apps/api/src/routes/auth.ts +++ b/apps/api/src/routes/auth.ts @@ -91,22 +91,18 @@ export async function authRoutes(app: FastifyInstance): Promise { }, }) - app.post( - '/api/v1/auth/logout', - { onRequest: requireAuth }, - async (request, reply) => { - const cookie = request.headers.cookie ?? '' - const match = cookie.match(new RegExp(`${REFRESH_COOKIE}=([^;]+)`)) - if (match?.[1]) { - revokeRefreshSession(app.db, match[1]) - } - reply.header( - 'Set-Cookie', - `${REFRESH_COOKIE}=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0`, - ) - return { ok: true } - }, - ) + app.post('/api/v1/auth/logout', async (request, reply) => { + const cookie = request.headers.cookie ?? '' + const match = cookie.match(new RegExp(`${REFRESH_COOKIE}=([^;]+)`)) + if (match?.[1]) { + revokeRefreshSession(app.db, match[1]) + } + reply.header( + 'Set-Cookie', + `${REFRESH_COOKIE}=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0`, + ) + return { ok: true } + }) app.get( '/api/v1/auth/me', diff --git a/apps/web/src/routeTree.gen.ts b/apps/web/src/routeTree.gen.ts index 496e285..28bdbd0 100644 --- a/apps/web/src/routeTree.gen.ts +++ b/apps/web/src/routeTree.gen.ts @@ -11,6 +11,7 @@ import { Route as rootRouteImport } from './routes/__root' import { Route as IndexRouteImport } from './routes/index' import { Route as AuthRouteImport } from './routes/_auth' +import { Route as LogoutRouteImport } from './routes/logout' import { Route as AuthAdminRouteImport } from './routes/_auth.admin' import { Route as AuthAppsRouteImport } from './routes/_auth.apps' import { Route as AuthAdminIndexRouteImport } from './routes/_auth.admin.index' @@ -25,6 +26,11 @@ const AuthRoute = AuthRouteImport.update({ id: '/_auth', getParentRoute: () => rootRouteImport, } as any) +const LogoutRoute = LogoutRouteImport.update({ + id: '/logout', + path: '/logout', + getParentRoute: () => rootRouteImport, +} as any) const AuthAdminRoute = AuthAdminRouteImport.update({ id: '/admin', path: '/admin', @@ -48,6 +54,7 @@ const AuthAdminUsersUserIdRoute = AuthAdminUsersUserIdRouteImport.update({ export interface FileRoutesByFullPath { '/': typeof IndexRoute + '/logout': typeof LogoutRoute '/admin': typeof AuthAdminRouteWithChildren '/apps': typeof AuthAppsRoute '/admin/': typeof AuthAdminIndexRoute @@ -55,6 +62,7 @@ export interface FileRoutesByFullPath { } export interface FileRoutesByTo { '/': typeof IndexRoute + '/logout': typeof LogoutRoute '/apps': typeof AuthAppsRoute '/admin': typeof AuthAdminIndexRoute '/admin/users/$userId': typeof AuthAdminUsersUserIdRoute @@ -63,6 +71,7 @@ export interface FileRoutesById { __root__: typeof rootRouteImport '/': typeof IndexRoute '/_auth': typeof AuthRouteWithChildren + '/logout': typeof LogoutRoute '/_auth/admin': typeof AuthAdminRouteWithChildren '/_auth/apps': typeof AuthAppsRoute '/_auth/admin/': typeof AuthAdminIndexRoute @@ -70,13 +79,15 @@ export interface FileRoutesById { } export interface FileRouteTypes { fileRoutesByFullPath: FileRoutesByFullPath - fullPaths: '/' | '/admin' | '/apps' | '/admin/' | '/admin/users/$userId' + fullPaths: + '/' | '/logout' | '/admin' | '/apps' | '/admin/' | '/admin/users/$userId' fileRoutesByTo: FileRoutesByTo - to: '/' | '/apps' | '/admin' | '/admin/users/$userId' + to: '/' | '/logout' | '/apps' | '/admin' | '/admin/users/$userId' id: | '__root__' | '/' | '/_auth' + | '/logout' | '/_auth/admin' | '/_auth/apps' | '/_auth/admin/' @@ -86,6 +97,7 @@ export interface FileRouteTypes { export interface RootRouteChildren { IndexRoute: typeof IndexRoute AuthRoute: typeof AuthRouteWithChildren + LogoutRoute: typeof LogoutRoute } declare module '@tanstack/react-router' { @@ -104,6 +116,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof AuthRouteImport parentRoute: typeof rootRouteImport } + '/logout': { + id: '/logout' + path: '/logout' + fullPath: '/logout' + preLoaderRoute: typeof LogoutRouteImport + parentRoute: typeof rootRouteImport + } '/_auth/admin': { id: '/_auth/admin' path: '/admin' @@ -164,6 +183,7 @@ const AuthRouteWithChildren = AuthRoute._addFileChildren(AuthRouteChildren) const rootRouteChildren: RootRouteChildren = { IndexRoute: IndexRoute, AuthRoute: AuthRouteWithChildren, + LogoutRoute: LogoutRoute, } export const routeTree = rootRouteImport ._addFileChildren(rootRouteChildren) diff --git a/apps/web/src/routes/logout.tsx b/apps/web/src/routes/logout.tsx new file mode 100644 index 0000000..c721197 --- /dev/null +++ b/apps/web/src/routes/logout.tsx @@ -0,0 +1,22 @@ +import { createFileRoute, redirect } from '@tanstack/react-router' +import { clearToken } from '@/lib/auth' +import { logout } from '@/queries/auth' + +/** + * Cross-app SSO logout landing. + * Apps (vps-tracker, CFDM, …) redirect here after clearing their local JWT + * so the portal session (localStorage + refresh cookie) is revoked — + * otherwise `/?return_to=…` would immediately hand a new token back. + */ +export const Route = createFileRoute('/logout')({ + beforeLoad: async () => { + try { + await logout() + } catch { + /* cookie/token already gone */ + } + clearToken() + throw redirect({ to: '/' }) + }, + component: () => null, +}) diff --git a/docs/integrate-cfdm.md b/docs/integrate-cfdm.md index e8298c9..7b33bfc 100644 --- a/docs/integrate-cfdm.md +++ b/docs/integrate-cfdm.md @@ -94,7 +94,11 @@ pnpm --filter web dev ## UI аккаунта -SidebarFooter → **NavUser** ([app-shell-1](https://reui.io/preview/base/app-shell-1)): Настройки, Тема, Выйти → portal. +SidebarFooter → **NavUser** ([app-shell-1](https://reui.io/preview/base/app-shell-1)): Настройки, Тема, Выйти → `AUTH_PORTAL_URL/logout`. + +## Logout (SSO) + +Очистить `cfdm_token` → редирект на **`/logout`** портала (не на `/?return_to=…` — иначе portal сразу выдаст новый SSO-токен). ## Troubleshooting @@ -104,3 +108,4 @@ SidebarFooter → **NavUser** ([app-shell-1](https://reui.io/preview/base/app-sh | 403 «Нет доступа к приложению» | у пользователя нет app `cfdm` в portal | | 403 «Недостаточно прав» | нет нужного `cfdm:…` permission | | return_to rejected | origin CFDM не в `RETURN_TO_ALLOWLIST` | +| «Выйти» сразу возвращает в CFDM | клиент должен открывать `/logout`, не login с `return_to` | diff --git a/docs/integrate-vps-tracker.md b/docs/integrate-vps-tracker.md index 08d71db..32fc049 100644 --- a/docs/integrate-vps-tracker.md +++ b/docs/integrate-vps-tracker.md @@ -108,8 +108,15 @@ pnpm --filter web dev # :5173 | 403 на write | Только `*:read` в permissions | | Loop на login | `return_to` не в `RETURN_TO_ALLOWLIST` | | Infinite SSO / 429 | Просроченный JWT в portal localStorage; или разный `JWT_SECRET`/`ISSUER`. Portal чистит expired token; VPS блокирует повторный handoff 12с | +| «Выйти» сразу возвращает в приложение | Старый клиент редиректил на `/?return_to=…` при живой portal-сессии. Нужен редирект на **`/logout`** (см. ниже) | | CORS | Portal и VPS на разных origin — fragment handoff не требует CORS для token | +## Logout (SSO) + +«Выйти» в приложении: очистить локальный JWT → `AUTH_PORTAL_URL/logout` (без `return_to`). + +Портал на `/logout`: `POST /api/v1/auth/logout` (revoke refresh cookie) → `clearToken()` → форма логина. + ## Production - Один `JWT_SECRET` в secret store обоих сервисов