Tokens
O que seu projeto emite, o que tem dentro de cada token, e como um resource server valida sem chamar a gente.
Um fluxo completo produz até três tokens. Eles não são intercambiáveis, e usar o errado é o bug de integração mais comum.
access_tokenpara chamar APIsOptionalEnviado como Authorization: Bearer …. Diz o que o portador pode fazer.
id_tokenpara saber quem entrouOptionalEmitido só com o escopo openid. Diz quem é o usuário. Nunca mande para uma
API como credencial — é uma afirmação sobre identidade, não uma concessão de
acesso.
refresh_tokenpara obter novo access tokenOptionalVida longa, sem interação do usuário. Guarde como guardaria uma senha.
O emissor
Tudo se ancora em um valor: o emissor, o endereço público do servidor de autorização do seu projeto.
https://seu-projeto.nulvless.co/authEle aparece como iss em todo token, e todos os endpoints do documento de
discovery são construídos a partir dele.
Ele precisa casar com onde o discovery é servido
Isso não é regra nossa, é da especificação. O OpenID Connect Discovery §4.3 exige
que o issuer do documento seja idêntico à URL que o cliente usou como
prefixo para buscá-lo:
cliente recebe https://seu-projeto.nulvless.co/auth
cliente busca https://seu-projeto.nulvless.co/auth/.well-known/openid-configuration
documento responde { "issuer": "https://seu-projeto.nulvless.co/auth", … }
└── as duas precisam ser a mesma stringSe divergirem, um cliente conforme rejeita o provedor — e é por isso que o emissor não é uma configuração. Existe exatamente um valor que funciona: a URL pública onde o seu projeto responde, que é a que o Console mostra. Ele é composto, não escolhido, então não há como digitá-lo errado.
Um domínio personalizado não o muda: o emissor continua na URL do projeto.
`.well-known` é só um caminho reservado
A RFC 8615 reserva /.well-known/ para
metadados que máquinas leem. Não há nada de especial nele além da convenção — é
por isso que entregar uma única URL ao cliente, o emissor, basta para ele achar
todo o resto.
Ele não é onde a sua tela de consentimento mora
Três URLs diferentes aparecem em um mesmo fluxo, e confundi-las é a fonte usual de dúvida.
| Exemplo | De quem é | Para que serve | |
|---|---|---|---|
| Emissor | https://acme.nulvless.co/auth | nulvless | Onde o protocolo vive: discovery, authorize, token, JWKS |
| Site URL + caminho de autorização | https://acme.com/oauth/consent | você | Onde a pessoa decide. A página que você constrói |
| Redirect URI | https://analytics.io/callback | o app de terceiro | Para onde o código vai no fim |
Sua tela de consentimento morar no seu próprio domínio não muda o emissor. São camadas diferentes, e o protocolo só conhece a primeira.
Na verdade a tela de consentimento é invisível para o OAuth. Não é um endpoint, não tem nome na especificação e nunca aparece no documento de discovery. Do ponto de vista do app de terceiro, ele mandou um usuário para o seu endpoint de autorização e o usuário voltou ao callback dele com um código. O que aconteceu no meio é assunto seu.
Seguindo uma requisição:
1. analytics.io → acme.nulvless.co/auth/oauth/authorize?… emissor
2. acme.nulvless.co → acme.com/oauth/consent?authorization_id=… seu Site URL
3. acme.com → acme.nulvless.co/auth/oauth/authorizations/… emissor de novo
4. acme.com → analytics.io/callback?code=… redirect URI
5. analytics.io → acme.nulvless.co/auth/oauth/token emissor de novoMais duas consequências
- Não é a URL pela qual a requisição chegou. É configuração, então o mesmo
projeto alcançado por outro caminho emite o mesmo
iss. - Cada ambiente tem o seu. Um token de staging carrega emissor de staging e não valida em produção, que é exatamente o que se quer.
Discovery
Todo cliente OpenID Connect começa aqui, e a maioria das bibliotecas faz isso por você se receber o emissor:
GET {issuer}/.well-known/openid-configuration{
"issuer": "https://seu-projeto.nulvless.co/auth",
"authorization_endpoint": "…/oauth/authorize",
"token_endpoint": "…/oauth/token",
"userinfo_endpoint": "…/oauth/userinfo",
"jwks_uri": "…/.well-known/jwks.json",
"response_types_supported": ["code"],
"grant_types_supported": ["authorization_code", "refresh_token"],
"code_challenge_methods_supported": ["S256"],
"id_token_signing_alg_values_supported": ["RS256"]
}Leia os endpoints daqui em vez de fixá-los no código. É a razão inteira de o documento existir.
O documento e o JWKS são públicos e cacheáveis — carregam
Cache-Control: public, max-age=… de propósito. Toda outra resposta do servidor
de autorização carrega no-store.
Validando um ID token
ID tokens são assinados com RS256, então sua API valida localmente, contra
as chaves públicas em jwks_uri. Sem chamada de rede à nulvless no caminho quente.
O access token é outra coisa: ele é assinado com o secret do seu projeto, não com
a chave publicada em jwks_uri. Não tente validá-lo contra o JWKS — use o ID
token quando precisar estabelecer quem é o usuário, e o
userinfo quando precisar das claims depois.
import { createRemoteJWKSet, jwtVerify } from 'jose';
const issuer = 'https://seu-projeto.nulvless.co/auth';
const jwks = createRemoteJWKSet(new URL(`${issuer}/.well-known/jwks.json`));
export async function verifyIdToken(idToken: string) {
const { payload } = await jwtVerify(idToken, jwks, {
issuer,
audience: 'sua-api',
});
return payload;
}Quatro coisas precisam ser checadas, e uma biblioteca que pule qualquer uma não está validando:
Assinaturacontra o jwks_uriOptionalProva que o seu projeto emitiu.
issigualdade exataOptionalProva qual projeto e ambiente. Pular isso deixa passar token de outro tenant.
audigualdade exataOptionalProva que o token era para a sua API e não repassado de outra.
expnão vencidoOptionalProva que ainda vale.
Rotação de chave
O jwtVerify escolhe a chave pelo kid do cabeçalho, então a rotação é
transparente — desde que seu cache do JWKS não seja mais velho que a chave nova.
O jose busca de novo diante de um kid desconhecido. Se você mesmo cacheia o
JWKS, faça o mesmo, e nunca cacheie para sempre.
Lendo o ID token
O ID token carrega as claims dos escopos concedidos.
| Claim | Concedida por |
|---|---|
sub | sempre — o id estável do usuário |
iss, aud, exp, iat | sempre |
nonce | ecoada da requisição de autorização |
email, email_verified | email |
name, picture | profile |
phone_number, phone_number_verified | phone |
Valide do mesmo jeito, e confira também que o nonce bate com o que você mandou.
sub é o que você guarda como chave estrangeira da conta — não o e-mail, que
muda.
Userinfo
As mesmas claims, buscadas com o access token, para quando você as quer frescas em vez de como estavam no login:
curl {issuer}/oauth/userinfo -H "Authorization: Bearer $ACCESS_TOKEN"Use o ID token quando precisar da identidade no login. Use userinfo quando precisar depois e o perfil possa ter mudado.
Expiração e renovação
Access tokens têm vida curta; expires_in volta junto com o token. Renove antes
de vencer em vez de esperar um 401:
const { data } = await nulvless.auth.oauth.refreshToken({ refreshToken, clientId });Se a renovação falhar, a concessão acabou — o usuário revogou, ou um administrador revogou. Isso é um pedido de login novo, não uma tentativa de repetir.