npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@tzam-org/oidc

v0.5.0

Published

OIDC agnóstico, sem dependências: provedor (authorization code + PKCE S256 obrigatório, código de uso único, id_token RS256, discovery, JWKS, userinfo, logout back-channel) e cliente (PKCE, validação do id_token e do logout token). Assinatura com node:cry

Downloads

848

Readme

@tzam-org/oidc

OpenID Connect dos dois lados, sem dependências: provedor (authorization code com PKCE S256 obrigatório, id_token RS256, discovery, JWKS, logout back-channel) e cliente (PKCE e validação de id_token e de logout token, e o "Sair" servidor a servidor). Assina com node:crypto.

npm

Instalação

pnpm add @tzam-org/oidc

Sem dependências de runtime.

Garantias

Provedor

  • Só authorization code (response_type=code, grant_type=authorization_code). Fluxo implícito, password grant, PKCE plain e alg: none não existem.
  • PKCE S256 obrigatório: sem code_challenge_method=S256 e um challenge válido, a autorização falha; no token endpoint o code_verifier precisa ter o formato da RFC 7636 e bater com o challenge.
  • Redirect URI comparada como string exata, sem normalização. Cliente ou redirect URI errados nunca redirecionam o erro (a URI não foi verificada).
  • Código de uso único: 32 bytes aleatórios, vale 60 s, só o SHA-256 dele vai para o store, e é consumido mesmo quando a troca falha. Fica preso a cliente, redirect URI e challenge.
  • Tokens RS256: id_token com iss, aud, exp, nonce (e sid, quando informado) e 1 h de validade; access token at+jwt de 15 min, com o sid da sessão do login quando ele foi informado; logout token logout+jwt de 2 min com jti.
  • Uma chave assina, várias podem ser publicadas. O JWKS leva a chave de assinatura e as de additionalKeys, que só verificam. Assim a troca da chave não derruba os tokens em circulação (veja "Troca da chave de assinatura"). Só os membros públicos (kty, n, e) saem no JWKS, mesmo quando o app entrega uma chave privada; kid repetido, chave que não é RSA ou com menos de 2048 bits é erro na criação do provedor.
  • Parâmetros de extensão não sobrescrevem os do protocolo em denyAuthorization (error, state, code, id_token…).

Cliente

  • verifyIdToken confere assinatura (só RS256, chave pelo kid), exp, iss, aud, o nonce enviado e a presença de sub.
  • verifyLogoutToken segue o Back-Channel Logout 1.0: typ logout+jwt, iss, aud, iat recente (até 5 min, 60 s de tolerância), o evento de logout, sub ou sid, jti e nenhum nonce. Rejeitar jti repetido é responsabilidade do app.

Uso

Provedor

O app implementa AuthorizationCodeStore (por exemplo Redis SET com TTL e GETDEL) e diz como achar um cliente e verificar o secret dele (por exemplo com @tzam-org/applications).

import {
  createOidcProvider,
  discoveryDocument,
  OidcError,
  parseClientCredentials,
  type AuthorizationCodeStore,
  type CodeRecord,
} from '@tzam-org/oidc';

// Em produção, `consume` precisa ler e apagar atomicamente (ex.: Redis GETDEL).
const saved = new Map<string, CodeRecord>();
const codes: AuthorizationCodeStore = {
  save: async (codeHash, record) => void saved.set(codeHash, record),
  async consume(codeHash) {
    const record = saved.get(codeHash) ?? null;
    saved.delete(codeHash);
    return record;
  },
};

const issuer = 'https://sso.exemplo.com';
const provider = createOidcProvider({
  issuer,
  privateKeyPem: process.env.OIDC_PRIVATE_KEY_PEM!, // chave RSA em PEM, nunca no código
  keyId: 'chave-1',
  codes,
  findClient: async (clientId) =>
    clientId === 'app-exemplo' ? { clientId, redirectUris: ['https://app.exemplo.com/callback'], active: true } : null,
  verifyClientSecret: async (clientId, secret) => clientId === 'app-exemplo' && secret === process.env.CLIENT_SECRET,
});

// GET /.well-known/openid-configuration e GET /oauth/jwks
const discovery = discoveryDocument(issuer);
const jwks = provider.jwks();

// GET /oauth/authorize
async function authorize(query: URLSearchParams, signedInUserId: string | null) {
  const v = await provider.validateAuthorization(query);
  if (!v.ok) return v.redirectable ? { redirectTo: v.redirectTo } : { status: 400, error: v.error };
  if (!signedInUserId) return provider.denyAuthorization(v.request, 'login_required');
  return provider.issueCode(v.request, signedInUserId); // { redirectTo } com ?code=…&state=…
}

