Custom Lambda Authorizer para o Amazon API Gateway que valida tokens JWT emitidos pelo Keycloak (OpenID Connect) e devolve um contexto de autorização para os serviços downstream.
A aplicação é escrita em NestJS, empacotada para Node.js 20 na AWS Lambda, e instrumentada com AWS X-Ray e Sentry.
- Visão geral
- Arquitetura
- Fluxo de validação
- Respostas
- Estrutura do projeto
- Pré-requisitos
- Configuração
- Scripts disponíveis
- Execução local
- Evento de teste (SAM)
- Endpoints HTTP
- Observabilidade
- Build e empacotamento Lambda
- CI/CD
- Ambientes
- Testes
- Troubleshooting
- Recebe um token JWT (via header
Authorization: Bearer …ou cookieaccessToken). - Valida o audience contra a lista de clientes permitidos (
KEYCLOAK_ALLOWED_CLIENTS). - Faz token introspection no Keycloak para garantir que o token ainda está ativo (não revogado/expirado no IdP).
- Verifica a assinatura JWT com as chaves públicas do realm (JWKS), algoritmo
RS256. - Em caso de sucesso, retorna
{ isAuthorized: true, context: { … } }para o API Gateway propagar aos backends. - Em caso de falha, dispara
UnauthorizedException/{ isAuthorized: false }, resultando em 401 no client.
| Área | Detalhes |
|---|---|
| Keycloak / OIDC | Validação JWT via JWKS; introspection no endpoint OpenID Connect; suporte a múltiplos realms e clientes |
| Entrada do token | Header Authorization ou cookie accessToken |
| Segurança | Checagem de aud, issuer (iss), assinatura RS256 e status ativo via introspection |
| Modos de execução | Lambda Authorizer (REQUEST) e HTTP API (/verify, /health) no mesmo handler |
| Observabilidade | AWS X-Ray (subsegmentos) + Sentry (spans, exceções, wrapper serverless) |
| Deploy | Bundle otimizado com esbuild + ZIP (dist.zip) via pipeline GitLab |
O entrypoint único é src/main.ts (handler). Ele classifica o evento recebido e encaminha para o bootstrap adequado:
┌─────────────────────────┐
│ Lambda handler │
│ (main.ts + Sentry) │
└───────────┬─────────────┘
│
┌─────────────────┼─────────────────┐
│ │ │
Authorizer event HTTP event NODE_ENV=build-test
(type REQUEST/JWT (requestContext.http) → { isAuthorized: false }
ou methodArn)
│ │
▼ ▼
bootstrap-guard.ts handler-http.ts
(Nest Application → bootstrap-http.ts
Context + Facade) (Nest + Fastify
+ @fastify/aws-lambda)
│ │
└────────┬────────┘
▼
AuthorizerFacade.validateToken()
│
▼
AuthorizerService.verifyToken()
| Camada | Responsabilidade |
|---|---|
| Handler / Bootstrap | Detecta tipo de evento; sobe contexto Nest ou app Fastify |
Controller (AuthorizerController) |
Endpoint HTTP GET /verify |
Facade (AuthorizerFacade) |
Orquestra validação, monta o context, captura erros no Sentry |
Service (AuthorizerService) |
Audience, introspection Keycloak, JWKS + jwt.verify |
| Observabilidade | SentryService, XRayService |
| Config | @nestjs/config com authorizerConfig, sentry, xray |
Em bootstrap-guard.ts:
Authorization/authorization(remove o prefixoBearer).- Se não houver header, lê o cookie
accessToken=…deCookie/cookie.
Implementado em AuthorizerService.verifyToken:
token
│
├─ 1. jwt.decode (completo) → exige header.kid
│
├─ 2. verifyTokenAudience
│ aud (string ou array) deve intersectar KEYCLOAK_ALLOWED_CLIENTS
│
├─ 3. verifyTokenIntrospect
│ POST {KEYCLOAK_BASE_URL}/realms/{realm}/protocol/openid-connect/token/introspect
│ body: token + client_id (aud permitido) + client_secret (por azp)
│ exige introspect.active === true
│
└─ 4. jwt.verify (RS256)
chave via JWKS do realm
audience = payload.azp
issuer = payload.iss
Ordem em getRealmFromPayload:
- Claim
realmno payload (se string não vazia). - Extração de
issno padrão/realms/{realm}. - Fallback:
KEYCLOAK_REALM.
- URI:
{KEYCLOAK_BASE_URL}/realms/{realm}/protocol/openid-connect/certs - Clients JWKS são cacheados em memória por realm (
Map).
Mapeados em authorizer.config.ts a partir do azp do token:
Client (azp) |
Variável de ambiente |
|---|---|
scrow-web |
SCROW_WEB_CLIENT_SECRET |
portal-web |
PORTAL_WEB_CLIENT_SECRET |
Clientes listados em KEYCLOAK_ALLOWED_CLIENTS sem secret configurado falharão na introspection.
{
"isAuthorized": true,
"context": {
"userId": "user-uuid",
"email": "user@email.com",
"name": "User Name",
"roles": ["role-a", "role-b"],
"realm": "media",
"tenants": [],
"allIsps": false,
"firstName": "User",
"lastName": "Name",
"legacyId": ""
}
}Campos do context (origem no JWT):
| Campo | Claim / origem |
|---|---|
userId |
sub |
email |
email |
name |
name |
roles |
resource_access[azp].roles ∪ realm_access.roles |
realm |
realm |
tenants |
tenants |
allIsps |
all_isps |
firstName |
given_name |
lastName |
family_name |
legacyId |
legacy_id |
No API Gateway (HTTP API simple response / REQUEST authorizer), esse payload é usado para permitir a chamada e injetar o context na integração com o backend.
A facade lança UnauthorizedException. Em modo authorizer, o efeito prático para o client é 401. Em modo HTTP, o filtro de exceção responde com corpo no formato:
{
"statusCode": 401,
"error": "UnauthorizedException",
"message": "…",
"timestamp": "…",
"path": "/verify",
"isAuthorized": false
}Erros comuns tratados como não autorizado:
- Token ausente
- Audience inválido
- Token inativo (introspection)
- Assinatura / issuer / expiração inválidos (
jwt.verify) - Falha ao obter chave JWKS (
kidinválido)
authorizer/
├── build/ # Sistema de build/empacotamento Lambda (esbuild + ZIP)
│ ├── index.mjs # Entrypoint do processo de deploy
│ ├── build.mjs # Compilação TS + bundle
│ ├── test.mjs # Smoke test do bundle
│ ├── package.mjs # Gera dist.zip
│ ├── deploy.mjs # Orquestra build → test → package
│ ├── app.config.json
│ └── utils/
├── events/
│ └── test.json # Evento de exemplo para `sam local invoke`
├── src/
│ ├── main.ts # Handler Lambda (roteamento Guard vs HTTP)
│ ├── app.module.ts
│ ├── health.controller.ts # GET /health
│ ├── authorizer/
│ │ ├── authorizer.module.ts
│ │ ├── authorizer.service.ts # Validação JWT / Keycloak
│ │ ├── authorizer.config.ts # Env → config + client secrets
│ │ ├── authorizer.interface.ts
│ │ ├── facade/
│ │ │ └── authorizer.facade.ts
│ │ └── __tests__/
│ ├── presentation/
│ │ └── controllers/
│ │ └── authorizer.controller.ts # GET /verify
│ ├── common/observability/
│ │ ├── sentry/
│ │ └── xray/
│ ├── shared/
│ │ ├── config/
│ │ │ ├── bootstrap/
│ │ │ │ ├── bootstrap-guard.ts # Modo Authorizer
│ │ │ │ └── bootstrap-http.ts # Modo HTTP (Fastify)
│ │ │ └── handlers/
│ │ │ └── handler-http.ts
│ │ └── exceptions/
│ └── utils/
│ ├── boolean.util.ts
│ └── number.util.ts
├── template.example.yaml # Modelo SAM (sem segredos)
├── template.yaml # SAM local (gitignored — criar a partir do example)
├── .env.example
├── .gitlab-ci.yml
├── package.json
└── README.md
- Node.js 20+
- npm (ou yarn)
- AWS SAM CLI (para invoke local)
- Docker (necessário para
sam local invoke) - Acesso a um Keycloak com realm/clientes configurados e secrets de client (confidential) para introspection
npm installCopie .env.example para .env na raiz e preencha:
cp .env.example .env| Variável | Obrigatória | Descrição |
|---|---|---|
KEYCLOAK_BASE_URL |
Sim | URL base do Keycloak (ex.: https://login.example.com) |
KEYCLOAK_REALM |
Sim* | Realm padrão (fallback se o token não trouxer realm/iss parseável) |
KEYCLOAK_ALLOWED_CLIENTS |
Sim | Lista CSV de aud permitidos (ex.: scrow-web,portal-web,watch-web) |
SCROW_WEB_CLIENT_SECRET |
Condicional | Secret do client scrow-web (introspection) |
PORTAL_WEB_CLIENT_SECRET |
Condicional | Secret do client portal-web (introspection) |
*Usado quando o realm não pode ser inferido do token.
| Variável | Descrição |
|---|---|
SENTRY_DSN |
DSN do projeto no Sentry |
SENTRY_ENVIRONMENT |
Ambiente reportado (ex.: dev, qa, prod) |
SENTRY_TRACES_SAMPLE_RATE |
Taxa de sampling de traces (ex.: 0.1) |
SENTRY_PROFILES_SAMPLE_RATE |
Taxa de profiling (ex.: 0) |
SENTRY_SEND_DEFAULT_PII |
Enviar PII padrão (true/false) |
SENTRY_ENABLE_LOGS |
Habilitar logs no Sentry |
SENTRY_ENABLED |
Liga integração Nest (true apenas quando desejado em ambientes não-local/development) |
SENTRY_DEBUG |
Debug do SDK |
SENTRY_SERVICE_NAME |
Nome do serviço (serverName) |
SENTRY_RELEASE |
Identificador de release |
APP_VERSION |
Versão da aplicação |
O módulo Sentry só ativa a integração Nest completa quando SENTRY_ENABLED=true, há SENTRY_DSN e NODE_ENV não é development nem local. O handler Lambda ainda é envolvido por @sentry/serverless (AWSLambda.wrapHandler).
| Variável | Descrição | Default |
|---|---|---|
AWS_XRAY_ENABLED |
Liga/desliga X-Ray | true |
AWS_XRAY_ENVIRONMENT |
Ambiente (ou usa NODE_ENV) |
— |
AWS_XRAY_DAEMON_ADDRESS |
Endereço do daemon (se aplicável) | vazio |
Setup de X-Ray é ignorado quando NODE_ENV é test ou local. Em execução Lambda com tracing ativo no template SAM, o subsegmento KeycloakVerifyToken é criado durante a verificação do JWT.
| Variável | Descrição |
|---|---|
NODE_ENV |
development, local, test, build-test, etc. |
PORT |
Porta do HTTP local via bootstrap Fastify (default 3001) |
template.yaml está no .gitignore. Crie a partir do exemplo:
cp template.example.yaml template.yamlAjuste em template.yaml:
- Variáveis de ambiente da função (Keycloak, Sentry, secrets dos clients).
- Runtime
nodejs20.x, timeout, memória eTracing: Active. - Handler:
dist/main.handler(build Nest) ou o path gerado pelo fluxobuild:lambda, conforme o processo de deploy usado.
Não versionar secrets no repositório. Em ambientes reais os valores vêm dos Secrets Manager / variáveis da pipeline (ver CI/CD).
Definidos em package.json:
| Script | Comando | Descrição |
|---|---|---|
build |
nest build |
Compila TypeScript para dist/ |
test |
jest |
Suite de testes unitários |
build:lambda |
node build/index.mjs |
Build + teste do bundle + empacota dist.zip |
sam:build |
sam build --no-cached |
Build SAM |
sam:invoke |
sam local invoke AuthorizerFunction --event events/test.json --skip-pull-image |
Invoca a Lambda localmente |
sam:invoke-logs |
idem + --log-file sam-local.log --debug |
Invoke com logs detalhados |
sam:build-and-invoke |
build → sam:build → sam:invoke |
Fluxo completo de teste SAM |
Documentação adicional do empacotamento: build/README.md.
- Configure
.env/ variáveis notemplate.yaml. - Coloque um token válido em
events/test.json(headerAuthorizationou cookieaccessToken). - Execute:
npm run build
npm run sam:build
npm run sam:invokeOu em um único passo:
npm run sam:build-and-invokeIsso simula um evento de REQUEST authorizer do API Gateway contra a função AuthorizerFunction.
npm run buildSaída em dist/ (handler dist/main.handler conforme template SAM típico).
Arquivo: events/test.json.
Formato mínimo de um REQUEST authorizer:
{
"type": "REQUEST",
"methodArn": "arn:aws:execute-api:us-east-1:123456789012:abcdef/prod/GET/protected",
"headers": {
"authorization": "Bearer <JWT>",
"cookie": "accessToken=<JWT>"
}
}O handler identifica authorizer quando:
event.typeéREQUESTouJWT, ou- existe
event.methodArn.
Eventos HTTP (API Gateway HTTP API) são detectados por event.requestContext.http e seguem o caminho Fastify (/verify, /health).
Disponíveis quando o evento é tratado como HTTP (HttpApi / Fastify):
| Método | Path | Descrição |
|---|---|---|
GET |
/health |
Health check — responde Healthy API |
GET |
/verify |
Valida o Bearer token do header e retorna o mesmo payload da facade |
Exemplo:
GET /verify
Authorization: Bearer <JWT>No template.example.yaml / template.yaml locais, eventos SAM podem expor rotas como /verify (HttpApi) e /protected (API de teste sem authorizer acoplado no template de exemplo).
- Wrapper do handler:
@sentry/serverless(AWSLambda.wrapHandler) comflushTimeout: 2000. - Spans de aplicação:
AuthorizerFacade.validateToken(operation: authorizer.validateToken). - Exceções capturadas na facade e em falhas de JWKS / audience.
- Filtro global
SentryExceptionFilterregistrado pelo módulo.
- Tracing da função:
Tracing: Activeno template SAM. - Subsegmento
KeycloakVerifyTokendurantejwt.verify. - Captura global de HTTP/HTTPS quando X-Ray está habilitado.
O diretório build/ gera um artefato otimizado para deploy:
npm run build:lambdaFluxo interno:
- Compilação TypeScript
- Bundle com esbuild
- Smoke tests do handler
- Empacotamento em
dist.zip - Manifeste de build
Objetivo: reduzir o tamanho do pacote (de dezenas de MB para centenas de KB no ZIP) e validar o handler antes do deploy. Detalhes: build/README.md.
Pipeline GitLab (.gitlab-ci.yml) usa o componente compartilhado:
include:
- component: $CI_SERVER_FQDN/watchbrasil/infrastructure/pipelines/typescript-lambda-nest-template/pipelines@~latestDisparada em branches e merge requests. O template de pipeline tipicamente:
- Instala dependências e roda testes/build.
- Gera o artefato Lambda (
dist.zip/ fluxo Nest Lambda). - Publica na função correspondente ao ambiente, lendo secrets do AWS Secrets Manager.
Nomes configurados na pipeline:
| Ambiente | Lambda | Secret (Secrets Manager) |
|---|---|---|
| Development | authentication-authorizer-dev-watch |
authentication-authorizer/dev/watch |
| QA | authentication-authorizer-qa-watch |
authentication-authorizer/qa/watch |
| Preproduction | authentication-authorizer-preprod-watch |
authentication-authorizer/preprod/watch |
| Production | authentication-authorizer-prod-watch |
authentication-authorizer/prod/watch |
npm testCobertura relevante:
src/authorizer/__tests__/authorizer.facade.spec.ts— sucesso, token vazio, erros →UnauthorizedException, montagem docontextsrc/authorizer/__tests__/authorizer.service.spec.ts— regras de verificação do servicesrc/common/observability/sentry/__tests__/sentry.service.spec.tssrc/health.controller.spec.ts
Jest está configurado em package.json (rootDir: src, *.spec.ts).
| Sintoma | Possível causa | O que verificar |
|---|---|---|
Missing token / 401 imediato |
Header/cookie ausente ou malformado | Authorization: Bearer … ou cookie accessToken= |
Invalid token audience |
aud não está em KEYCLOAK_ALLOWED_CLIENTS |
CSV de clients e claim aud do JWT |
Inactive token |
Token revogado/expirado no Keycloak | Introspection; clock skew; logout/session |
| Erro de introspection / secret | Secret ausente ou client errado | SCROW_WEB_CLIENT_SECRET / PORTAL_WEB_CLIENT_SECRET alinhados ao azp |
Falha JWKS / getSigningKey |
kid desconhecido ou URL de realm errada |
KEYCLOAK_BASE_URL, realm no iss, endpoint /certs |
| SAM não sobe | Docker/SAM/template | Docker rodando; template.yaml presente; npm run build antes do sam build |
| Bundle / handler inválido | Build incompleto | npm run build:lambda e logs em build/ |
| Sentry silencioso localmente | Feature flag / NODE_ENV |
SENTRY_ENABLED, SENTRY_DSN, ambiente ≠ local/development para integração Nest |
- Runtime: Node.js 20.x
- Framework: NestJS 11 + Fastify (
@nestjs/platform-fastify,@fastify/aws-lambda) - Auth:
jsonwebtoken,jwks-rsa, introspection Keycloak - Config:
@nestjs/config - Observabilidade:
@sentry/nestjs,@sentry/serverless,aws-xray-sdk-core - IaC local: AWS SAM (
template.yaml) - CI: GitLab CI + componente
typescript-lambda-nest-template