diff --git a/TODO.md b/TODO.md index d281f60..13f5af6 100644 --- a/TODO.md +++ b/TODO.md @@ -1788,6 +1788,46 @@ secao 96-103, 124, 169) + achado real de autorização em `/ai/models` mobile — logos aparecendo corretos em todos, sem o artefato de retângulo branco no Handix +## PHASE 51 — Renovação automática de sessão (achado real, reportado pelo +usuário: `Ctrl+F5` depois de um tempo logado quebrava com 401 cru) +- [x] achado real: `ACCESS_TOKEN_TTL = "15m"` (secao 148) e `apiFetch` + nunca tentava nenhum refresh — qualquer reload (ou até só navegar) + depois de 15min logado batia 401 em toda Server Component que + chamasse a API, e o erro (`ApiError` cru, JSON da API) subia sem + tratamento nenhum até virar a tela de erro genérica do Next.js. Não + era um caso raro: qualquer sessão de teste mais longa que 15min + batia nisso +- [x] `apps/frontend/src/middleware.ts` (novo) — decodifica o `exp` do + access token guardado no cookie (sem verificar assinatura, só + leitura — a verificação de verdade continua sendo feita pela API) e, + se faltar menos de 60s pra vencer, chama `POST /auth/refresh` com o + refresh token *antes* da Server Component rodar, gravando o cookie + novo tanto na resposta (`response.cookies`) quanto na própria + requisição (`request.cookies`, via `NextResponse.next({ request })`) + — sem isto, a MESMA requisição que disparou o refresh ainda leria o + cookie velho (padrão documentado do Next.js pra refresh de auth em + middleware que precisa valer já na requisição atual, não só na + próxima) +- [x] Rede de segurança pro caso do refresh token TAMBÉM já ter vencido/ + sido revogado (sessão parada por mais de 30 dias, ou logout em outro + lugar): `apps/frontend/src/app/error.tsx` (Error Boundary do App + Router) detecta uma mensagem de sessão expirada/401, desloga + (`POST /api/logout`) e manda pro `/login` sozinho, em vez de mostrar + a tela crua "Application error: a server-side exception...". Tem + que ficar na raiz de `app/`, não dentro de `app/app/` ou + `app/platform/` — `error.tsx` nunca pega erro do PRÓPRIO layout do + mesmo segmento (é o layout de `/app`/`/platform` que chama + `/auth/me` e lança o erro), só de layouts/páginas aninhados abaixo + (achado testando: o primeiro corte com um `error.tsx` por segmento + não pegava nada, confirmado só depois de mover pra raiz) +- [x] Testado ponta a ponta com refresh token de verdade: token forjado + pra vencer em 20s + refresh token real → renovação automática + confirmada (token novo no cookie, página protegida carrega normal, + sem nenhum 401 visível). Com refresh token inválido de propósito → + confirmado o fallback: erro 500 na resposta inicial (esperado, SSR), + mas o error boundary do client detecta, desloga e redireciona pro + login sozinho — sem crash na tela pro usuário + --- ## Riscos conhecidos diff --git a/apps/frontend/src/app/error.tsx b/apps/frontend/src/app/error.tsx new file mode 100644 index 0000000..b626e26 --- /dev/null +++ b/apps/frontend/src/app/error.tsx @@ -0,0 +1,7 @@ +"use client"; + +import { SessionErrorView } from "@/components/session-error-view"; + +export default function Error(props: { error: Error & { digest?: string }; reset: () => void }) { + return ; +} diff --git a/apps/frontend/src/components/session-error-view.tsx b/apps/frontend/src/components/session-error-view.tsx new file mode 100644 index 0000000..34b57f2 --- /dev/null +++ b/apps/frontend/src/components/session-error-view.tsx @@ -0,0 +1,56 @@ +"use client"; + +import { useEffect } from "react"; +import { useRouter } from "next/navigation"; +import { Button } from "@/components/ui/button"; + +/** Next.js `error.tsx` global-error boundary por segmento (`/app`, + * `/platform`) — pega qualquer erro não tratado durante o render de uma + * Server Component, inclusive um `ApiError` 401 propagado cru de + * `apiFetch` (achado real: sem isto, um access token vencido — TTL de + * 15min, secao 148 — sem sessão de refresh ativa quebrava com uma tela de + * erro genérica do Next.js em vez de mandar de volta pro login). + * `middleware.ts` já renova o token proativamente antes de vencer; este + * boundary é só a rede de segurança pro caso raro do refresh token também + * ter expirado/sido revogado (30 dias de sessão parada, ou logout em + * outro lugar). */ +function isSessionExpired(message: string): boolean { + return /token de acesso|unauthorized|statuscode":401/i.test(message); +} + +export function SessionErrorView({ error, reset }: { error: Error & { digest?: string }; reset: () => void }) { + const router = useRouter(); + const expired = isSessionExpired(error.message); + + useEffect(() => { + if (!expired) return; + fetch("/api/logout", { method: "POST" }).finally(() => { + router.push("/login"); + }); + }, [expired, router]); + + if (expired) { + return ( +
+