// POST /oauth/token
async function token(authorization: string | null, body: URLSearchParams) {
  const client = parseClientCredentials(authorization, body);
  if (!client) return { status: 401, body: { error: 'invalid_client' } };
  try {
    const tokens = await provider.exchangeCode(Object.fromEntries(body), client, async (sub) => ({
      sub,
      email: '[email protected]',
      name: 'Ana Exemplo',
    }));
    return { status: 200, body: tokens };
  } catch (e) {
    if (e instanceof OidcError) return { status: e.status, body: { error: e.code } };
    throw e;
  }
}

// GET /oauth/userinfo
async function userinfo(bearer: string) {
  const claims = await provider.verifyAccessToken(bearer); // lança OidcError('invalid_token', …, 401)
  const user = await loadUser(claims.sub);
  return user && provider.userinfo(claims, user); // sub, email e name sempre; claims extras só pelos escopos do token
}

// Logout back-channel: um token por app (audience).
const logoutToken = provider.signLogoutToken({ audience: 'app-exemplo', sub: 'u1' });

discoveryDocument anuncia os endpoints /oauth/authorize, /oauth/token, /oauth/userinfo e /oauth/jwks sob o issuer; o app expõe essas rotas.

sid no access token

Quando o login tem uma sessão do provedor (issueCode(request, sub, { sid })), o access token leva a claim sid, a mesma do id_token, e a renovação por refresh token a mantém. Com ela, o serviço que valida o token (por exemplo com o @tzam-org/client, que devolve sid) sabe a qual sessão ele pertence e pode cruzar com o logout back-channel. Login sem sid não ganha a claim.

Troca da chave de assinatura

O provedor assina sempre com uma chave (privateKeyPem + keyId). As chaves de additionalKeys são públicas: aparecem no JWKS e valem quando o provedor confere os próprios tokens (userinfo, id_token_hint), mas nunca assinam. Uma troca sem derrubar ninguém tem três passos:

  1. Publicar a próxima. A chave nova entra em additionalKeys e a atual segue assinando. Espere o tempo de cache do seu JWKS (o Cache-Control da rota e os caches de quem valida).
  2. Promover. A nova vira privateKeyPem/keyId, e a pública da antiga passa para additionalKeys, com a hora em que deixou de assinar. Os tokens que ela assinou continuam valendo.
  3. Retirar. A antiga sai do JWKS quando passa a sobreposição. keysInOverlap faz essa conta:
import { createOidcProvider, keysInOverlap, ACCESS_TOKEN_TTL_SECONDS } from '@tzam-org/oidc';

const provider = createOidcProvider({
  // ...
  privateKeyPem: current.pem,
  keyId: current.kid,
  additionalKeys: [
    ...(next ? [{ keyId: next.kid, publicKey: next.publicPem }] : []),
    // [{ keyId, publicKey, retiredAt }] → só as que ainda estão na sobreposição
    ...keysInOverlap(retired, { now: new Date(), overlapSeconds: 3600 }),
  ],
});
  • A sobreposição nunca é menor que ACCESS_TOKEN_TTL_SECONDS (900 s, a vida do access token): abaixo disso keysInOverlap lança erro, porque um token ainda válido seria recusado. Um id_token vive 1 h; use 3600 s se algum cliente guarda o id_token para o id_token_hint.
  • A chave fica até retiredAt + overlapSeconds, exclusive. Um retiredAt ilegível é erro, e não "publica para sempre" nem "some em silêncio".
  • O conjunto é fixo por instância do provedor. Recrie o provedor quando o conjunto mudar (uma chave promovida, ou uma aposentada que saiu da sobreposição).
  • O kid é do app. Derive-o da chave pública (por exemplo, um hash do SPKI), para que mude só quando a chave muda e nunca se repita.
  • Chave comprometida não tem sobreposição: troque a chave e não publique a antiga. Os tokens assinados por ela caem na hora, que é o que se quer.

Escopos extras (opt-in)

sub, email e name saem sempre, como antes. Além de openid email profile, o provedor aceita escopos próprios do app em extraScopes: { escopo: ['claim', …] }. Cada claim listada sai só quando o cliente pediu o escopo, e vai apenas no id_token e no userinfo, nunca no access token. O valor vem de UserProfile.claims, preenchido pelo loadUser. Claims do perfil que nenhum escopo concedido declara não saem. profile e email podem liberar claims a mais (ex.: { profile: ['picture'] }), só com o próprio escopo. openid e offline_access não podem ser redefinidos, e claim reservada (sub, iss, aud, email, nonce, sid, scope…) é recusada na criação do provedor. Passe o mesmo mapa a discoveryDocument(issuer, { extraScopes }) para anunciar escopos e claims.

