Teu progresso
0 / 83 módulos0%
Estágio 02 · 02-13
BloqueadoAuth é o subsistema onde mais erros caros se cometem. "Login funciona" não basta, você precisa entender token leakage, replay, CSRF, XSS reflection of secret, refresh flows, cookie attributes, RBAC vs ABAC, mistura de OAuth2 com OIDC, expiração e revogação, federation. A maioria das brechas em apps modernos vem de auth implementado por intuição.
Este módulo é auth com clareza: distinção entre autenticação e autorização, sessions com cookie, JWT (e suas armadilhas), OAuth2 grants com OIDC pra identidade, MFA, passkeys (WebAuthn), e modelo de autorização. Sem isso, você terceiriza pra Auth0/Clerk e ainda implementa errado em redor.
Erros comuns vêm de misturar: tokens que provam identidade mas servem como "blank check" de permissão.
Nunca armazene plain text. Nunca SHA-256 simples. Use algoritmos de hashing pra senhas (slow, salted):
Cada hash inclui salt. Custo (work factor) ajustável; aumente a cada N anos.
Verificação:
import { hash, verify } from 'argon2';
const h = await hash(password); // store h
const ok = await verify(h, candidate); // compares
Não compare strings com == em código de auth (timing attacks). Use crypto.timingSafeEqual ou bibliotecas que já fazem.
Padrão clássico, ainda viável e em muitos casos melhor que JWT:
{sessionId → userId, expiresAt, ...} em store (Redis, DB).Set-Cookie: sid=....Atributos cruciais:
HttpOnly: JS não acessa (mitiga XSS exfiltrar).Secure: só HTTPS.SameSite=Lax (default moderno) ou Strict (mais restritivo) ou None (cross-site, exige Secure). Mitiga CSRF.Path=/ ou específico.Max-Age / Expires.__Host- prefix força Secure + Path=/ + sem Domain.Vantagens sessions:
Desvantagens:
JWT é spec (RFC 7519). Estrutura: header.payload.signature (base64-url encoded).
{ alg, typ }.HMAC(header + payload, secret) (HS256) ou RSA/ECDSA sign (RS256/ES256).Vantagens:
Desvantagens reais:
alg: none, key confusion (HS vs RS), bad signature comparison, expired token aceito.Quando JWT vence: APIs entre serviços, mobile com token em secure storage, identity provider pra múltiplos serviços. Quando session vence: app web monolítico.
Padrão moderno híbrido: refresh token longo-lived em cookie HttpOnly + access token JWT short-lived em memória do client. Refresh roda quando access expira.
alg: none: algumas libs aceitavam token sem assinatura. Verifique sempre.alg: HS256 mas server usando key pública RSA como secret HMAC. Atacante assina com a public key.exp não verificado: lib mal usada não checa expiração.iss/aud checks: token de outro tenant aceito.Authorization: Bearer ... ou cookie.OAuth2 (RFC 6749) é framework pra autorização delegada: "deixe app B acessar recurso meu em provider A". Não foi desenhado pra autenticação, mas usado errado pra isso por anos.
OIDC (OpenID Connect) é camada sobre OAuth2 que adiciona identidade: ID token (JWT) com claims de quem é o user. Pra "Login com Google", você usa OIDC.
Grants OAuth2:
PKCE (Proof Key for Code Exchange), extensão pra Authorization Code. Cliente gera code_verifier, manda hash (code_challenge) na auth request; troca code por token enviando code_verifier. Mitiga interceptação do code. Sempre use PKCE, mesmo em SPAs e apps mobile.
https://provider/authorize?response_type=code&client_id=X&redirect_uri=Y&scope=openid+email&state=...&code_challenge=...&code_challenge_method=S256.redirect_uri?code=ABC&state=....state (CSRF guard), troca code + code_verifier em /token endpoint → recebe access_token, id_token, refresh_token.id_token (assinatura via JWKS, iss, aud, nonce, exp).OIDC providers comuns: Google, GitHub (GitHub não é OIDC strict, é OAuth2 + API), Apple, Microsoft, Auth0, Clerk, Keycloak, Authentik.
Fatores:
Formas:
otplib no Node.Modelo errado: "logou uma vez = pode fazer tudo até logout". Modelo correto: autenticação tem nível, ações sensíveis (delete account, transferência alta, mudar email/senha, exfiltrar dados) exigem re-auth recente, mesmo se sessão está viva.
OIDC formaliza com 2 claims:
acr (Authentication Context Class Reference): nível de assurance. Convenção comum:
urn:fathom:acr:1 = senha apenasurn:fathom:acr:2 = senha + segundo fatorurn:fathom:acr:3 = passkey ou hardware token + biometric (highest)amr (Authentication Methods References): array de métodos usados. ["pwd"], ["pwd", "otp"], ["webauthn", "biometric"].auth_time (RFC 9700): unix timestamp do último prompt interativo.Cliente declara nível mínimo via acr_values no authorize request:
GET /authorize?...&acr_values=urn:fathom:acr:2&max_age=300
max_age=300 força re-auth se auth_time + 300 < now.
// middleware/require-step-up.ts
type StepUpRequirement = {
minAcr: 1 | 2 | 3;
maxAuthAgeSeconds?: number; // re-auth window
acceptableAmr?: string[]; // ['webauthn'] força hardware-backed only
};
export function requireStepUp(req: StepUpRequirement) {
return async (ctx, next) => {
const claims = ctx.user; // do JWT validado
const now = Math.floor(Date.now() / 1000);
const ok =
claims.acr_level >= req.minAcr &&
(!req.maxAuthAgeSeconds || (now - claims.auth_time) <= req.maxAuthAgeSeconds) &&
(!req.acceptableAmr || req.acceptableAmr.some(m => claims.amr.includes(m)));
if (!ok) {
ctx.status = 401;
ctx.set('WWW-Authenticate',
`Bearer error="insufficient_user_authentication", ` +
`acr_values="${req.minAcr === 3 ? 'urn:fathom:acr:3' : 'urn:fathom:acr:2'}", ` +
`max_age="${req.maxAuthAgeSeconds ?? 300}"`);
ctx.body = {
error: 'step_up_required',
required_acr: req.minAcr,
max_age_seconds: req.maxAuthAgeSeconds,
};
return;
}
await next();
};
}
// Uso: deletar conta exige passkey + < 5min
app.delete('/account',
requireAuth,
requireStepUp({ minAcr: 3, maxAuthAgeSeconds: 300, acceptableAmr: ['webauthn'] }),
deleteAccountHandler
);
// Mudar payout bank account (lojista): MFA recente
app.put('/lojista/:id/payout-account',
requireAuth,
requireStepUp({ minAcr: 2, maxAuthAgeSeconds: 600 }),
updatePayoutHandler
);
// Ler dashboard normal: nível 1 ok
app.get('/dashboard', requireAuth, dashboardHandler);
async function fetchWithStepUp(url: string, opts: RequestInit = {}): Promise<Response> {
const res = await fetch(url, opts);
if (res.status !== 401) return res;
const body = await res.clone().json().catch(() => ({}));
if (body.error !== 'step_up_required') return res;
// Inicia step-up interativo (passkey prompt ou redirect a IdP com acr_values)
const challenge = parseWWWAuthenticate(res.headers.get('WWW-Authenticate')!);
await initiateStepUp(challenge); // dispara WebAuthn ou redirect /authorize
// Retry com novo token (silenciosamente)
return fetch(url, opts);
}
OpenID CAEP (Continuous Access Evaluation Profile) propõe que IdP emita eventos de risco em real-time: token revogado, MFA invalidado, device flagged. RP (Relying Party) consome event stream e revalida sessões em curso. Adoção crescendo entre IdPs (Okta, Azure AD); ainda emergente em apps comuns.
Padrão pragmático antes de adotar CAEP:
Cruza com 02-13 §2.9 (Passkeys são amr=webauthn pra ACR 3), 02-13 §2.15 (refresh rotation complementa step-up), 04-12 §2.6 (security incident pode forçar bulk session revoke).
WebAuthn (W3C Level 3 em 2024) + CTAP2 (FIDO Alliance) são o substrato técnico. Passkey é o termo de marketing pra credentials WebAuthn que sincronizam entre devices via iCloud Keychain, Google Password Manager, 1Password, Bitwarden. Em 2025-2026 viraram default em Apple/Google/Microsoft accounts, GitHub, Stripe.
Modelo criptográfico:
challenge, recebe attestation (assinada). Pública é guardada no server, associada ao user.assertion (challenge + clientDataJSON + authenticatorData assinados). Verifica com pública.clientDataJSON, phishing falha porque attacker em domínio errado não consegue produzir assinatura válida pro origin real.Tipos de credential (decisão importante):
| Tipo | Onde reside | Sincroniza? | Backup | Use case |
|---|---|---|---|---|
| Synced passkey (default 2024+) | iCloud / Google PM / 1Password | Sim | Sim (cloud do provider) | Consumer apps. UX prioridade. |
Device-bound (authenticatorAttachment: "platform") | Secure enclave do device | Não | Não | High-assurance: banking, gov |
| Roaming (security key física: YubiKey) | Hardware key | Manual | Não | Enterprise admins, dev signing |
Server-side flow (registration):
// 1. Server gera options
const options = await generateRegistrationOptions({
rpName: 'Logistica',
rpID: 'logistica.com', // domain, ESSENCIAL pra anti-phishing
userID: bytesFromUuid(user.id),
userName: user.email,
attestationType: 'none', // 'direct' se quiser auditar fabricantes (corporate)
excludeCredentials: existingCreds.map((c) => ({ id: c.credentialID, type: 'public-key' })),
authenticatorSelection: {
residentKey: 'preferred', // 'required' pra usernameless login
userVerification: 'preferred',
},
});
// 2. Cliente chama navigator.credentials.create(options)
// 3. Server verifica resposta com verifyRegistrationResponse, persiste credentialID + publicKey + counter
Pegadinhas reais:
rpID precisa ser registrable suffix do origin. logistica.com cobre app.logistica.com mas não acme.io. Subdomain isolation é decisão consciente.counter em authenticatorData: detecta clonagem. Cresce em cada uso. Se vier menor que o último guardado: alerta (possível attacker com cópia). Synced passkeys reportam counter=0 sempre, não dá pra detectar clone via counter em passkey synced. Trade-off conhecido.mediation: 'conditional'): mostra autofill de passkey direto no input. Implementação do navegador. Default em 2025+.PublicKeyCredential.isUserVerifyingPlatformAuthenticatorAvailable() e degrade pra password+OTP.Server libs (2026):
@simplewebauthn/server (de longe o mais usado e atualizado).github.com/go-webauthn/webauthn.webauthn package.webauthn-rs.Quando NÃO usar passkey:
Estratégia de migração pragmática (2025+):
Apple, Google, Microsoft já fizeram esse caminho. Stripe e GitHub também. Vale seguir.
Login sem senha: user digita email; server manda link com token único; clicar autentica.
Pontos:
Cross-Site Request Forgery: site malicioso induz user logado a enviar request indesejado pro server.
Mitigações:
SameSite=Lax ou Strict em cookies de sessão (default moderno faz quase tudo).X-Requested-With ou similar (browser bloqueia em cross-origin).Em APIs JWT em header Authorization, CSRF é menos relevante (browser não envia automaticamente). Mas se você usa cookie pra JWT, vira problema.
XSS = injeção de JS no app. Se atacante executa JS:
CSP (Content Security Policy) é defesa em camadas: limita origens de scripts, mitiga XSS impact. Em aplicações modernas, configure CSP.
user X is editor of doc Y).Implementação:
roles, role_permissions, user_roles. Middleware checa.OpenFGA, SpiceDB, baseados em Zanzibar.Em apps típicos, comece RBAC. Se permissions ficam complexas (compartilhamento granular tipo Google Drive), ReBAC é o caminho.
Tenant isolation:
tenantId claim.WHERE tenant_id = ?). Postgres RLS (Row Level Security) pode ser última linha.Cuidado com:
Tipo:
OAuth 2.0 BCP (RFC 9700, 2025) recomenda rotation + family invalidation. Modelo: cada refresh emitido carrega family_id + version. Uso emite novo token na mesma family; uso de versão antiga = replay → mata family inteira.
Schema:
CREATE TABLE refresh_tokens (
jti UUID PRIMARY KEY, -- token id (em JWT claim)
family_id UUID NOT NULL, -- todos descendentes do login original
user_id UUID NOT NULL REFERENCES users(id),
parent_jti UUID REFERENCES refresh_tokens(jti),
status TEXT NOT NULL DEFAULT 'active', -- active | rotated | revoked
issued_at TIMESTAMPTZ NOT NULL DEFAULT now(),
expires_at TIMESTAMPTZ NOT NULL,
rotated_at TIMESTAMPTZ, -- quando foi consumido
user_agent TEXT,
ip INET
);
CREATE INDEX ON refresh_tokens (family_id) WHERE status = 'active';
CREATE INDEX ON refresh_tokens (user_id, status);
Algoritmo de rotação atômico (Postgres advisory lock por family):
async function rotateRefreshToken(presentedJti: string) {
return db.transaction(async tx => {
// Lock pra serializar requests concorrentes da mesma family
const token = await tx.queryOne(
`SELECT * FROM refresh_tokens WHERE jti = $1 FOR UPDATE`,
[presentedJti]
);
if (!token) throw new InvalidTokenError('unknown');
if (token.expires_at < new Date()) throw new InvalidTokenError('expired');
if (token.status === 'rotated') {
// REPLAY DETECTED — mata family inteira (todas sessões do device comprometido)
await tx.execute(
`UPDATE refresh_tokens SET status='revoked' WHERE family_id=$1`,
[token.family_id]
);
await emitSecurityEvent({
kind: 'refresh_replay',
user_id: token.user_id,
family_id: token.family_id,
replayed_jti: presentedJti,
});
throw new InvalidTokenError('replay_detected_family_revoked');
}
if (token.status !== 'active') throw new InvalidTokenError('not_active');
// Marca atual como rotated, emite novo na mesma family
const newJti = randomUUID();
await tx.execute(
`UPDATE refresh_tokens SET status='rotated', rotated_at=now() WHERE jti=$1`,
[presentedJti]
);
await tx.execute(
`INSERT INTO refresh_tokens (jti, family_id, user_id, parent_jti, expires_at, user_agent, ip)
VALUES ($1, $2, $3, $4, $5, $6, $7)`,
[newJti, token.family_id, token.user_id, presentedJti,
new Date(Date.now() + REFRESH_TTL_MS), currentUA(), currentIP()]
);
return {
access_token: signAccessJWT({ sub: token.user_id, exp: Math.floor(Date.now()/1000) + 900 }),
refresh_token: signRefreshJWT({ jti: newJti, family_id: token.family_id }),
};
});
}
Por que mata a family inteira no replay: refresh roubado pode ter sido usado por atacante; vítima continua usando seu refresh "antigo" achando que está ok. Quando vítima rotacionar, vai ver rotated → atacante já consumiu. Ambos invalidados, vítima precisa re-autenticar (incluindo MFA). Trade-off: false positive raro (clock skew + retry de cliente), mas segurança vence.
Sliding vs absolute expiration:
expires_at. Sessão ativa não expira. Bom UX.family_id tem cap inicial (ex: 30 dias); rotation mantém o cap original. Forces re-login periodic.Storage trade-off:
| Local | Pro | Con |
|---|---|---|
| HttpOnly cookie + Secure + SameSite=Strict | Imune a XSS read; CSRF defendido por SameSite | CSRF em sub-domínios; precisa double-submit em cross-origin |
| localStorage | Funciona cross-domain, fácil de SDK | XSS = roubo total. Evite em apps com user-generated content |
| In-memory only (access) + cookie HttpOnly (refresh) | Padrão moderno: access vive em memory (XSS roubo limitado a sessão atual), refresh em cookie | Tab refresh re-fetch access via /refresh |
| Mobile Keychain / Keystore | Hardware-backed | Plataforma-específico |
Mobile (Logística courier app): refresh em iOS Keychain / Android Keystore + biometric prompt opcional. Access em RAM only. Rotação em foreground re-entry.
Detecção avançada:
device_fingerprint (hash de UA + IP /24 + screen + timezone). Mismatch em rotation = suspect; pede re-MFA antes de aceitar.Cruza com 02-13 §2.5 (JWT armadilhas — refresh JWT precisa aud ≠ access JWT pra evitar misuse) e 04-12 §2.6 (security incident postmortem ao detectar replay em massa).
Single Sign-On em corporações: SAML 2.0 (legado, ainda predominante em enterprise) ou OIDC (moderno).
samlify, passport-saml).Just-In-Time Provisioning: ao primeiro login via SSO, criar conta automaticamente.
SCIM (System for Cross-domain Identity Management): API pra IdP gerenciar usuários no app.
Decisão: managed (Auth0/Clerk) acelera mas vendor lock e custo escalando. Self-hosted (Keycloak, Authentik) flexível, op heavier. Lib + DB próprio (Lucia, Better-Auth) controle máximo, código maior.
OWASP Top 10 pra auth (Identification and Authentication Failures): credential stuffing, brute force, weak password reset, session fixation, etc.
Defesas:
PKCE (RFC 7636) é mandatório pra mobile e SPA desde OAuth 2.1 (BCP 2025). Implicit flow está oficialmente deprecated. Mobile sem PKCE = code intercept attack (malicious app interceptando redirect URI). Esta seção entrega flow completo Logística mobile (Expo/React Native) — server side + client side com keychain storage + refresh rotation handling.
PKCE mecânica — code_verifier e code_challenge:
code_verifier: random 43-128 chars [A-Z][a-z][0-9]-._~.code_challenge = BASE64URL(SHA256(code_verifier)).code_challenge + code_challenge_method=S256.code_challenge associado ao authorization_code emitido.code_verifier original; server recomputa SHA256 e valida match.authorization_code não consegue trocar por token sem o code_verifier (que nunca saiu do device).Generating PKCE pair (Expo / React Native):
import * as Crypto from 'expo-crypto';
function base64urlEncode(buffer: ArrayBuffer): string {
return Buffer.from(buffer).toString('base64')
.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
}
async function generatePkcePair(): Promise<{ verifier: string; challenge: string }> {
const randomBytes = await Crypto.getRandomBytesAsync(32);
const verifier = base64urlEncode(randomBytes.buffer);
const digest = await Crypto.digestStringAsync(
Crypto.CryptoDigestAlgorithm.SHA256,
verifier,
{ encoding: Crypto.CryptoEncoding.BASE64 }
);
const challenge = digest
.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
return { verifier, challenge };
}
Authorization request — opening browser tab:
import * as AuthSession from 'expo-auth-session';
import * as SecureStore from 'expo-secure-store';
const AUTH_URL = 'https://auth.logistica.app/authorize';
const TOKEN_URL = 'https://auth.logistica.app/token';
const CLIENT_ID = 'mobile-courier';
const REDIRECT_URI = AuthSession.makeRedirectUri({ scheme: 'logistica' });
export async function login(): Promise<TokenSet> {
const { verifier, challenge } = await generatePkcePair();
const state = base64urlEncode((await Crypto.getRandomBytesAsync(16)).buffer);
await SecureStore.setItemAsync('pkce_verifier', verifier);
await SecureStore.setItemAsync('oauth_state', state);
const params = new URLSearchParams({
response_type: 'code',
client_id: CLIENT_ID,
redirect_uri: REDIRECT_URI,
scope: 'openid profile offline_access courier:read courier:write',
state,
code_challenge: challenge,
code_challenge_method: 'S256',
});
const result = await AuthSession.startAsync({ authUrl: `${AUTH_URL}?${params}` });
if (result.type !== 'success' || result.params.error) {
throw new Error(result.params.error_description ?? 'Auth canceled');
}
const savedState = await SecureStore.getItemAsync('oauth_state');
if (result.params.state !== savedState) throw new Error('State mismatch — possible CSRF');
return exchangeCodeForToken(result.params.code);
}
Token exchange:
async function exchangeCodeForToken(code: string): Promise<TokenSet> {
const verifier = await SecureStore.getItemAsync('pkce_verifier');
if (!verifier) throw new Error('Missing PKCE verifier');
const body = new URLSearchParams({
grant_type: 'authorization_code',
code,
redirect_uri: REDIRECT_URI,
client_id: CLIENT_ID,
code_verifier: verifier,
});
const resp = await fetch(TOKEN_URL, {
method: 'POST',
headers: { 'content-type': 'application/x-www-form-urlencoded' },
body: body.toString(),
});
if (!resp.ok) throw new Error(`Token exchange failed: ${await resp.text()}`);
const tokens: TokenSet = await resp.json();
await persistTokens(tokens);
await SecureStore.deleteItemAsync('pkce_verifier');
await SecureStore.deleteItemAsync('oauth_state');
return tokens;
}
Storage — Keychain (iOS) e Keystore (Android) via SecureStore:
type TokenSet = { access_token: string; refresh_token: string; expires_in: number; id_token: string };
async function persistTokens(t: TokenSet) {
const expiresAt = Date.now() + t.expires_in * 1000;
await SecureStore.setItemAsync('access_token', t.access_token, {
keychainAccessible: SecureStore.WHEN_UNLOCKED_THIS_DEVICE_ONLY,
});
await SecureStore.setItemAsync('refresh_token', t.refresh_token, {
keychainAccessible: SecureStore.WHEN_PASSCODE_SET_THIS_DEVICE_ONLY,
});
await SecureStore.setItemAsync('access_token_exp', String(expiresAt));
}
WHEN_PASSCODE_SET_THIS_DEVICE_ONLY pra refresh token: requer dispositivo com passcode set + biometric/passcode unlock. Sem isso, jailbreak recupera token.Refresh token rotation handling no client:
let refreshInFlight: Promise<TokenSet> | null = null;
async function getValidAccessToken(): Promise<string> {
const expStr = await SecureStore.getItemAsync('access_token_exp');
const exp = expStr ? parseInt(expStr) : 0;
if (Date.now() < exp - 60_000) {
return (await SecureStore.getItemAsync('access_token'))!;
}
if (!refreshInFlight) {
refreshInFlight = doRefresh().finally(() => { refreshInFlight = null; });
}
const newTokens = await refreshInFlight;
return newTokens.access_token;
}
async function doRefresh(): Promise<TokenSet> {
const refreshToken = await SecureStore.getItemAsync('refresh_token');
if (!refreshToken) throw new ReAuthRequired();
const resp = await fetch(TOKEN_URL, {
method: 'POST',
headers: { 'content-type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
grant_type: 'refresh_token',
refresh_token: refreshToken,
client_id: CLIENT_ID,
}).toString(),
});
if (resp.status === 400) {
await wipeTokens();
throw new ReAuthRequired();
}
const tokens: TokenSet = await resp.json();
await persistTokens(tokens);
return tokens;
}
refreshInFlight): 5 requests paralelas batendo refresh ao mesmo tempo geram 5 calls + cada uma invalida a anterior (refresh rotation com replay detection mata family). Singleflight resolve.App-level redirect — Universal Links / App Links:
apple-app-site-association JSON em https://logistica.app/.well-known/apple-app-site-association (não em /apple-app-site-association). Requer HTTPS válido + entitlement com.apple.developer.associated-domains.assetlinks.json em https://logistica.app/.well-known/assetlinks.json + intent-filter com autoVerify="true".logistica://callback) — qualquer app pode registrar mesmo scheme e interceptar code. Universal Links / App Links garantem domain-bound.Logout completo — RFC 7009 token revocation + RP-initiated logout:
export async function logout() {
const refreshToken = await SecureStore.getItemAsync('refresh_token');
if (refreshToken) {
await fetch(`${AUTH_URL.replace('/authorize', '/revoke')}`, {
method: 'POST',
headers: { 'content-type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
token: refreshToken,
token_type_hint: 'refresh_token',
client_id: CLIENT_ID,
}).toString(),
});
}
await wipeTokens();
const idToken = await SecureStore.getItemAsync('id_token');
const endSessionUrl = `${AUTH_URL.replace('/authorize', '/logout')}?id_token_hint=${idToken}&post_logout_redirect_uri=${encodeURIComponent(REDIRECT_URI)}`;
await WebBrowser.openBrowserAsync(endSessionUrl);
}
Anti-patterns observados em mobile auth:
code_challenge_method=plain): equivalente a sem PKCE. Sempre S256.state faltando: sem CSRF protection no callback.response_type=token): deprecated; access token em URL fragment loga em referer/history.client_secret embedded em mobile binary: secret extraível via strings binary | grep -i secret. Mobile clients são "public" — sem secret.Config minimal authorization server compatível (Hydra/Keycloak/Auth0):
code_challenge_method=S256 (reject plain).client_type: public pra mobile (sem secret).redirect_uris lista exact-match com Universal/App link URI.refresh_token_grant_types: enable refresh rotation com family detection (cruza com 02-13 §2.15).Cruza com 02-13 §2.4 (OAuth2 fundamentos), 02-13 §2.8 (step-up authentication; PKCE + step-up = mobile sensível protegido), 02-13 §2.15 (refresh rotation com family-based replay detection é par essencial), 04-04 §2.4 (idempotency em refresh — singleflight padrão).
Por que passkey supera password + SMS OTP:
logistica.example.com); attacker em logistica.evil.com NÃO spoofa — browser recusa assinar challenge para origin diferente.Spec hierarchy:
navigator.credentials.create / get.residentKey: required.Registration flow (server-side TS, @simplewebauthn/server v11+, mantida pela Duo/Cisco):
// server: gera registration options
import { generateRegistrationOptions, verifyRegistrationResponse } from '@simplewebauthn/server';
const RP_ID = process.env.RP_ID!; // 'logistica.example.com' em prod, 'localhost' em dev
const RP_NAME = 'Logística';
const ORIGIN = process.env.ORIGIN!; // 'https://logistica.example.com'
app.post('/auth/passkey/register/options', async (req, res) => {
const user = await getUser(req.session.userId);
const options = await generateRegistrationOptions({
rpName: RP_NAME,
rpID: RP_ID,
userID: new TextEncoder().encode(user.id),
userName: user.email,
userDisplayName: user.name,
attestationType: 'none', // 'direct' só em enterprise/regulated; consumer = 'none'
authenticatorSelection: {
residentKey: 'preferred', // discoverable credential = passkey real
userVerification: 'preferred',
authenticatorAttachment: 'platform', // 'cross-platform' libera security keys (YubiKey)
},
excludeCredentials: user.passkeys.map(p => ({ id: p.credentialId, type: 'public-key' })),
});
await saveChallenge(user.id, options.challenge, { ttlSeconds: 300 });
res.json(options);
});
// client: invoca browser API
import { startRegistration } from '@simplewebauthn/browser';
const opts = await fetch('/auth/passkey/register/options', { method: 'POST' }).then(r => r.json());
const regResp = await startRegistration(opts); // browser dispara Touch ID / Face ID
await fetch('/auth/passkey/register/verify', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(regResp),
});
// server: verify + persist
app.post('/auth/passkey/register/verify', async (req, res) => {
const expectedChallenge = await getChallenge(req.session.userId);
const verification = await verifyRegistrationResponse({
response: req.body,
expectedChallenge,
expectedOrigin: ORIGIN,
expectedRPID: RP_ID,
});
if (!verification.verified) return res.status(400).json({ error: 'Verification failed' });
await savePasskey(req.session.userId, {
credentialId: verification.registrationInfo!.credentialID,
publicKey: verification.registrationInfo!.credentialPublicKey,
counter: verification.registrationInfo!.counter,
deviceLabel: req.headers['user-agent'],
});
res.json({ ok: true });
});
Authentication flow (login):
// server: gera authentication options
app.post('/auth/passkey/login/options', async (req, res) => {
const user = req.body.email ? await getUserByEmail(req.body.email) : null;
const options = await generateAuthenticationOptions({
rpID: RP_ID,
userVerification: 'preferred',
allowCredentials: user?.passkeys.map(p => ({ id: p.credentialId, type: 'public-key' })) ?? [],
});
await saveChallenge(user?.id ?? `anon:${options.challenge}`, options.challenge, { ttlSeconds: 300 });
res.json(options);
});
// server: verify + cria session
app.post('/auth/passkey/login/verify', async (req, res) => {
const passkey = await findPasskey(req.body.id);
if (!passkey) return res.status(404).json({ error: 'Unknown credential' });
const verification = await verifyAuthenticationResponse({
response: req.body,
expectedChallenge: await getChallenge(passkey.userId),
expectedOrigin: ORIGIN,
expectedRPID: RP_ID,
authenticator: { credentialPublicKey: passkey.publicKey, credentialID: passkey.credentialId, counter: passkey.counter },
});
if (!verification.verified) return res.status(401).json({ error: 'Verification failed' });
await updateCounter(passkey.id, verification.authenticationInfo.newCounter);
const session = await createSession(passkey.userId);
res.cookie('session', session, { httpOnly: true, secure: true, sameSite: 'lax' });
res.json({ ok: true });
});
Conditional UI (Autofill UI) — UX moderno: browser sugere passkey no autofill dropdown sem user precisar selecionar account antes. Pattern:
<input type="text" name="email" autocomplete="username webauthn" />
if (await PublicKeyCredential.isConditionalMediationAvailable?.()) {
const opts = await fetch('/auth/passkey/login/options', { method: 'POST' }).then(r => r.json());
const authResp = await startAuthentication(opts, /* useBrowserAutofill */ true);
await fetch('/auth/passkey/login/verify', { method: 'POST', body: JSON.stringify(authResp), headers: { 'Content-Type': 'application/json' } });
}
User clica no campo email → browser mostra passkey → tap → autenticado.
Account recovery — story crítica:
Multi-passkey management UI: settings page lista cada passkey com name ("MacBook Pro Touch ID", "iPhone Face ID"), created date, last used date, authenticator type (platform/cross-platform), delete com confirmation. Pattern Logística: courier app + lojista web enrollam separadamente; settings page por device.
Migration strategy de password + SMS:
Logística applied stack:
@simplewebauthn/server + Postgres passkeys table (credential_id BYTEA UNIQUE, public_key BYTEA, counter BIGINT, user_id UUID FK, device_label TEXT, created_at, last_used_at); conditional UI no /login.react-native-passkey (wrapper Stytch sobre ASAuthorizationController iOS / CredentialManager Android — cruza com 02-17).Anti-patterns observados (10):
RP_ID mismatch (logistica.example.com em prod, localhost em dev) sem env-aware config — signature verification falha silenciosamente.attestationType: 'direct' em consumer app (privacy leak via attestation cert + manufacturer lookup overhead; use 'none').excludeCredentials em registration — user reenrolla mesmo authenticator, cria duplicates no DB.autocomplete="webauthn" no input — autofill dropdown não dispara.Cruza com 02-06 (RN, react-native-passkey biometric wrapper), 02-17 (native mobile, ASAuthorizationController iOS / CredentialManager Android), 03-08 (security, OWASP A07 identification & auth failures), 04-12 (tech leadership, migration strategy multi-quarter), 02-04 (React, conditional UI hooks).
RBAC escala bem até ~50 roles; depois vira role explosion (role-per-tenant, role-per-feature, role-per-region) e IF user.role IN [...] espalhado em cada handler. ABAC (attribute-based) escala em flexibilidade mas debug é nightmare: política negou acesso e ninguém sabe qual atributo faltou. Zanzibar (Pang et al., USENIX 2019, paper Google interno) propôs ReBAC: modela autorização como graph de tuples (subject, relation, object) — user:42 editor document:99, team:eng member user:42, document:99 parent folder:specs. Check API responde "user:42 pode editor document:99?" via graph traversal + userset rewrite rules. 2024-2026: implementações Zanzibar maduraram a GA — OpenFGA (CNCF Sandbox 2023 → Incubating 2024, Auth0/Okta-backed, 1.7+ Q4 2024), SpiceDB (Authzed, 1.40+ Q4 2024, mais maduro em performance), Cerbos (0.40+ Q4 2024, stateless, sem relation graph — pure ABAC sidecar), Permit.io (managed wrapper OpenFGA/Cedar/Rego), AWS Verified Permissions (GA Q2 2023, usa Cedar policy language formally-verified, open-source 2023).
namespace = resource type (document, folder, organization, team)
tuple = (user:42, editor, document:99) // subject relation object
relation = nome da aresta no grafo
userset rewrite:
- computed_userset: "viewers de doc = viewers ∪ editors do mesmo doc"
- tuple_to_userset: "viewers de doc = viewers do parent folder do doc"
- union/intersection/exclusion entre usersets
check API: boolean "subject pode relation object?" → graph traversal
list-objects API: "quais objects subject tem relation?" → inverse query
list-users API: "quais users tem relation a object?" → forward fan-out
expand API: retorna tree completo do userset → debug/audit
Consistency: zedtoken (SpiceDB) / consistency token (OpenFGA) — após write retorna token; passa em check para garantir read-after-write em sistema distribuído (sem token, leitura pode vir de cache stale). Trade-off: token fresh = latency maior + load no leader; token any = p99 baixo mas pode dar stale read de poucos segundos.
# fga-model.yaml — authorization model do tenant
schema_version: "1.1"
type_definitions:
- type: user
- type: organization
relations:
member: { this: {} }
admin: { this: {} }
- type: team
relations:
parent: { this: {} } # team belongs to org
member:
union:
child:
- this: {}
- tupleToUserset:
tupleset: { relation: parent }
computedUserset: { relation: member } # org members são team members
- type: order
relations:
tenant: { this: {} }
courier: { this: {} }
customer: { this: {} }
viewer:
union:
child:
- this: {}
- computedUserset: { relation: editor }
- tupleToUserset:
tupleset: { relation: tenant }
computedUserset: { relation: admin }
editor:
union:
child:
- this: {}
- computedUserset: { relation: courier }
- computedUserset: { relation: customer }
// authz.ts — Fastify middleware + OpenFGA check
import { OpenFgaClient } from '@openfga/sdk';
const fga = new OpenFgaClient({
apiUrl: process.env.FGA_API_URL!,
storeId: process.env.FGA_STORE_ID!, // do env, NUNCA hardcode
authorizationModelId: process.env.FGA_MODEL_ID!,
});
export async function requireCheck(
user: string, relation: string, object: string,
contextualTuples?: Array<{ user: string; relation: string; object: string }>,
) {
const { allowed } = await fga.check({
user, relation, object,
contextual_tuples: contextualTuples ? { tuple_keys: contextualTuples } : undefined,
consistency: 'MINIMIZE_LATENCY', // ou 'HIGHER_CONSISTENCY' se read-after-write crítico
});
if (!allowed) throw new ForbiddenError(`${user} cannot ${relation} ${object}`);
}
// uso no handler
fastify.get('/orders/:id', async (req) => {
await requireCheck(`user:${req.userId}`, 'viewer', `order:${req.params.id}`);
return ordersRepo.findById(req.params.id);
});
// list-objects para multi-tenant filter (NÃO faça SELECT * + filter em memória)
const { objects } = await fga.listObjects({
user: `user:${userId}`,
relation: 'viewer',
type: 'order',
});
const orderIds = objects.map(o => o.replace('order:', ''));
return ordersRepo.findByIds(orderIds); // SQL: WHERE id IN (...)
// batch check para N decisões em uma chamada (UI render conditional buttons)
const { responses } = await fga.batchCheck({
checks: orders.map(o => ({
tuple_key: { user: `user:${userId}`, relation: 'editor', object: `order:${o.id}` },
correlation_id: o.id,
})),
});
definition user {}
definition organization {
relation member: user
relation admin: user
}
definition order {
relation tenant: organization
relation courier: user
relation customer: user
permission view = customer + courier + tenant->admin + tenant->member
permission edit = customer + courier + tenant->admin
}
// SpiceDB check com zedtoken (consistency at-this-write)
const { writtenAt } = await spicedb.writeRelationships({ updates: [...] });
// passa zedtoken em chamadas subsequentes para read-after-write garantido
const { permissionship } = await spicedb.checkPermission({
resource: { objectType: 'order', objectId: '99' },
permission: 'view',
subject: { object: { objectType: 'user', objectId: '42' } },
consistency: { atLeastAsFresh: writtenAt }, // zedtoken
});
# resources/order.yaml — Cerbos resource policy
apiVersion: api.cerbos.dev/v1
resourcePolicy:
resource: "order"
version: "default"
rules:
- actions: ["view"]
effect: EFFECT_ALLOW
roles: ["customer"]
condition:
match: { expr: "request.resource.attr.customerId == request.principal.id" }
- actions: ["view", "edit"]
effect: EFFECT_ALLOW
roles: ["admin"]
condition:
match: { expr: "request.resource.attr.tenantId == request.principal.attr.tenantId" }
Cerbos = PDP sidecar/lambda, decisão pure-function de (principal, resource, action) → permit/deny. Sem state, sem list-objects (não sabe quais resources existem). Use se ABAC puro resolve e relationships não são essenciais.
permit (
principal in Group::"engineers",
action == Action::"viewOrder",
resource is Order
) when {
resource.tenantId == principal.tenantId
};
Cedar = formally-verified semantics (sem cycles, decidível), entity store + policy store, native AWS integration. Use se já AWS-heavy + quer audit log gerenciado.
| Cenário | Escolha |
|---|---|
| < 50 roles, < 5 tenants, sem hierarquia | RBAC inline (DB roles table) |
| Decisão depende de attributes contextuais (time, IP, risk) | ABAC — Cerbos ou OPA |
| Hierarchies dominam (folder→doc, org→team→user, projeto→role) | Zanzibar ReBAC — OpenFGA/SpiceDB |
| Stateless preferred, sem relation graph | Cerbos sidecar |
| AWS-native, formally-verified policies | Cedar + Verified Permissions |
| Equipe pequena, quer UI low-code | Permit.io (managed) |
| Performance crítica (p99 < 5ms), high-throughput | SpiceDB (mais maduro perf) |
Latencies típicas 2026: OpenFGA check p99 ~5-10ms, SpiceDB p99 ~3-8ms (com cache), Cerbos PDP sidecar p99 < 5ms (in-process), AWS Verified Permissions p99 ~10-20ms (cross-region penalty). list-objects pode custar 100s ms para permission sets grandes — paginar e/ou usar list-users (inverse).
order, team, org); relations = verbs/roles (viewer, editor, member, admin).tuple_to_userset: viewer de doc = viewer do parent folder evita propagar tuples manualmente.editor ⊆ viewer via union.(user:42, on_call, team:eng) no check sem persistir.permission view = viewer with valid_ip onde valid_ip é CEL expression.OpenFGA gerencia autz de orders/routes/shipments. Schema: organization → courier (member), order → tenant + courier + customer, route → courier + dispatcher. Check via Fastify middleware em todo handler. Postgres backend para FGA store (mesmo cluster RDS, schema separado). Authorization model versionado em git, deploy via CI cria nova model_id (zero-downtime — handlers velhos seguem usando model antigo via env var pinned). list-objects nas queries de listagem (GET /orders filtra por viewer antes do SQL). Batch check no UI para renderizar botões condicionais sem N round-trips.
permit when resource.owner == principal && principal.org == resource.org.parent.parent — Cedar valida em parse, mas humanos não detectam fácil; use static analyzer.writtenAt no consistency.editor de order.Cruza com 02-13 §2.13 (Authorization intro RBAC/ABAC/ReBAC), §2.14 (multi-tenant + auth), §2.16 (federated identity), §2.18 (threats reais), 03-08 §2.21 (OWASP A01 broken access control), 04-08 §2.11 (service mesh — authz at network layer complementary), 04-05 §2.27 (API spec — OpenFGA SDK from OpenAPI), 03-04 (CI/CD — policy testing in pipeline), 02-17 §2.20 (mobile native 2026 — Keychain iOS / EncryptedSharedPreferences Android pra OAuth tokens, biometric gate via LocalAuthentication / BiometricPrompt).
Você precisa, sem consultar:
alg: none JWT existiu e como evita.Implementar auth completo do Logística, sem libs auth-as-a-service.
argon2, jose (JWT), @simplewebauthn/server (passkeys), otplib (TOTP).POST /auth/signup, email + senha, validação (zxcvbn ou similar pra força).POST /auth/login, verifica senha, emite session.__Host-sid HttpOnly + Secure + SameSite=Lax.POST /auth/logout revoga (delete Redis).POST /auth/logout-all revoga todas sessions do user.otpauth://totp/...)./auth/passkey/register e /auth/passkey/login usando WebAuthn.GET /auth/google redireciona pra Google com PKCE + state.GET /auth/google/callback valida state, troca code, valida ID token via JWKS, cria/upgrade conta./auth/mobile/login retorna access (10 min) + refresh (30 dias) tokens.lojista, courier, admin.requireRole(['admin']).tenantId./auth/login (5 falhas em 15 min, Redis).auth_events (user, type, ip, ua, ts).passport-tudo (você implementa o flow OIDC manualmente; pode usar openid-client pra parsing).oidc-provider.subtle em edge.Threshold de Maestria
Acerte todas as 5 pra marcar o módulo como concluído. Sem pressa, sem timer. Tudo fica salvo no teu navegador.
Q1Por que SHA-256 simples é inadequado para armazenar senhas, exigindo Argon2id/bcrypt/scrypt?
Q2Qual a maior diferença prática entre OAuth2 e OIDC?
Q3Qual JWT pitfall consiste em o atacante mudar o `alg` no header e o server aceitar a forja?
Q4No fluxo OAuth2 Authorization Code com PKCE, qual o papel do `code_verifier`?
Q5Em refresh token rotation com family-based detection, o que acontece se um refresh token já rotacionado for reutilizado?
Destrava
02-13 é prereq dos seguintes módulos: