fix(frontend): renovação automática de sessão — achado real reportado pelo usuário

Achado real: ACCESS_TOKEN_TTL = 15min 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. Reportado pelo
usuário: "Ctrl+F5" depois de um tempo deu "Token de acesso inválido ou expirado".

apps/frontend/src/middleware.ts (novo) decodifica o exp do access token guardado no
cookie (só leitura, sem verificar assinatura — isso continua sendo a API) e, se
faltar menos de 60s pra vencer, chama POST /auth/refresh antes da Server Component
rodar, gravando o cookie novo tanto na resposta quanto na própria requisição (padrão
documentado do Next.js — sem isso a MESMA requisição que disparou o refresh ainda
leria o cookie velho).

Rede de segurança pro caso do refresh token também ter vencido/sido revogado:
apps/frontend/src/app/error.tsx detecta uma mensagem de sessão expirada, desloga e
manda pro /login sozinho, em vez de mostrar a tela crua de erro. 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 (achado testando o primeiro corte).

Testado ponta a ponta com refresh token de verdade: token forjado pra vencer em 20s
+ refresh token real → renovação automática confirmada, página protegida carrega
normal. Com refresh token inválido de propósito → confirmado o fallback: error
boundary detecta, desloga e redireciona pro login sozinho.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BFaBaBSQGhyXGEgtTYZGV8
This commit is contained in:
2026-08-30 11:18:24 -03:00
parent a15ea26cb5
commit 7bc4b2e819
4 changed files with 189 additions and 0 deletions

40
TODO.md
View File

@@ -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

View File

@@ -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 <SessionErrorView {...props} />;
}

View File

@@ -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 (
<div className="flex min-h-dvh items-center justify-center px-6">
<p className="text-sm text-muted-foreground">Sua sessão expirou redirecionando pro login</p>
</div>
);
}
return (
<div className="flex min-h-dvh flex-col items-center justify-center gap-4 px-6 text-center">
<h1 className="text-lg font-semibold text-foreground">Algo deu errado</h1>
<p className="max-w-md text-sm text-muted-foreground">
Tente de novo se persistir, avise o suporte com o que você estava fazendo quando aconteceu.
</p>
<div className="flex gap-3">
<Button type="button" onClick={reset}>
Tentar de novo
</Button>
<Button type="button" variant="outline" onClick={() => router.push("/login")}>
Voltar ao login
</Button>
</div>
</div>
);
}

View File

@@ -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*"],
};