""" SSO do painel para o GTI Fiscal: o painel é a autoridade da identidade do funcionário e entrega ao Fiscal uma asserção assinada, de vida curta. - Assinatura Ed25519 (JWT "EdDSA"). A chave privada existe só aqui (SSO_FISCAL_CHAVE_PRIVADA, semente de 32 bytes em base64url); o Fiscal só tem a chave pública, então não consegue emitir asserções. - Claims: iss/aud fixos, sub = "funcionario:" (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. - 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 na URL: não aparece em log de proxy, histórico do navegador nem Referer. SSO_FISCAL_CHAVE_PRIVADA é exclusiva do SSO — não reaproveitar SESSION_SECRET nem nenhuma outra credencial. """ import base64 import os import time import uuid from urllib.parse import urlsplit import jwt from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey EMISSOR = "gti-gerenciador" AUDIENCIA = "gti-fiscal" VALIDADE_SEGUNDOS = 60 TAMANHO_MAXIMO_NEXT = 512 def configurado() -> bool: return bool(os.environ.get("SSO_FISCAL_CHAVE_PRIVADA")) and bool(os.environ.get("SSO_FISCAL_CALLBACK_URL")) def url_callback() -> str: """URL do callback do Fiscal, vinda só de configuração (nunca da requisição). Recusa qualquer coisa que não seja https://host/caminho, sem credenciais.""" url = os.environ["SSO_FISCAL_CALLBACK_URL"] partes = urlsplit(url) if partes.scheme != "https" or not partes.hostname or partes.username or partes.password or partes.query or partes.fragment: raise RuntimeError("SSO_FISCAL_CALLBACK_URL precisa ser https://host/caminho") return url def origem_callback() -> str: partes = urlsplit(url_callback()) return f"{partes.scheme}://{partes.netloc}" def proximo_seguro(valor: str | None) -> str: """Só caminho relativo dentro do Fiscal. Qualquer outra coisa vira "/".""" if ( not valor or len(valor) > TAMANHO_MAXIMO_NEXT or not valor.startswith("/") or valor.startswith("//") or "\\" in valor or any(ord(c) < 32 or ord(c) == 127 for c in valor) ): return "/" return valor def _chave_privada() -> Ed25519PrivateKey: bruto = os.environ["SSO_FISCAL_CHAVE_PRIVADA"].strip() semente = base64.urlsafe_b64decode(bruto + "=" * (-len(bruto) % 4)) return Ed25519PrivateKey.from_private_bytes(semente) def emitir_assercao(funcionario: dict, agora: float | None = None) -> str: momento = int(agora if agora is not None else time.time()) claims = { "iss": EMISSOR, "aud": AUDIENCIA, "sub": f"funcionario:{funcionario['ID']}", "iat": momento, "nbf": momento, "exp": momento + VALIDADE_SEGUNDOS, "jti": str(uuid.uuid4()), "login": funcionario["LOGIN"], "name": funcionario["NOME"], "perfil": funcionario["PERFIL"], } return jwt.encode(claims, _chave_privada(), algorithm="EdDSA")