@luanpdd/kit-mcp 1.31.0 → 1.33.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- package/kit/COMPATIBILITY.md +5 -0
- package/kit/agents/designer-ui.md +216 -0
- package/kit/agents/supabase-auth-bootstrapper.md +15 -1
- package/kit/agents/supabase-auth-hook-writer.md +418 -0
- package/kit/agents/supabase-mfa-implementer.md +439 -0
- package/kit/agents/supabase-oauth-server-implementer.md +507 -0
- package/kit/agents/supabase-social-auth-implementer.md +451 -0
- package/kit/agents/supabase-sso-saml-architect.md +549 -0
- package/kit/commands/supabase.md +21 -1
- package/kit/file-manifest.json +29 -6
- package/kit/skills/supabase-auth-hardening/SKILL.md +674 -0
- package/kit/skills/supabase-auth-hooks/SKILL.md +875 -0
- package/kit/skills/supabase-auth-methods/SKILL.md +486 -0
- package/kit/skills/supabase-auth-sessions/SKILL.md +579 -0
- package/kit/skills/supabase-auth-ssr/SKILL.md +60 -14
- package/kit/skills/supabase-enterprise-sso-saml/SKILL.md +545 -0
- package/kit/skills/supabase-jwt-signing-keys/SKILL.md +399 -0
- package/kit/skills/supabase-mfa/SKILL.md +488 -0
- package/kit/skills/supabase-oauth-server/SKILL.md +537 -0
- package/kit/skills/supabase-social-oauth/SKILL.md +480 -0
- package/kit/skills/supabase-third-party-auth/SKILL.md +450 -0
- package/kit/skills/ui-anti-padroes-ia/SKILL.md +261 -0
- package/kit/skills/ui-contexto-produto/SKILL.md +248 -0
- package/kit/skills/ui-cor-estrategia/SKILL.md +213 -0
- package/kit/skills/ui-critica-auditoria/SKILL.md +260 -0
- package/kit/skills/ui-motion-funcional/SKILL.md +264 -0
- package/kit/skills/ui-ritmo-espacial/SKILL.md +259 -0
- package/kit/skills/ui-tipografia/SKILL.md +211 -0
- package/package.json +1 -1
|
@@ -0,0 +1,579 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: supabase-auth-sessions
|
|
3
|
+
description: Use ao configurar sessões Supabase — PKCE vs implicit flow, exchangeCodeForSession, lifetime, JWT expiry e refresh token reuse detection.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Supabase — Sessões e Fluxos de Autenticação
|
|
7
|
+
|
|
8
|
+
## Quando usar
|
|
9
|
+
|
|
10
|
+
LLM carrega esta skill quando configurar ou depurar **sessões de autenticação** no Supabase — fluxo de token, tempo de vida, expiração de JWT e detecção de reutilização de refresh token.
|
|
11
|
+
|
|
12
|
+
Trigger phrases:
|
|
13
|
+
|
|
14
|
+
- "implicit flow", "PKCE flow", "fluxo PKCE"
|
|
15
|
+
- "exchangeCodeForSession", "code exchange Supabase"
|
|
16
|
+
- "refresh token Supabase", "session lifetime", "JWT expiry"
|
|
17
|
+
- "session timeout", "inactivity timeout Supabase"
|
|
18
|
+
- "single session per user", "uma sessão por usuário"
|
|
19
|
+
- "refresh token reuse detection", "token reuse interval"
|
|
20
|
+
- "flowType pkce", "detectSessionInUrl"
|
|
21
|
+
- "access token expirado", "JWT expirado"
|
|
22
|
+
- "SupportedStorage", "custom storage adapter"
|
|
23
|
+
- "session_id claim", "JWT payload Supabase"
|
|
24
|
+
|
|
25
|
+
## Regras absolutas
|
|
26
|
+
|
|
27
|
+
1. **SSR sempre usa PKCE.** Contextos server-side (Next.js App Router, Route Handlers, Edge Middleware) nunca recebem URL fragments — o código de autorização PKCE (`?code=...`) é a única forma de passar tokens ao servidor.
|
|
28
|
+
2. **Implicit flow é client-only.** Só funciona em Single Page Applications (SPA) sem SSR. Mesmo em SPAs, PKCE é mais seguro e preferível.
|
|
29
|
+
3. **Code exchange no mesmo browser/device.** O code verifier PKCE é armazenado localmente (cookie ou localStorage) no dispositivo que iniciou o fluxo. Trocar o código em outro dispositivo falha.
|
|
30
|
+
4. **Expiração do JWT nunca abaixo de 5 minutos.** Valores muito baixos geram carga excessiva no Auth Server e problemas de clock skew.
|
|
31
|
+
5. **Refresh token é uso único.** Após ser trocado por um novo par access+refresh, o token antigo é invalidado. Reutilização é detectada como ataque e pode encerrar a sessão.
|
|
32
|
+
6. **Validar sessão no servidor com `getClaims()`**, não com `getSession()`.
|
|
33
|
+
|
|
34
|
+
## O que é uma sessão Supabase
|
|
35
|
+
|
|
36
|
+
Uma sessão é composta por **dois tokens**:
|
|
37
|
+
|
|
38
|
+
| Token | Tipo | Duração padrão | Uso |
|
|
39
|
+
|-------|------|----------------|-----|
|
|
40
|
+
| Access token | JWT assinado | 1 hora (configurável) | Autenticar requisições ao PostgREST, Storage, Edge Functions |
|
|
41
|
+
| Refresh token | Opaque string | Configurável (dias/semanas) | Obter novo access token após expirar |
|
|
42
|
+
|
|
43
|
+
### Claims do JWT (payload)
|
|
44
|
+
|
|
45
|
+
```json
|
|
46
|
+
{
|
|
47
|
+
"sub": "uuid-do-usuario", // auth.uid() em RLS
|
|
48
|
+
"email": "usuario@exemplo.com",
|
|
49
|
+
"role": "authenticated", // postgres role
|
|
50
|
+
"aud": "authenticated",
|
|
51
|
+
"iss": "https://<project>.supabase.co/auth/v1",
|
|
52
|
+
"iat": 1716000000, // issued at
|
|
53
|
+
"exp": 1716003600, // expiration (iat + JWT TTL)
|
|
54
|
+
"session_id": "uuid-da-sessao", // identificador único da sessão
|
|
55
|
+
"is_anonymous": false, // true em signInAnonymously
|
|
56
|
+
"user_role": "admin" // custom claim via auth hook (opcional)
|
|
57
|
+
}
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
O campo `session_id` identifica a sessão específica — útil para "single session per user" e auditoria.
|
|
61
|
+
|
|
62
|
+
### Benefícios vs sessões tradicionais (server-side sessions)
|
|
63
|
+
|
|
64
|
+
| | JWT + Refresh Token | Sessão server-side (ex: Redis) |
|
|
65
|
+
|---|---|---|
|
|
66
|
+
| Validação | Criptográfica (sem DB lookup) | Requer lookup no DB/cache a cada request |
|
|
67
|
+
| Revogação | Eventual (até expirar) | Imediata (deletar da store) |
|
|
68
|
+
| Escalabilidade | Stateless — sem coordenação entre servidores | Requer store compartilhado |
|
|
69
|
+
| Segurança offline | JWT pode ser validado sem conectividade | Requer acesso à session store |
|
|
70
|
+
| Overhead por request | Mínimo (verificação de assinatura) | Network + DB lookup |
|
|
71
|
+
|
|
72
|
+
## Implicit Flow vs PKCE Flow
|
|
73
|
+
|
|
74
|
+
### Implicit Flow (client-only)
|
|
75
|
+
|
|
76
|
+
```
|
|
77
|
+
Usuário clica "Login"
|
|
78
|
+
↓
|
|
79
|
+
Browser redireciona para provider OAuth / Supabase Auth
|
|
80
|
+
↓
|
|
81
|
+
Após auth, provider redireciona para:
|
|
82
|
+
https://meuapp.com/callback#access_token=eyJ...&refresh_token=abc...
|
|
83
|
+
↑
|
|
84
|
+
URL Fragment (tudo após #)
|
|
85
|
+
↓
|
|
86
|
+
JavaScript no browser lê o fragment e extrai os tokens
|
|
87
|
+
(Servidor NUNCA vê o fragment — não é enviado pelo browser no HTTP request)
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
```ts
|
|
91
|
+
// Cliente SPA — flowType 'implicit' (padrão legado)
|
|
92
|
+
import { createClient } from '@supabase/supabase-js'
|
|
93
|
+
|
|
94
|
+
const supabase = createClient(
|
|
95
|
+
process.env.NEXT_PUBLIC_SUPABASE_URL!,
|
|
96
|
+
process.env.NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY!,
|
|
97
|
+
{
|
|
98
|
+
auth: {
|
|
99
|
+
flowType: 'implicit', // padrão para SPAs sem SSR
|
|
100
|
+
detectSessionInUrl: true, // processa #access_token= automaticamente
|
|
101
|
+
},
|
|
102
|
+
}
|
|
103
|
+
)
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
**Limitações do implicit flow:**
|
|
107
|
+
- Tokens ficam expostos no histórico do browser e logs de servidor (se fragment vazar)
|
|
108
|
+
- Não há `code_verifier` — ataque de interceptação é possível
|
|
109
|
+
- Não funciona em SSR (servidor não recebe o fragment)
|
|
110
|
+
|
|
111
|
+
### PKCE Flow (recomendado para SSR e SPAs modernos)
|
|
112
|
+
|
|
113
|
+
```
|
|
114
|
+
Usuário clica "Login"
|
|
115
|
+
↓
|
|
116
|
+
Cliente gera code_verifier (string aleatória) e code_challenge (hash SHA-256 do verifier)
|
|
117
|
+
↓
|
|
118
|
+
Armazena code_verifier localmente (cookie ou localStorage)
|
|
119
|
+
↓
|
|
120
|
+
Browser redireciona para provider OAuth com code_challenge
|
|
121
|
+
↓
|
|
122
|
+
Após auth, provider redireciona para:
|
|
123
|
+
https://meuapp.com/auth/callback?code=xyz123
|
|
124
|
+
↑
|
|
125
|
+
Query param (visível ao servidor)
|
|
126
|
+
↓
|
|
127
|
+
Route handler /auth/callback recebe o code
|
|
128
|
+
↓
|
|
129
|
+
exchangeCodeForSession(code) envia code + code_verifier ao Supabase
|
|
130
|
+
↓
|
|
131
|
+
Supabase verifica: hash(code_verifier) == code_challenge (enviado no início)?
|
|
132
|
+
↓ sim
|
|
133
|
+
Emite access_token + refresh_token e cria sessão
|
|
134
|
+
```
|
|
135
|
+
|
|
136
|
+
```ts
|
|
137
|
+
// lib/supabase/client.ts — configurar PKCE no browser client
|
|
138
|
+
import { createBrowserClient } from '@supabase/ssr'
|
|
139
|
+
|
|
140
|
+
export function createClient() {
|
|
141
|
+
return createBrowserClient(
|
|
142
|
+
process.env.NEXT_PUBLIC_SUPABASE_URL!,
|
|
143
|
+
process.env.NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY!,
|
|
144
|
+
{
|
|
145
|
+
auth: {
|
|
146
|
+
flowType: 'pkce', // obrigatório para SSR
|
|
147
|
+
detectSessionInUrl: true, // processa ?code= automaticamente
|
|
148
|
+
},
|
|
149
|
+
}
|
|
150
|
+
)
|
|
151
|
+
}
|
|
152
|
+
```
|
|
153
|
+
|
|
154
|
+
### Route handler de troca de código PKCE
|
|
155
|
+
|
|
156
|
+
```ts
|
|
157
|
+
// app/auth/callback/route.ts
|
|
158
|
+
import { NextResponse, type NextRequest } from 'next/server'
|
|
159
|
+
import { createServerClient } from '@supabase/ssr'
|
|
160
|
+
import { cookies } from 'next/headers'
|
|
161
|
+
|
|
162
|
+
export async function GET(request: NextRequest) {
|
|
163
|
+
const { searchParams, origin } = new URL(request.url)
|
|
164
|
+
const code = searchParams.get('code') // código PKCE da URL
|
|
165
|
+
const next = searchParams.get('next') ?? '/'
|
|
166
|
+
|
|
167
|
+
if (!code) {
|
|
168
|
+
return NextResponse.redirect(`${origin}/auth/error?reason=missing_code`)
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
const cookieStore = await cookies()
|
|
172
|
+
const supabase = createServerClient(
|
|
173
|
+
process.env.NEXT_PUBLIC_SUPABASE_URL!,
|
|
174
|
+
process.env.NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY!,
|
|
175
|
+
{
|
|
176
|
+
cookies: {
|
|
177
|
+
getAll() { return cookieStore.getAll() },
|
|
178
|
+
setAll(cookiesToSet) {
|
|
179
|
+
cookiesToSet.forEach(({ name, value, options }) =>
|
|
180
|
+
cookieStore.set(name, value, options)
|
|
181
|
+
)
|
|
182
|
+
},
|
|
183
|
+
},
|
|
184
|
+
}
|
|
185
|
+
)
|
|
186
|
+
|
|
187
|
+
// Código PKCE é válido por 5 minutos e uso único
|
|
188
|
+
const { error } = await supabase.auth.exchangeCodeForSession(code)
|
|
189
|
+
|
|
190
|
+
if (error) {
|
|
191
|
+
console.error('Falha no code exchange:', error.message)
|
|
192
|
+
return NextResponse.redirect(`${origin}/auth/error?reason=exchange_failed`)
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
// Tratar proxy reverso em produção
|
|
196
|
+
const forwardedHost = request.headers.get('x-forwarded-host')
|
|
197
|
+
const isLocalEnv = process.env.NODE_ENV === 'development'
|
|
198
|
+
|
|
199
|
+
if (isLocalEnv) {
|
|
200
|
+
return NextResponse.redirect(`${origin}${next}`)
|
|
201
|
+
} else if (forwardedHost) {
|
|
202
|
+
return NextResponse.redirect(`https://${forwardedHost}${next}`)
|
|
203
|
+
} else {
|
|
204
|
+
return NextResponse.redirect(`${origin}${next}`)
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
```
|
|
208
|
+
|
|
209
|
+
**Propriedades do código PKCE:**
|
|
210
|
+
- Válido por **5 minutos** após emissão
|
|
211
|
+
- **Uso único** — após `exchangeCodeForSession`, fica invalidado
|
|
212
|
+
- Vinculado ao **browser/device** que iniciou o fluxo (via `code_verifier` armazenado localmente)
|
|
213
|
+
- Trocar em outro dispositivo sempre falha — o `code_verifier` não está lá
|
|
214
|
+
|
|
215
|
+
## Custom Storage Adapter
|
|
216
|
+
|
|
217
|
+
Para ambientes sem `localStorage` (React Native, Electron, ambientes headless):
|
|
218
|
+
|
|
219
|
+
```ts
|
|
220
|
+
import { createClient, SupportedStorage } from '@supabase/supabase-js'
|
|
221
|
+
import * as SecureStore from 'expo-secure-store' // exemplo React Native
|
|
222
|
+
|
|
223
|
+
// Implementar SupportedStorage — interface do Supabase para storage customizado
|
|
224
|
+
const ExpoSecureStoreAdapter: SupportedStorage = {
|
|
225
|
+
async getItem(key: string): Promise<string | null> {
|
|
226
|
+
return SecureStore.getItemAsync(key)
|
|
227
|
+
},
|
|
228
|
+
async setItem(key: string, value: string): Promise<void> {
|
|
229
|
+
await SecureStore.setItemAsync(key, value)
|
|
230
|
+
},
|
|
231
|
+
async removeItem(key: string): Promise<void> {
|
|
232
|
+
await SecureStore.deleteItemAsync(key)
|
|
233
|
+
},
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
const supabase = createClient(
|
|
237
|
+
process.env.EXPO_PUBLIC_SUPABASE_URL!,
|
|
238
|
+
process.env.EXPO_PUBLIC_SUPABASE_PUBLISHABLE_KEY!,
|
|
239
|
+
{
|
|
240
|
+
auth: {
|
|
241
|
+
flowType: 'pkce',
|
|
242
|
+
storage: ExpoSecureStoreAdapter, // storage seguro nativo
|
|
243
|
+
detectSessionInUrl: false, // sem deep links automáticos
|
|
244
|
+
},
|
|
245
|
+
}
|
|
246
|
+
)
|
|
247
|
+
```
|
|
248
|
+
|
|
249
|
+
## Lifetime de Sessão — Configurações (Pro+)
|
|
250
|
+
|
|
251
|
+
Acessível em `Authentication > Sessions` no Dashboard (plano Pro ou superior).
|
|
252
|
+
|
|
253
|
+
### Time-box da sessão
|
|
254
|
+
|
|
255
|
+
Limita o tempo máximo de vida de uma sessão independentemente de atividade:
|
|
256
|
+
|
|
257
|
+
```
|
|
258
|
+
Time-box: 24h (exemplo)
|
|
259
|
+
→ Usuário que logar às 08:00 terá sessão encerrada às 08:00 do dia seguinte
|
|
260
|
+
mesmo se estiver ativamente usando o app
|
|
261
|
+
```
|
|
262
|
+
|
|
263
|
+
Configuração típica por caso de uso:
|
|
264
|
+
|
|
265
|
+
| Caso de uso | Time-box recomendado |
|
|
266
|
+
|-------------|---------------------|
|
|
267
|
+
| App bancário / fintech | 8–24 horas |
|
|
268
|
+
| App corporativo interno | 8–12 horas |
|
|
269
|
+
| App de conteúdo / mídia | 30–90 dias |
|
|
270
|
+
| E-commerce | 30 dias |
|
|
271
|
+
|
|
272
|
+
### Inactivity timeout
|
|
273
|
+
|
|
274
|
+
Encerra sessão após período de inatividade (sem refresh token usado):
|
|
275
|
+
|
|
276
|
+
```
|
|
277
|
+
Inactivity timeout: 2h
|
|
278
|
+
→ Usuário que fechar o app e não voltar em 2h precisa fazer login novamente
|
|
279
|
+
```
|
|
280
|
+
|
|
281
|
+
**Nota:** o refresh automático do Supabase Client conta como atividade — configure com valor maior que o intervalo de refresh.
|
|
282
|
+
|
|
283
|
+
### Single session per user
|
|
284
|
+
|
|
285
|
+
Força que cada usuário tenha no máximo uma sessão ativa:
|
|
286
|
+
|
|
287
|
+
```
|
|
288
|
+
Single session: habilitado
|
|
289
|
+
→ Login no dispositivo B automaticamente invalida sessão do dispositivo A
|
|
290
|
+
```
|
|
291
|
+
|
|
292
|
+
**Implementação via `session_id`:** ao habilitar, o Supabase rastreia `session_id` no JWT e invalida sessões anteriores ao emitir nova.
|
|
293
|
+
|
|
294
|
+
## Expiração do JWT — Configurações
|
|
295
|
+
|
|
296
|
+
Configurável em `Authentication > JWT` no Dashboard:
|
|
297
|
+
|
|
298
|
+
```
|
|
299
|
+
JWT expiry: 3600 (1 hora) — padrão recomendado
|
|
300
|
+
```
|
|
301
|
+
|
|
302
|
+
### Recomendações de expiração
|
|
303
|
+
|
|
304
|
+
| Cenário | JWT TTL | Notas |
|
|
305
|
+
|---------|---------|-------|
|
|
306
|
+
| Padrão / maioria dos apps | 3600s (1h) | Bom equilíbrio segurança/performance |
|
|
307
|
+
| App de alta segurança | 300s (5min) | Mínimo prático — abaixo causa clock skew |
|
|
308
|
+
| App de baixa sensibilidade | 7200s (2h) | Reduz carga no Auth Server |
|
|
309
|
+
| **Nunca usar** | < 300s | Clock skew + sobrecarga de refresh |
|
|
310
|
+
|
|
311
|
+
**Por que não abaixo de 5 minutos:**
|
|
312
|
+
1. **Clock skew:** servidores com relógio desincronizado (±30s é comum) rejeitam JWTs válidos
|
|
313
|
+
2. **Carga no Auth Server:** refresh a cada 5min × milhares de usuários = sobrecarga massiva
|
|
314
|
+
3. **Latência de rede:** em redes lentas, o refresh pode falhar na janela de expiração
|
|
315
|
+
|
|
316
|
+
## Refresh Token Reuse Detection
|
|
317
|
+
|
|
318
|
+
### Funcionamento
|
|
319
|
+
|
|
320
|
+
O Supabase implementa detecção de reutilização para prevenir ataques de replay:
|
|
321
|
+
|
|
322
|
+
```
|
|
323
|
+
Usuário A tem refresh_token: "abc123" (válido)
|
|
324
|
+
↓
|
|
325
|
+
Ataque ou bug: "abc123" é usado duas vezes quase simultaneamente
|
|
326
|
+
↓
|
|
327
|
+
Supabase: primeira troca → emite novo par (access: "jwt2", refresh: "def456")
|
|
328
|
+
segunda troca → detecta reutilização!
|
|
329
|
+
↓
|
|
330
|
+
Comportamento: ENCERRA a sessão atual E invalida todos os tokens desta sessão
|
|
331
|
+
(o atacante com o token roubado também perde acesso)
|
|
332
|
+
```
|
|
333
|
+
|
|
334
|
+
### Intervalo de reutilização (reuse interval)
|
|
335
|
+
|
|
336
|
+
```
|
|
337
|
+
Reuse interval: 10 segundos (padrão)
|
|
338
|
+
```
|
|
339
|
+
|
|
340
|
+
O intervalo de 10s existe para tratar race conditions legítimas (cliente em tab dupla, retry de rede). Dentro do intervalo, a mesma troca é aceita. Após o intervalo, qualquer reuso é tratado como ataque.
|
|
341
|
+
|
|
342
|
+
```ts
|
|
343
|
+
// Configurar listener para detecção de reuso (client-side)
|
|
344
|
+
supabase.auth.onAuthStateChange((event, session) => {
|
|
345
|
+
if (event === 'TOKEN_REFRESHED') {
|
|
346
|
+
console.log('Token renovado com sucesso:', session?.expires_at)
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
if (event === 'SIGNED_OUT') {
|
|
350
|
+
// Pode ter sido encerramento por reuse detection — redirecionar para login
|
|
351
|
+
console.warn('Sessão encerrada — possível reutilização de refresh token')
|
|
352
|
+
window.location.href = '/login?reason=session_terminated'
|
|
353
|
+
}
|
|
354
|
+
})
|
|
355
|
+
```
|
|
356
|
+
|
|
357
|
+
### Rotação de refresh token
|
|
358
|
+
|
|
359
|
+
Cada uso do refresh token gera um novo par — o token antigo é imediatamente invalidado:
|
|
360
|
+
|
|
361
|
+
```
|
|
362
|
+
Refresh token: [abc123]
|
|
363
|
+
↓ usuario.auth.refreshSession() ou auto-refresh
|
|
364
|
+
Novo par emitido: access_token: [novoJWT], refresh_token: [def456]
|
|
365
|
+
Antigo invalidado: [abc123] ← inválido a partir daqui
|
|
366
|
+
```
|
|
367
|
+
|
|
368
|
+
**Consequência:** não armazene refresh tokens em cache de longa duração (Redis, banco) sem invalidação adequada — o valor muda a cada refresh.
|
|
369
|
+
|
|
370
|
+
## Refresh manual e escuta de estado
|
|
371
|
+
|
|
372
|
+
```ts
|
|
373
|
+
// Renovar sessão manualmente (raramente necessário — SDK faz auto-refresh)
|
|
374
|
+
const { data, error } = await supabase.auth.refreshSession()
|
|
375
|
+
|
|
376
|
+
// Escutar mudanças de estado de auth (login, logout, refresh, recovery)
|
|
377
|
+
const { data: { subscription } } = supabase.auth.onAuthStateChange(
|
|
378
|
+
async (event, session) => {
|
|
379
|
+
console.log('Evento:', event)
|
|
380
|
+
// Eventos possíveis:
|
|
381
|
+
// 'INITIAL_SESSION' — sessão inicial ao carregar o app
|
|
382
|
+
// 'SIGNED_IN' — login bem-sucedido
|
|
383
|
+
// 'SIGNED_OUT' — logout
|
|
384
|
+
// 'TOKEN_REFRESHED' — access token renovado
|
|
385
|
+
// 'USER_UPDATED' — updateUser chamado
|
|
386
|
+
// 'PASSWORD_RECOVERY' — link de recovery clicado
|
|
387
|
+
}
|
|
388
|
+
)
|
|
389
|
+
|
|
390
|
+
// Cancelar subscription quando o componente desmontar
|
|
391
|
+
subscription.unsubscribe()
|
|
392
|
+
```
|
|
393
|
+
|
|
394
|
+
## Validar sessão no servidor (`getClaims`)
|
|
395
|
+
|
|
396
|
+
```ts
|
|
397
|
+
// app/dashboard/page.tsx — Server Component
|
|
398
|
+
import { createServerClient } from '@supabase/ssr'
|
|
399
|
+
import { cookies } from 'next/headers'
|
|
400
|
+
import { redirect } from 'next/navigation'
|
|
401
|
+
|
|
402
|
+
export default async function DashboardPage() {
|
|
403
|
+
const cookieStore = await cookies()
|
|
404
|
+
const supabase = createServerClient(
|
|
405
|
+
process.env.NEXT_PUBLIC_SUPABASE_URL!,
|
|
406
|
+
process.env.NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY!,
|
|
407
|
+
{
|
|
408
|
+
cookies: {
|
|
409
|
+
getAll() { return cookieStore.getAll() },
|
|
410
|
+
setAll(cookiesToSet) {
|
|
411
|
+
cookiesToSet.forEach(({ name, value, options }) =>
|
|
412
|
+
cookieStore.set(name, value, options)
|
|
413
|
+
)
|
|
414
|
+
},
|
|
415
|
+
},
|
|
416
|
+
}
|
|
417
|
+
)
|
|
418
|
+
|
|
419
|
+
// getClaims() valida a assinatura JWT criptograficamente
|
|
420
|
+
const { data: { claims }, error } = await supabase.auth.getClaims()
|
|
421
|
+
|
|
422
|
+
if (error || !claims) {
|
|
423
|
+
redirect('/login')
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
// Claims disponíveis sem round-trip ao banco:
|
|
427
|
+
const userId = claims.sub // auth.uid()
|
|
428
|
+
const sessionId = claims.session_id // identificador único da sessão
|
|
429
|
+
const isAnonymous = claims.is_anonymous
|
|
430
|
+
|
|
431
|
+
return <Dashboard userId={userId} />
|
|
432
|
+
}
|
|
433
|
+
```
|
|
434
|
+
|
|
435
|
+
## Debug de problemas comuns
|
|
436
|
+
|
|
437
|
+
### Sessão não persiste após refresh da página (SPA)
|
|
438
|
+
|
|
439
|
+
```ts
|
|
440
|
+
// Verificar se detectSessionInUrl está habilitado E se o storage está configurado
|
|
441
|
+
const supabase = createBrowserClient(URL, KEY, {
|
|
442
|
+
auth: {
|
|
443
|
+
detectSessionInUrl: true, // processa tokens da URL
|
|
444
|
+
persistSession: true, // persiste em localStorage (padrão true)
|
|
445
|
+
autoRefreshToken: true, // renova automaticamente (padrão true)
|
|
446
|
+
},
|
|
447
|
+
})
|
|
448
|
+
|
|
449
|
+
// Debug: verificar sessão atual
|
|
450
|
+
const { data: { session } } = await supabase.auth.getSession()
|
|
451
|
+
console.log('Sessão local:', session)
|
|
452
|
+
|
|
453
|
+
const { data: { claims } } = await supabase.auth.getClaims()
|
|
454
|
+
console.log('Claims validados:', claims)
|
|
455
|
+
```
|
|
456
|
+
|
|
457
|
+
### Code exchange falha ("Code verifier not found")
|
|
458
|
+
|
|
459
|
+
```
|
|
460
|
+
Erro: "Code verifier not found in storage"
|
|
461
|
+
```
|
|
462
|
+
|
|
463
|
+
Causas possíveis:
|
|
464
|
+
1. O fluxo foi iniciado em um browser/dispositivo e o callback veio em outro
|
|
465
|
+
2. `localStorage` foi limpo entre o início do fluxo e o callback
|
|
466
|
+
3. Cookies bloqueados (modo privado, extensões de browser)
|
|
467
|
+
4. Timeout: o código PKCE expirou (> 5 minutos entre redirect e callback)
|
|
468
|
+
|
|
469
|
+
```ts
|
|
470
|
+
// Solução: usar cookie como storage em vez de localStorage (mais resiliente)
|
|
471
|
+
const supabase = createBrowserClient(URL, KEY, {
|
|
472
|
+
auth: {
|
|
473
|
+
flowType: 'pkce',
|
|
474
|
+
storage: {
|
|
475
|
+
// Implementar com cookies que persistem cross-tab
|
|
476
|
+
getItem: (key) => document.cookie.match(`${key}=([^;]*)`)?.[1] ?? null,
|
|
477
|
+
setItem: (key, value) => {
|
|
478
|
+
document.cookie = `${key}=${value}; path=/; SameSite=Lax; Secure`
|
|
479
|
+
},
|
|
480
|
+
removeItem: (key) => {
|
|
481
|
+
document.cookie = `${key}=; path=/; expires=Thu, 01 Jan 1970 00:00:00 GMT`
|
|
482
|
+
},
|
|
483
|
+
},
|
|
484
|
+
},
|
|
485
|
+
})
|
|
486
|
+
```
|
|
487
|
+
|
|
488
|
+
## Anti-patterns
|
|
489
|
+
|
|
490
|
+
### 1. Implicit flow em contexto SSR
|
|
491
|
+
|
|
492
|
+
**Errado:**
|
|
493
|
+
```ts
|
|
494
|
+
// pages/api/auth.ts ou app/api/auth/route.ts (servidor)
|
|
495
|
+
// Usa flowType 'implicit' — fragmento nunca chega ao servidor
|
|
496
|
+
const supabase = createClient(URL, KEY, {
|
|
497
|
+
auth: { flowType: 'implicit' },
|
|
498
|
+
})
|
|
499
|
+
// Resultado: getSession() sempre retorna null no servidor
|
|
500
|
+
```
|
|
501
|
+
|
|
502
|
+
**Por quê:** o URL fragment (`#access_token=...`) nunca é enviado pelo browser ao servidor — é uma feature de segurança do protocolo HTTP. O servidor literalmente não tem acesso.
|
|
503
|
+
|
|
504
|
+
**Certo:** usar `flowType: 'pkce'` com rota `/auth/callback` que chama `exchangeCodeForSession(code)`.
|
|
505
|
+
|
|
506
|
+
### 2. JWT com expiração muito curta
|
|
507
|
+
|
|
508
|
+
**Errado:**
|
|
509
|
+
```
|
|
510
|
+
JWT expiry: 60 (1 minuto)
|
|
511
|
+
```
|
|
512
|
+
|
|
513
|
+
**Por quê:**
|
|
514
|
+
- Clock skew de 30s entre servidores → token de 60s pode ser considerado expirado com apenas 30s de uso efetivo
|
|
515
|
+
- Em 1000 usuários simultâneos com refresh a cada minuto → 1000 req/min no Auth Server só para refresh
|
|
516
|
+
- Qualquer latência de rede > 30s durante refresh resulta em logout inesperado do usuário
|
|
517
|
+
|
|
518
|
+
**Certo:** mínimo 300s (5min); padrão recomendado 3600s (1h).
|
|
519
|
+
|
|
520
|
+
### 3. Assumir refresh token reutilizável múltiplas vezes
|
|
521
|
+
|
|
522
|
+
**Errado:**
|
|
523
|
+
```ts
|
|
524
|
+
// Salvar refresh token e reutilizar várias vezes
|
|
525
|
+
const savedRefreshToken = session.refresh_token
|
|
526
|
+
localStorage.setItem('refresh', savedRefreshToken)
|
|
527
|
+
|
|
528
|
+
// Mais tarde, tentar usar o mesmo token em múltiplos requests
|
|
529
|
+
const { data } = await supabase.auth.refreshSession({
|
|
530
|
+
refresh_token: localStorage.getItem('refresh')!,
|
|
531
|
+
})
|
|
532
|
+
// Segunda chamada com o mesmo token → detecção de reuso → sessão encerrada!
|
|
533
|
+
```
|
|
534
|
+
|
|
535
|
+
**Por quê:** refresh token é uso único. Após a primeira troca, o token é invalidado e um novo é emitido. Reutilizar o token antigo aciona a detecção de reuso e encerra TODA a sessão.
|
|
536
|
+
|
|
537
|
+
**Certo:** sempre use o refresh token mais recente retornado pelo SDK. Não armazene refresh tokens externamente — deixe o SDK gerenciar o ciclo de vida.
|
|
538
|
+
|
|
539
|
+
### 4. `getSession()` para validação no servidor
|
|
540
|
+
|
|
541
|
+
**Errado:**
|
|
542
|
+
```ts
|
|
543
|
+
// Route Handler — NÃO valida criptograficamente
|
|
544
|
+
const { data: { session } } = await supabase.auth.getSession()
|
|
545
|
+
if (!session) return Response.json({ error: 'Unauthorized' }, { status: 401 })
|
|
546
|
+
// Um cookie forjado passa nessa verificação!
|
|
547
|
+
```
|
|
548
|
+
|
|
549
|
+
**Por quê:** `getSession()` lê o JWT dos cookies sem validar a assinatura. Um atacante pode forjar um cookie com qualquer `user_id` e passar na verificação.
|
|
550
|
+
|
|
551
|
+
**Certo:**
|
|
552
|
+
```ts
|
|
553
|
+
const { data: { claims }, error } = await supabase.auth.getClaims()
|
|
554
|
+
if (error || !claims) return Response.json({ error: 'Unauthorized' }, { status: 401 })
|
|
555
|
+
// getClaims() valida a assinatura criptográfica — impossível forjar
|
|
556
|
+
```
|
|
557
|
+
|
|
558
|
+
### 5. Code exchange fora do mesmo browser/device
|
|
559
|
+
|
|
560
|
+
**Errado:**
|
|
561
|
+
```
|
|
562
|
+
Fluxo: Usuário inicia login no Chrome do Desktop
|
|
563
|
+
Link de callback é enviado por email e o usuário abre no celular
|
|
564
|
+
Celular tenta exchangeCodeForSession(code)
|
|
565
|
+
→ FALHA: "Code verifier not found"
|
|
566
|
+
```
|
|
567
|
+
|
|
568
|
+
**Por quê:** o `code_verifier` PKCE é gerado e armazenado localmente no device que iniciou o fluxo. O celular não tem o verifier — a troca sempre falha.
|
|
569
|
+
|
|
570
|
+
**Certo:** garantir que o callback URL seja aberto no MESMO browser/device. Para magic links enviados por email, o usuário deve clicar no mesmo dispositivo que iniciou a ação. Não há workaround seguro para cross-device PKCE.
|
|
571
|
+
|
|
572
|
+
## Ver também
|
|
573
|
+
|
|
574
|
+
- [supabase-auth-methods](../supabase-auth-methods/SKILL.md) — métodos de auth do usuário final (senha, OTP, anônimo)
|
|
575
|
+
- [supabase-social-oauth](../supabase-social-oauth/SKILL.md) — login social com Google, GitHub, Apple etc.
|
|
576
|
+
- [supabase-auth-ssr](../supabase-auth-ssr/SKILL.md) — setup completo do cliente SSR com `@supabase/ssr` no Next.js
|
|
577
|
+
- [supabase-jwt-signing-keys](../supabase-jwt-signing-keys/SKILL.md) — rotação de chaves JWT, `getClaims()` vs `getSession()`
|
|
578
|
+
- [supabase-custom-claims-rbac](../supabase-custom-claims-rbac/SKILL.md) — custom claims no JWT via Auth Hook
|
|
579
|
+
- [supabase-edge-functions-auth](../supabase-edge-functions-auth/SKILL.md) — validação de JWT em Edge Functions
|