Skip to content

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çaRegistada porO que faz
EnricherauthPluginLê 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
GuardauthPluginUma rota que declara meta: { auth: true } exige ctx().user — anónimo → 401 AUTH_REQUIRED
Enricher + guardapiKeysPluginAutentica bearers com prefixo mk_ / x-api-key para ctx().apiKey, e impõe meta.scopes
Serviço AuthauthPlugin, token AUTHTudo o que as rotas chamam: registo, login, refresh, sessões, verificação, reposição, MFA. Alcança-o com app.container.get(AUTH)
RotasauthRoutes() · 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:

ts
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):

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

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

Atualizar 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):

ts
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, nunca AuthUser — uma pesquisa de directório não tem nada que transportar uma hash de password, e os drivers incluídos nem sequer fazem SELECT das 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.
ts
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:

ts
import { authRoutes, mfaRoutes, apiKeyRoutes } from '@basaltkit/auth'
import { fastifyPlugin } from '@basaltkit/fastify'

fastifyPlugin({ routes: [...appRoutes, ...authRoutes(), ...mfaRoutes(), ...apiKeyRoutes()] })

authRoutes() expõe:

EndpointBodyNotas
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) ​

bash
# 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:

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

Rotaçã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:

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

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

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

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:

ts
authPlugin({ users, secret, csrf: { trustedOrigins: ['https://app.example.com'] } })
authPlugin({ users, secret, csrf: false }) // não recomendado

Rotas 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.auth

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

ts
fastifyPlugin({ routes, allowUnguardedMeta: ['auth'] }) // ou `true` para todas as chaves

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

ts
fastifyPlugin({ routes: [...authRoutes(), ...mfaRoutes()] })

O fluxo enroll → activate → recovery:

ts
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álido

Por 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):

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

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

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

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

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

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

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

ts
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/callback como 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 }; passa successRedirect para 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 com oauthPlugin({ …, 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:

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

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

ts
import { samlPlugin, samlRoutes } from '@basaltkit/auth-saml'

samlPlugin({ providers: [{ name: 'okta', entryPoint, idpCert, issuer, callbackUrl }] })
// rotas: GET /auth/saml/:provider/login · POST …/acs · GET …/metadata

Cada 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).

ts
samlPlugin({
  providers: [
    { name: 'acme', /* … */ allowedEmailDomains: ['acme.com'] },
    { name: 'globex', /* … */ allowedEmailDomains: ['globex.com', 'globex.co.uk'] },
  ],
})
OpçãoTipoOmissãoPropósito
providersSamlProvider[]— (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)
bindToBrowserbooleantrueLiga 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
cacheProviderSamlCacheProvidercache em processo do node-samlOnde vivem os ids de AuthnRequest pendentes — obrigatório em deployments com várias réplicas
assertionReplayCacheSamlAssertionReplayCacheem processoArmazenamento 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) => SamlClientnode-samlFábrica do cliente subjacente (testes)
hoststring—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:

ts
// 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}`)
})
bash
# 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 passes allowTenantlessKeys: true.
  • Os scopes são um limite máximo. Uma chave sem * só alcança rotas que declaram meta.scopes que ela tem; numa rota protegida por meta.auth, can, teamRole ou audience sem meta.scopes é recusada (AUTH_SCOPE_REQUIRED), mesmo que users a deixe definir ctx().user. Uma chave * age como o seu dono. allowNarrowKeysOnUnscopedRoutes: true repõe o comportamento antigo, mais permissivo.
  • Rotas só de sessão. Uma rota com meta.apiKey: false recusa qualquer chave (AUTH_APIKEY_NOT_ALLOWED). apiKeyRoutes() e mfaRoutes() declaram-no, por isso uma chave nunca pode criar, listar ou revogar chaves, nem alterar o MFA.
ts
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):

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

Aviso

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:

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