const provider = createOidcProvider({ ...options, extraScopes: { access_level: ['access_level'] } });
// loadUser → { sub, email, name, claims: { access_level: 40 } }

Refresh token (opcional)

Com refresh: { store, policy(clientId) }, o provedor oferece o grant refresh_token. Ele sai na troca do código só quando as quatro condições valem:

  • o cliente pediu offline_access;
  • a policy do app diz enabled;
  • o cliente é confidential;
  • o login tem sid.

Pedir o escopo não basta: o app precisa ter o refresh ligado, e vice-versa.

Regras fixas, que não podem ser desligadas:

  • Rotação: cada uso gasta o token e emite o próximo, na mesma família.
  • Reuso: um token já gasto, usado de novo, revoga a família inteira, inclusive o token mais novo e legítimo. Não há janela de tolerância.
  • Só o hash fica guardado (RefreshTokenStore.save(hash, record)).
  • Preso ao sid: o record.sid permite ao app revogar a família quando a sessão termina (logout, back-channel).
  • A família morre quando outro cliente apresenta o token, quando passa da validade total (contada desde o login, e que a rotação não estende) ou da inatividade, quando o app desliga o refresh ou é desativado, e quando loadUser(record) devolve null. É nesse ponto que o app reconfere se a pessoa está ativa, se a sessão está aberta e se ainda tem acesso.
  • Claims atuais: os tokens renovados trazem as claims de agora, com o mesmo scope do login. O id_token não traz nonce. Um scope mais estreito no pedido é ignorado.

discoveryDocument(issuer, { refresh: true }) anuncia o grant e offline_access.

Lado cliente: refreshTokens({ tokenEndpoint, clientId, clientSecret, refreshToken, fetch? }) devolve os tokens novos. Guarde o refresh_token novo e descarte o antigo. Os erros são OidcError tipados:

  • invalid_grant: entrar de novo;
  • invalid_client: as credenciais do app;
  • invalid_response: a resposta não foi uma resposta de token.

Cliente

import { createPkcePair, randomToken, verifyIdToken, verifyLogoutToken, type Jwks } from '@tzam-org/oidc';

// Antes de redirecionar: guarde verifier, state e nonce no servidor (ou em cookie HttpOnly).
const { verifier, challenge } = createPkcePair();
const state = randomToken();
const nonce = randomToken();
const url = new URL('https://sso.exemplo.com/oauth/authorize');
url.search = new URLSearchParams({
  response_type: 'code',
  client_id: 'app-exemplo',
  redirect_uri: 'https://app.exemplo.com/callback',
  scope: 'openid email profile',
  state,
  nonce,
  code_challenge: challenge,
  code_challenge_method: 'S256',
}).toString();

// No callback, depois de trocar o code (enviando `verifier` como code_verifier):
async function onTokens(idToken: string, jwks: Jwks) {
  const claims = await verifyIdToken(idToken, { issuer: 'https://sso.exemplo.com', audience: 'app-exemplo', nonce, jwks });
  return claims.sub;
}

// POST de logout back-channel recebido do provedor:
async function onLogout(logoutToken: string, jwks: Jwks) {
  const { jti, sub, sid } = await verifyLogoutToken(logoutToken, { issuer: 'https://sso.exemplo.com', audience: 'app-exemplo', jwks });
  return { jti, sub, sid }; // recuse jti já visto; encerre as sessões de sub/sid
}

Sair: a dinâmica completa (logout do app, login do app)

O "Sair" de um app não leva o navegador ao provedor. O servidor do app encerra a sessão do provedor servidor a servidor, apaga a sessão local e manda a pessoa para a tela de login do próprio app. Lá vale a regra de sempre: primeiro a tentativa transparente (prompt=none); como a sessão do provedor acabou, volta login_required e o app mostra o formulário dele (que posta direto no provedor). A interface do provedor só aparece quando nem o transparente nem o formulário resolvem (consentimento, escolha de conta, interação).

