← Documentação

Para quem desenvolve

Como ligar um sistema à Central de Identidade para login (OpenID Connect) e como usar a API da central para gerir pessoas.

A Central de Identidade é um provedor OpenID Connect (OIDC): o padrão aberto de login que fica por cima do OAuth2. Em vez de guardar senhas, o seu sistema manda a pessoa para a central, ela entra lá, e o sistema recebe de volta quem ela é. Qualquer biblioteca cliente de OIDC da sua linguagem serve, e uma de OAuth2 puro também (veja o checklist abaixo); esta página diz o que a central espera e onde ela foge do comum.

Antes de integrar, leia também o Checklist de integração: o que o seu sistema precisa fazer, além do protocolo.

Antes de começar: o client

Para a central aceitar o seu sistema, ele precisa de um client: o cadastro do sistema na central, com um identificador (client_id). Quem cadastra é um administrador global. Leve para ele:

  • Tipo: confidencial, se o sistema guarda um segredo no servidor (o caso comum de um backend), ou público, se não tem onde guardar (um app que roda só no navegador ou no celular). Essa escolha não muda depois.
  • Redirect URIs: os endereços para onde a central devolve a pessoa depois do login.
  • Post-logout redirect URIs, se o sistema for usar a saída pela central (veja Sair).
  • Scopes de que o sistema precisa (veja Quem é a pessoa).
  • Grants: authorization_code e refresh_token para login; client_credentials só se o sistema for chamar a API em nome próprio (veja API).

Um client confidencial recebe um secret, a senha do sistema. Ele aparece uma vez só, para o administrador, no momento do cadastro: guarde na hora, num cofre de segredos.

Login

Endpoints do login

O quê Caminho
Discovery GET /.well-known/openid-configuration
Autorização GET /authorize
Token POST /token
UserInfo GET ou POST /userinfo
Chaves públicas (JWKS) GET /jwks
Saída GET /end-session

O documento de discovery tem esses endereços e o issuer, mas não lista os scopes e claims: eles estão na seção Quem é a pessoa, abaixo.

O fluxo

A central só aceita o fluxo authorization code com PKCE, e o PKCE é obrigatório para todo client, inclusive os confidenciais. PKCE é a prova de que quem troca o código pelo token é o mesmo sistema que pediu o login: o sistema gera um segredo aleatório (code_verifier), manda o hash dele no pedido de login (code_challenge) e o segredo original na troca.

  1. O sistema manda a pessoa para /authorize:

    https://home-hml.vetorzero.com.br/authorize
      ?response_type=code
      &client_id=meu-sistema
      &redirect_uri=https://meu-sistema.exemplo.com.br/callback
      &scope=openid profile email
      &state=<aleatório>
      &nonce=<aleatório>
      &code_challenge=<BASE64URL(SHA256(code_verifier))>
      &code_challenge_method=S256
    

    Mande code_challenge_method=S256 sempre. Sem ele, a biblioteca do servidor assume plain, que a central recusa.

  2. A pessoa entra com a conta Google (ou passa direto, se já tiver entrado) e volta para a redirect_uri com code e state. Não existe tela de consentimento: todos os sistemas ligados à central são da própria empresa.

  3. O sistema troca o código por tokens:

    curl -X POST "https://home-hml.vetorzero.com.br/token" \
      -u 'meu-sistema:SECRET' \
      -d grant_type=authorization_code \
      -d code=CODE \
      -d redirect_uri=https://meu-sistema.exemplo.com.br/callback \
      -d code_verifier=CODE_VERIFIER
    

    Um client confidencial se autentica com HTTP Basic (como acima) ou com client_id e client_secret no corpo. Um client público manda só o client_id no corpo.

  4. A resposta traz access_token, id_token e refresh_token. O id_token já tem as claims dos scopes pedidos; confira nele o nonce que você mandou.

Tokens

Token Validade
Código de autorização 10 minutos
Access token 1 hora
ID token 1 hora
Refresh token 1 mês
  • O access token e o ID token são JWT assinados com RS256. As chaves públicas estão em /jwks.
  • Cada refresh token vale uma vez só: ao usá-lo, você recebe um novo e o antigo é revogado. Guarde sempre o último.
  • A central não tem endpoint de revogação nem de introspecção de token.

