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:
40
TODO.md
40
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
|
mobile — logos aparecendo corretos em todos, sem o artefato de
|
||||||
retângulo branco no Handix
|
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
|
## Riscos conhecidos
|
||||||
|
|||||||
7
apps/frontend/src/app/error.tsx
Normal file
7
apps/frontend/src/app/error.tsx
Normal 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} />;
|
||||||
|
}
|
||||||
56
apps/frontend/src/components/session-error-view.tsx
Normal file
56
apps/frontend/src/components/session-error-view.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
86
apps/frontend/src/middleware.ts
Normal file
86
apps/frontend/src/middleware.ts
Normal 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*"],
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user