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/validateqrcodenem/token/verifyqrcodelogin— tudo foi remodelado como operações sobre/sessions/qr-challenges.
Funcionamento
- Iniciar (
POST /sessions/qr-challenges, público): valida o client OAuth (ativo, com grantauthorization_code,redirect_urie escopos permitidos), gera um token opaco de 32 bytes (base64url) e grava só o hash SHA-256 dele em um registropendingno 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. - Aprovar (
POST /sessions/qr-challenges/approve, autenticado pelo app): o app escaneia o QR e envia otoken. 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 comoapproved, associando usuário e sessão criada. - 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 oauthorization_code(mesmo serviço usado por Autorizar aplicação) e marca o desafio comoconsumed(uso único).
Endpoints
| Método | Rota | Descrição |
|---|---|---|
| POST | /v1/sessions/qr-challenges | Web inicia o desafio de QR-code (público) |
| POST | /v1/sessions/qr-challenges/approve | App aprova o desafio (autenticado) |
| POST | /v1/sessions/qr-challenges/complete | Web 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
| Rota | Guards | Acesso |
|---|---|---|
POST /sessions/qr-challenges | nenhum (rate limit) | Público |
POST /sessions/qr-challenges/approve | IdentityOAuthAccessTokenGuard | App com sessão mobile ativa |
POST /sessions/qr-challenges/complete | nenhum (rate limit) | Público |
Headers
| Header | Obrigatório | Descrição |
|---|---|---|
Authorization | Sim (approve) | Bearer <access_token> do app |
Content-Type | Sim | application/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"}
| Campo | Tipo | Obrigatório | Validação |
|---|---|---|---|
client_id | string | Sim | @IsUUID |
code_challenge | string | Sim | @Length(43, 128) |
code_challenge_method | string | Sim | livre (sem @IsIn) — diferente do switch, que só aceita S256 |
redirect_uri | string | Sim | @IsUrl |
scopes | string[] | Não | array de strings |
state | string | Não | @Length(1, 512) |
nonce | string | Não | @Length(1, 255) |
Aprovar / Concluir — IdentityQrChallengeTokenRequest:
json{ "token": "opaque-qr-token" }
| Campo | Tipo | Obrigatório | Validação |
|---|---|---|---|
token | string | Sim | @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
| Rota | Classe de erro | errorCode | Status | Quando ocorre |
|---|---|---|---|---|
| iniciar / aprovar / concluir | (validação de payload) | BAD_REQUEST | 400 | corpo inválido |
| iniciar | — | IDENTITY_QR_CHALLENGE_UNAVAILABLE | 400 | client inativo, sem grant authorization_code, redirect_uri ou escopos não permitidos |
| aprovar | IdentityInvalidOAuthAccessTokenError | IDENTITY_INVALID_OAUTH_ACCESS_TOKEN | 401 | access token do app ausente/inválido |
| aprovar | — | IDENTITY_QR_CHALLENGE_UNAVAILABLE | 400 | desafio inexistente/expirado, não está pending, ou sessão mobile do app inativa |
| concluir | — | IDENTITY_QR_CHALLENGE_UNAVAILABLE | 400 | desafio 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_INVALID | 400 | token fora do formato esperado |
Importante para quem consome esta API: o polling da web precisa tratar HTTP 400 com
errorCode: IDENTITY_QR_CHALLENGE_UNAVAILABLEemcompletecomo "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 respondia200 {completed:false}sem erro enquanto pendente.
Regras de negócio
| ID | Regra | Comportamento |
|---|---|---|
| RN-01 | Iniciar não expõe tokens | resposta só com challenge_id/qr_payload/expires_in |
| RN-02 | Token do QR é opaco e guardado por hash | 32 bytes aleatórios em base64url; só o SHA-256 vai para o banco |
| RN-03 | TTL do desafio é configurável | QR_CHALLENGE_TTL_MINUTES (default 5 min, máx. 30) — armazenamento é Postgres, não Redis |
| RN-04 | Aprovação exige app com sessão mobile ativa | sub/sessão vêm do access token do app, não do corpo |
| RN-05 | Aprovação já cria a sessão de navegador | a sessão web nasce no approve, não no complete |
| RN-06 | Conclusão é de uso único | desafio vira consumed; nova chamada de complete no mesmo token cai no erro de indisponibilidade |
| RN-07 | code_challenge_method do início do QR não é restrito a S256 no DTO | diferente de authorize/switch; a confirmar se isso é intencional |
Compliance
| Órgão / norma | Exigência | Como a rota atende |
|---|---|---|
| LGPD | minimização; não trafegar tokens em claro no QR | o QR carrega só um token opaco de posse; tokens de sessão nunca aparecem no QR |
| HIPAA | autenticaçã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 controle | desafio single-use, TTL curto, hash em repouso |
Variáveis de ambiente
| Variável | Uso | Default |
|---|---|---|
QR_CHALLENGE_TTL_MINUTES | TTL do desafio de QR-code | 5 min (máx. 30) — nome sem o prefixo IDENTITY_ |
IDENTITY_OAUTH_AUTHORIZATION_CODE_LIFETIME_SECONDS | TTL do authorization_code emitido na conclusão | 600s |
Tempo médio de resposta
A confirmar — responsável: time de Identity; data: 24/09/2026. Sem medição publicada.
Requisitos não funcionais
| Requisito | Definição |
|---|---|
| Idempotência | Aprovação e conclusão não são idempotentes (consomem o desafio); iniciar sempre cria um desafio novo |
| Rate limit | qr-challenges e qr-challenges/complete: 120 requisições / 60s; approve não tem @Throttle próprio |
| Cache | Não — o desafio é persistido em Postgres, não em Redis |
| Auditoria | Sim — 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
- 🖥️ Tela:
A confirmar — responsável: time de frontend; data: 24/09/2026.(não localizada neste levantamento, que cobriu apenas o backend) - 📂 Módulo: Authentication
- 🔁 Token (troca do authorization_code)
- 🔑 Sessões e identidade