ts
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çãoTipoPredefiniçãoPropósito
usersUserSource— (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
secretstring— (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)
hasherPasswordHashernew ScryptPasswordHasher()Hashing de passwords. Troca por uma implementação argon2id sem mexer nos pontos de chamada
sessionsSessionStoreem memóriaSessões por cookie/x-session-id — troca para durabilidade
refreshTokensRefreshTokenStoreem memóriaFamílias de refresh tokens; em memória significa que cada redeploy expulsa toda a gente
tokensAuthTokenStoreem memóriaTokens de verificação de email e de reposição de password
mfaMfaStoreem memóriaEstado de inscrição TOTP e códigos de recuperação
accessTtlDurationInput'15m'Duração do access token. Curta por desenho — é o refresh token que sustenta a sessão
refreshTtlDurationInput'30d'Duração do refresh token — na prática, "quanto tempo até o utilizador ter de entrar outra vez"
sessionTtlDurationInput'30d'Duração da sessão do lado do servidor
sessionCookieSessionCookieOptionsbasalt_session, HttpOnly, SameSite=Lax, Path=/Atributos do cookie de sessão; Secure activo por omissão salvo com NODE_ENV explicitamente development/test
verificationTtlDurationInput'24h'Duração do link de verificação de email
resetTtlDurationInput'1h'Duração do link de reposição de password; mantém-na curta
loginThrottleLoginThrottle | falsenew LoginThrottle() (5 por 15m, por email)Bloqueio por força bruta por email. false desativa-o — só em testes
emailRequestThrottleLoginThrottle | false3 por 15m, por conta e finalidadeLimita emails de reposição/verificação; acima do orçamento o pedido é ignorado em silêncio e o link em vigor continua válido
throttleStoreThrottleStoreem memória, por processoOnde 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[] } | falseligadoVerificação CSRF da sessão por cookie em métodos não seguros — ver Sessões por cookie e CSRF
ipLoginThrottleLoginThrottle | falsenew 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
enumerationSafeRegisterbooleantrueImpede que o POST /auth/register revele que um email já tem conta. false repõe o 409 AUTH_EMAIL_TAKEN
tokenVersionsTokenVersionStore— (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
accountLinksAccountLinkStoreem memóriaLigaçõ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
mfaEncryptionKeystring | Buffer— (texto simples)Atalho para mfaEncryption: { keys: [{ id: 'default', key }] } (≥ 32 bytes). Não lê os envelopes v1: antigos — migra com mfaEncryption.legacy
mfaIssuerstring'Basalt'Nome do emissor mostrado na app autenticadora

new LoginThrottle(options):

OpçãoTipoPredefiniçãoPropósito
maxAttemptsnumber5Tentativas falhadas permitidas dentro da janela
windowMsnumber900_000 (15m)Janela fixa aberta pela primeira tentativa; um login bem-sucedido limpa o contador
storeThrottleStoreum MemoryThrottleStore próprioOnde vivem os contadores — RedisThrottleStore para os partilhar entre réplicas
namespacestring—Prefixo de chave que separa throttles que partilham um store
maxEntriesnumber100_000Limite 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() => numberDate.nowReló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çãoTipoPredefiniçãoPropósito
storeApiKeyStoreem memóriaOnde vivem os hashes das chaves — durável em produção, ou as chaves morrem no redeploy
headerstring'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
usersUserSource—Quando definido, uma chave com userId também preenche ctx().user, para que as rotas protegidas por scopes leiam o utilizador que age
allowTenantlessKeysbooleanfalseDeixa chaves emitidas sem tenant agir em pedidos com tenant (só chaves de plataforma de confiança)
allowNarrowKeysOnUnscopedRoutesbooleanfalseDeixa uma chave sem * alcançar rotas meta.auth/can/teamRole/audience que não declaram meta.scopes
now() => numberDate.nowRelógio injetável (testes)

webauthnPlugin(options) e a sua config:

OpçãoTipoPredefiniçãoPropósito
verifierWebAuthnVerifier— (obrigatório)A fronteira criptográfica que implementas sobre o @simplewebauthn/server, para que a framework não carregue nenhuma dependência WebAuthn
credentialsPasskeyStorenew MemoryPasskeyStore()Passkeys registadas — troca por um store durável (s.passkeys). Tem de implementar compareAndSetCounter
challengesWebAuthnChallengeStorenew MemoryWebAuthnChallengeStore()Desafios de cerimónia de uso único
config.rpIdstring— (obrigatório)Relying Party ID — o teu domínio registável, p. ex. 'example.com'
config.rpNamestring— (obrigatório)Nome legível mostrado no diálogo do sistema operativo
config.originstring | string[]— (obrigatório)Origem(ns) esperada(s), p. ex. 'https://example.com'
config.challengeTtlMsnumber300_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.timeoutMsnumber60_000Tempo limite da cerimónia anunciado ao browser
config.pubKeyCredParamsPublicKeyParam[]ES256 + RS256Sobrepõe os algoritmos de assinatura aceites

oauthPlugin(options) e oauthRoutes(options):

OpçãoTipoPredefiniçãoPropósito
providersOAuthProvider[]— (obrigatório)googleProvider(), githubProvider(), oidcProvider() / discoverOidcProvider() — uma entrada por IdP
secretstring— (obrigatório)Chave HMAC que assina o state de CSRF; tipicamente o mesmo APP_SECRET
stateTtlMsnumber600_000 (10m)Quanto tempo um state assinado se mantém válido — a janela para concluir o redirecionamento
fetchtypeof fetchfetch globalCliente HTTP injetado (testes)
now() => numberDate.nowReló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
timeoutMsnumber10_000Prazo 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 minO cookie HttpOnly que liga o fluxo ao browser (__Host-basalt_oauth quando secure)
rateLimit (rotas){ limit, windowMs } | false10 / min por ip e rotameta.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 ​

ErroCódigoHTTPQuando
InvalidCredentialsErrorAUTH_INVALID_CREDENTIALS401Email desconhecido ou password errada — deliberadamente indistinguíveis, e com custo igual
EmailTakenErrorAUTH_EMAIL_TAKEN409auth.register() sobre um email existente (a rota mantém-se à prova de enumeração, salvo enumerationSafeRegister: false)
AuthRequiredErrorAUTH_REQUIRED401Uma rota com meta.auth (ou uma rota de MFA) correu sem ctx().user
TokenInvalidError / TokenExpiredErrorAUTH_TOKEN_INVALID / AUTH_TOKEN_EXPIRED401O access token apresentado está malformado/mal assinado, ou passou o TTL
AuthTokenInvalidErrorAUTH_TOKEN_INVALID400Um token de link de verificação ou reposição é desconhecido, já usado ou expirado
RefreshInvalidErrorAUTH_REFRESH_INVALID401Refresh token desconhecido, revogado ou expirado
RefreshReusedErrorAUTH_REFRESH_REUSED401Um refresh token já consumido voltou — indicador de roubo; a família inteira é revogada
MfaRequiredErrorAUTH_MFA_REQUIRED401Password correta, MFA ativo, sem mfaCode. Conta nos orçamentos de login e por IP como uma falha (revela que a password estava certa)
MfaStepUpRequiredErrorAUTH_MFA_REQUIRED403requireMfa / meta.mfa: true: o MFA está ativo, mas este token ou sessão foi obtido sem código — entra de novo com um
MfaEnrollmentRequiredErrorAUTH_MFA_ENROLLMENT_REQUIRED403requireMfa / meta.mfa: true: a conta não tem MFA — inscreve-a e entra de novo com um código
MfaInvalidCodeErrorAUTH_MFA_INVALID401Código TOTP ou de recuperação errado — este conta para o throttle
MfaNotEnrolledErrorAUTH_MFA_NOT_ENROLLED400Ativar/desativar MFA sem nenhuma inscrição em curso
MfaAlreadyEnabledErrorAUTH_MFA_ALREADY_ENABLED409enrollMfa numa conta com MFA ativo — desativa-o primeiro com um código
CsrfRejectedErrorAUTH_CSRF_REJECTED403Uma rota meta.auth recebeu um pedido cross-site, só com cookie, que altera estado
AccountLockedErrorAUTH_LOCKED429O orçamento de logins falhados por email ou por IP esgotou-se; traz retryAfterMs
UserUpdateUnsupportedErrorAUTH_UPDATE_UNSUPPORTED500O teu UserSource não tem update() — obrigatório para verificação e reposição
WeakJwtSecretErrorAUTH_WEAK_SECRETarranquesecret em falta, ou com menos de 32 caracteres fora de um NODE_ENV=development/test explícito
ScopeRequiredErrorAUTH_SCOPE_REQUIRED403Uma 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
ApiKeyTenantMismatchErrorAUTH_APIKEY_TENANT_MISMATCH403Uma chave usada fora do tenant em que foi emitida (ou uma chave sem tenant num pedido com tenant)
ApiKeyNotAllowedErrorAUTH_APIKEY_NOT_ALLOWED403Uma chave usada numa rota só de sessão (meta.apiKey: false: gestão de chaves, MFA)
ApiKeyAmbiguousErrorAUTH_APIKEY_AMBIGUOUS400Duas chaves diferentes no mesmo pedido (Authorization: Bearer mk_… e o header da chave)
ApiKeyForbiddenErrorAUTH_APIKEY_NOT_FOUND404DELETE /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
ApiKeySchemaOutdatedErrorAUTH_API_KEY_SCHEMA_OUTDATED500@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
WebAuthnChallengeErrorWEBAUTHN_CHALLENGE_INVALID400O desafio da passkey expirou ou já foi usado (são de uso único)
WebAuthnVerificationErrorWEBAUTHN_VERIFICATION_FAILED400O verifier rejeitou a resposta do browser
WebAuthnSubjectMismatchErrorWEBAUTHN_SUBJECT_MISMATCH403O 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
PasskeyNotFoundErrorPASSKEY_NOT_FOUND404Nenhuma credencial guardada corresponde ao id apresentado, ou remove(userId, id) indicou uma passkey que não pertence a userId
PasskeyClonedErrorPASSKEY_CLONED401O contador de assinaturas não aumentou, ou uma asserção concorrente mudou-o primeiro — o autenticador pode estar clonado
PasskeyStoreOutdatedErrorPASSKEY_STORE_OUTDATEDarranqueO PasskeyStore não tem compareAndSetCounter()
PasskeyExistsErrorPASSKEY_EXISTS409Essa credencial já está registada
OAuthProviderUnknownErrorAUTH_OAUTH_UNKNOWN_PROVIDER404O :provider não está no array providers
OAuthStateInvalidErrorAUTH_OAUTH_STATE_INVALID400O 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
SocialLinkRefusedErrorAUTH_SOCIAL_LINK_REFUSED403Um login social encontrou uma conta existente por um email que o provider não verificou
AccountLinkConflictErrorAUTH_ACCOUNT_LINK_CONFLICT409A conta está ligada a um subject diferente desse fornecedor (ou o subject acabou de ser ligado a outra conta)
AccountEmailAmbiguousErrorAUTH_EMAIL_AMBIGUOUS500O store de utilizadores tem várias linhas cujo email só difere em maiúsculas — funde-as (o normalizeAuthUserEmails() lista-as)
SecretUnreadableErrorAUTH_SECRET_UNREADABLE500Um 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)
SecretBoxKeyErrorAUTH_SECRET_BOX_KEY_INVALIDarranqueChave do mfaEncryption com menos de 32 bytes, um id de chave inválido ou duplicado, ou mfaEncryption e mfaEncryptionKey definidos em conjunto
AuthModelMissingErrorAUTH_PRISMA_MODEL_MISSING500@basaltkit/auth-prisma: o client foi gerado sem AuthAccountLink / AuthPasskey
OAuthExchangeErrorAUTH_OAUTH_EXCHANGE_FAILED502O 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.
OAuthProviderConfigErrorAUTH_OAUTH_PROVIDER_CONFIGarranqueVários fornecedores com um IdP empresarial (OIDC) sem allowedEmailDomains, uma entrada de domínio inválida, ou um nome de fornecedor duplicado
SamlResponseInvalidErrorAUTH_SAML_RESPONSE_INVALID400A 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
SamlProviderConfigErrorAUTH_SAML_PROVIDER_CONFIGarranqueVá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
UnguardedRouteMetaErrorHTTP_UNGUARDED_ROUTE_METAarranqueUma rota declara meta.auth e o authPlugin não está registado
  • Todos os pedidos ficam anónimos mesmo com um Authorization válido — confirma que o bearer não tem o prefixo mk_ (esses pertencem ao apiKeysPlugin), e que o authPlugin está registado antes do plugin do adaptador, para que o seu enricher esteja no pipeline.
  • 401 AUTH_REQUIRED numa rota que julgavas pública — ficou lá o meta.auth. E se a app recusa arrancar com HTTP_UNGUARDED_ROUTE_META, é o problema inverso: a meta está lá, o plugin não.
  • AUTH_REFRESH_REUSED logo 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. Passa refreshTokens/sessions para auth-sqlite ou auth-prisma.
  • AUTH_UPDATE_UNSUPPORTED na verificação ou na reposição — o teu UserSource personalizado omite o update(). É opcional para o login, obrigatório para estes.
  • AUTH_LOCKED para 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 o ipLoginThrottle, 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_SECRET só em produção — o mínimo de comprimento é imposto salvo com NODE_ENV explicitamente development ou test (um NODE_ENV não definido conta como produção); um secret de tamanho de dev arranca localmente e falha no deploy.

Eventos ​

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

Publicado sob a licença MIT.