@sbkbs/portal-auth
v1.0.0
Published
Integração do Portal Unificado SBK (Nexus) para produtos Node.js/Fastify: troca do código de autorização e validação do access_token via JWKS, com cache, rotação de chave e erros tipados.
Readme
@sbkbs/portal-auth
Integração padronizada com o Portal Unificado SBK (Nexus) para produtos Node.js/Fastify. Cobre o lado do produto no fluxo Authorization Code:
- troca do código recebido no callback pelo
access_token(POST /oauth/token), com a credencial de sistema; - validação do token: busca e cacheia o JWKS público do Nexus, resolve a chave pelo
kid, verifica assinatura RS256,issuer,audiencee expiração, e devolve claims tipadas.
Toda falha vira um erro tipado com o motivo, para o produto decidir a resposta sem parsear mensagens.
O problema que esta lib resolve
Sem uma biblioteca comum, cada produto que consome o Nexus implementa a troca de código e a validação do token do seu jeito, e é fácil esquecer uma das checagens. @sbkbs/portal-auth empacota o padrão correto (JWKS com cache, chave resolvida pelo kid, jwt.verify com algorithms: ['RS256'], issuer e audience), junto com a troca de código, numa lib reutilizável.
O sistema de destino deve validar o JWT recebido na troca (assinatura, aud e exp) antes de criar a sessão local. trocarCodigo faz as duas coisas numa chamada só, então não há como trocar o código e esquecer de validar.
Uso
Troca de código no callback (uso principal)
import { createPortalAuthClient, TrocaCodigoError, TokenInvalidoError } from '@sbkbs/portal-auth';
const portal = createPortalAuthClient({
tokenUrl: 'https://<api-do-nexus>/oauth/token',
jwksUrl: 'https://<api-do-nexus>/.well-known/jwks.json',
issuer: 'portal-unificado-sbk',
clientId: 'meu-sistema-id', // sistemaId no Nexus; também vira a audience esperada
clientSecret: () => obterClientSecret(), // string, ou função chamada a cada troca (rotação)
});
app.get('/auth/nexus/callback', async (request, reply) => {
const { code } = request.query as { code?: string };
try {
const { claims } = await portal.trocarCodigo(code ?? '');
// claims.sub, claims.email, claims.nome, claims.tenant_id, claims.permissao:
// já validadas. Crie aqui a sessão local do produto.
} catch (err) {
if (err instanceof TrocaCodigoError || err instanceof TokenInvalidoError) {
return reply.code(401).send({ erro: 'Acesso via Portal recusado', motivo: err.motivo });
}
throw err; // NexusIndisponivelError / JwksIndisponivelError: infraestrutura, normalmente 503
}
});Motivos de TrocaCodigoError:
codigo_invalido: o Nexus respondeu 400. Código inexistente, expirado (TTL de 30s), já utilizado ou emitido para outro sistema. Também é lançado sem chamar o Nexus quando o callback chega semcode.credencial_invalida: o Nexus respondeu 401.client_id/client_secretrecusados, ou sistema desativado no Nexus.resposta_invalida: resposta 2xx semaccess_token.
O token devolvido passa pela mesma validação de verifyToken (abaixo); se falhar, o erro é TokenInvalidoError. O client também expõe portal.verifyToken(token), com a mesma configuração.
Validação de token recebido como Bearer
import { createPortalAuthVerifier, TokenInvalidoError } from '@sbkbs/portal-auth';
const verifier = createPortalAuthVerifier({
jwksUrl: 'https://<api-do-nexus>/.well-known/jwks.json',
issuer: 'portal-unificado-sbk',
audience: 'meu-sistema-id', // obrigatório: o sistemaId cadastrado no Nexus para este produto
});
// Em qualquer rota Fastify que receba Authorization: Bearer <access_token>:
try {
const claims = await verifier.verifyToken(token);
// claims.sub, claims.email, claims.nome, claims.tenant_id, claims.permissao
} catch (err) {
if (err instanceof TokenInvalidoError) {
// err.motivo: 'assinatura_invalida' | 'expirado' | 'issuer_invalido'
// | 'audience_invalida' | 'chave_nao_encontrada' | 'token_malformado'
return reply.code(401).send({ erro: 'Token inválido', motivo: err.motivo });
}
throw err; // erro de infraestrutura (ex.: JwksIndisponivelError), não é sobre o token
}Qual jwksUrl usar
O JWKS e o /oauth/token são servidos pelo backend do Nexus (host da API), não pelo host do painel: apontar para o painel devolve HTML, e a lib responde com JwksIndisponivelError. Os endereços de cada ambiente, inclusive o endereço interno para produtos no mesmo cluster, estão na documentação interna de integração com o Portal Unificado.
Por que audience é obrigatória
O Nexus assina os tokens de todos os sistemas com a mesma chave. O que diferencia um token emitido para Ofícios de um emitido para Subsídios é só a claim aud. Sem checar audience, um usuário com acesso a um sistema conseguiria usar o próprio token em outro. Por isso createPortalAuthVerifier recusa ser criado sem ela (lança TypeError). Se um serviço precisar aceitar tokens de mais de um sistema, passe uma lista: audience: ['sistema-a', 'sistema-b'].
Cache e rotação de chave
O JWKS é buscado uma vez e cacheado em memória por 10 minutos ajustável via cacheTtlMs. Quando chega um token com kid que não está no cache (o caso típico de rotação de chave no Nexus), a lib busca o JWKS de novo antes de rejeitar. Para que tokens com kid inventado não virem uma busca por request, essa busca forçada acontece no máximo uma vez a cada 30 segundos (intervaloMinimoRefreshMs). Buscas simultâneas compartilham a mesma requisição.
Plugin Fastify (opcional, secundário)
Para quem prefere não chamar verifyToken manualmente em cada rota, há um plugin fino que decora request.portalAuthClaims:
import { registerPortalAuth } from '@sbkbs/portal-auth';
await app.register(registerPortalAuth, {
jwksUrl: 'https://<api-do-nexus>/.well-known/jwks.json',
issuer: 'portal-unificado-sbk',
audience: 'meu-sistema-id',
});Isso adiciona um preHandler que vale para todas as rotas do escopo onde o plugin foi registrado (no exemplo acima, a aplicação inteira) e exige Authorization: Bearer <token>:
- token ausente ou inválido:
401, commotivono corpo quando o token foi lido; - JWKS do Nexus inacessível:
503(não é culpa do token, e um401faria o cliente achar que precisa logar de novo).
Para liberar uma rota sem token, como um healthcheck, marque-a com config: { portalAuth: false }:
app.get('/health', { config: { portalAuth: false } }, async () => ({ ok: true }));Nessa rota request.portalAuthClaims fica null. Para tratamento de erro customizado, ou qualquer necessidade fora desse caminho, use createPortalAuthVerifier diretamente.
Erros
TokenInvalidoError: o token foi lido, mas rejeitado. Tem um campomotivodiscriminado (token_malformado,chave_nao_encontrada,assinatura_invalida,expirado,issuer_invalido,audience_invalida) para o chamador decidir a resposta/log sem parsear a mensagem.TrocaCodigoError: a troca do código foi recusada. Campomotivo(codigo_invalido,credencial_invalida,resposta_invalida), ver acima.JwksIndisponivelError: erro de infraestrutura, porque o endpoint JWKS do Nexus não respondeu (rede, timeout, status não-2xx, corpo inesperado). Não diz nada sobre a validade do token; normalmente deveria virar um503, não um401.NexusIndisponivelError: erro de infraestrutura na troca, porque o/oauth/tokennão respondeu (rede, timeout, 5xx). Também deveria virar503.
Desenvolvimento
npm install
npm run build # tsc -> dist/
npm test # vitest run
npm run typechecknpm publish roda typecheck, testes e build antes (prepublishOnly).
Os testes mockam o fetch do JWKS e do /oauth/token (via a opção fetchImpl) e assinam tokens de teste com pares de chave RSA gerados na hora. Nenhuma chamada de rede real é feita.
Publicação
Publicado no npm público, no escopo @sbkbs (publishConfig.access: public). O pacote não contém segredos: a segurança depende das chaves de assinatura do Nexus e do client_secret de cada sistema, que ficam fora da lib.
npm publish # roda typecheck, testes e build antes (prepublishOnly)