Contrato de confiança antes de ler claims
Uma API fictícia recebe um access token para consultar lotes. Antes de usar tenant ou scope, define o emissor aceite, a identificação desta API, o perfil de token e a origem autorizada das chaves. O exemplo aceita apenas RS256 com chaves públicas pré-configuradas. kid seleciona uma dessas chaves; não dá confiança a uma chave fornecida pelo cliente. Cabeçalhos com URLs de chaves são rejeitados neste subconjunto, sem pedidos de rede. Numa integração real, documenta discovery e atualização de JWKS através de configuração confiável. Um endpoint de chaves inacessível é um problema operacional a diagnosticar, não uma razão para desativar a validação da assinatura.
Cada validação responde a uma pergunta diferente
O segundo grupo do script assina tokens de teste e verifica assinatura, tipo de access token, emissor, audiência e tempo. Usa um relógio fixo para repetir as condições. Tokens assinados com audiência errada, prazo terminado ou início de validade futuro são rejeitados. Um ID token não substitui o access token que esta API exige. A política local também exige tipos adequados para claims obrigatórias e rejeita iat no futuro sem tolerância; uma integração real deve definir a tolerância de relógio explicitamente. A assinatura protege a integridade dos campos, mas não os esconde: não coloques segredos no payload de um JWT apenas assinado. Nunca registes o token completo para facilitar troubleshooting.
Autoriza o objeto e a ação em cada pedido
Depois da validação, authorize() compara o tenant confiável do token com o tenant do objeto mantido no servidor. Exige ainda que sub pertença aos leitores desse objeto, que exista scope batch:read e que a ação seja read. A ausência de uma condição resulta em recusa. As verificações mostram que um token válido não pode ler um objeto de outro tenant, nem escrever com permissão de leitura. Não uses o tenant enviado num parâmetro pelo cliente como prova de propriedade do objeto. O exemplo usa um registo em memória; o exercício seguinte numa aplicação real deve incluir endpoints alternativos, consultas à base de dados, alterações de permissões e pedidos concorrentes.
Planeia a transição das chaves de assinatura
O exercício começa com duas chaves públicas confiáveis. Durante a sobreposição, tokens assinados com old e new são aceites. Quando old sai do conjunto confiável, o token antigo é rejeitado apesar de ainda não ter expirado. Numa transição normal, coordena emissão, publicação, caches e validade dos tokens para evitar interrupções desnecessárias. Num compromisso confirmado, esperar pela expiração pode conservar acesso indevido; a decisão de contenção tem de considerar o risco e o impacto. Uma chave retirada deste Map não atualiza caches remotas nem revoga sessões em todos os serviços. Entrega à operação os responsáveis, critérios de escalada e evidência de rejeição nos consumidores relevantes.
Executa, observa e delimita a conclusão
Guarda o código como run.mjs e executa node run.mjs em Node.js 25.8.0. Não são necessárias contas cloud, pacotes adicionais ou credenciais. O relatório JSON contém 36 resultados esperados e observados; uma diferença termina a execução com erro. O grupo de tokens tem 24 verificações. O script não é uma biblioteca JWT de produção: não implementa rejeição de membros JSON duplicados, discovery, revogação, introspeção ou prevenção de replay. Um bearer token roubado pode continuar utilizável durante a validade sem controlos adicionais. Resume a aceitação em três partes: integridade e perfil do token, autorização de objeto e ação, e comportamento operacional durante falhas. Relaciona as conclusões com IAM, APIs seguras e resposta a incidentes.
// Original dr.pt teaching exercise. Synthetic data; no network or credentials.
// This deliberately limited JWT verifier is not a production OAuth/JWT library.
import assert from 'node:assert/strict';
import fs from 'node:fs';
import {createCipheriv,createDecipheriv,randomBytes,generateKeyPairSync,sign,verify,createHash,constants} from 'node:crypto';
const checks=[];
function check(name,inputs,actual,expected){assert.deepEqual(actual,expected,name);checks.push({name,inputs,actual,expected});}
function outcome(fn){try{fn();return 'accepted';}catch{return 'rejected';}}
function seal(key,plaintext,aad){
const iv=randomBytes(12),c=createCipheriv('aes-256-gcm',key,iv,{authTagLength:16});
c.setAAD(Buffer.from(aad));const ciphertext=Buffer.concat([c.update(plaintext),c.final()]);
return {iv,ciphertext,tag:c.getAuthTag()};
}
function open(key,envelope,aad){
const d=createDecipheriv('aes-256-gcm',key,envelope.iv,{authTagLength:16});
d.setAAD(Buffer.from(aad));d.setAuthTag(envelope.tag);
// Never return unauthenticated output from update() before final() succeeds.
return Buffer.concat([d.update(envelope.ciphertext),d.final()]);
}
const payload=Buffer.from('SYNTHETIC fund A settlement batch 17'),context='tenant=A;object=batch-17;version=1';
const dek=randomBytes(32),oldKek=randomBytes(32),newKek=randomBytes(32),registry=new Map([['old',oldKek],['new',newKek]]);
const data=seal(dek,payload,context),wrapContext='purpose=data-key;tenant=A';
const oldWrap={keyId:'old',...seal(oldKek,dek,wrapContext)};
function unwrap(w){const key=registry.get(w.keyId);if(!key)throw Error('key unavailable');return open(key,w,wrapContext);}
function restore(w){return open(unwrap(w),data,context).equals(payload);}
check('baseline envelope restoration',{wrapper:'old',context},restore(oldWrap),true);
const changed=Buffer.from(data.ciphertext);changed[0]^=1;
check('changed ciphertext rejected',{mutation:'first ciphertext byte'},outcome(()=>open(dek,{...data,ciphertext:changed},context)),'rejected');
const changedTag=Buffer.from(data.tag);changedTag[0]^=1;
check('changed tag rejected',{mutation:'first tag byte'},outcome(()=>open(dek,{...data,tag:changedTag},context)),'rejected');
check('wrong context rejected',{context:'tenant=B;object=batch-17;version=1'},outcome(()=>open(dek,data,'tenant=B;object=batch-17;version=1')),'rejected');
check('wrong wrapping key rejected',{wrapper:'old',key:'new'},outcome(()=>open(newKek,oldWrap,wrapContext)),'rejected');
const before=createHash('sha256').update(data.ciphertext).digest('hex');
const newWrap={keyId:'new',...seal(newKek,unwrap(oldWrap),wrapContext)};
check('rewrapped data key restores payload',{wrapper:'new'},restore(newWrap),true);
check('rewrap leaves data ciphertext unchanged',{operation:'rewrap same DEK'},createHash('sha256').update(data.ciphertext).digest('hex')===before,true);
check('old backup still uses old wrapper',{wrapper:'old'},restore(oldWrap),true);
registry.delete('old'); // Logical availability fault, NOT erasure from memory.
check('old backup fails when old key unavailable',{operation:'delete registry entry only'},outcome(()=>restore(oldWrap)),'rejected');
check('new wrapper survives old key unavailability',{wrapper:'new'},restore(newWrap),true);
registry.set('old',oldKek);
check('restoring registry entry restores old backup',{operation:'restore registry entry'},restore(oldWrap),true);
check('retained data key still decrypts after rewrap',{operation:'use original DEK directly'},open(dek,data,context).equals(payload),true);
const now=1800000000,issuer='https://issuer.example.invalid/',audience='funds-api';
const pairs={old:generateKeyPairSync('rsa',{modulusLength:2048}),new:generateKeyPairSync('rsa',{modulusLength:2048})};
const trusted=new Map(Object.entries(pairs).map(([id,p])=>[id,{key:p.publicKey,alg:'RS256'}]));
const claims={iss:issuer,aud:audience,sub:'operator-7',client_id:'batch-client',iat:now-30,exp:now+300,jti:'synthetic-17',tenant:'A',scope:'batch:read'};
const encode=x=>Buffer.from(JSON.stringify(x)).toString('base64url');
function issue(body=claims,kid='old',header={}){
const prefix=encode({typ:'at+jwt',alg:'RS256',kid,...header})+'.'+encode(body);
return prefix+'.'+sign('RSA-SHA256',Buffer.from(prefix),{key:pairs[kid].privateKey,padding:constants.RSA_PKCS1_PADDING}).toString('base64url');
}
function validate(token){
if(typeof token!=='string'||token.length>8192)throw Error('size');
const parts=token.split('.');if(parts.length!==3||parts.some(p=>!p||!/^[A-Za-z0-9_-]+$/.test(p)))throw Error('format');
const decode=p=>JSON.parse(Buffer.from(p,'base64url').toString('utf8'));
const h=decode(parts[0]),c=decode(parts[1]);
if(!h||Array.isArray(h)||!c||Array.isArray(c))throw Error('object');
// Local policy supports only the signed access-token subset demonstrated here.
if(h.alg!=='RS256'||!['at+jwt','application/at+jwt'].includes(h.typ))throw Error('profile');
if(['jku','x5u','jwk','crit','b64'].some(k=>Object.hasOwn(h,k)))throw Error('unsupported header');
const trust=trusted.get(h.kid);if(!trust||trust.alg!==h.alg)throw Error('untrusted key');
if(!verify('RSA-SHA256',Buffer.from(parts[0]+'.'+parts[1]),{key:trust.key,padding:constants.RSA_PKCS1_PADDING},Buffer.from(parts[2],'base64url')))throw Error('signature');
for(const k of ['iss','sub','client_id','jti'])if(typeof c[k]!=='string'||!c[k])throw Error('required claim');
if(c.iss!==issuer)throw Error('issuer');
const audiences=typeof c.aud==='string'?[c.aud]:c.aud;
if(!Array.isArray(audiences)||audiences.some(a=>typeof a!=='string')||!audiences.includes(audience))throw Error('audience');
if(!Number.isFinite(c.iat)||!Number.isFinite(c.exp)||c.exp<=now||c.iat>now||c.exp<=c.iat)throw Error('time');
if(c.nbf!==undefined&&(!Number.isFinite(c.nbf)||c.nbf>now))throw Error('not yet valid');
return c;
}
const record={id:'batch-17',tenant:'A',readers:['operator-7']};
function authorize(c,object,action){
return action==='read'&&c.tenant===object.tenant&&object.readers.includes(c.sub)&&typeof c.scope==='string'&&c.scope.split(' ').includes('batch:read');
}
const valid=issue();
check('valid access token accepted',{kid:'old',audience},outcome(()=>validate(valid)),'accepted');
for(const [name,body] of [
['wrong issuer rejected',{...claims,iss:'https://other.example.invalid/'}],
['wrong audience rejected',{...claims,aud:'other-api'}],
['expired token rejected',{...claims,exp:now}],
['future activation rejected',{...claims,nbf:now+60}],
['future issue time rejected',{...claims,iat:now+1}],
['missing subject rejected',{...claims,sub:undefined}],
['missing client rejected',{...claims,client_id:undefined}],
['non-numeric expiry rejected',{...claims,exp:String(now+300)}]
])check(name,{variation:name},outcome(()=>validate(issue(body))),'rejected');
const pieces=valid.split('.');pieces[1]=encode({...claims,tenant:'B'});
check('changed claims rejected',{mutation:'tenant without resigning'},outcome(()=>validate(pieces.join('.'))),'rejected');
for(const [name,header] of [
['unsigned algorithm rejected',{alg:'none'}],
['ID token type rejected',{typ:'JWT'}],
['unknown key identifier rejected',{kid:'attacker'}],
['token key URL rejected',{jku:'https://keys.example.invalid/jwks'}]
])check(name,{header},outcome(()=>validate(issue(claims,'old',header))),'rejected');
check('audience array accepted',{aud:['other-api',audience]},outcome(()=>validate(issue({...claims,aud:['other-api',audience]}))),'accepted');
check('authorized resource read',{record:'batch-17',action:'read'},authorize(validate(valid),record,'read'),true);
check('valid token cannot cross tenant',{recordTenant:'B'},authorize(validate(valid),{...record,tenant:'B'},'read'),false);
check('valid token requires object membership',{readers:['operator-8']},authorize(validate(valid),{...record,readers:['operator-8']},'read'),false);
check('read scope does not grant write',{action:'write'},authorize(validate(valid),record,'write'),false);
check('missing scope denies access',{scope:''},authorize(validate(issue({...claims,scope:''})),record,'read'),false);
const newToken=issue(claims,'new');
check('new signing key accepted during overlap',{kid:'new'},outcome(()=>validate(newToken)),'accepted');
check('old signing key accepted during overlap',{kid:'old'},outcome(()=>validate(valid)),'accepted');
trusted.delete('old');
check('retired trusted key rejects unexpired token',{kid:'old',expiresIn:300},outcome(()=>validate(valid)),'rejected');
check('new signing key survives retirement',{kid:'new'},outcome(()=>validate(newToken)),'accepted');
console.log(JSON.stringify({checkedAt:new Date().toISOString(),runtime:process.version,openssl:process.versions.openssl,scriptSha256:createHash('sha256').update(fs.readFileSync(new URL(import.meta.url))).digest('hex'),groups:['AES-256-GCM envelope operations','RS256 access-token validation and local authorization'],network:false,checks,limitations:[
'Actual local cryptographic operations, not AWS KMS, HSM or provider IAM execution; no FIPS-validation claim.',
'Registry deletion is logical key unavailability, not memory erasure or cryptographic sanitization.',
'Teaching JWT subset with fixed clock and preconfigured keys; not a production parser or OAuth/OIDC implementation. Duplicate JSON member rejection, discovery, JWKS cache, introspection, revocation, replay prevention, DPoP and mTLS are not implemented.',
'No provider outage, production application, concurrent request or tenant database test; all records and claims are synthetic.'
]},null,2));
Dados sintéticos de um lote de liquidação; nenhuma informação interna do BNP Paribas.
Armadilhas comuns
Aceitar um indicador técnico isolado como prova do controlo completo; omitir testes negativos e dependências.
Tópicos relacionados: Recuperação e incidentes cloud · Proteção de dados e IAM
Valida access tokens e testa permissões de objeto, ação e transição de chaves.
Referência: JWT Profile for OAuth 2.0 Access Tokens · CCSP examination outline effective 2026-08-01; January2026 V2 PDF