Skip to content

Commit 72b3676

Browse files
authored
Merge pull request #176 from marketingcroma/fix/auth-confirm-redirect-origin
fix(auth): usar NEXT_PUBLIC_APP_URL em vez do Host da requisição no /auth/confirm
2 parents ceb28f6 + ea29ec4 commit 72b3676

6 files changed

Lines changed: 138 additions & 12 deletions

File tree

app/actions/auth/requestPasswordReset.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,11 @@ export async function requestPasswordReset(
4747

4848
const supabase = await createClient();
4949
const { error } = await supabase.auth.resetPasswordForEmail(parsed.data.email, {
50-
redirectTo: `${origin}/auth/confirm`,
50+
// ?type=recovery sobrevive ao redirect do GoTrue (preserva query string
51+
// existente ao anexar `code=`/`token_hash=`) — sem SMTP customizado o
52+
// Supabase usa o template padrão dele, que só devolve `code` (PKCE), sem
53+
// `type`; /auth/confirm depende deste param pra saber que é recovery.
54+
redirectTo: `${origin}/auth/confirm?type=recovery`,
5155
});
5256

5357
if (error) {

app/actions/auth/signUp.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,10 @@ export async function signUp(input: SignupInput): Promise<SignUpResult> {
5252
email: parsed.data.email,
5353
password: parsed.data.password,
5454
options: {
55-
emailRedirectTo: `${origin}/auth/confirm`,
55+
// Ver comentário equivalente em requestPasswordReset.ts: ?type=signup
56+
// sobrevive ao redirect do GoTrue e é o que distingue este fluxo do de
57+
// recovery quando a verificação chega via `code` (PKCE), não `token_hash`.
58+
emailRedirectTo: `${origin}/auth/confirm?type=signup`,
5659
data: { org_name: parsed.data.org_name },
5760
},
5861
});

app/auth/confirm/route.ts

Lines changed: 28 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -4,35 +4,55 @@ import type { EmailOtpType } from "@supabase/supabase-js";
44
import { createClient } from "@/lib/supabase/server";
55
import { ensureTenantForUser } from "@/lib/auth/provision";
66
import { audit } from "@/lib/audit";
7+
import { env } from "@/lib/env";
78

89
/**
9-
* GET /auth/confirm — troca o token do e-mail (token_hash) por uma sessão.
10+
* GET /auth/confirm — troca o token do e-mail por uma sessão.
1011
*
11-
* É o destino único dos links de e-mail do GoTrue (templates customizados em
12-
* supabase/templates/): confirmação de signup E redefinição de senha.
12+
* É o destino único dos links de e-mail do GoTrue: confirmação de signup E
13+
* redefinição de senha. Dois formatos de link chegam aqui, dependendo de como
14+
* o projeto Supabase está configurado:
15+
*
16+
* - `token_hash` + `type`: template de e-mail customizado (supabase/templates/)
17+
* linkando direto pro app — exige SMTP customizado configurado no painel
18+
* (sem isso o Supabase não deixa editar o corpo do e-mail).
19+
* - `code` (PKCE): template PADRÃO do Supabase (nenhum SMTP customizado
20+
* configurado — caso mais comum em instalação fresca). O e-mail linka pro
21+
* `/auth/v1/verify` do próprio GoTrue, que valida e SÓ ENTÃO redireciona pra
22+
* cá com o code; não inclui `type`, por isso requestPasswordReset.ts e
23+
* signUp.ts anexam `?type=` no redirectTo/emailRedirectTo — é o único jeito
24+
* desse dado sobreviver ao hop pelo GoTrue nesse formato.
1325
*
1426
* - type=signup → provisiona o tenant (org + membership admin) e entra no
1527
* onboarding. Provisionamento é idempotente (link clicado 2x).
1628
* - type=recovery → sessão de recovery estabelecida; segue para /login/reset
1729
* onde o usuário define a senha nova.
1830
*
19-
* Fluxo canônico do @supabase/ssr: verifyOtp grava os cookies de sessão via
20-
* cookies() do next/headers; o Next anexa os Set-Cookie ao redirect retornado.
31+
* Fluxo canônico do @supabase/ssr: verifyOtp/exchangeCodeForSession grava os
32+
* cookies de sessão via cookies() do next/headers; o Next anexa os Set-Cookie
33+
* ao redirect retornado.
2134
*/
2235
export async function GET(request: NextRequest) {
2336
const url = request.nextUrl;
2437
const tokenHash = url.searchParams.get("token_hash");
38+
const code = url.searchParams.get("code");
2539
const type = url.searchParams.get("type") as EmailOtpType | null;
2640
const requestId = request.headers.get("x-request-id");
2741

28-
const redirectTo = (path: string) => NextResponse.redirect(new URL(path, url.origin));
42+
// NUNCA usar url.origin aqui: é derivado do header Host, que o proxy/container
43+
// pode entregar como o bind interno (ex.: 0.0.0.0:3000) em vez do domínio
44+
// público — o link de recovery quebra silenciosamente para o usuário final.
45+
const redirectTo = (path: string) => NextResponse.redirect(new URL(path, env.NEXT_PUBLIC_APP_URL));
2946

30-
if (!tokenHash || !type) {
47+
if (!(tokenHash && type) && !code) {
3148
return redirectTo("/login?error=link_invalido");
3249
}
3350

3451
const supabase = await createClient();
35-
const { data, error } = await supabase.auth.verifyOtp({ type, token_hash: tokenHash });
52+
const { data, error } =
53+
tokenHash && type
54+
? await supabase.auth.verifyOtp({ type, token_hash: tokenHash })
55+
: await supabase.auth.exchangeCodeForSession(code as string);
3656

3757
if (error || !data.user) {
3858
await audit({

supabase/templates/confirmation.html

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,13 @@
22
<body style="font-family: sans-serif; color: #1a1a1a; max-width: 480px; margin: 0 auto; padding: 24px;">
33
<h2 style="font-size: 18px;">Confirme seu e-mail</h2>
44
<p>Sua conta no DeskcommCRM está quase pronta. Clique no botão abaixo para confirmar seu e-mail e ativar sua conta:</p>
5+
<!--
6+
`&`, nunca `?`: ver comentário equivalente em recovery.html. `.RedirectTo`
7+
já chega com `?type=signup` embutido (app/actions/auth/signUp.ts).
8+
-->
59
<p style="margin: 24px 0;">
610
<a
7-
href="{{ .RedirectTo }}?token_hash={{ .TokenHash }}&type=signup"
11+
href="{{ .RedirectTo }}&token_hash={{ .TokenHash }}"
812
style="background: #4a5d52; color: #ffffff; padding: 10px 20px; border-radius: 6px; text-decoration: none; display: inline-block;"
913
>Confirmar e-mail</a
1014
>

supabase/templates/recovery.html

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,19 @@
22
<body style="font-family: sans-serif; color: #1a1a1a; max-width: 480px; margin: 0 auto; padding: 24px;">
33
<h2 style="font-size: 18px;">Redefinir senha</h2>
44
<p>Recebemos um pedido para redefinir a senha da sua conta no DeskcommCRM. Clique no botão abaixo para escolher uma nova senha:</p>
5+
<!--
6+
`&`, nunca `?`: `.RedirectTo` já chega com `?type=recovery` embutido
7+
(app/actions/auth/requestPasswordReset.ts) — é o que sobrevive ao hop
8+
pelo GoTrue quando o link chega via PKCE (`code`) em vez de token_hash.
9+
Usar `?` aqui duplica o separador (`...?type=recovery?token_hash=...`),
10+
o parser de URL do browser para de reconhecer `token_hash` como
11+
parâmetro (vira parte do valor de `type`), e /auth/confirm redireciona
12+
pra `/login?error=link_invalido` mesmo com o link correto — medido nas
13+
specs password-recovery.spec.ts e signup-journey.spec.ts.
14+
-->
515
<p style="margin: 24px 0;">
616
<a
7-
href="{{ .RedirectTo }}?token_hash={{ .TokenHash }}&type=recovery"
17+
href="{{ .RedirectTo }}&token_hash={{ .TokenHash }}"
818
style="background: #4a5d52; color: #ffffff; padding: 10px 20px; border-radius: 6px; text-decoration: none; display: inline-block;"
919
>Redefinir senha</a
1020
>
Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
/**
2+
* O LINK DO E-MAIL DE AUTH É MONTADO POR DOIS ARQUIVOS QUE NÃO SE CONHECEM.
3+
*
4+
* Quem inicia a query é o Server Action (`?type=recovery` / `?type=signup` em
5+
* `redirectTo`/`emailRedirectTo`); quem a continua é o template HTML que o
6+
* GoTrue renderiza (`&token_hash={{ .TokenHash }}`). Um é TypeScript, o outro
7+
* é HTML lido por um serviço em Go — nenhum compilador, tipo ou teste de
8+
* unidade existente liga os dois.
9+
*
10+
* ## O defeito que fez este arquivo existir
11+
*
12+
* O template usava `?token_hash=...`. Quando o Action passou a anexar
13+
* `?type=...` ao redirect (necessário porque, com o template PADRÃO do
14+
* Supabase, o link chega via PKCE `code` e o `type` não sobrevive ao hop pelo
15+
* GoTrue), o link virou:
16+
*
17+
* /auth/confirm?type=recovery?token_hash=pkce_abc&type=recovery
18+
* ↑ segundo `?`
19+
*
20+
* O parser de URL do browser trata tudo depois do PRIMEIRO `?` como query, e o
21+
* segundo `?` vira parte do VALOR de `type`. `token_hash` deixa de ser um
22+
* parâmetro, `/auth/confirm` recebe `null` e manda o usuário para
23+
* `/login?error=link_invalido` — com um token perfeitamente válido na mão.
24+
*
25+
* Nada acusava: `typecheck`, `lint` e `test:unit` passam, porque HTML errado
26+
* não compila nem é importado. O sintoma que o usuário vê ("link expirado") não
27+
* se parece com a causa.
28+
*
29+
* ## O que se guarda
30+
*
31+
* Os DOIS sentidos, porque consertar um lado sozinho reintroduz o bug pelo
32+
* outro: se o Action parar de abrir a query, o `&` do template passa a ser o
33+
* separador errado e o link quebra igual.
34+
*/
35+
import { readFileSync } from "node:fs";
36+
import { join } from "node:path";
37+
38+
import { describe, expect, it } from "vitest";
39+
40+
const raiz = process.cwd();
41+
const ler = (p: string) => readFileSync(join(raiz, p), "utf8");
42+
43+
const PARES = [
44+
{
45+
fluxo: "recovery",
46+
action: "app/actions/auth/requestPasswordReset.ts",
47+
template: "supabase/templates/recovery.html",
48+
},
49+
{
50+
fluxo: "signup",
51+
action: "app/actions/auth/signUp.ts",
52+
template: "supabase/templates/confirmation.html",
53+
},
54+
] as const;
55+
56+
describe("o link do e-mail de auth tem UMA query só", () => {
57+
for (const { fluxo, action, template } of PARES) {
58+
it(`${fluxo}: o Action ABRE a query com ?type=`, () => {
59+
const fonte = ler(action);
60+
// Guarda de vacuidade: arquivo movido ou vazio faria o `toMatch` abaixo
61+
// falhar por ausência de dado, e a mensagem não diria isso.
62+
expect(fonte.length, `${action} veio vazio — o teste ficou cego`).toBeGreaterThan(200);
63+
expect(
64+
fonte,
65+
`${action} precisa anexar ?type=${fluxo} ao redirect — é o que sobrevive ao hop do GoTrue no formato PKCE`,
66+
).toMatch(new RegExp(`/auth/confirm\\?type=${fluxo}`));
67+
});
68+
69+
it(`${fluxo}: o template CONTINUA a query com &token_hash=`, () => {
70+
const html = ler(template);
71+
expect(html.length, `${template} veio vazio — o teste ficou cego`).toBeGreaterThan(200);
72+
73+
// A régua é o HREF, não o arquivo: a primeira versão deste teste usava
74+
// `not.toContain("?token_hash")` sobre o texto inteiro e reprovou por
75+
// causa do COMENTÁRIO que explica o bug. Medir o arquivo quando se quer
76+
// medir um atributo é o mesmo erro que o gate do canal zernio cometia.
77+
const href = /href="(\{\{ \.RedirectTo \}\}[^"]*)"/.exec(html)?.[1];
78+
expect(href, `${template}: não achei o href do RedirectTo — o teste ficou cego`).toBeTruthy();
79+
expect(
80+
href,
81+
`${template} precisa usar & (a query já foi aberta pelo Action); ? aqui duplica o separador e o browser para de enxergar token_hash`,
82+
).toBe("{{ .RedirectTo }}&token_hash={{ .TokenHash }}");
83+
});
84+
}
85+
});

0 commit comments

Comments
 (0)