Skip to main content

Login por QR-code — API

Permite que a web inicie um desafio de QR-code, o app já autenticado o aprove, e a web, em polling, conclua o login recebendo um authorization_code. O desafio nunca expõe tokens — só um token opaco de posse; quem materializa a sessão web é o app, ao aprovar.

Esta é uma reescrita completa do fluxo antigo. Não existem mais as rotas /qrcode, /token/validateqrcode nem /token/verifyqrcodelogin — tudo foi remodelado como operações sobre /sessions/qr-challenges.

Funcionamento​

  1. Iniciar (POST /sessions/qr-challenges, público): valida o client OAuth (ativo, com grant authorization_code, redirect_uri e escopos permitidos), gera um token opaco de 32 bytes (base64url) e grava só o hash SHA-256 dele em um registro pending no banco (Postgres, sem Redis), com TTL configurável. Devolve o token em claro (qr_payload) para virar o QR-code — quem materializa o QR-code (imagem) é responsabilidade do front.
  2. Aprovar (POST /sessions/qr-challenges/approve, autenticado pelo app): o app escaneia o QR e envia o token. O serviço localiza o desafio pelo hash (com lock, já filtrando expirado), confirma que a sessão mobile do app está ativa, cria a sessão de navegador (com IP/user-agent do desafio, não do app) e marca o desafio como approved, associando usuário e sessão criada.
  3. Concluir (POST /sessions/qr-challenges/complete, público, chamado em polling pela web): busca o desafio pelo hash. Se ainda não foi aprovado (pending), expirado ou já consumido, responde erro 400 (não um corpo de sucesso com uma flag) — a web deve tratar esse erro como "ainda pendente" e repetir a chamada. Quando aprovado, emite o authorization_code (mesmo serviço usado por Autorizar aplicação) e marca o desafio como consumed (uso único).

Endpoints​

MétodoRotaDescrição
POST/v1/sessions/qr-challengesWeb inicia o desafio de QR-code (público)
POST/v1/sessions/qr-challenges/approveApp aprova o desafio (autenticado)
POST/v1/sessions/qr-challenges/completeWeb conclui o login (público, polling)

Versão: v1

Swagger: Identity — Sessions and account switching · Rota (Dev): http://localhost:3000/v1/sessions/qr-challenges

Fluxo completo (web inicia → app aprova → web conclui em polling):

Permissões​

RotaGuardsAcesso
POST /sessions/qr-challengesnenhum (rate limit)Público
POST /sessions/qr-challenges/approveIdentityOAuthAccessTokenGuardApp com sessão mobile ativa
POST /sessions/qr-challenges/completenenhum (rate limit)Público

Headers​

HeaderObrigatórioDescrição
AuthorizationSim (approve)Bearer <access_token> do app
Content-TypeSimapplication/json

Path parameters​

Nenhum.

Query parameters​

Nenhum.

Body​

Iniciar — IdentityQrChallengeStartRequest:

json
{
"client_id": "web-portal",
"code_challenge": "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM",
"code_challenge_method": "S256",
"redirect_uri": "https://app.exemplo.com/callback",
"scopes": ["openid", "profile"],
"state": "random-state",
"nonce": "random-nonce"
}
CampoTipoObrigatórioValidação
client_idstringSim@IsUUID
code_challengestringSim@Length(43, 128)
code_challenge_methodstringSimlivre (sem @IsIn) — diferente do switch, que só aceita S256
redirect_uristringSim@IsUrl
scopesstring[]Nãoarray de strings
statestringNão@Length(1, 512)
noncestringNão@Length(1, 255)

Aprovar / Concluir — IdentityQrChallengeTokenRequest:

json
{ "token": "opaque-qr-token" }
CampoTipoObrigatórioValidação
tokenstringSim@Length(1, 512)

Response​

Iniciar — 200 (IdentityQrChallengeResponse):

json
{ "challenge_id": "b3f1...-uuid", "qr_payload": "opaque-qr-token", "expires_in": 300 }

Aprovar — 200: { "message": "QR challenge approved." } (envelope de mensagem genérico — o app não recebe tokens nem código nesta etapa).

Concluir — 200 (IdentityQrChallengeCompletionResponse):

json
{
"code": "opaque-authorization-code",
"redirect_uri": "https://app.exemplo.com/callback",
"state": "random-state",
"session_id": "b3f1...-uuid",
"session_expires_at": "2026-10-24T12:00:00.000Z",
"user_id": "8f2a...-uuid"
}