Regras de redirect URI

  • Tem que ser https. A exceção é http em localhost ou 127.0.0.1, para desenvolvimento.
  • A comparação é exata, caractere por caractere. Em http://127.0.0.1 a porta é ignorada; em http://localhost, a porta precisa bater.

Quem é a pessoa: scopes e claims

Cada scope libera um conjunto de claims (os dados da pessoa). O scope openid entra sempre.

Scope Claim Tipo O que é
openid sub string Identificador da pessoa, como vlidc_usr_…
profile name string Nome
profile picture string ou null Foto da conta Google principal
email email string Email principal
email email_verified bool Se o email principal foi confirmado
verified_emails verified_emails string[] Todos os emails confirmados
employment employer string ou null Domínio da empresa que contratou a pessoa; null para freelancer
employment served_organizations string[] Domínios das empresas que ela atende
slack slack objeto ou null Conta no Slack: {"user_id": "U…", "team_id": "T…"}
access groups objeto Grupos da pessoa: slug do grupo → caminho até ele, {} sem grupo
access permissions string[] Permissões efetivas da pessoa; vazio sem nenhuma

As claims vêm no ID token e também em /userinfo.

O scope slack é opcional e só serve a quem precisa falar com a pessoa no Slack.

O scope access entrega a autorização da pessoa. Ele é opcional e precisa ser pedido:

{
  "groups": { "animacao_2d": ["producao", "2d", "animacao_2d"] },
  "permissions": ["something.read", "another_thing"]
}
  • groups: cada chave é o slug de um grupo a que a pessoa foi atribuída diretamente, e o valor é o caminho de slugs da raiz até ele, incluindo ele. Para saber se a pessoa "está em X", procure a chave; para "está sob X" (X ou qualquer subgrupo), confira o prefixo do caminho. Sem grupo, vem {}, nunca [].
  • permissions: todas as permissões da pessoa, já somadas as dos grupos dela e as de todos os subgrupos. Vêm todas, não só as do seu sistema: o catálogo é global, e o seu sistema escolhe quais conhece.
  • Use os slugs como chave: o nome do grupo pode mudar. Um administrador pode trocar um slug, e aí o seu sistema deixa de reconhecê-lo até ser atualizado.
  • Mudanças na estrutura só aparecem no próximo token (ou chamada a /userinfo com um token novo).

Identifique a pessoa pelo sub, nunca pelo email. Isso e o resto do que o seu sistema precisa fazer com essas claims, incluindo como associar usuários que já existiam, estão no Checklist de integração.

/userinfo

/userinfo devolve as mesmas claims a partir de um access token. O token vai só no header Authorization: Bearer <token>, tanto em GET quanto em POST; não há parâmetro no corpo.

curl "https://home-hml.vetorzero.com.br/userinfo" -H "Authorization: Bearer ACCESS_TOKEN"

Um token de client_credentials é recusado aqui (403): ele não representa uma pessoa.

Sair

Sair da central não tira a pessoa do seu sistema, e sair do seu sistema não tira ela da central. Cada lado tem a sua sessão.

Para encerrar também a sessão na central, mande a pessoa para /end-session, com os parâmetros na query string:

https://home-hml.vetorzero.com.br/end-session
  ?client_id=<o client_id do seu sistema>
  &id_token_hint=<o id_token da pessoa>
  &post_logout_redirect_uri=https://meu-sistema.exemplo.com.br/
  &state=<opcional>
  • A post_logout_redirect_uri precisa estar cadastrada no client. Ela também exige id_token_hint ou client_id.
  • A saída é direta quando vem o client_id do seu sistema junto com um id_token_hint emitido para ele, ou junto com uma post_logout_redirect_uri cadastrada. Nos outros casos (por exemplo, só id_token_hint, sem client_id), a central mostra antes uma tela de confirmação ("Sair da Central de Identidade?", com "Sair" e "Cancelar").

Pessoas desativadas

Quando um administrador desativa alguém, a central revoga os tokens dessa pessoa: /userinfo e o refresh passam a recusar. Um JWT que o seu sistema valida sozinho, só com a chave pública, continua válido até expirar (no máximo 1 hora). Se o seu sistema precisa reagir na hora, consulte /userinfo ou faça refresh em vez de confiar só na validade do JWT.

API

