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