@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.
Instalação
pnpm add @tzam-org/oidcSem dependências de runtime.
Garantias
Provedor
- Só authorization code (
response_type=code,grant_type=authorization_code). Fluxo implícito, password grant, PKCEplainealg: nonenão existem. - PKCE S256 obrigatório: sem
code_challenge_method=S256e um challenge válido, a autorização falha; no token endpoint ocode_verifierprecisa 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_tokencomiss,aud,exp,nonce(esid, quando informado) e 1 h de validade; access tokenat+jwtde 15 min, com osidda sessão do login quando ele foi informado; logout tokenlogout+jwtde 2 min comjti. - 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;kidrepetido, 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
verifyIdTokenconfere assinatura (só RS256, chave pelokid),exp,iss,aud, ononceenviado e a presença desub.verifyLogoutTokensegue o Back-Channel Logout 1.0:typlogout+jwt,iss,aud,iatrecente (até 5 min, 60 s de tolerância), o evento de logout,subousid,jtie nenhumnonce. Rejeitarjtirepetido é 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:
- Publicar a próxima. A chave nova entra em
additionalKeyse a atual segue assinando. Espere o tempo de cache do seu JWKS (oCache-Controlda rota e os caches de quem valida). - Promover. A nova vira
privateKeyPem/keyId, e a pública da antiga passa paraadditionalKeys, com a hora em que deixou de assinar. Os tokens que ela assinou continuam valendo. - Retirar. A antiga sai do JWKS quando passa a sobreposição.
keysInOverlapfaz 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 dissokeysInOverlaplança erro, porque um token ainda válido seria recusado. Umid_tokenvive 1 h; use 3600 s se algum cliente guarda oid_tokenpara oid_token_hint. - A chave fica até
retiredAt + overlapSeconds, exclusive. UmretiredAtilegí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
policydo app dizenabled; - 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: orecord.sidpermite 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)devolvenull. É 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
scopedo login. Oid_tokennão traznonce. Umscopemais 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 restoCliente: 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
sidpara 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ãotrue) sem logout confirmado, o destino passa a serformLoginPath: a tentativa transparente entraria de novo na hora. endSessiontem 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
sidde 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.
