feat: bind the GTI Fiscal SSO assertion to the browser (state/nonce)

/sso/fiscal now requires the state that GTI Fiscal creates when the flow
starts and signs it as the "nonce" claim; GTI Fiscal checks it against
its own one-time cookie. Without a valid state the panel sends the
browser to GTI Fiscal's /login, so the flow always starts there. This
prevents someone from pushing an assertion for their own account into
another person's browser (login CSRF).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Gustavo
2026-09-23 12:21:27 -03:00
co-authored by Claude Opus 5.5
parent 9139a1f6f3
commit 161da90e02
3 changed files with 50 additions and 20 deletions
+17 -3
View File
@@ -7,7 +7,8 @@ funcionário e entrega ao Fiscal uma asserção assinada, de vida curta.
tem a chave pública, então não consegue emitir asserções.
- Claims: iss/aud fixos, sub = "funcionario:<ID>" (o ID de
FUNCIONARIO_PAINEL — o LOGIN é editável, então não serve de identificador),
iat/nbf/exp (60 s) e jti único, que o Fiscal grava para recusar replay.
iat/nbf/exp (60 s), jti único (o Fiscal grava para recusar replay) e nonce
= state do Fiscal (amarra a asserção ao navegador que iniciou o fluxo).
- Nenhuma senha, hash ou cookie deste painel sai daqui: só nome, login e
perfil, que o Fiscal usa para exibir e para autorização futura.
- A asserção vai no corpo de um POST auto-submetido (routers/sso.py), nunca
@@ -18,9 +19,10 @@ nem nenhuma outra credencial.
"""
import base64
import os
import re
import time
import uuid
from urllib.parse import urlsplit
from urllib.parse import urlencode, urlsplit
import jwt
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
@@ -50,6 +52,17 @@ def origem_callback() -> str:
return f"{partes.scheme}://{partes.netloc}"
def estado_valido(valor: str | None) -> bool:
"""state gerado pelo Fiscal (cookie do navegador que iniciou o fluxo); volta
assinado como "nonce", e o Fiscal confere contra o cookie — assim ninguém
consegue empurrar a própria asserção para o navegador de outra pessoa."""
return bool(valor) and bool(re.fullmatch(r"[A-Za-z0-9_\-]{16,128}", valor))
def url_login_fiscal(proximo: str) -> str:
return f"{origem_callback()}/login?{urlencode({'next': proximo})}"
def proximo_seguro(valor: str | None) -> str:
"""Só caminho relativo dentro do Fiscal. Qualquer outra coisa vira "/"."""
if (
@@ -70,7 +83,7 @@ def _chave_privada() -> Ed25519PrivateKey:
return Ed25519PrivateKey.from_private_bytes(semente)
def emitir_assercao(funcionario: dict, agora: float | None = None) -> str:
def emitir_assercao(funcionario: dict, nonce: str, agora: float | None = None) -> str:
momento = int(agora if agora is not None else time.time())
claims = {
"iss": EMISSOR,
@@ -80,6 +93,7 @@ def emitir_assercao(funcionario: dict, agora: float | None = None) -> str:
"nbf": momento,
"exp": momento + VALIDADE_SEGUNDOS,
"jti": str(uuid.uuid4()),
"nonce": nonce,
"login": funcionario["LOGIN"],
"name": funcionario["NOME"],
"perfil": funcionario["PERFIL"],