Autenticação
@basaltkit/auth fornece autenticação completa do lado do servidor com os dados na tua base de dados — sem dependência de fornecedor. Hashing de passwords, JWT com rotação de refresh, sessões, MFA (TOTP), passkeys, login social, API keys e rotas prontas a usar. Responde a quem está a chamar; deliberadamente não responde a o que essa pessoa pode fazer — isso é autorização — e é desacoplado da framework HTTP, pelo que a mesma ligação funciona em Fastify, Express e Hono (vê Adaptadores).
Modelo mental
Cinco peças, e só ligas as duas primeiras à mão:
| Peça | Registada por | O que faz |
|---|---|---|
| Enricher | authPlugin | Lê Authorization: Bearer <jwt> ou x-session-id e define ctx().user. Sem credenciais → o pedido fica anónimo, sem erro. Um token explicitamente inválido ou expirado → 401 |
| Guard | authPlugin | Uma rota que declara meta: { auth: true } exige ctx().user — anónimo → 401 AUTH_REQUIRED |
| Enricher + guard | apiKeysPlugin | Autentica bearers com prefixo mk_ / x-api-key para ctx().apiKey, e impõe meta.scopes |
Serviço Auth | authPlugin, token AUTH | Tudo o que as rotas chamam: registo, login, refresh, sessões, verificação, reposição, MFA. Alcança-o com app.container.get(AUTH) |
| Rotas | authRoutes() · mfaRoutes() · apiKeyRoutes() · oauthRoutes() | Invólucros finos e substituíveis sobre o serviço |
Duas durações de token sustentam a sessão: um access token curto (JWT, 15m) enviado em cada pedido, e um refresh token longo (30d) trocado por um novo par. O refresh é rotativo com deteção de reutilização — repetir um token já consumido revoga a família inteira.
Para aplicações de browser, o POST /auth/login também cria uma sessão no servidor e devolve um cabeçalho Set-Cookie. O cookie basalt_session é HttpOnly, SameSite=Lax, limitado a / e marcado como Secure em produção — o que, como em todo o Basalt, significa tudo excepto um NODE_ENV=development ou test explícito (um NODE_ENV não definido conta como produção). Pedidos same-origin do browser enviam-no automaticamente, sem expor o JWT ao JavaScript. Mantém os access e refresh tokens fora de localStorage.
O meta.auth é um pedido de proteção, e é verificado no arranque
O authPlugin reivindica a chave de meta auth. Uma rota que declara meta.auth sem o authPlugin registado serviria desprotegida, por isso cada adaptador recusa arrancar com UnguardedRouteMetaError (HTTP_UNGUARDED_ROUTE_META) — detalhado em Proteger rotas mais abaixo.
Configuração
O arranque mais rápido usa os stores em memória — perfeitos para experimentar, mas tudo desaparece ao reiniciar. Regista o plugin e as rotas prontas a usar:
import { createApp } from '@basaltkit/core'
import { fastifyPlugin, FASTIFY } from '@basaltkit/fastify'
import { authPlugin, authRoutes, MemoryUserSource } from '@basaltkit/auth'
const app = await createApp({
plugins: [
authPlugin({
users: new MemoryUserSource(), // em produção: implementa UserSource sobre a tua BD
secret: process.env.AUTH_SECRET!, // assina os JWTs (HS256) — mantém-no secreto
accessTtl: '15m', // access token de curta duração (predefinição)
refreshTtl: '30d', // refresh token de longa duração (predefinição)
}),
fastifyPlugin({ routes: authRoutes() }),
],
}).boot()
await app.container.get(FASTIFY).listen({ port: 3000 })Aviso
O secret é a chave do cofre. Usa um valor longo e aleatório (openssl rand -base64 48), carrega-o a partir de uma variável de ambiente e nunca o faças commit. Se for exposto, qualquer um pode forjar tokens. Serve sempre a autenticação sobre HTTPS.
Stores duráveis (produção)
authPlugin aceita um store para cada peça móvel — troca os padrões Memory* por um backend durável e os utilizadores mantêm-se autenticados, as API keys continuam a funcionar e os tokens de password-reset sobrevivem a um redeploy. Dois backends oficiais vêm prontos a usar.
SQLite — @basaltkit/auth-sqlite
Zero dependências externas, construído sobre o node:sqlite do Node (Node 22.5+; no 22.x corre com --experimental-sqlite, estável e sem flag no Node 24):
import { authPlugin, apiKeysPlugin } from '@basaltkit/auth'
import { sqliteAuthStores } from '@basaltkit/auth-sqlite'
const s = sqliteAuthStores('./data/auth.db') // ':memory:' por padrão; abre + migra
const app = await createApp({
plugins: [
authPlugin({
secret: process.env.AUTH_SECRET!,
users: s.users,
sessions: s.sessions,
refreshTokens: s.refreshTokens,
tokens: s.tokens, // verificação de email + reposição de password
mfa: s.mfa,
accountLinks: s.accountLinks, // subject do fornecedor OAuth/OIDC → conta
}),
apiKeysPlugin({ store: s.apiKeys, users: s.users }),
fastifyPlugin({ routes: authRoutes() }),
],
}).boot()sqliteAuthStores() também aceita um DatabaseSync que já tenhas aberto, para que a autenticação possa partilhar uma ligação com o resto da tua app. Os stores individuais (SqliteUserSource, SqliteSessionStore, …) também são exportados se quiseres misturar backends. O s.passkeys é um PasskeyStore durável para webauthnPlugin({ credentials }).
Uma base de dados criada antes de os emails serem canonicalizados pode ter linhas que só diferem em maiúsculas/minúsculas; a pesquisa desse email lança AUTH_EMAIL_AMBIGUOUS em vez de escolher uma. O normalizeAuthUserEmails(s.db) passa a minúsculas as linhas sem gémea, reporta as gémeas para as fundires ({ normalized, conflicts }, dryRun: true para pré-visualizar) e, quando já não houver nenhuma, cria o índice único insensível a maiúsculas.
Prisma — @basaltkit/auth-prisma
Para PostgreSQL/MySQL. Copia os modelos Auth* de @basaltkit/auth-prisma/schema.prisma para o teu schema.prisma, corre prisma migrate dev && prisma generate e depois:
import { authPlugin, apiKeysPlugin } from '@basaltkit/auth'
import { prismaAuthStores } from '@basaltkit/auth-prisma'
import { PrismaClient } from '@prisma/client'
const prisma = new PrismaClient()
const s = prismaAuthStores(prisma) // passa o client diretamente, sem cast
authPlugin({
secret: process.env.AUTH_SECRET!,
users: s.users,
sessions: s.sessions,
refreshTokens: s.refreshTokens,
tokens: s.tokens,
mfa: s.mfa,
accountLinks: s.accountLinks, // precisa do modelo AuthAccountLink
})
apiKeysPlugin({ store: s.apiKeys, users: s.users })
webauthnPlugin({ config, verifier, credentials: s.passkeys }) // precisa do modelo AuthPasskeyAtualizar para o auth-prisma 2.0
O 2.0 acrescenta dois modelos: AuthAccountLink (auth_account_links, as ligações de contas OAuth/OIDC) e AuthPasskey (auth_passkeys, credenciais WebAuthn). Copia-os de @basaltkit/auth-prisma/schema.prisma (ou corre basalt prisma:sync) e depois prisma migrate dev --name auth_account_links_passkeys — com schema-per-tenant, em todos os schemas de tenant. Um client gerado sem eles continua a compilar; o s.accountLinks / s.passkeys lançam AUTH_PRISMA_MODEL_MISSING no primeiro uso. As chaves primárias são hashes SHA-256, por isso todas as colunas indexadas cabem no VARCHAR(191) do MySQL; em MySQL copia-os antes de @basaltkit/auth-prisma/schema.mysql.prisma, onde as colunas longas não indexadas (subject, credentialId, publicKey) são @db.Text, e passa prismaAuthStores(prisma, { columnLimits: 'mysql' }) — ver MySQL.
As pesquisas por email são insensíveis a maiúsculas em PostgreSQL e recusam a ambiguidade: duas linhas legadas que só diferem em maiúsculas fazem o findByEmail lançar AUTH_EMAIL_AMBIGUOUS, e o create recusa uma variante de maiúsculas de uma linha existente (AUTH_EMAIL_TAKEN). Corre normalizeAuthUserEmails(prisma) uma vez depois de atualizar: passa a minúsculas as linhas sem gémea e devolve as gémeas ({ normalized, conflicts }, dryRun: true para pré-visualizar) para as fundires.
Atualizar para o auth-prisma 1.5
O 1.5.0 acrescentou uma coluna expiresAt anulável ao AuthApiKey (auth_api_keys) para a expiração das API keys. Regenerar o client não chega: acrescenta uma migração (prisma migrate dev --name add_api_key_expires_at), que em PostgreSQL é ALTER TABLE "auth_api_keys" ADD COLUMN "expiresAt" TIMESTAMP(3);. Com schema-per-tenant, aplica-a em todos os schemas de tenant — acrescenta-a às tuas migrações de tenant e corre basalt tenant:migrate. Até lá, os pedidos com API key falham com AUTH_API_KEY_SCHEMA_OUTDATED. O auth-sqlite acrescenta a coluna sozinho.
Dica
Traz o teu próprio UserSource. Não tens base de dados? Implementa o contrato UserSource tu mesmo — quatro métodos sobre as tuas tabelas. update é opcional mas obrigatório para verificação de email e reposição de password (AUTH_UPDATE_UNSUPPORTED se faltar):
import type { UserSource, AuthUser, UserPatch } from '@basaltkit/auth'
const users: UserSource = {
async findByEmail(email) { /* SELECT … WHERE email = ? */ return null },
async findById(id) { /* SELECT … WHERE id = ? */ return null },
async create(data) { // data = { email, passwordHash } — hash já calculado
return { id: crypto.randomUUID(), ...data } as AuthUser
},
async update(id, patch: UserPatch) { /* UPDATE … */ return null },
// Opcional: pesquisa de contactos em lote — ver abaixo.
async findByIds(ids) { /* SELECT id, email, email_verified WHERE id IN (…) */ return [] },
}Pesquisa de contactos em lote (findByIds)
O findById responde por um id de cada vez, o que empurra tudo o que precisa dos contactos de um grupo ("enviar email a todos os admins deste tenant") ou para N idas à base de dados ou — pior — para ler as tabelas de auth directamente no código da aplicação, acoplando o produto ao schema de auth.
O findByIds é o equivalente em lote (opcional) e tem um contrato deliberadamente mais estreito do que o do findById:
- devolve
PublicUser, nuncaAuthUser— uma pesquisa de directório não tem nada que transportar uma hash de password, e os drivers incluídos nem sequer fazemSELECTdas colunas de credenciais; - uma entrada por id encontrado, pela ordem de
ids; os ids sem conta são omitidos, por isso o resultado pode ser mais curto do que a entrada; - ids repetidos colapsam numa única entrada e uma lista vazia devolve
[]sem tocar na base de dados.
await users.findByIds?.(['u1', 'u2', 'ghost'])
// → [{ id: 'u1', email: 'ada@acme.test', emailVerified: true }, { id: 'u2', … }]O MemoryUserSource e os dois drivers incluídos implementam-no; os de SQL enviam um WHERE id IN (…) por bloco de 500 ids, para que um tenant grande não exceda o limite de parâmetros do driver (idChunkSize afina esse valor).
Quem o consome trata-o como uma optimização, nunca como um requisito: o @basaltkit/teams usa o caminho em lote quando a tua fonte tem findByIds e recorre a um findById por membro quando não tem — passa o mesmo UserSource a teamsPlugin({ users }) e teams.roleRecipients('acme', 'admin') devolve os contactos dos admins.
Rotas prontas a usar
Regista as rotas incorporadas — cada uma é uma rota simples que podes substituir ou omitir:
import { authRoutes, mfaRoutes, apiKeyRoutes } from '@basaltkit/auth'
import { fastifyPlugin } from '@basaltkit/fastify'
fastifyPlugin({ routes: [...appRoutes, ...authRoutes(), ...mfaRoutes(), ...apiKeyRoutes()] })authRoutes() expõe:
| Endpoint | Body | Notas |
|---|---|---|
POST /auth/register | { email, password } | Sempre 202 { ok: true } — à prova de enumeração (ver abaixo) |
POST /auth/login | { email, password, mfaCode? } | → { user, accessToken, refreshToken } |
POST /auth/refresh | { refreshToken } | novo par de tokens; mata a família em caso de reutilização |
POST /auth/logout | { refreshToken? } (corpo opcional) | 204; revoga a família de refresh quando é dado, e termina a sessão do cookie / x-session-id e expira o cookie. Uma SPA só com cookie não envia corpo. Um logout cross-site só com cookie é recusado (403 AUTH_CSRF_REJECTED) |
GET /auth/me | — | meta.auth — requer Authorization: Bearer <jwt> |
POST /auth/verify/request · POST /auth/verify | { email } · { token } | verificação de email |
POST /auth/password/forgot · POST /auth/password/reset | { email } · { token, password } | reposição de password |
A password é validada com min(8) e o email como endereço de email em todas as rotas que os recebem — uma password mais curta é um erro de validação 400, não uma conta fraca. Os inputs também têm limite de tamanho antes de qualquer trabalho: emails até 254 caracteres, passwords até 1024 (seja qual for a política de password que passes), tokens até 512 — um corpo sobredimensionado é um 400, nunca um hash.
Os emails são identidades insensíveis a maiúsculas: o Auth apara e converte para minúsculas cada email antes de o procurar ou de criar uma conta, por isso Bob@acme.test e bob@acme.test são a mesma conta. Os stores incluídos também encontram, sem distinguir maiúsculas, as linhas escritas antes disso.
As rotas não autenticadas que fazem hash, enviam email ou aceitam tentativas — register, login, verify/request, password/forgot e password/reset — declaram meta.rateLimit: { limit: 10, windowMs: 60_000 } (por ip de cliente e rota), imposto quando o rate limiter do securityPlugin está ligado. Altera-o ou remove-o com authRoutes({ rateLimit: { limit, windowMs } }) / authRoutes({ rateLimit: false }). Independentemente disso, o Auth envia no máximo 3 emails de reposição e 3 de verificação por conta a cada 15 minutos (emailRequestThrottle): pedidos a mais continuam a responder 200 mas não criam token, por isso os endpoints não servem para inundar um utilizador de emails nem para ir invalidando o link que acabou de receber.
Nada aqui revela se uma conta existe
O POST /auth/register responde o mesmo 202 { ok: true } para um registo novo e para um email já existente, e faz trabalho equivalente (continua a fazer o hash da password) para que o tempo de resposta também coincida. A colisão é sinalizada fora de banda através do hook auth:register_existing_email — envia um email à morada a dizer "já tens conta, entra ou repõe a password" em vez de deixar escapar a existência na resposta HTTP. Define enumerationSafeRegister: false para voltar ao comportamento antigo de 409 AUTH_EMAIL_TAKEN; o auth.register() de nível mais baixo lança sempre num duplicado, independentemente disso.
As rotas verify/request e password/forgot respondem 200 pela mesma razão; os seus tokens são enviados por email através dos hooks auth:verify_requested / auth:password_reset_requested, nunca devolvidos por HTTP. Uma reposição de password concluída revoga todas as sessões e refresh tokens.
O fluxo register → login → refresh (HTTP)
# 1. Registo
curl -X POST http://localhost:3000/auth/register \
-H 'content-type: application/json' \
-d '{"email":"ada@example.com","password":"secretpassword1"}'
# 2. Login → { user, accessToken, refreshToken }
curl -X POST http://localhost:3000/auth/login \
-H 'content-type: application/json' \
-d '{"email":"ada@example.com","password":"secretpassword1"}'
# 3. Chama uma rota protegida com o access token
curl http://localhost:3000/auth/me -H 'authorization: Bearer <accessToken>'
# 4. Quando o access token expira (15m), troca o refresh token por um novo par
curl -X POST http://localhost:3000/auth/refresh \
-H 'content-type: application/json' -d '{"refreshToken":"<refreshToken>"}'O mesmo fluxo em código (a classe Auth)
Cada rota é um invólucro fino sobre o serviço Auth — alcança-o a partir do container com o token AUTH, ou constrói um diretamente:
import { AUTH } from '@basaltkit/auth'
const auth = app.container.get(AUTH)
const user = await auth.register('ada@example.com', 'secretpassword1')
const { user: u, tokens } = await auth.login('ada@example.com', 'secretpassword1')
// tokens = { accessToken, refreshToken }
const next = await auth.refresh(tokens.refreshToken) // → novo { accessToken, refreshToken }
await auth.revoke(next.refreshToken) // logout para clientes baseados em tokensRotação de refresh com deteção de reutilização
Cada refresh consome o token e emite um novo na mesma família. Se um token já consumido reaparecer — um indicador de roubo — toda a família é revogada:
const { tokens } = await auth.login(email, password)
const next = await auth.refresh(tokens.refreshToken) // token antigo agora morto
// reproduzir o token antigo lança RefreshReusedError (401 AUTH_REFRESH_REUSED)
// e mata a família inteira — o utilizador tem de voltar a autenticar-se
await auth.refresh(tokens.refreshToken)O consumo é um compare-and-swap, não um ler-depois-escrever: o markUsed marca o token como usado só se ele ainda estiver por usar e reporta se foi esta chamada a fazê-lo. Dois refreshes concorrentes do mesmo token — o cliente legítimo e um ladrão a correr com ele — resolvem-se em no máximo um vencedor e um RefreshReusedError; sem o CAS ambos teriam sucesso e a deteção de reutilização nunca dispararia. O perdedor revoga a família; se isso acontecer antes de o vencedor guardar o token rodado, o vencedor também é recusado, pelo que nenhum token sobrevive à sua família revogada. O mesmo CAS aplica-se aos tokens de uso único de verificação e reposição. Um refresh token cujo utilizador já não existe é recusado.
O auth.revokeAllTokens(userId) é um "terminar sessão em todo o lado" completo: revoga todos os refresh tokens e sessões de servidor do utilizador e, com um store tokenVersions, todos os access tokens em circulação.
Escrever o teu próprio store
AuthTokenStore.markUsed e RefreshTokenStore.markUsed devolvem Promise<boolean | void>. Torna o update condicional (WHERE token = ? AND used_at IS NULL) e devolve se alterou alguma linha. Devolver void mantém o comportamento antigo de ler-depois-escrever — continua a compilar e a correr, mas sem a proteção contra a race.
As passwords são hasheadas com scrypt (memory-hard, zero dependências); um driver argon2id pode ser trocado através do contrato PasswordHasher (hasher: new MyArgon2Hasher()). O custo é lido de cada hash guardado, por isso tem tecto: um hash que declare mais do que N=2^20, r=32, p=16 (ou 512 MiB) nunca verifica, e uma linha adulterada não consegue prender o CPU em cada tentativa de login.
Proteger rotas e ler o utilizador
authPlugin regista um enricher (lê Authorization: Bearer <jwt>, o cookie de sessão ou x-session-id e define ctx().user) e um guard. Declara meta.auth numa rota; o guard devolve 401 AUTH_REQUIRED para pedidos anónimos. ctx().user é um PublicUser — nunca inclui o hash da password:
import { ctx } from '@basaltkit/core'
import { route } from '@basaltkit/fastify'
route({
method: 'GET',
url: '/me',
meta: { auth: true }, // anónimo → 401 AUTH_REQUIRED
async handler() {
const user = ctx().user // { id, email, emailVerified, … }
return { hello: user?.email }
},
})A sessão de browser pode ser configurada através de sessionCookie:
authPlugin({
users,
secret: process.env.AUTH_SECRET!,
sessionCookie: {
name: 'app_session',
path: '/app',
sameSite: 'Strict',
secure: true,
},
})Um pedido sem credenciais permanece anónimo (sem erro); um token explicitamente inválido ou expirado devolve 401 AUTH_TOKEN_INVALID / AUTH_TOKEN_EXPIRED.
Sessões por cookie e CSRF
O cookie de sessão é ambiente — o browser anexa-o a pedidos que outros sites desencadeiam. Por isso, um pedido cuja única credencial é esse cookie, com um método que não seja GET/HEAD/OPTIONS, não é autenticado quando o browser o marca com Sec-Fetch-Site: cross-site ou same-site (um subdomínio irmão, que o SameSite=Lax não trava), ou quando o seu Origin não é nem o host do próprio pedido nem uma origem de confiança. Uma rota com meta.auth responde então 403 AUTH_CSRF_REJECTED. Bearer tokens, x-session-id e API keys não são credenciais ambiente e não são afetados; pedidos sem metadados de browser (curl, servidores) passam. Permite um front-end servido de outra origem, ou desativa:
authPlugin({ users, secret, csrf: { trustedOrigins: ['https://app.example.com'] } })
authPlugin({ users, secret, csrf: false }) // não recomendadoRotas de conta: meta.account e meta.mfa
Todas as rotas de authRoutes(), de mfaRoutes() e as duas de oauthRoutes() declaram meta.account: true — dizem respeito à identidade de quem chama, não aos dados de um tenant, por isso o tenantMembershipPlugin do @basaltkit/teams deixa passar um não-membro (entrar no subdomínio de uma empresa antes de aceitar o convite dela). account é uma chave neutra: marca também as tuas rotas de perfil/definições com ela. Declaram ainda meta.mfa: false (exceto POST /auth/mfa/disable), o que as mantém acessíveis com requireMfa. ACCOUNT_META exporta o par.
O meta.auth é verificado no arranque
Declarar meta.auth é um pedido de proteção; quem o impõe é o guard que o authPlugin regista. Uma rota que pede proteção que ninguém impõe serviria desprotegida e responderia 200 — por isso cada adaptador corre a verificação de meta protegida ao registar as rotas e recusa arrancar:
UnguardedRouteMetaError: Refusing to boot: 1 route(s) declare security meta that
NO registered guard enforces — they would serve unprotected:
- GET /me declares meta.authO erro carrega code: 'HTTP_UNGUARDED_ROUTE_META' e nomeia todos os infratores. A correção é normalmente registar o plugin que impõe: auth → authPlugin, can → permissionsPlugin, teamRole → teamsPlugin. Quando a proteção acontece genuinamente numa fronteira exterior (um gateway de API que já autentica), desliga a verificação explicitamente no adaptador:
fastifyPlugin({ routes, allowUnguardedMeta: ['auth'] }) // ou `true` para todas as chavesO meta: { auth: false } é uma desativação explícita, não um pedido de proteção, e nunca é assinalado. A mesma opção existe no expressPlugin e no honoPlugin — vê Adaptadores — e a separação guard/meta está explicada em Autorização.
Autenticação multifator (TOTP)
Regista mfaRoutes() para enroll / activate / status / disable (todas requerem login). TOTP é o código de 6 dígitos de apps como o Google Authenticator.
fastifyPlugin({ routes: [...authRoutes(), ...mfaRoutes()] })O fluxo enroll → activate → recovery:
const auth = app.container.get(AUTH)
// 1. Enroll: gera um secret pendente + um URI de QR para renderizar
const { secret, otpauthUri } = await auth.enrollMfa(user.id)
// otpauthUri → renderiza como um código QR; secret → alternativa de introdução manual
// 2. Activate com um código da app autenticadora → recovery codes (mostrados UMA vez)
const { recoveryCodes } = await auth.activateMfa(user.id, '123456')
// recoveryCodes: 10 códigos de uso único guardados como hashes SHA-256
// 3. Status / disable
await auth.mfaStatus(user.id) // { enabled, pending }
await auth.disableMfa(user.id, '123456') // requer um código atual ou de recovery válidoPor HTTP o mesmo fluxo é POST /auth/mfa/enroll → POST /auth/mfa/activate{ code } → GET /auth/mfa/status / POST /auth/mfa/disable { code }.
Uma vez o MFA ativo, login requer um código — passa-o como o terceiro argumento opcional (ou o campo mfaCode no POST /auth/login):
await auth.login(email, password) // → MfaRequiredError (401 AUTH_MFA_REQUIRED)
await auth.login(email, password, '123456') // → { user, tokens }Uma password correta com um código em falta conta para o throttle de login (por conta e por IP): o AUTH_MFA_REQUIRED revela que a password estava certa, por isso é orçamentado como uma tentativa (vê Bloqueio por força bruta); o login bem-sucedido com o código limpa o contador da conta. Um código errado lança MfaInvalidCodeError e também conta para o throttle. Tanto um código TOTP como um código de recovery são aceites (os códigos de recovery são consumidos ao usar). A implementação de TOTP não tem dependências e é verificada contra os vetores de teste da RFC 6238.
O enrollMfa numa conta com MFA já ativo lança MfaAlreadyEnabledError (409 AUTH_MFA_ALREADY_ENABLED) — uma nova inscrição desligaria o segundo fator sem código; desativa-o primeiro com um código. As rotas de MFA só aceitam sessão (meta.apiKey: false): uma API key nunca pode inscrever nem desativar MFA.
Cifrar os segredos TOTP em repouso
Com mfaEncryption, os segredos TOTP são guardados como envelopes bka2.<keyId>.…: AES-256-GCM com uma chave derivada por HKDF-SHA256, ligada ao utilizador (um segredo copiado para a linha de outro utilizador não decifra aí). O keys é um anel — a primeira chave sela os segredos novos, as outras continuam legíveis — por isso rodar é acrescentar uma chave à frente e recifrar com calma:
authPlugin({
users, secret: env.APP_SECRET,
mfaEncryption: { keys: [{ id: '2026-09', key: env.MFA_KEY }, { id: '2025-01', key: env.MFA_KEY_OLD }] },
})
for (const userId of usersWithMfa) await auth.reencryptMfaSecret(userId) // 'resealed' | 'current' | 'none'Um valor guardado que não seja um destes envelopes é recusado (AUTH_SECRET_UNREADABLE): quem tem acesso de escrita à tabela não consegue trocar um segredo cifrado por um em texto simples que conhece. As chaves têm de ter pelo menos 32 bytes; mfaEncryptionKey: key é um atalho para um anel de uma chave com id default.
Migrar do formato v1: ou de linhas em texto simples (antes do @basaltkit/auth 4.0): lê-as através de uma adesão explícita e temporária, recifra todas as linhas e depois remove-a:
mfaEncryption: {
keys: [{ id: '2026-09', key: env.MFA_KEY }],
legacy: { v1Keys: [env.OLD_MFA_ENCRYPTION_KEY], plaintext: true }, // só durante a migração
}O SecretBox (o mesmo envelope, com o teu próprio purpose/subject) é exportado para outros segredos que guardes em repouso.
Exigir MFA por política
requireMfa torna o segundo fator obrigatório — para todos, ou por utilizador / pedido com uma função de política:
authPlugin({ users, secret, requireMfa: true })
authPlugin({ users, secret, requireMfa: (user, context) => user.email.endsWith('@acme.test') })Tokens e sessões registam como o utilizador entrou (amr): ['pwd'], ou ['pwd', 'mfa'] quando um código foi verificado no login (['fed', …] no login social). O access token leva-o na claim amr, cada refresh desse login mantém-no, e o cookie de sessão leva-o assinado com HMAC pelo secret (sem alterar o esquema dos stores); o enricher expõe-no em ctx().amr. Um pedido autenticado sem mfa no amr é recusado:
403 AUTH_MFA_ENROLLMENT_REQUIRED— a conta ainda não tem MFA: inscreve-a (/auth/mfa/enroll+/activate) e entra de novo com um código;403 AUTH_MFA_REQUIRED— o MFA está ativo mas esta credencial foi obtida sem ele (p. ex. emitida antes da inscrição): entra de novo com um código.
As rotas com meta.mfa: false estão isentas (todo o authRoutes() — login, /auth/me, logout… — e enroll / activate / status do MFA). Pedidos com API key não estão sujeitos à política; criar uma chave exige uma sessão com MFA sob ela. Independentemente da política, meta: { auth: true, mfa: true } exige MFA numa única rota (step-up para uma ação sensível). Desligado por omissão.
Escrever o teu próprio MfaStore
Implementa os opcionais consumeTotpStep(userId, step) e consumeRecoveryCode(userId, hash) como atualizações condicionais que devolvem se foi esta chamada a consumir o código (os stores de memória, SQLite e Prisma fazem-no). Sem eles, o Auth recorre a ler-depois-escrever, e dois pedidos em paralelo com o mesmo código capturado podem passar ambos.
Passkeys (WebAuthn)
As passkeys deixam os utilizadores entrar com Face ID, Touch ID ou uma chave de segurança física — sem password para phishing ou fugas. O Basalt conduz toda a cerimónia (challenges, opções do browser, storage de credenciais, challenges de uso único, o contador de deteção de clone) e delega só a criptografia a um pequeno verifier que implementas sobre @simplewebauthn/server, por isso a framework não carrega dependência WebAuthn.
import { webauthnPlugin, type WebAuthnVerifier } from '@basaltkit/auth'
import { verifyRegistrationResponse, verifyAuthenticationResponse } from '@simplewebauthn/server'
const verifier: WebAuthnVerifier = {
async verifyRegistration(i) {
const v = await verifyRegistrationResponse({
response: i.response as never,
expectedChallenge: i.expectedChallenge,
expectedOrigin: i.expectedOrigin,
expectedRPID: i.expectedRpId,
requireUserVerification: i.requireUserVerification,
})
if (!v.verified || !v.registrationInfo) return { verified: false }
const c = v.registrationInfo.credential
return { verified: true, credential: {
id: c.id, publicKey: Buffer.from(c.publicKey).toString('base64url'), counter: c.counter,
} }
},
async verifyAuthentication(i) {
const v = await verifyAuthenticationResponse({
response: i.response as never,
expectedChallenge: i.expectedChallenge,
expectedOrigin: i.expectedOrigin,
expectedRPID: i.expectedRpId,
requireUserVerification: i.requireUserVerification,
credential: {
id: i.credential.id,
publicKey: Buffer.from(i.credential.publicKey, 'base64url'),
counter: i.credential.counter,
},
})
return { verified: v.verified, newCounter: v.authenticationInfo?.newCounter ?? i.credential.counter }
},
}
app.use(webauthnPlugin({
config: { rpId: 'example.com', rpName: 'Example', origin: 'https://example.com' },
verifier,
}))Os quatro passos
Resolve o serviço a partir do token WEBAUTHN e conduz a partir das tuas rotas. O sessionKey liga um challenge à sessão atual — o id do utilizador quando autenticado, ou um id de sessão para login sem sessão iniciada.
import { WEBAUTHN } from '@basaltkit/auth'
const passkeys = container.get(WEBAUTHN)
// 1. Registo — um utilizador autenticado adiciona uma passkey
const regOptions = await passkeys.startRegistration(sessionKey, { id: user.id, name: user.email })
// → @simplewebauthn/browser startRegistration(regOptions), depois faz POST do resultado:
await passkeys.finishRegistration(sessionKey, user.id, browserResponse, 'MacBook')
// 2. Entrar — sem username: omite o userId
await passkeys.startAuthentication(sessionKey)
const { userId } = await passkeys.finishAuthentication(sessionKey, browserResponse)
// → emite a tua sessão / JWT para userIdO finishAuthentication procura a credencial pelo id, verifica-a, confirma que o contador de assinatura aumentou (um clone lança PasskeyClonedError; um contador não inteiro é recusado), e guarda o novo contador. Quando startAuthentication(sessionKey, userId) indica um utilizador — step-up ou re-autenticação — só uma passkey desse utilizador satisfaz o desafio (a de outra conta lança WEBAUTHN_SUBJECT_MISMATCH).
Usa passkeys.list(userId) / passkeys.remove(userId, credentialId) para um ecrã de "gerir dispositivos". O remove só apaga uma passkey que pertença a userId — um id desconhecido ou alheio lança PASSKEY_NOT_FOUND — por isso passa o id do utilizador autenticado, nunca um vindo do pedido. A omissão userVerification: 'preferred' deixa assinar um autenticador sem PIN/biometria (só posse): usa 'required' quando a passkey é o único fator.
Security
O challenge é vinculado ao utilizador que passas ao startRegistration; o finishRegistration lança WEBAUTHN_SUBJECT_MISMATCH se o userId for diferente, por isso uma passkey nunca pode ser vinculada à conta de outra pessoa — tira sempre o userId da sessão autenticada, nunca do input do pedido. Em produção, troca os PasskeyStore / WebAuthnChallengeStore em memória por versões duráveis (s.passkeys do auth-sqlite / auth-prisma).
A deteção de clones é atómica: o novo contador de assinaturas é escrito com PasskeyStore.compareAndSetCounter(id, expected, next, lastUsedAt), uma atualização condicional que só tem êxito se o contador guardado ainda for aquele contra o qual a asserção foi verificada. Duas asserções concorrentes com o mesmo contador (um autenticador clonado usado ao lado do genuíno) não podem passar ambas — a que perde recebe PASSKEY_CLONED. Um store próprio tem de o implementar; um sem ele é recusado na construção (PASSKEY_STORE_OUTDATED).
Login social (OAuth)
Entra com Google ou GitHub via o fluxo authorization-code do OAuth 2.0 — sem SDK. O fluxo fica ligado ao browser que o iniciou: GET /auth/oauth/:provider define um cookie de curta duração HttpOnly, SameSite=Lax (__Host-basalt_oauth em produção) com um valor aleatório de ligação; o state assinado com HMAC leva o seu hash, o verificador PKCE (S256) e o nonce OIDC derivam dele, e o callback recusa um state que chegue sem o cookie correspondente. Cada state é de uso único. Assim, o URL de callback de um atacante aberto no browser de uma vítima (login CSRF) ou um authorization code injetado são rejeitados.
import {
authPlugin, oauthPlugin, oauthRoutes, authRoutes, googleProvider, githubProvider,
} from '@basaltkit/auth'
createApp({
plugins: [
authPlugin({ users, secret: env.APP_SECRET }),
oauthPlugin({
secret: env.APP_SECRET, // assina o state
providers: [
googleProvider({ clientId: env.GOOGLE_ID, clientSecret: env.GOOGLE_SECRET }),
githubProvider({ clientId: env.GITHUB_ID, clientSecret: env.GITHUB_SECRET }),
],
}),
],
})
// regista as rotas
fastifyPlugin({ routes: [...authRoutes(), ...oauthRoutes({ callbackBaseUrl: 'https://app.example.com' })] })São adicionadas duas rotas por provider:
GET /auth/oauth/:provider→ redireciona para o provider. Regista${callbackBaseUrl}/auth/oauth/:provider/callbackcomo o redirect URI do provider.GET /auth/oauth/:provider/callback→ verifica o state contra o cookie de ligação (e limpa-o), troca o code com o verificador PKCE e faz o login do utilizador. A resposta é JSON{ user, accessToken, refreshToken }; passasuccessRedirectpara devolver o browser à tua SPA com os tokens no fragmento do URL.
As contas novas são criadas sem password (autenticam via provider até definires uma password); um email verificado pelo provider ativa o emailVerified. O Auth.socialLogin(email, { emailVerified, identity: { provider, subject } }) é a primitiva subjacente se ligares um provider próprio (ou chamares tu mesmo oauth.authorize() / oauth.callback({ …, binding })).
As contas são ligadas pelo subject do fornecedor. O primeiro login de uma conta do fornecedor regista uma ligação — nome do fornecedor + o seu subject (sub) estável → conta local — no store accountLinks (authPlugin({ accountLinks }); o auth-sqlite / auth-prisma trazem um, a omissão é em memória). A partir daí:
- decide a ligação, não o email: se o utilizador mudar o email no IdP, continua a chegar à mesma conta;
- um subject diferente do mesmo fornecedor que afirme o email dessa conta é recusado com
AccountLinkConflictError(409 AUTH_ACCOUNT_LINK_CONFLICT) — uma segunda conta do IdP não pode ocupar o lugar da primeira. Para um IdP que reemite subjects (uma migração de diretório), adere comoauthPlugin({ …, subjectConflict: 'link' }).
Emite auth:account_linked quando uma ligação é registada.
Um primeiro login só liga a uma conta existente por um email verificado
Ligar uma conta do fornecedor a uma conta existente exige que o provider garanta o email (emailVerified: true); caso contrário o socialLogin lança SocialLinkRefusedError (403 AUTH_SOCIAL_LINK_REFUSED) e o utilizador tem de entrar com a password. O driver do GitHub trata o email de recurso de /user como não verificado. Quando a conta existente nunca verificou o próprio email (alguém pode ter registado o endereço primeiro), a password, as sessões, os refresh tokens e o MFA dessa conta são revogados antes de o dono verificado entrar (auth:social_account_adopted) — juntamente com as ligações de contas que esse registante tenha feito.
Uma conta existente com MFA ativo não entra só pelo provider: o socialLogin lança MfaRequiredError a menos que passes mfaCode. Se o teu IdP impõe o seu próprio segundo fator, desativa explicitamente com oauthPlugin({ …, mfa: 'skip' }) (ou socialLogin(email, { …, mfa: 'skip' })).
SSO empresarial (OIDC)
Qualquer IdP OpenID Connect — Okta, Azure AD / Entra ID, Auth0, Google Workspace, Keycloak — encaixa como provider. Passa os três endpoints, ou deixa o discoverOidcProvider lê-los do .well-known/openid-configuration do IdP:
import { oidcProvider, discoverOidcProvider, oauthPlugin } from '@basaltkit/auth'
// endpoints explícitos…
oidcProvider({ name: 'okta', authorizeUrl, tokenUrl, userInfoUrl, clientId, clientSecret })
// …ou por descoberta (await no arranque)
const okta = await discoverOidcProvider({ name: 'okta', issuer: 'https://acme.okta.com', clientId, clientSecret })
oauthPlugin({ secret: env.APP_SECRET, providers: [okta] })A descoberta confirma que o issuer do documento é igual ao configurado e que todos os endpoints são https: (http: simples só para um host de loopback).
Restringe cada IdP aos seus domínios de email. O admin do IdP de um cliente decide que emails esse IdP afirma como verificados, e um email verificado liga-se à conta existente que o tem — por isso, sem restrição, o IdP da Acme poderia afirmar ceo@globex.com e entrar na conta do CEO da Globex. Dá a cada fornecedor empresarial os seus allowedEmailDomains; um login de qualquer outro domínio falha com AUTH_OAUTH_EXCHANGE_FAILED antes de se procurar a conta:
const acme = await discoverOidcProvider({ name: 'acme', issuer, clientId, clientSecret, allowedEmailDomains: ['acme.com'] })
const globex = oidcProvider({ name: 'globex', /* … */ allowedEmailDomains: ['globex.com'] })
oauthPlugin({ secret: env.APP_SECRET, providers: [googleProvider(keys), acme, globex] })Com mais do que um fornecedor, cada entrada oidcProvider / discoverOidcProvider tem de declarar allowedEmailDomains ou allowAnyEmailDomain: true (só para um IdP que controlas totalmente), ou o serviço recusa arrancar com AUTH_OAUTH_PROVIDER_CONFIG. O Google e o GitHub só afirmam emails que eles próprios verificaram e não são afetados; um OAuthProvider próprio adere com enterprise: true. O primeiro login de uma conta do IdP liga-a pelo email verificado — é a allowlist que delimita que contas cada IdP pode ligar; depois disso decide o subject do fornecedor (ver acima).
As respostas do fornecedor são validadas: um perfil sem sub/email em string (ou com um email que não seja um endereço com um único @) faz falhar o login; um fluxo openid tem de devolver um id_token cujos nonce, aud (o teu client id), exp e — quando o fornecedor declara um issuer — iss coincidam; cada chamada ao fornecedor tem prazo (timeoutMs, omissão 10 s).
Para IdPs SAML 2.0 legados (ADFS, Shibboleth, ou um IdP configurado para SAML), usa o pacote companheiro @basaltkit/auth-saml — SSO iniciado pelo SP construído sobre a biblioteca de XML-DSig auditada @node-saml/node-saml, que encaixa no mesmo Auth.socialLogin:
import { samlPlugin, samlRoutes } from '@basaltkit/auth-saml'
samlPlugin({ providers: [{ name: 'okta', entryPoint, idpCert, issuer, callbackUrl }] })
// rotas: GET /auth/saml/:provider/login · POST …/acs · GET …/metadataCada login fica ligado ao browser que o iniciou (proteção contra login CSRF): o samlRoutes define um cookie HttpOnly __Host-basalt_saml no login e envia o seu SHA-256 como RelayState, e o ACS recusa uma resposta cujo RelayState não corresponda ao cookie enviado com ela — assim um atacante não consegue obter um SAMLResponse válido para a sua própria conta e submetê-lo automaticamente a partir do browser de uma vítima. O IdP regressa com um POST cross-site, por isso o cookie é SameSite=None; Secure (samlRoutes({ bindingCookie: { secure } }); os browsers tratam http://localhost como seguro). Rotas próprias usam saml.authorize(name) → { url, binding } e saml.consume(name, body, { binding }); bindToBrowser: false desliga a proteção, e o SSO iniciado pelo IdP (abaixo) não pode ser ligado. Qualquer erro lançado pelo node-saml (XML malformado, assinatura errada, assertion cifrada…) é um 400 AUTH_SAML_RESPONSE_INVALID, nunca um 500.
As assertions têm de ser assinadas (wantAssertionsSigned) e ligadas a um login que esta app iniciou: o validateInResponseTo tem omissão 'always', por isso todas as respostas têm de trazer um InResponseTo que corresponda a um AuthnRequest pendente e ainda não consumido. Respostas não solicitadas (iniciadas pelo IdP) são recusadas, a menos que optes por validateInResponseTo: 'ifPresent'; nesse caso cada id de assertion consumido fica também numa assertionReplayCache de uso único, para que um SAMLResponse capturado não possa ser reenviado antes do seu NotOnOrAfter.
Só são aceites algoritmos de assinatura fortes. Cada SignatureMethod e DigestMethod da resposta — o envelope e a assinatura da assertion aninhada — tem de ser RSA/ECDSA-SHA256/384/512 sobre digests SHA-256/384/512; qualquer outro (SHA-1 incluído) ou um DOCTYPE dá 400 AUTH_SAML_RESPONSE_INVALID. Define allowSha1: true num IdP legado que não consiga assinar com SHA-256, ou substitui as listas por provider com signatureAlgorithms / digestAlgorithms (URIs de algoritmo).
Cada IdP só pode afirmar os seus próprios domínios de email. Em SaaS B2B o administrador do IdP de cada cliente controla o que esse IdP assina, por isso dá a cada provider uma lista allowedEmailDomains — uma assertion para qualquer outro domínio é rejeitada. Com mais de um provider isto é obrigatório no arranque (AUTH_SAML_PROVIDER_CONFIG); usa allowAnyEmailDomain: true apenas num IdP que controlas totalmente. O pacote recusa também arrancar com @node-saml/node-saml < 5.1.0 (CVEs de bypass de assinatura).
samlPlugin({
providers: [
{ name: 'acme', /* … */ allowedEmailDomains: ['acme.com'] },
{ name: 'globex', /* … */ allowedEmailDomains: ['globex.com', 'globex.co.uk'] },
],
})| Opção | Tipo | Omissão | Propósito |
|---|---|---|---|
providers | SamlProvider[] | — (obrigatória) | IdPs: name, entryPoint, idpCert, issuer, callbackUrl, emailAttribute opcional (passa a ser a única fonte — sem fallback), allowedEmailDomains (obrigatório com vários IdPs), allowAnyEmailDomain, wantAuthnResponseSigned (omissão true; false para IdPs que só assinam a assertion, p. ex. AD FS / Entra ID), acceptedClockSkewMs (omissão 0, máx. 5 min), signatureAlgorithms / digestAlgorithms (URIs de algoritmo XML-DSig aceites; omissão RSA/ECDSA-SHA256/384/512, SHA-256/384/512), allowSha1 (opt-in legado) |
bindToBrowser | boolean | true | Liga cada login iniciado pelo SP ao browser que o iniciou (login CSRF) |
validateInResponseTo | 'never' | 'ifPresent' | 'always' | 'always' | Proteção contra replay — liga a resposta a um AuthnRequest emitido por este SP; 'ifPresent' ativa o SSO iniciado pelo IdP |
cacheProvider | SamlCacheProvider | cache em processo do node-saml | Onde vivem os ids de AuthnRequest pendentes — obrigatório em deployments com várias réplicas |
assertionReplayCache | SamlAssertionReplayCache | em processo | Armazenamento de uso único dos ids de assertion consumidos (consume(key, ttlMs) → boolean) — partilha-o entre réplicas se ativares o SSO iniciado pelo IdP |
createClient | (provider) => SamlClient | node-saml | Fábrica do cliente subjacente (testes) |
host | string | — | Host usado ao construir o AuthnRequest |
SAML com várias réplicas precisa de um cacheProvider partilhado
Os ids de pedido usam por omissão uma cache em processo. Com várias réplicas sem sessões pegajosas, um login iniciado numa réplica e a regressar noutra falha com AUTH_SAML_RESPONSE_INVALID. Passa um cacheProvider partilhado (Redis, a tua base de dados). Optar por sair com validateInResponseTo: 'never' deixa só a assertionReplayCache por réplica como proteção contra replay — passa uma partilhada.
Reposição de password (ponta a ponta)
O módulo nunca envia email — emite um hook que transporta um token de uso único (válido 1 hora por padrão) para a tua app enviar por email. Liga o hook uma vez no arranque, depois expõe as duas rotas:
// 1. No arranque: transforma o hook num email
app.hooks.on('auth:password_reset_requested', async ({ user, token }) => {
await mailer.send(user.email, `https://app.example.com/reset?token=${token}`)
})# 2. O utilizador pede a reposição — responde sempre 200 (sem enumeração de contas)
curl -X POST http://localhost:3000/auth/password/forgot \
-H 'content-type: application/json' -d '{"email":"ada@example.com"}'
# 3. O utilizador segue o link enviado por email e submete a nova password
curl -X POST http://localhost:3000/auth/password/reset \
-H 'content-type: application/json' \
-d '{"token":"<token-from-email>","password":"a-new-strong-password"}'Em código os mesmos passos são auth.requestPasswordReset(email) (devolve { user, token } ou null quando nenhuma conta corresponde) e auth.resetPassword(token, newPassword). Concluir uma reposição termina a sessão do utilizador em todo o lado — todas as sessões e refresh tokens são revogados. A verificação de email funciona de forma idêntica: hook auth:verify_requested, rotas POST /auth/verify/request e POST /auth/verify (token válido 24h).
API keys
apiKeysPlugin() autentica chaves mk_live_… (via Authorization: Bearer ou x-api-key) e impõe meta.scopes nas rotas. As chaves são criadas por um utilizador autenticado através de apiKeyRoutes(), e guardadas apenas como um hash SHA-256 mais um prefixo curto de exibição — o texto simples é mostrado exatamente uma vez. Podem ter uma expiração opcional; chaves expiradas são rejeitadas pelo servidor e omitidas das listagens.
O guard do plugin impõe três fronteiras em cada pedido autenticado por chave (403 em cada caso, com um evento auth:apikey_rejected):
- Ligação ao tenant. Uma chave criada dentro de um tenant só funciona quando o pedido resolve esse mesmo tenant — nunca noutro escolhido via
x-tenant-id, um subdomínio ou o Host, e nunca num pedido sem tenant (AUTH_APIKEY_TENANT_MISMATCH). Uma chave emitida sem tenant (chave de máquina) é recusada em pedidos com tenant, a menos que passesallowTenantlessKeys: true. - Os scopes são um limite máximo. Uma chave sem
*só alcança rotas que declarammeta.scopesque ela tem; numa rota protegida pormeta.auth,can,teamRoleouaudiencesemmeta.scopesé recusada (AUTH_SCOPE_REQUIRED), mesmo queusersa deixe definirctx().user. Uma chave*age como o seu dono.allowNarrowKeysOnUnscopedRoutes: truerepõe o comportamento antigo, mais permissivo. - Rotas só de sessão. Uma rota com
meta.apiKey: falserecusa qualquer chave (AUTH_APIKEY_NOT_ALLOWED).apiKeyRoutes()emfaRoutes()declaram-no, por isso uma chave nunca pode criar, listar ou revogar chaves, nem alterar o MFA.
import { authPlugin, apiKeysPlugin, apiKeyRoutes, authRoutes, MemoryUserSource } from '@basaltkit/auth'
const users = new MemoryUserSource()
const app = await createApp({
plugins: [
authPlugin({ users, secret: process.env.AUTH_SECRET! }),
apiKeysPlugin({ users }), // passa `users` para que uma chave com userId também defina ctx().user
fastifyPlugin({
routes: [
...authRoutes(),
...apiKeyRoutes(), // POST /apikeys, GET /apikeys, DELETE /apikeys/:id (só sessão de login)
route({
method: 'GET',
url: '/reports',
meta: { scopes: ['reports:read'] }, // precisa de uma chave com este scope (ou `*`)
async handler() {
const key = ctx().apiKey // { id, scopes, tenantId?, userId? }
return { ok: true, keyId: key?.id }
},
}),
],
}),
],
}).boot()Emite uma chave em código com o serviço ApiKeys (o texto simples aparece só aqui):
import { API_KEYS } from '@basaltkit/auth'
const apiKeys = app.container.get(API_KEYS)
const { record, key } = await apiKeys.issue({ name: 'CI pipeline', scopes: ['reports:read'] })
// key = 'mk_live_…' → mostra uma vez, nunca guardes; record tem prefix/scopes mas não o hashAviso
Regista ambos os plugins. Um bearer com prefixo mk_ é ignorado pelo authPlugin e tratado pelo apiKeysPlugin. Se as chaves "não funcionam", provavelmente falta-te o apiKeysPlugin().
Bloqueio por força bruta
Ativo por padrão: 5 tentativas falhadas por email em 15 minutos → AccountLockedError (429 AUTH_LOCKED); um login bem-sucedido limpa o contador. Cada tentativa é reservada antes de a password (ou o código MFA) ser verificada, por isso uma rajada de pedidos em paralelo não consegue fazer mais tentativas do que o orçamento. O throttle guarda digests SHA-256 dos identificadores, nunca o email em bruto, e no máximo maxEntries (100 000) deles.
O AUTH_MFA_REQUIRED também conta. Numa conta com MFA essa resposta só aparece com a password certa, por isso é um oráculo de password — o mesmo que qualquer login MFA em dois passos tem. Por isso gasta os orçamentos por conta e por IP exatamente como uma password errada, e não pode ser usado para adivinhar sem limite. O fluxo legítimo não é afetado: entrar com o código limpa o contador da conta; a vaga de IP do primeiro passo simplesmente expira com a janela.
Ajusta-o ou desativa-o:
import { authPlugin, LoginThrottle } from '@basaltkit/auth'
authPlugin({
users,
secret: process.env.AUTH_SECRET!,
loginThrottle: new LoginThrottle({ maxAttempts: 3, windowMs: 10 * 60_000 }),
// loginThrottle: false // desativa-o — não recomendado (usa em testes)
})Entre réplicas: um ThrottleStore partilhado
Por omissão os contadores vivem em memória, por processo, por isso N réplicas concedem N orçamentos e um bloqueio numa não é visto pelas outras. Passa um throttleStore partilhado — RedisThrottleStore aceita qualquer cliente compatível com ioredis (só eval e del; sem dependência de Redis) e conta cada tentativa num script atómico, por isso uma rajada espalhada por todas as réplicas continua a correr no máximo maxAttempts verificações:
import Redis from 'ioredis'
import { authPlugin, RedisThrottleStore } from '@basaltkit/auth'
authPlugin({ users, secret, throttleStore: new RedisThrottleStore(new Redis(process.env.REDIS_URL!)) })Serve os throttles de login (e de código MFA), por IP e de pedidos de email, cada um no seu namespace (basalt:throttle:login:…, login-ip, email-request); as chaves são digests SHA-256. Um throttle passado explicitamente leva o seu próprio new LoginThrottle({ store }). Implementa ThrottleStore (hit / peek / release / reset, com hit atómico) para outro backend.
Referência de opções
authPlugin(options) — todas as opções do serviço Auth exceto hooks, que o plugin fornece:
| Opção | Tipo | Predefinição | Propósito |
|---|---|---|---|
users | UserSource | — (obrigatório) | Onde vivem as contas. MemoryUserSource em dev; auth-sqlite/auth-prisma, ou os teus quatro métodos sobre as tuas tabelas. O quinto, opcional, findByIds, acrescenta pesquisas de contactos em lote |
secret | string | — (obrigatório) | Chave de assinatura HS256 dos access tokens. Rejeitada vazia, e rejeitada abaixo de 32 caracteres salvo com NODE_ENV explicitamente development/test — não definido conta como produção (AUTH_WEAK_SECRET) |
hasher | PasswordHasher | new ScryptPasswordHasher() | Hashing de passwords. Troca por uma implementação argon2id sem mexer nos pontos de chamada |
sessions | SessionStore | em memória | Sessões por cookie/x-session-id — troca para durabilidade |
refreshTokens | RefreshTokenStore | em memória | Famílias de refresh tokens; em memória significa que cada redeploy expulsa toda a gente |
tokens | AuthTokenStore | em memória | Tokens de verificação de email e de reposição de password |
mfa | MfaStore | em memória | Estado de inscrição TOTP e códigos de recuperação |
accessTtl | DurationInput | '15m' | Duração do access token. Curta por desenho — é o refresh token que sustenta a sessão |
refreshTtl | DurationInput | '30d' | Duração do refresh token — na prática, "quanto tempo até o utilizador ter de entrar outra vez" |
sessionTtl | DurationInput | '30d' | Duração da sessão do lado do servidor |
sessionCookie | SessionCookieOptions | basalt_session, HttpOnly, SameSite=Lax, Path=/ | Atributos do cookie de sessão; Secure activo por omissão salvo com NODE_ENV explicitamente development/test |
verificationTtl | DurationInput | '24h' | Duração do link de verificação de email |
resetTtl | DurationInput | '1h' | Duração do link de reposição de password; mantém-na curta |
loginThrottle | LoginThrottle | false | new LoginThrottle() (5 por 15m, por email) | Bloqueio por força bruta por email. false desativa-o — só em testes |
emailRequestThrottle | LoginThrottle | false | 3 por 15m, por conta e finalidade | Limita emails de reposição/verificação; acima do orçamento o pedido é ignorado em silêncio e o link em vigor continua válido |
throttleStore | ThrottleStore | em memória, por processo | Onde os throttles por omissão guardam os contadores — RedisThrottleStore para um só orçamento entre réplicas. Ver Entre réplicas |
requireMfa (plugin) | boolean | (user, context) => boolean | Promise<boolean> | — (desligado) | Exige uma entrada com MFA em todas as rotas autenticadas exceto as meta.mfa: false — ver Exigir MFA por política |
csrf (plugin) | { trustedOrigins?: string[] } | false | ligado | Verificação CSRF da sessão por cookie em métodos não seguros — ver Sessões por cookie e CSRF |
ipLoginThrottle | LoginThrottle | false | new LoginThrottle({ maxAttempts: 50, windowMs: 900_000 }) | Orçamento por IP que apanha password spraying (uma tentativa em muitas contas), que um contador por email não vê. Só se aplica quando quem chama passa o ip do cliente — o authRoutes() passa |
enumerationSafeRegister | boolean | true | Impede que o POST /auth/register revele que um email já tem conta. false repõe o 409 AUTH_EMAIL_TAKEN |
tokenVersions | TokenVersionStore | — (desligado) | Revogação opcional de access tokens: os tokens levam uma claim tv que o resetPassword/revokeAllTokens incrementa, matando os tokens em circulação antes do TTL. Custa uma leitura ao store por pedido autenticado |
accountLinks | AccountLinkStore | em memória | Ligações de contas OAuth/OIDC (fornecedor + subject → conta) — durável em produção, ou as ligações perdem-se ao reiniciar |
mfaEncryption | { keys: SecretBoxKey[]; legacy?: { v1Keys?, plaintext? } } | — (texto simples) | Cifra os segredos TOTP em repouso (AES-256-GCM, chaves HKDF com id, ligadas ao utilizador); valores que não sejam envelopes são recusados salvo adesão em legacy. Ver Cifrar os segredos TOTP em repouso |
mfaEncryptionKey | string | Buffer | — (texto simples) | Atalho para mfaEncryption: { keys: [{ id: 'default', key }] } (≥ 32 bytes). Não lê os envelopes v1: antigos — migra com mfaEncryption.legacy |
mfaIssuer | string | 'Basalt' | Nome do emissor mostrado na app autenticadora |
new LoginThrottle(options):
| Opção | Tipo | Predefinição | Propósito |
|---|---|---|---|
maxAttempts | number | 5 | Tentativas falhadas permitidas dentro da janela |
windowMs | number | 900_000 (15m) | Janela fixa aberta pela primeira tentativa; um login bem-sucedido limpa o contador |
store | ThrottleStore | um MemoryThrottleStore próprio | Onde vivem os contadores — RedisThrottleStore para os partilhar entre réplicas |
namespace | string | — | Prefixo de chave que separa throttles que partilham um store |
maxEntries | number | 100_000 | Limite de identificadores seguidos (store em memória); as entradas expiradas são limpas e depois as mais antigas não bloqueadas despejadas — um identificador bloqueado é mantido (só é despejado quando todas as entradas estão bloqueadas) |
clock | () => number | Date.now | Relógio injetável do store em memória (testes) |
Com um store síncrono (o por omissão) todos os métodos de LoginThrottle continuam síncronos; com um assíncrono devolvem promises — o Auth espera por ambos.
apiKeysPlugin(options):
| Opção | Tipo | Predefinição | Propósito |
|---|---|---|---|
store | ApiKeyStore | em memória | Onde vivem os hashes das chaves — durável em produção, ou as chaves morrem no redeploy |
header | string | 'x-api-key' | Header alternativo ao Authorization: Bearer mk_…. Duas chaves diferentes (uma em cada) → 400 AUTH_APIKEY_AMBIGUOUS, nunca uma a ganhar em silêncio |
users | UserSource | — | Quando definido, uma chave com userId também preenche ctx().user, para que as rotas protegidas por scopes leiam o utilizador que age |
allowTenantlessKeys | boolean | false | Deixa chaves emitidas sem tenant agir em pedidos com tenant (só chaves de plataforma de confiança) |
allowNarrowKeysOnUnscopedRoutes | boolean | false | Deixa uma chave sem * alcançar rotas meta.auth/can/teamRole/audience que não declaram meta.scopes |
now | () => number | Date.now | Relógio injetável (testes) |
webauthnPlugin(options) e a sua config:
| Opção | Tipo | Predefinição | Propósito |
|---|---|---|---|
verifier | WebAuthnVerifier | — (obrigatório) | A fronteira criptográfica que implementas sobre o @simplewebauthn/server, para que a framework não carregue nenhuma dependência WebAuthn |
credentials | PasskeyStore | new MemoryPasskeyStore() | Passkeys registadas — troca por um store durável (s.passkeys). Tem de implementar compareAndSetCounter |
challenges | WebAuthnChallengeStore | new MemoryWebAuthnChallengeStore() | Desafios de cerimónia de uso único |
config.rpId | string | — (obrigatório) | Relying Party ID — o teu domínio registável, p. ex. 'example.com' |
config.rpName | string | — (obrigatório) | Nome legível mostrado no diálogo do sistema operativo |
config.origin | string | string[] | — (obrigatório) | Origem(ns) esperada(s), p. ex. 'https://example.com' |
config.challengeTtlMs | number | 300_000 (5m) | Quanto tempo um desafio se mantém utilizável |
config.userVerification | 'required' | 'preferred' | 'discouraged' | 'preferred' | Se o autenticador tem de verificar o utilizador (PIN/biometria) — 'required' para login sem password |
config.timeoutMs | number | 60_000 | Tempo limite da cerimónia anunciado ao browser |
config.pubKeyCredParams | PublicKeyParam[] | ES256 + RS256 | Sobrepõe os algoritmos de assinatura aceites |
oauthPlugin(options) e oauthRoutes(options):
| Opção | Tipo | Predefinição | Propósito |
|---|---|---|---|
providers | OAuthProvider[] | — (obrigatório) | googleProvider(), githubProvider(), oidcProvider() / discoverOidcProvider() — uma entrada por IdP |
secret | string | — (obrigatório) | Chave HMAC que assina o state de CSRF; tipicamente o mesmo APP_SECRET |
stateTtlMs | number | 600_000 (10m) | Quanto tempo um state assinado se mantém válido — a janela para concluir o redirecionamento |
fetch | typeof fetch | fetch global | Cliente HTTP injetado (testes) |
now | () => number | Date.now | Relógio injetável (testes) |
mfa | 'required' | 'skip' | 'required' | Uma conta existente com MFA ativo é recusada (AUTH_MFA_REQUIRED); 'skip' só para um IdP que impõe o seu próprio MFA |
timeoutMs | number | 10_000 | Prazo de cada pedido a um fornecedor (token endpoint, userinfo) |
subjectConflict | 'refuse' | 'link' | 'refuse' | Um subject diferente de um fornecedor que afirme o email de uma conta já ligada a esse fornecedor: recusado (AUTH_ACCOUNT_LINK_CONFLICT), ou também ligado ('link', só para um IdP que reemite subjects) |
callbackBaseUrl (rotas) | string | — (obrigatório) | URL base pública da tua app; o redirect URI é ${callbackBaseUrl}/auth/oauth/:provider/callback e tem de ser registado em cada fornecedor |
successRedirect (rotas) | string | — (resposta JSON) | Devolve o browser para aqui com #access_token=…&refresh_token=… em vez de responder JSON — o fluxo para SPA |
bindingCookie (rotas) | { secure?, maxAgeSeconds? } | secure salvo com NODE_ENV development/test, 15 min | O cookie HttpOnly que liga o fluxo ao browser (__Host-basalt_oauth quando secure) |
rateLimit (rotas) | { limit, windowMs } | false | 10 / min por ip e rota | meta.rateLimit nas duas rotas (aplicado pelo securityPlugin do http) |
Regista o oauthPlugin depois do authPlugin: o serviço resolve o AUTH para autenticar os utilizadores.
Modos de falha & resolução de problemas
| Erro | Código | HTTP | Quando |
|---|---|---|---|
InvalidCredentialsError | AUTH_INVALID_CREDENTIALS | 401 | Email desconhecido ou password errada — deliberadamente indistinguíveis, e com custo igual |
EmailTakenError | AUTH_EMAIL_TAKEN | 409 | auth.register() sobre um email existente (a rota mantém-se à prova de enumeração, salvo enumerationSafeRegister: false) |
AuthRequiredError | AUTH_REQUIRED | 401 | Uma rota com meta.auth (ou uma rota de MFA) correu sem ctx().user |
TokenInvalidError / TokenExpiredError | AUTH_TOKEN_INVALID / AUTH_TOKEN_EXPIRED | 401 | O access token apresentado está malformado/mal assinado, ou passou o TTL |
AuthTokenInvalidError | AUTH_TOKEN_INVALID | 400 | Um token de link de verificação ou reposição é desconhecido, já usado ou expirado |
RefreshInvalidError | AUTH_REFRESH_INVALID | 401 | Refresh token desconhecido, revogado ou expirado |
RefreshReusedError | AUTH_REFRESH_REUSED | 401 | Um refresh token já consumido voltou — indicador de roubo; a família inteira é revogada |
MfaRequiredError | AUTH_MFA_REQUIRED | 401 | Password correta, MFA ativo, sem mfaCode. Conta nos orçamentos de login e por IP como uma falha (revela que a password estava certa) |
MfaStepUpRequiredError | AUTH_MFA_REQUIRED | 403 | requireMfa / meta.mfa: true: o MFA está ativo, mas este token ou sessão foi obtido sem código — entra de novo com um |
MfaEnrollmentRequiredError | AUTH_MFA_ENROLLMENT_REQUIRED | 403 | requireMfa / meta.mfa: true: a conta não tem MFA — inscreve-a e entra de novo com um código |
MfaInvalidCodeError | AUTH_MFA_INVALID | 401 | Código TOTP ou de recuperação errado — este conta para o throttle |
MfaNotEnrolledError | AUTH_MFA_NOT_ENROLLED | 400 | Ativar/desativar MFA sem nenhuma inscrição em curso |
MfaAlreadyEnabledError | AUTH_MFA_ALREADY_ENABLED | 409 | enrollMfa numa conta com MFA ativo — desativa-o primeiro com um código |
CsrfRejectedError | AUTH_CSRF_REJECTED | 403 | Uma rota meta.auth recebeu um pedido cross-site, só com cookie, que altera estado |
AccountLockedError | AUTH_LOCKED | 429 | O orçamento de logins falhados por email ou por IP esgotou-se; traz retryAfterMs |
UserUpdateUnsupportedError | AUTH_UPDATE_UNSUPPORTED | 500 | O teu UserSource não tem update() — obrigatório para verificação e reposição |
WeakJwtSecretError | AUTH_WEAK_SECRET | arranque | secret em falta, ou com menos de 32 caracteres fora de um NODE_ENV=development/test explícito |
ScopeRequiredError | AUTH_SCOPE_REQUIRED | 403 | Uma rota com meta.scopes foi chamada sem uma API key que tenha esse scope (ou *), ou uma chave sem * chamou uma rota protegida por identidade que não declara meta.scopes |
ApiKeyTenantMismatchError | AUTH_APIKEY_TENANT_MISMATCH | 403 | Uma chave usada fora do tenant em que foi emitida (ou uma chave sem tenant num pedido com tenant) |
ApiKeyNotAllowedError | AUTH_APIKEY_NOT_ALLOWED | 403 | Uma chave usada numa rota só de sessão (meta.apiKey: false: gestão de chaves, MFA) |
ApiKeyAmbiguousError | AUTH_APIKEY_AMBIGUOUS | 400 | Duas chaves diferentes no mesmo pedido (Authorization: Bearer mk_… e o header da chave) |
ApiKeyForbiddenError | AUTH_APIKEY_NOT_FOUND | 404 | DELETE /apikeys/:id para uma chave fora do âmbito tenant/utilizador de quem chama — um 404, nunca um 403, para que os ids das chaves não possam ser sondados |
ApiKeySchemaOutdatedError | AUTH_API_KEY_SCHEMA_OUTDATED | 500 | @basaltkit/auth-prisma: a auth_api_keys da base de dados não tem uma coluna (normalmente expiresAt, acrescentada no 1.5.0) — migra-a, em todos os schemas de tenant com schema-per-tenant |
WebAuthnChallengeError | WEBAUTHN_CHALLENGE_INVALID | 400 | O desafio da passkey expirou ou já foi usado (são de uso único) |
WebAuthnVerificationError | WEBAUTHN_VERIFICATION_FAILED | 400 | O verifier rejeitou a resposta do browser |
WebAuthnSubjectMismatchError | WEBAUTHN_SUBJECT_MISMATCH | 403 | O finishRegistration recebeu um userId diferente daquele para quem o desafio foi emitido, ou uma autenticação iniciada para um utilizador recebeu a passkey de outro |
PasskeyNotFoundError | PASSKEY_NOT_FOUND | 404 | Nenhuma credencial guardada corresponde ao id apresentado, ou remove(userId, id) indicou uma passkey que não pertence a userId |
PasskeyClonedError | PASSKEY_CLONED | 401 | O contador de assinaturas não aumentou, ou uma asserção concorrente mudou-o primeiro — o autenticador pode estar clonado |
PasskeyStoreOutdatedError | PASSKEY_STORE_OUTDATED | arranque | O PasskeyStore não tem compareAndSetCounter() |
PasskeyExistsError | PASSKEY_EXISTS | 409 | Essa credencial já está registada |
OAuthProviderUnknownError | AUTH_OAUTH_UNKNOWN_PROVIDER | 404 | O :provider não está no array providers |
OAuthStateInvalidError | AUTH_OAUTH_STATE_INVALID | 400 | O state de CSRF está em falta, foi adulterado, é mais velho que stateTtlMs, já foi usado, ou chegou sem o cookie de ligação do browser |
SocialLinkRefusedError | AUTH_SOCIAL_LINK_REFUSED | 403 | Um login social encontrou uma conta existente por um email que o provider não verificou |
AccountLinkConflictError | AUTH_ACCOUNT_LINK_CONFLICT | 409 | A conta está ligada a um subject diferente desse fornecedor (ou o subject acabou de ser ligado a outra conta) |
AccountEmailAmbiguousError | AUTH_EMAIL_AMBIGUOUS | 500 | O store de utilizadores tem várias linhas cujo email só difere em maiúsculas — funde-as (o normalizeAuthUserEmails() lista-as) |
SecretUnreadableError | AUTH_SECRET_UNREADABLE | 500 | Um segredo TOTP guardado não é um envelope selado para esse utilizador com uma chave do anel (texto simples / v1: sem a adesão legacy, adulteração, uma chave removida) |
SecretBoxKeyError | AUTH_SECRET_BOX_KEY_INVALID | arranque | Chave do mfaEncryption com menos de 32 bytes, um id de chave inválido ou duplicado, ou mfaEncryption e mfaEncryptionKey definidos em conjunto |
AuthModelMissingError | AUTH_PRISMA_MODEL_MISSING | 500 | @basaltkit/auth-prisma: o client foi gerado sem AuthAccountLink / AuthPasskey |
OAuthExchangeError | AUTH_OAUTH_EXCHANGE_FAILED | 502 | O fornecedor rejeitou a troca do código, a obtenção do perfil falhou ou excedeu o prazo, o perfil não tem sub/email utilizável, o email está fora dos allowedEmailDomains do fornecedor, ou o id_token falta, é de outro nonce/audiência/emissor ou expirou. A resposta do fornecedor fica no log; o cliente recebe Bad gateway. |
OAuthProviderConfigError | AUTH_OAUTH_PROVIDER_CONFIG | arranque | Vários fornecedores com um IdP empresarial (OIDC) sem allowedEmailDomains, uma entrada de domínio inválida, ou um nome de fornecedor duplicado |
SamlResponseInvalidError | AUTH_SAML_RESPONSE_INVALID | 400 | A resposta não está ligada a este browser, ou a assertion falhou a validação — malformada, assinatura errada, expirada, InResponseTo ausente/desconhecido, já usada, cifrada (não suportada), um algoritmo de assinatura/digest fora da allowlist (SHA-1 por omissão), um DOCTYPE, ou um email fora dos allowedEmailDomains do provider |
SamlProviderConfigError | AUTH_SAML_PROVIDER_CONFIG | arranque | Vários providers SAML sem allowedEmailDomains, uma entrada de domínio inválida, uma lista signatureAlgorithms / digestAlgorithms vazia/inválida, um acceptedClockSkewMs fora de 0..5 min, ou @node-saml/node-saml < 5.1.0 |
UnguardedRouteMetaError | HTTP_UNGUARDED_ROUTE_META | arranque | Uma rota declara meta.auth e o authPlugin não está registado |
- Todos os pedidos ficam anónimos mesmo com um
Authorizationválido — confirma que o bearer não tem o prefixomk_(esses pertencem aoapiKeysPlugin), e que oauthPluginestá registado antes do plugin do adaptador, para que o seu enricher esteja no pipeline. 401 AUTH_REQUIREDnuma rota que julgavas pública — ficou lá ometa.auth. E se a app recusa arrancar comHTTP_UNGUARDED_ROUTE_META, é o problema inverso: a meta está lá, o plugin não.AUTH_REFRESH_REUSEDlogo a seguir a um login normal — dois clientes (ou um pedido repetido) fizeram refresh com o mesmo token. A rotação é de uso único por token; serializa os refreshes no cliente, não os repitas às cegas.- Toda a gente é expulsa depois de um redeploy — os stores
Memory*são por processo. PassarefreshTokens/sessionsparaauth-sqliteouauth-prisma. AUTH_UPDATE_UNSUPPORTEDna verificação ou na reposição — o teuUserSourcepersonalizado omite oupdate(). É opcional para o login, obrigatório para estes.AUTH_LOCKEDpara um utilizador que escreveu a password certa — o orçamento por IP pode disparar primeiro com NAT partilhado ou num teste de carga (o primeiro passo, sem código, de cada login MFA gasta uma vaga de IP). Ajusta oipLoginThrottle, e lembra-te de que ambos os throttles são em processo: com várias réplicas, o orçamento efetivo é por réplica.AUTH_WEAK_SECRETsó em produção — o mínimo de comprimento é imposto salvo comNODE_ENVexplicitamentedevelopmentoutest(umNODE_ENVnão definido conta como produção); um secret de tamanho de dev arranca localmente e falha no deploy.
Eventos
| Hook | Payload | Uso típico |
|---|---|---|
auth:registered | { user } | Email de boas-vindas, provisionamento |
auth:register_existing_email | { email } | O email fora de banda "já tens conta" — o sinal que a resposta HTTP retém deliberadamente |
auth:login · auth:login_failed | { user } · { email } | Trilho de auditoria, alertas |
auth:logout | { user } | Trilho de auditoria |
auth:verify_requested · auth:email_verified | { user, token } · { user } | Envia o token por email — nunca é devolvido por HTTP |
auth:password_reset_requested · auth:password_reset | { user, token } · { user } | Envia o token por email; o segundo confirma a alteração |
auth:mfa_enabled · auth:mfa_disabled | { user } | Notificação de segurança |
auth:apikey_issued · auth:apikey_revoked | { id, tenantId?, userId? } · { id } | Trilho de auditoria |
auth:apikey_rejected | { id?, reason, tenantId? } | Alertas — reason é invalid, tenant_mismatch, not_allowed ou scope; nunca a chave |
auth:mfa_failed · auth:locked_out | { userId } · { email, ip? } | Alertas de força bruta de MFA e de bloqueio |
auth:refresh_reused | { userId, familyId } | Alertas de roubo de token — um refresh token consumido voltou |
auth:social_account_adopted | { user } | Um login social verificado assumiu uma conta não verificada; as credenciais antigas e as ligações de contas foram revogadas |
auth:account_linked | { user, provider } | Uma conta do fornecedor foi ligada a esta conta no seu primeiro login social — notificação de segurança |
São consumidos gratuitamente pelo audit e pelas notificações (vê Pacotes). Para a ligação completa ponta a ponta — encanamento de email, teams e billing — vê o cookbook do ciclo de vida da conta.