A API em /api/v1 permite que outro sistema gerencie pessoas: listar, consultar, cadastrar, editar, desativar e cuidar de emails. A especificação completa, em OpenAPI, está em /api/doc (para ler no navegador) e em /api/doc.json.

Acesso

Toda chamada leva um access token no header Authorization: Bearer. Há dois jeitos de obter um:

  • Em nome do próprio sistema (client_credentials): o sistema age como ele mesmo, com os papéis que o client recebeu. Exige client confidencial.

    curl -X POST "https://home-hml.vetorzero.com.br/token" -u 'meu-sistema:SECRET' -d grant_type=client_credentials
    
  • Em nome de uma pessoa: o access token do login dela. A chamada vale com os papéis da pessoa.

Nos dois casos, o client precisa ter "Acesso à API" liberado por um administrador global, que também define os IPs de onde ele pode chamar (sem nenhum IP, qualquer um é aceito). Leituras (GET e QUERY) exigem o papel de leitor (de pessoas ou de diretório, conforme o recurso); escritas exigem o administrador correspondente. Um client que só consulta deve receber só o leitor. As chamadas ficam registradas na auditoria da central, com o client de origem.

O bearer é o access token, não o secret. Mandar o secret no lugar dá 401.

Testar a API pelo /api/doc

O botão "Authorize" do /api/doc oferece três esquemas:

  • client: você informa o client id e o secret, e a página busca o token (client_credentials).
  • user: abre o login da central e volta com o token da pessoa logada, que age com os papéis dela (leitor ou administrador, conforme a chamada). Papel de client não vale aqui, só em client_credentials.
  • bearer: para um access token que veio de outro lugar.

Para usar o user, um administrador global cadastra um client público, só com o grant authorization_code, tendo https://home-hml.vetorzero.com.br/api/doc/oauth2-redirect como redirect URI, e libera o acesso à API para ele, sem papéis. Quem testa digita o client id no "Authorize"; ele não vem preenchido. A redirect URI tem que bater exatamente com a cadastrada, inclusive a porta quando o host é localhost.

Endpoints da API

Método Caminho O quê
GET /api/v1/users?limit=&offset= Lista pessoas (filtros opcionais: email, ownsEmail, active, claimable, employer, served)
QUERY /api/v1/users/email-owners Descobre o dono de vários emails de uma vez
POST /api/v1/users Cadastra uma pessoa (name, email; opcionais active, invite, employment e slack)
GET /api/v1/users/{id} Consulta uma pessoa
PUT /api/v1/users/{id} Troca nome, email e conta no Slack
PUT /api/v1/users/{id}/employment Troca empregador, empresas atendidas
POST /api/v1/users/{id}/emails Adiciona um email (a pessoa confirma pelo link, ou já confirmado com verified)
DELETE /api/v1/users/{id}/emails/{address} Remove um email
POST /api/v1/users/{id}/deactivate Desativa
POST /api/v1/users/{id}/reactivate Reativa

O {id} é o mesmo sub do login. Empresas são identificadas pelo domínio. Uma pessoa cadastrada pela API recebe o convite por email, como no cadastro pela tela, a menos que você mande "invite": false. Não há exclusão de pessoa: o equivalente é desativar.

A resposta traz picture: a URL da foto da conta Google principal, a mesma do claim picture (a imagem fica no Google, não é copiada). É null sem conta Google vinculada ou sem foto.

No PUT /api/v1/users/{id}, email é livre só enquanto a pessoa não tem nenhum email confirmado (convite pendente). Depois, só aceita um email confirmado dela; qualquer outro dá 422.

Em slack, a pessoa tem {userId, teamId} ou null: os dois campos vêm juntos ou nenhum. userId começa com U ou W e teamId com T. O mesmo par não pode estar em duas pessoas (409). No PUT /api/v1/users/{id}, slack é obrigatório (null desvincula); no POST /api/v1/users, é opcional.

Em POST /api/v1/users, employment define o vínculo já na criação, no mesmo formato do corpo de PUT /api/v1/users/{id}/employment (employer, servedOrganizations). Sem employment, a pessoa nasce sem vínculo. Empresa desconhecida dá 422. A central não deduz o empregador pelo domínio do email: quem cadastra informa.