sequenceDiagram
    actor P as Pessoa
    participant A as App (servidor)
    participant T as Provedor (Tzam)
    participant B as Outros apps

    P->>A: POST /api/auth/logout (Origin = app, CSRF)
    A->>T: POST /oauth/session/end (Basic client_id:secret, sid ou id_token_hint)
    T->>T: autentica o cliente, valida o hint, confere sid x client_id
    T->>T: revoga os refresh tokens do sid (revokeSession)
    T->>T: encerra a sessão (hook end), audita
    T-->>A: 200 {ended: true}
    T--)B: back-channel logout (logout+jwt), só para os OUTROS apps da sessão
    A->>A: apaga a sessão local (sempre, mesmo se o provedor falhou)
    A-->>P: 303 para o login do app
    P->>A: GET /login (tentativa transparente)
    A-->>P: 302 para /oauth/authorize?prompt=none
    P->>T: authorize sem sessão
    T-->>P: volta com error=login_required
    P->>A: callback, loginEntry → app_form
    A-->>P: formulário do app (posta direto no provedor)

Provedor (ProviderOptions.endSession): o pacote autentica o cliente (como no token endpoint), valida o id_token_hint (assinatura com a chave do provedor, typ JWT, iss, sub, aud deste cliente, expirado vale) ou o sid, confere com clientHasSession que o cliente entrou naquela sessão, revoga os refresh tokens do sid (RefreshTokenStore.revokeSession, exigido quando há refresh) e só então chama end. O app implementa end: encerrar a sessão, avisar os outros apps por back-channel (o que pediu já sabe) e auditar.

const provider = createOidcProvider({
  /* ... */
  endSession: {
    clientHasSession: (clientId, sid) => links.exists({ clientId, sid }),
    end: ({ clientId, sid, sub }) => sessions.endFromApp({ clientId, sid, sub }),
  },
});

// POST /oauth/session/end
const client = parseClientCredentials(request.headers.get('authorization'), form);
await provider.endSession(Object.fromEntries(form), client!); // OidcError tipado no resto

Cliente: appLogout faz a ordem certa e endSession faz a chamada.

import { appLogout, endSession, loginEntry } from '@tzam-org/oidc';

const { redirectTo } = await appLogout({
  endProvider: sid
    ? () => endSession({ endpoint: `${internalUrl}/oauth/session/end`, clientId, clientSecret, sid })
    : null,
  endLocal: () => sessions.revoke(sessionId),
  loginPath: `${publicUrl}/api/auth/login`, // tentativa transparente → formulário do app
  formLoginPath: `${publicUrl}/api/auth/login?interactive=1`, // provedor fora: direto ao formulário
  onProviderFailure: (code) => log.warn(`provedor não encerrou a sessão: ${code}`),
});

// No callback do login, quando não houve code:
const entry = loginEntry({ error: params.get('error'), reason: params.get('reason') });
// app_form → o formulário do app; provider → a tela do provedor; denied / failed → explicar
  • A sessão local termina sempre. Se o provedor estiver fora — ou se o app não tem o sid para nomear a sessão dele (sessão antiga, índice perdido) — a sessão do provedor pode estar viva; por isso, havendo sessão local (hadLocalSession, padrão true) sem logout confirmado, o destino passa a ser formLoginPath: a tentativa transparente entraria de novo na hora.
  • endSession tem prazo curto (3 s por padrão) e erros tipados: o código do provedor (invalid_session, invalid_client…), network_error, invalid_response, invalid_request.
  • Um cliente nunca encerra o sid de outro: invalid_session (400).

Sair pela página do provedor (apps de terceiros)

Caminho secundário, para quem prefere o RP-Initiated Logout 1.0 pelo navegador: buildEndSessionUrl({ endSessionEndpoint, idTokenHint, postLogoutRedirectUri, state, clientId }) monta a URL; no provedor, validateEndSessionRequest(params) nunca lança e devolve hinted, clientId, sub, sid, redirectTo e redirectRefused. Hint inválido conta como ausente (o provedor pede confirmação à pessoa: evita logout forçado por CSRF). A post_logout_redirect_uri só é aceita se estiver em OidcClient.postLogoutRedirectUris de um cliente ativo, comparada como string exata (nunca open redirect), e o state vai junto.

API

