Login com Axuz
Implemente "Entrar com Axuz" no seu app com OAuth 2.1 e OpenID Connect, PKCE S256, refresh com rotação e validação do id_token, com exemplo Node completo.
“Login com Axuz” permite que o seu app identifique pessoas pela conta Axuz sem tocar em senha, MFA ou passkey: a Axuz autentica, a pessoa consente e o seu app recebe um id_token assinado e, se pedir, um access_token para chamar APIs. O protocolo é Authorization Code com PKCE S256 (OAuth 2.1) mais OpenID Connect.
Decisões que valem para qualquer app
- Só existem os grants
authorization_codeerefresh_token. Não há implicit, senha nemclient_credentials. - PKCE é obrigatório e só aceita
S256, inclusive para client confidential.plainé recusado. stateé obrigatório (de 8 a 512 caracteres).- A
redirect_uriprecisa ser idêntica a uma das registradas: sem curinga, sem prefixo, sem normalização. - O
subé o ID interno imutável do usuário (um UUID). Nunca é e-mail nem telefone: a chave de conta no seu app é o pariss+sub. - Só uma sessão plena Axuz autoriza (senha, e-mail e TOTP ou passkey). Quem tem sessão restrita é mandado cadastrar os fatores.
- O
codevale 60 segundos e é de uso único; reusá-lo revoga os tokens que ele já gerou.
1. Registrar o client
Cada app tem um client_id. O tipo public (SPA, app nativo) não tem segredo e se prova só por PKCE; o confidential (servidor) recebe também um client_secret.
/oauth/clients
O cadastro exige sessão plena e uma Origin confiável (proteção CSRF), por isso é feito pela sessão do navegador da conta, não por token pessoal. Ainda não há tela dedicada para a gestão de clients.
curl
curl -sS -X POST https://auth-api.axuz.net/oauth/clients \
--cookie "access_token=$AXUZ_SESSION_JWT" \
-H "Origin: https://auth.axuz.net" \
-H "Content-Type: application/json" \
-d '{
"name": "Meu App",
"client_type": "confidential",
"redirect_uris": ["http://localhost:3000/callback", "https://app.example.test/callback"],
"scopes": ["openid", "profile", "email", "offline_access"]
}'
A resposta 201 traz client_id, client_type, redirect_uris, scopes, verified e, só para confidential, o client_secret, mostrado uma única vez (no banco fica só o hash). Regras do cadastro:
| Campo | Regra |
|---|---|
name | de 1 a 80 caracteres |
redirect_uris | de 1 a 10; https, ou http só em loopback (localhost, 127.0.0.1, [::1]), ou esquema próprio em client public/nativo; nunca fragmento, curinga, javascript: ou data: |
scopes | do catálogo abaixo; o app só poderá pedir um subconjunto deles |
team_id | opcional; exige ser owner ou admin da equipe |
Cada pessoa pode ter até 20 clients ativos (409 ao passar). Escopos sensíveis (forms:write, tasks:write, files:write, webhooks:manage, mail:send, me:write, me:profile) exigem client verified, aprovado por um operador da Axuz; no auto-atendimento eles são recusados. Também existem GET, PATCH e DELETE /oauth/clients/{client_id} e POST /oauth/clients/{client_id}/rotate-secret (o segredo antigo morre na hora). Excluir o client revoga todos os grants dele.
Escopos
| Escopo | O que entrega |
|---|---|
openid | liga o modo OIDC: id_token e acesso ao /oauth/userinfo (sub) |
profile | name |
email | email e email_verified |
offline_access | emite refresh token |
forms:*, tasks:*, files:*, webhooks:manage, mail:send, me:* | acesso de API, espelhando o catálogo público GET /v1/scopes |
2. O fluxo, passo a passo
- Gere o segredo da transação. Crie
code_verifier(32 bytes aleatórios em base64url = 43 caracteres),code_challenge = BASE64URL(SHA256(code_verifier)), umstatee umnonce, todos por CSPRNG. Guardecode_verifier,nonceestateligados ao navegador (cookie HttpOnly mais armazenamento no servidor). - Redirecione a pessoa para
GET /oauth/authorizedo provedor. Se ela não tem sessão, o provedor a leva ao login; se não consentiu ainda, à tela de consentimento; senão, volta direto. - Receba o callback na sua
redirect_uricomcode,stateeiss. Confira que ostatebate com o do navegador e queissé o issuer esperado (RFC 9207). Em caso de recusa vemerrorem vez decode. - Troque o
codeemPOST /oauth/tokencom ocode_verifier. - Valide o
id_tokene só então abra a sessão do seu app. - Opcional: chame
/oauth/userinfocom oaccess_token.
Passo 2: pedido de autorização
GET https://auth-api.axuz.net/oauth/authorize
?response_type=code
&client_id=CLIENT_ID
&redirect_uri=http%3A%2F%2Flocalhost%3A3000%2Fcallback
&scope=openid%20profile%20email
&state=ESTADO_ALEATORIO
&nonce=NONCE_ALEATORIO
&code_challenge=CHALLENGE_43_CHARS
&code_challenge_method=S256
| Parâmetro | Obrigatório | Regra |
|---|---|---|
response_type | sim | só code |
client_id, redirect_uri | sim | erro aqui não redireciona: responde 400 JSON (evita open redirect) |
scope | sim | separado por espaço; inclua openid para OIDC; cada escopo precisa estar no client |
state | sim | de 8 a 512 caracteres |
code_challenge | sim | 43 caracteres (SHA-256 em base64url) |
code_challenge_method | sim | só S256 |
nonce | recomendado | até 512 caracteres; volta dentro do id_token |
prompt | não | none (sem tela: devolve login_required ou consent_required por redirect) ou consent (força a tela) |
Os demais erros voltam por redirect à sua redirect_uri com error, error_description, state e iss.
Passo 4: troca do code
POST/oauth/token
O corpo é application/x-www-form-urlencoded. Client confidential se autentica com client_secret_basic (header Authorization: Basic) ou client_secret_post (campos no corpo); client public envia só o client_id.
curl
curl -sS -X POST https://auth-api.axuz.net/oauth/token \
-u "$AXUZ_CLIENT_ID:$AXUZ_CLIENT_SECRET" \
-d grant_type=authorization_code \
-d "code=$CODE" \
--data-urlencode "redirect_uri=http://localhost:3000/callback" \
-d "code_verifier=$CODE_VERIFIER"
Node
const res = await fetch('https://auth-api.axuz.net/oauth/token', {
method: 'POST',
headers: { 'content-type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
grant_type: 'authorization_code',
code,
redirect_uri: 'http://localhost:3000/callback',
client_id: process.env.AXUZ_CLIENT_ID,
client_secret: process.env.AXUZ_CLIENT_SECRET, // omita em client public
code_verifier,
}),
});
const tokens = await res.json();
A resposta 200 (com Cache-Control: no-store) tem access_token, token_type: "Bearer", expires_in (600), scope, id_token (com openid) e refresh_token (só com offline_access). Qualquer falha na troca (código expirado, já usado, redirect_uri diferente, verifier errado) responde a mesma mensagem invalid_grant, de propósito.
3. Validar o id_token
O id_token é um JWT de três partes assinado com EdDSA (Ed25519). Antes de confiar nele:
- Leia o header e exija
alg = EdDSA. Recuse qualquer outro, inclusivenone. - Busque a chave pelo
kidno JWKS (jwks_urido discovery,/.well-known/jwks.json) e verifique a assinatura. Se okidfor novo, releia o JWKS uma vez: as chaves rotacionam. - Confira
issigual ao issuer,audigual ao seuclient_id,expno futuro (tolere pouca diferença de relógio) enonceigual ao que você guardou. - Use
iss+subcomo identidade.
| Claim | Quando vem |
|---|---|
iss, sub, aud, azp, exp, iat | sempre (validade de 10 minutos) |
nonce | se você enviou no pedido |
name | escopo profile |
email, email_verified | escopo email |
O access_token é um JWT typ: at+jwt (RFC 9068) de 10 minutos para as APIs Axuz (aud: axuz-api); não o confunda com o id_token nem o use como prova de login. Ele pode ser revogado a qualquer momento, por isso APIs que o aceitam o verificam no servidor.
4. Userinfo
GET/oauth/userinfo
curl -sS https://auth-api.axuz.net/oauth/userinfo -H "Authorization: Bearer $ACCESS_TOKEN"
Devolve {"sub":"...","name":"...","email":"...","email_verified":true} conforme os escopos. Sem o escopo openid a resposta é 403 insufficient_scope; token inválido, 401 invalid_token com WWW-Authenticate. Também aceita POST. Compare sub com o do id_token.
5. Refresh com rotação
Com offline_access você recebe um refresh token opaco. Renove em POST /oauth/token com grant_type=refresh_token e refresh_token; pode enviar scope com um subconjunto do concedido (nunca amplia, senão invalid_scope).
- Cada refresh é de uso único: a resposta traz um novo, e você deve descartar o antigo.
- Reusar um refresh já trocado é tratado como vazamento e revoga o grant inteiro (todos os tokens daquela autorização).
- Validade deslizante de 30 dias, com teto absoluto de 90 dias desde o consentimento; depois disso a pessoa precisa consentir de novo.
6. Revogar e introspectar
POST/oauth/revoke
POST /oauth/revoke (RFC 7009) recebe token (access ou refresh) e a autenticação do client, e revoga o grant inteiro do token. Token desconhecido, expirado ou de outro client também responde 200, para não revelar existência. A pessoa pode retirar o consentimento em DELETE /oauth/consents/{client_id}, que revoga os grants dela naquele app. POST /oauth/introspect (RFC 7662) existe só para client confidential e só enxerga tokens do próprio client.
7. Discovery
GET /.well-known/openid-configuration lista issuer, authorization_endpoint, token_endpoint, userinfo_endpoint, jwks_uri, revocation_endpoint, introspection_endpoint, scopes_supported, code_challenge_methods_supported: ["S256"], id_token_signing_alg_values_supported: ["EdDSA"], token_endpoint_auth_methods_supported (client_secret_basic, client_secret_post, none) e authorization_response_iss_parameter_supported: true. Bibliotecas OIDC configuram tudo a partir dele; o issuer do documento deve ser igual ao que você configurou.
Erros OAuth
Os erros do protocolo seguem a RFC 6749: JSON {"error":"...","error_description":"..."}.
error | HTTP | Causa comum |
|---|---|---|
invalid_request | 400 | parâmetro ausente, redirect_uri não registrada, state curto, PKCE ausente |
invalid_client | 401 | client_id desconhecido ou client_secret errado |
invalid_grant | 400 | code ou refresh inválido, expirado ou reusado; verifier errado |
unsupported_grant_type | 400 | qualquer grant além de authorization_code e refresh_token |
invalid_scope | 400 | escopo desconhecido, fora do client ou maior que o concedido |
access_denied | redirect | a pessoa negou o consentimento |
login_required, consent_required | redirect | prompt=none sem sessão ou sem consentimento |
enrollment_required | 403 | sessão restrita: cadastre os fatores |
invalid_token, insufficient_scope | 401, 403 | /oauth/userinfo com token ruim ou sem openid |
rate_limited | 429 | falhas demais (client ou IP); respeite Retry-After |
Exemplo Node completo
O repositório da documentação traz um servidor mínimo e executável em examples/login-with-axuz/server.mjs (Node 22 ou superior, sem dependências) e um teste contra um provedor simulado em tests/examples/login-with-axuz.test.ts. Ele faz /login (PKCE, state, nonce, discovery), /callback (confere state com o cookie do navegador e iss, troca o code, valida assinatura EdDSA e claims do id_token, chama o userinfo) e mostra o resultado.
export AXUZ_ISSUER=https://auth-api.axuz.net
export AXUZ_CLIENT_ID=SEU_CLIENT_ID
export AXUZ_CLIENT_SECRET=SEU_SECRET # vazio em client public
export AXUZ_REDIRECT_URI=http://localhost:3000/callback
node examples/login-with-axuz/server.mjs
# abra http://localhost:3000/login
O núcleo do /login é este:
import { createHash, randomBytes } from 'node:crypto';
const b64u = (b) => Buffer.from(b).toString('base64url');
const verifier = b64u(randomBytes(32)); // 43 caracteres
const challenge = b64u(createHash('sha256').update(verifier).digest());
const state = b64u(randomBytes(16));
const nonce = b64u(randomBytes(16));
// guarde verifier, nonce e state (cookie HttpOnly + servidor) e redirecione:
const url = new URL(`${process.env.AXUZ_ISSUER}/oauth/authorize`);
url.search = new URLSearchParams({
response_type: 'code', client_id: process.env.AXUZ_CLIENT_ID,
redirect_uri: process.env.AXUZ_REDIRECT_URI, scope: 'openid profile email',
state, nonce, code_challenge: challenge, code_challenge_method: 'S256',
}).toString();
Segurança: o que não fazer
- Não use
plain, nem implicit, nem coloque oclient_secretem app de navegador ou mobile: esses são clientspublic. - Não pule a checagem de
state(CSRF de login) nem a denonce(reuso deid_token). - Não aceite
id_tokensem verificar assinatura,iss,audeexp; não aceite oalgque o token mandar. - Não use e-mail como identidade: ele muda e uma pessoa pode ter vários. Use
iss+sub. - Não guarde tokens em
localStoragenem em log; refresh só no servidor. - Não registre
redirect_urigenérica: quanto mais exata, menos superfície.
Servidor OAuth antigo (axuz_api)
O axuz_api já expõe GET /oauth/authorize, POST /oauth/token, POST /oauth/revoke, GET /oauth/userinfo, /.well-known/openid-configuration, /.well-known/jwks.json e o cadastro em /v1/oauth/apps, documentados na referência OAuth. Ele difere do IdP novo: os tokens são opacos (axot_, uma hora de validade, refresh de 30 dias), o code vale 10 minutos, o consentimento é por equipe (exige o cookie axuz_active_team, e o sub é derivado do par usuário e equipe, com axuz_user_id e axuz_team_id à parte), e PKCE também é só S256. Além disso, o escopo openid não está no catálogo desse servidor: cadastrar um app com openid hoje responde 400 unknown scope, então o modo OIDC dele não é utilizável por apps de terceiros até esse ajuste. Para “Login com Axuz” novo, planeje contra o IdP do axuz_auth_api.