Em POST /api/v1/users/{id}/emails, "verified": true faz você atestar o endereço no lugar da pessoa: ele entra já confirmado (ou, se estava pendente, passa a confirmado) e nenhum email é enviado. reason é obrigatório nesse caso, sem ele a resposta é 422, e vai para a auditoria junto com o seu client. Se o endereço já está confirmado nessa pessoa, nada muda. Se está confirmado em outra pessoa, dá 409. Sem verified, a pessoa recebe o link e confirma sozinha. Use só quando o sistema de origem é confiável quanto àquele endereço: quem tem o papel de administrador de pessoas pode confirmar um email que não controla.

Receber webhooks

Em vez de consultar a API de tempos em tempos, o sistema pode ser avisado de cada mudança. Um administrador global informa a "URL de webhook" no client (seção "API", que exige o acesso à API). O sistema passa a receber o que os papéis dele deixam ver, com a mesma regra da API: eventos de pessoas pedem o leitor de pessoas, de clients o administrador global, e o resto do diretório (empresas, apps, grupos, permissões) o leitor de diretório. Mudança de papel de uma pessoa nunca é enviada. Um client desativado não recebe nada, e um webhook novo começa do momento em que é criado, sem histórico.

Cada aviso é um Security Event Token (SET, RFC 8417): um JWT entregue por POST (RFC 8935) com Content-Type: application/secevent+jwt, e o JWT inteiro como corpo.

Como verificar. O JWT é assinado (RS256) com a mesma chave do login: valide a assinatura pelo JWKS da central. O header tem typ: secevent+jwt. Confira iss (o issuer da central) e aud (o seu client id). Não há exp: o aviso é um fato, e uma retentativa pode chegar dias depois.

Claim O quê
jti Id do evento, igual em todas as retentativas. Use para deduplicar.
iat Quando o aviso foi assinado.
toe Quando a ação aconteceu (segundos Unix).
events Objeto com um tipo de evento como chave e {"subject": {"type", "id"}} como valor.
urn:vl-idc:claim:own true só quando a mudança foi feita pelo seu próprio client via API; ausente nos demais.

O tipo é urn:vl-idc:event:<ação> (texto fixo, compare como string), com a ação igual à da auditoria, como user.updated, user.email_verified, client.updated, group.created ou organization.created. O subject traz o tipo do alvo (user, client, organization, app, group ou permission) e o mesmo id que a API usa para ele. O aviso não traz dados da pessoa nem quem agiu: para saber o estado, consulte a API com o seu token. A claim own permite pular essa consulta quando foi o seu próprio sistema que acabou de escrever. Ela não evita loops por si: escrita sem mudança não gera evento.

Exemplo de events decodificado:

{"urn:vl-idc:event:user.updated": {"subject": {"type": "user", "id": "vlidc_usr_…"}}}

O que responder. Qualquer resposta 2xx confirma o aviso (a RFC pede 202, mas 200 serve). Qualquer outra coisa, ou erro de rede, ou mais de 5 segundos sem resposta, conta como falha; redirecionamentos não são seguidos. A central não lê o corpo da resposta.

Ordem e retentativas. Os avisos de um client saem em ordem. Uma falha segura a fila: o mesmo aviso volta com espera crescente (10s, 20s, 40s… até 1 hora entre tentativas). Depois de cerca de 24h falhando, o webhook é pausado e nada mais é enviado até um administrador global usar "Retomar agora" na tela do client; a entrega recomeça de onde parou, sem perder aviso. Em produção, a URL tem que ser https e não pode apontar para rede privada.

Testar. Na tela do client, "Enviar evento de verificação" manda na hora um SET de tipo https://schemas.openid.net/secevent/ssf/event-type/verification, com events vazio de dados, e mostra se o receptor respondeu 2xx. Ele não entra na fila nem na auditoria.

Achar uma pessoa pelo email

Para achar o id de alguém a partir do email que seu sistema tem, use os filtros de GET /api/v1/users. Todos são opcionais, combináveis, de valor exato e sem diferenciar maiúsculas de minúsculas; total, next e prev respeitam o filtro.

  • ownsEmail= acha quem é dono do endereço: o email principal ou um email secundário confirmado. Dá no máximo uma pessoa. Email não confirmado nunca casa, porque pode se repetir entre pessoas. É o filtro certo para associar usuários.
  • email= casa só o email principal.
  • active=true ou active=false separa quem está ativo de quem foi desativado.
  • claimable=true ou claimable=false separa quem ainda não foi assumido por ninguém no login.
  • employer= filtra pelo domínio da empresa contratante (vetorzero.com.br), ou employer=none para quem não tem empregador (freelancers).
  • served= filtra pelo domínio de uma organização que a pessoa atende.