| Nome | Tipo | O que faz | |---|---|---| | createOidcProvider(options) | função | Provedor: jwks, validateAuthorization, issueCode, denyAuthorization, exchangeCode, refreshTokens, endSession, validateEndSessionRequest, userinfo, signLogoutToken, verifyAccessToken. | | OidcProvider | tipo | Retorno de createOidcProvider. | | ProviderOptions | interface | issuer, privateKeyPem, keyId, codes, findClient, verifyClientSecret, clock?, extraScopes?, refresh?, endSession?, additionalKeys?. | | PublishedKey / RetiredKey | interfaces | Chave pública para o JWKS (keyId, publicKey em PEM ou JWK) e a aposentada (mais retiredAt). | | keysInOverlap(retired, { now, overlapSeconds }) | função | As chaves aposentadas que ainda devem ser publicadas. | | ACCESS_TOKEN_TTL_SECONDS | constante | Vida do access token (900 s) e piso da sobreposição de chaves. | | EndSessionHooks | interface | O que o app implementa para o encerramento servidor a servidor: clientHasSession(clientId, sid) e end({ clientId, sid, sub? }). | | EndSessionRequest | interface | O que validateEndSessionRequest devolve (página de logout do provedor). | | endSession(input) | função | Cliente: encerra a sessão do provedor servidor a servidor (sid ou idTokenHint), com a credencial do cliente. | | appLogout(input) | função | Cliente: o "Sair" na ordem certa (provedor, sessão local sempre) e o destino (login do app). | | loginEntry({ error, reason? }) | função | Cliente: depois da tentativa transparente, onde a pessoa entra (code, app_form, provider, denied, failed). | | buildEndSessionUrl(input) | função | Cliente: URL do end_session_endpoint (caminho do navegador). | | AuthorizationCodeStore | interface | O que o app implementa: save(codeHash, record, ttlSeconds) e consume(codeHash) atômico. | | CodeRecord | interface | O que fica guardado para um código (cliente, redirect URI, scope, nonce, challenge, sub, sid?, expiresAt). | | OidcClient | interface | clientId, redirectUris, active, postLogoutRedirectUris?: o que findClient devolve. | | AuthorizationRequest | interface | Pedido de autorização validado. | | AuthorizationResult | tipo | { ok: true, request } ou erro, com redirectTo quando pode voltar ao cliente. | | UserProfile | interface | sub, email, name, claims?: o que loadUser devolve em exchangeCode. | | ExtraScopes | tipo | Escopo extra → claims que ele libera. | | parseClientCredentials(authorization, form) | função | Credenciais do cliente por HTTP Basic ou no corpo; null se malformadas. | | discoveryDocument(issuer, { extraScopes?, refresh? }) | função | Documento de discovery. | | RefreshTokenStore / RefreshRecord / RefreshPolicy | interfaces | Store de refresh do app, registro guardado por hash, política por cliente. | | TokenResponse | interface | Resposta do token endpoint (refresh_token quando emitido). | | refreshTokens(input) | função | Cliente: troca o refresh token por tokens novos. | | createPkcePair() | função | { verifier, challenge } S256. | | randomToken() | função | 32 bytes aleatórios em base64url (state, nonce). | | verifyIdToken(token, options) | função | Valida um id_token como cliente. | | VerifyIdTokenOptions | interface | issuer, audience, nonce, jwks, now?. | | verifyLogoutToken(token, options) | função | Valida um logout token back-channel. | | VerifyLogoutTokenOptions | interface | issuer, audience, jwks, now?. | | Jwks | interface | Conjunto de chaves públicas RS256. | | OidcError | classe | Erro com code OAuth e status HTTP. |

Erros

OidcError traz code (o error OAuth para devolver ao cliente) e status (HTTP):

| code | status | Quando | |---|---|---| | unsupported_grant_type | 400 | exchangeCode com grant_type diferente de authorization_code. | | invalid_client | 401 | exchangeCode com cliente ou secret inválido. | | invalid_grant | 400 | Código desconhecido, expirado, já usado, de outro cliente, redirect URI diferente, PKCE inválido ou usuário que não existe mais. | | invalid_token | 401 | verifyIdToken e verifyAccessToken: token malformado, alg diferente de RS256, chave desconhecida, assinatura, expiração, iss, aud, nonce ou sub. | | invalid_token | 400 | verifyLogoutToken: qualquer regra do logout token. endSession: id_token_hint inválido ou de outro cliente. | | invalid_request | 400 | endSession sem sid nem hint, hint sem sid, sid malformado ou divergente do hint. | | invalid_session | 400 | endSession: o cliente não entrou naquela sessão. | | unsupported | 404 | endSession num provedor sem endSession configurado. | | network_error / invalid_response | 503 / do HTTP | Cliente (endSession, refreshTokens): provedor inalcançável ou lento; resposta que não é a esperada. |

validateAuthorization não lança: devolve error igual a invalid_client ou invalid_redirect_uri (não redirecionáveis) ou unsupported_response_type, invalid_scope ou invalid_request (com redirectTo). denyAuthorization aceita access_denied, login_required e login_failed.


Faz parte dos módulos @tzam-org/* (github.com/Tzam-St/packages-modules): sem framework e sem texto de produto.