A web troca esse code em POST /oauth/token (grant_type=authorization_code). Não existe mais um JWT trocado diretamente entre app e web neste fluxo, nem um campo completed: true/false — "ainda pendente" é modelado como erro HTTP, não como um corpo de sucesso com flag.

Erros​

RotaClasse de erroerrorCodeStatusQuando ocorre
iniciar / aprovar / concluir(validação de payload)BAD_REQUEST400corpo inválido
iniciar—IDENTITY_QR_CHALLENGE_UNAVAILABLE400client inativo, sem grant authorization_code, redirect_uri ou escopos não permitidos
aprovarIdentityInvalidOAuthAccessTokenErrorIDENTITY_INVALID_OAUTH_ACCESS_TOKEN401access token do app ausente/inválido
aprovar—IDENTITY_QR_CHALLENGE_UNAVAILABLE400desafio inexistente/expirado, não está pending, ou sessão mobile do app inativa
concluir—IDENTITY_QR_CHALLENGE_UNAVAILABLE400desafio inexistente/expirado, ainda pending (não aprovado) ou já consumed — inclui o caso "ainda não escaneado", que não é um erro real de negócio, apenas "tente de novo"
token malformado—IDENTITY_QR_CHALLENGE_TOKEN_INVALID400token fora do formato esperado

Importante para quem consome esta API: o polling da web precisa tratar HTTP 400 com errorCode: IDENTITY_QR_CHALLENGE_UNAVAILABLE em complete como "ainda pendente, repita" — não há como, pela resposta, distinguir "ainda não aprovado" de "expirou" ou "já foi usado". Isso é uma inversão de contrato em relação a uma versão antiga, que respondia 200 {completed:false} sem erro enquanto pendente.

Regras de negócio​

IDRegraComportamento
RN-01Iniciar não expõe tokensresposta só com challenge_id/qr_payload/expires_in
RN-02Token do QR é opaco e guardado por hash32 bytes aleatórios em base64url; só o SHA-256 vai para o banco
RN-03TTL do desafio é configurávelQR_CHALLENGE_TTL_MINUTES (default 5 min, máx. 30) — armazenamento é Postgres, não Redis
RN-04Aprovação exige app com sessão mobile ativasub/sessão vêm do access token do app, não do corpo
RN-05Aprovação já cria a sessão de navegadora sessão web nasce no approve, não no complete
RN-06Conclusão é de uso únicodesafio vira consumed; nova chamada de complete no mesmo token cai no erro de indisponibilidade
RN-07code_challenge_method do início do QR não é restrito a S256 no DTOdiferente de authorize/switch; a confirmar se isso é intencional

Compliance​

Órgão / normaExigênciaComo a rota atende
LGPDminimização; não trafegar tokens em claro no QRo QR carrega só um token opaco de posse; tokens de sessão nunca aparecem no QR
HIPAAautenticação forte com fator de posse (dispositivo)aprovação exige sessão mobile já autenticada; desafio de vida curta
ANVISA (indireto)autenticação validada e sob controledesafio single-use, TTL curto, hash em repouso

Variáveis de ambiente​

VariávelUsoDefault
QR_CHALLENGE_TTL_MINUTESTTL do desafio de QR-code5 min (máx. 30) — nome sem o prefixo IDENTITY_
IDENTITY_OAUTH_AUTHORIZATION_CODE_LIFETIME_SECONDSTTL do authorization_code emitido na conclusão600s

Tempo médio de resposta​

A confirmar — responsável: time de Identity; data: 24/09/2026. Sem medição publicada.

Requisitos não funcionais​

RequisitoDefinição
IdempotênciaAprovação e conclusão não são idempotentes (consomem o desafio); iniciar sempre cria um desafio novo
Rate limitqr-challenges e qr-challenges/complete: 120 requisições / 60s; approve não tem @Throttle próprio
CacheNão — o desafio é persistido em Postgres, não em Redis
AuditoriaSim — eventos identity.qr_challenge.started, identity.qr_challenge.approved, identity.qr_challenge.completed

Divergências e lacunas confirmadas​

  • Não existem specs unitários dedicados às três etapas (start/approve/complete-identity-qr-challenge.service); a cobertura automatizada localizada cobre apenas o modelo do token opaco (identity-qr-challenge-token.spec.ts).
  • "Ainda pendente" virou um erro HTTP (400), não um corpo de sucesso com flag — registrar isso ao integrar um cliente novo, para não tratar esse 400 como falha definitiva.

Relacionado​