Sua sessão expirou — redirecionando pro login…

+
+ ); + } + + return ( +
+

Algo deu errado

+

+ Tente de novo — se persistir, avise o suporte com o que você estava fazendo quando aconteceu. +

+
+ + +
+
+ ); +} diff --git a/apps/frontend/src/middleware.ts b/apps/frontend/src/middleware.ts new file mode 100644 index 0000000..9a78140 --- /dev/null +++ b/apps/frontend/src/middleware.ts @@ -0,0 +1,86 @@ +import { NextResponse, type NextRequest } from "next/server"; + +const COOKIE_NAME = "b2bcall_session"; +const API_BASE_URL = process.env.B2BCALL_API_URL ?? "http://localhost:3000"; + +// Renova o access token um pouco antes de vencer (secao 148: TTL de 15min) +// pra nunca deixar uma requisição de verdade bater 401 no meio de uso normal +// — sem isto, qualquer reload (ou até só navegar) depois de 15min logado +// quebrava com "Token de acesso invalido ou expirado" sem nenhum tratamento +// (achado real, reportado pelo usuário testando: `apiFetch` nunca tenta +// refresh nenhum, só propaga o erro cru). Buffer de 60s: espaço suficiente +// pro request atual terminar antes do token realmente vencer. +const REFRESH_BUFFER_MS = 60_000; + +interface SessionCookie { + accessToken: string; + refreshToken: string; +} + +function decodeExpiry(jwt: string): number | null { + try { + const payload = jwt.split(".")[1]; + const base64 = payload.replace(/-/g, "+").replace(/_/g, "/").padEnd(payload.length + ((4 - (payload.length % 4)) % 4), "="); + const json = JSON.parse(atob(base64)) as { exp?: number }; + return typeof json.exp === "number" ? json.exp * 1000 : null; + } catch { + return null; + } +} + +export async function middleware(request: NextRequest) { + const raw = request.cookies.get(COOKIE_NAME)?.value; + if (!raw) return NextResponse.next(); + + let session: SessionCookie; + try { + session = JSON.parse(raw); + } catch { + return NextResponse.next(); + } + + const expiresAt = decodeExpiry(session.accessToken); + if (expiresAt == null || expiresAt - Date.now() > REFRESH_BUFFER_MS) { + return NextResponse.next(); + } + + try { + const res = await fetch(`${API_BASE_URL}/auth/refresh`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ refreshToken: session.refreshToken }), + cache: "no-store", + }); + if (!res.ok) throw new Error("refresh failed"); + const { accessToken, refreshToken } = (await res.json()) as { accessToken: string; refreshToken: string }; + const newCookieValue = JSON.stringify({ accessToken, refreshToken }); + + // Grava no `request` também (não só na resposta) — sem isto, a + // renovação só valeria a partir da PRÓXIMA navegação; a própria + // Server Component desta requisição (que disparou o refresh) ainda + // leria o cookie antigo via `cookies()` e podia bater 401 de novo na + // hora. Padrão documentado do Next.js pra "refresh de token em + // middleware que precisa valer já nesta requisição". + request.cookies.set(COOKIE_NAME, newCookieValue); + const response = NextResponse.next({ request }); + response.cookies.set(COOKIE_NAME, newCookieValue, { + httpOnly: true, + sameSite: "lax", + secure: process.env.NODE_ENV === "production", + path: "/", + maxAge: 60 * 60 * 8, + }); + return response; + } catch { + // Refresh token também inválido/expirado/revogado (sessão de mais de + // 30 dias, ou já deslogada em outro lugar) — deixa passar com o token + // velho; a página vai bater 401 normalmente e cair no tratamento de + // sessão expirada (layout/error boundary), não trava aqui no meio do + // middleware. + return NextResponse.next(); + } +} + +export const config = { + matcher: ["/app/:path*", "/platform/:path*"], +};