Domínio e id bem formados mas que não existem dão lista vazia: aqui é filtro, não referência (diferente do corpo de PUT /api/v1/users/{id}/employment, onde seria 422). Valor vazio ou malformado (domínio que não é um hostname, id fora do formato) dá 422, não "todos". Não há filtro por papel.

Para vários emails, QUERY /api/v1/users/email-owners aplica a regra do ownsEmail em lote. O método QUERY (RFC 10008) lê sem alterar nada, como o GET, mas leva a lista no corpo:

{"emails": ["ada@example.com", "ninguem@example.com"]}

São de 1 a 100 emails; fora disso, ou com um endereço malformado, a resposta é 422. A resposta traz items, um objeto com cada email exatamente como você mandou e a pessoa dona dele, ou null se ninguém é dona:

{"items": {"ada@example.com": {"id": "…", "name": "Ada Lovelace"}, "ninguem@example.com": null}}

Os dois exigem o papel de leitor de pessoas. A documentação OpenAPI em /api/doc segue a versão 3.2, a primeira que descreve o QUERY; ferramentas mais antigas podem não reconhecê-lo.

Importar pessoas de outro sistema

Para trazer uma base existente, use um client por script de importação: a auditoria registra o client de origem, e assim dá para saber de onde cada pessoa veio.

  • POST /api/v1/users com "active": false cria quem já saiu da empresa: a pessoa nasce desativada, sem convite por email, e continua reclamável. Sem active, vale true.
  • "invite": false cadastra a pessoa ativa sem mandar o convite por email, útil para importar quem ainda não deve ser avisado. Sem invite, vale true e o convite sai (para quem nasce ativo). A auditoria registra "Usuário criado sem convite".
  • employment traz o vínculo de cada pessoa na mesma chamada.
  • Para saber antes quem já existe, consulte por email (veja Achar uma pessoa pelo email).
  • Email já usado dá 409 com user_id no corpo, o id de quem o possui (como email principal ou como email secundário confirmado). Nada é alterado. Se nome ou estado divergem, o script corrige com PUT /api/v1/users/{id} ou POST /api/v1/users/{id}/deactivate. O mesmo vale para um PUT /api/v1/users/{id} que bate no email de outra pessoa.
  • POST /api/v1/users/{id}/emails com "verified": true traz os emails secundários já confirmados.

O repositório traz dois exemplos completos (Python, só biblioteca padrão), com um client próprio: scripts/import-users-from-ferias/, que importa as pessoas do ferias.

Convenções

  • Os nomes da API estão em inglês (campos, mensagens de erro).
  • Não há PATCH. O PUT manda o estado inteiro do recurso: toda chave é obrigatória, inclusive as que aceitam null. Chave faltando dá erro.
  • Idempotency-Key (opcional, só em POST): repetir a mesma chamada com a mesma chave devolve a resposta guardada, com o header Idempotent-Replayed: true, em vez de repetir o efeito. Só respostas 2xx são guardadas, por pelo menos 7 dias. A mesma chave com outro corpo dá 422; uma chamada enquanto a primeira ainda roda dá 409.
  • Coleção sem ordem (como o resultado de email-owners) sai como objeto indexado por uma chave útil, não como lista; vazia, é {}. Lista só quando a ordem importa.
  • Paginação: limit de 1 a 100 (padrão 50) e offset. A resposta traz items, total e os links next e prev.
  • Erros seguem o RFC 9457 (application/problem+json), com title, status e, quando ajuda, detail. Erro de validação é 422, com a lista em violations; recurso inexistente é 404; conflito é 409. Email já usado traz user_id, o id de quem o possui.
  • Limite de uso: a API conta as chamadas por client, e a conta vale também para o token de uma pessoa obtido por esse client. O limite é alto, só para um programa em loop não derrubar a central: uma integração normal não chega nele, e a rajada de um script cabe. Passou do limite, a resposta é 429 (problem details) com o header Retry-After, em segundos até poder tentar de novo. Espere esse tempo antes de repetir. Todo endpoint da API pode responder 429; o /token não entra na conta.

Chamada entre sistemas

Um sistema (o consumidor) pode chamar a API de outro sistema (o destino) em nome de si mesmo, sem pessoa no meio. O consumidor pede um token à central com client_credentials e o destino confere o token sozinho, sem falar com a central a cada chamada.

Como o consumidor pede o token

O client do consumidor precisa ser confidencial e ter o grant client_credentials. No /token, o parâmetro audience diz para qual sistema o token vale: é o client id do destino. Vários destinos vão separados por espaço.

curl -X POST "https://home-hml.vetorzero.com.br/token" -u 'consumidor:SECRET' \
  -d grant_type=client_credentials -d audience=destino
  • O aud do access token passa a ter só os ids pedidos. O consumidor deixa de constar nele.
  • Cada id tem que ser de um client que existe e está ativo. Senão, 400 invalid_target.
  • audience só vale no client_credentials. Em outro grant, 400 invalid_request.
  • Sem audience, o aud é o próprio client que pediu, como sempre.
  • Guarde o token em cache até perto do exp e peça outro só então. Não há refresh token no client_credentials: quando vence, é só pedir de novo.

Como o destino valida o token

O access token é um JWT assinado com RS256. O destino confere, sempre, com uma biblioteca de JWT:

  1. Assinatura, com as chaves de /jwks. Guarde-as em cache e busque de novo se a assinatura não bater.
  2. exp e nbf: o token não pode estar vencido nem ser de uso futuro.
  3. iss igual ao issuer de /.well-known/openid-configuration. Token de outro emissor é recusado.
  4. aud contém o próprio client id do destino. Token feito para outro sistema é recusado.
  5. client_id diferente do próprio client id do destino. O client_id é o client que pediu o token. Um token que o próprio destino pediu (por exemplo, o login de uma pessoa nele) tem aud e client_id iguais a ele, e não é uma chamada de outro sistema: recuse.

O client_id identifica quem está chamando.

Por enquanto, mantenha também uma lista dos client_id aceitos. Hoje qualquer client da central pode pedir um token com o seu id no audience: o aud impede só que o token seja usado em outro destino. Sem a lista, qualquer sistema da central entra. Quando a central passar a controlar quem pode pedir token para quem (acompanhe em #123), a lista deixa de ser necessária.

Quando algo dá errado

invalid_request "PKCE is required" no /authorize: faltou o code_challenge. Se ele foi, confira o code_challenge_method=S256.

invalid_client no /token: secret errado, client desativado ou, no client_credentials, chamada de um IP fora da lista do client.

invalid_target no /token: algum id do audience não é de um client existente e ativo.

invalid_request no /token citando application/x-www-form-urlencoded: o corpo foi enviado em outro formato, como JSON. O /token só aceita formulário.

invalid_request no /token citando audience: o parâmetro só vale com client_credentials.

Página "Não deu pra entrar" no /authorize: erro que a central não pode devolver ao seu sistema (client desconhecido ou desativado, redirect_uri ausente ou fora do cadastro) aparece como página na própria central, com o código do erro e, em "Detalhes pra quem integra", o client_id e a redirect_uri recebidos. Quando é a redirect_uri, ela provavelmente não bate exatamente com a cadastrada: atenção à barra no fim e à porta do localhost. A página não lista as redirect_uri cadastradas; confira no cadastro do client.

Página "Não deu pra sair" no /end-session: o pedido de saída veio inválido. A página traz a mensagem da central sobre o parâmetro.

access_denied no /authorize durante um teste: o administrador está impersonando outra pessoa na central. Impersonando, a central não deixa entrar em nenhum sistema em produção. Nos ambientes de teste (com faixa de segurança no topo das telas) isso é permitido.

403 em /userinfo: o token é de client_credentials, que não representa uma pessoa.

401 na API: falta o bearer, o bearer é o secret em vez do access token, ou o client está desativado, sem acesso à API ou chamando de um IP não liberado. O detail diz qual.

429 na API: o client passou do limite de uso. Espere o Retry-After e confira se não há um loop ou paralelismo sem controle chamando a API.