Files
GTI_GERENCIADOR_FISCAL/central/app/sso.py
T
gtiandClaude Opus 5.5 64d3fc56e2 feat: SSO from GTI Gerenciador and the Gerenciador visual identity
Collaborators logged into GTI Gerenciador open GTI Fiscal without a
second password. The panel signs a 60 s JWT (Ed25519; the Hub holds only
the public key); the Hub checks signature, iss/aud/exp/nbf, maximum
lifetime, nonce against a one-time state cookie set when the flow starts
at GET /login (login-CSRF protection) and a single-use jti
(sso_used_assertions). Collaborators are provisioned in admin_users with
auth_source=GTI_GERENCIADOR and no password; a CHECK constraint makes a
passwordless local account or a federated account with a password
impossible (migration 0021). Sessions last FEDERATED_SESSION_SECONDS
(1 h) and are renewed silently through the panel.

The local admin stays as break-glass at /login/local, audited. Logout ends
only the Hub session. Every SSO success/rejection (stable reason code),
logout and local login is audited with the real client IP, never the
token. verify_password now fails closed on a missing hash instead of
raising.

Visual identity: gti-theme.css is a verbatim copy of the panel's
design system (tokens, sidebar, light/dark theme) plus its tema.js,
sidebar.js and logo; control-panel.css keeps only Hub components, all
colours from the panel tokens. Page structure and features unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-23 12:21:26 -03:00

174 lines
6.9 KiB
Python

"""SSO from GTI Gerenciador: the panel is the identity authority.
Flow (docs/PILOT_DEPLOYMENT.md, "Human access"):
GET /login -> Hub sets a one-time state cookie and redirects to the
panel's /sso/fiscal?state=...&next=...
panel -> for a logged-in, active employee signs a JWT (Ed25519,
60 s, unique jti, nonce=state) and auto-POSTs it to
POST /auth/sso/callback -> verify_assertion + check state + consume_jti +
provision -> Hub session of its own.
The Hub only holds the panel's *public* key, never the panel's cookies,
passwords or database. The state cookie binds the assertion to the browser
that started the flow, so nobody can push their own assertion into someone
else's browser (login CSRF).
"""
from __future__ import annotations
import base64
import hmac
import re
from dataclasses import dataclass
from datetime import datetime, timedelta, timezone
from urllib.parse import urlsplit
import jwt
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey
from sqlalchemy import delete, select
from sqlalchemy.exc import IntegrityError
from . import config
from .models import AdminUser, SsoUsedAssertion
ISSUER = "gti-gerenciador"
AUDIENCE = "gti-fiscal"
AUTH_SOURCE = "GTI_GERENCIADOR"
REQUIRED_CLAIMS = ["iss", "aud", "sub", "iat", "nbf", "exp", "jti", "nonce"]
MAX_LIFETIME_SECONDS = 120 # the panel issues 60 s; anything longer is refused
CLOCK_LEEWAY_SECONDS = 10
STATE_COOKIE = "gti_sso_state"
STATE_COOKIE_PATH = "/auth/sso"
STATE_SECONDS = 300
MAX_NEXT_LENGTH = 512
_SUBJECT = re.compile(r"funcionario:[1-9][0-9]{0,18}")
_TOKEN_CHARS = re.compile(r"[A-Za-z0-9_\-]{16,128}")
class SsoRejected(Exception):
"""Reason codes are stable: they go to the audit log (never the token)."""
def __init__(self, reason: str, subject: str | None = None):
super().__init__(reason)
self.reason = reason
self.subject = subject
@dataclass(frozen=True)
class Identity:
subject: str
login: str
name: str
role: str | None
jti: str
expires_at: datetime
def enabled() -> bool:
return bool(config.settings.sso_public_key) and panel_home() is not None
def panel_home() -> str | None:
"""https origin of GTI Gerenciador, from SSO_START_URL (None if unset/not https)."""
parts = urlsplit(config.settings.sso_start_url or "")
if parts.scheme != "https" or not parts.hostname or parts.username or parts.password:
return None
return f"https://{parts.netloc}/"
def safe_next(value: str | None) -> str:
"""Only a relative path inside the Hub; anything else becomes "/"."""
if (
not value
or len(value) > MAX_NEXT_LENGTH
or not value.startswith("/")
or value.startswith("//")
or "\\" in value
or any(ord(char) < 32 or ord(char) == 127 for char in value)
):
return "/"
return value
def valid_state(value: str | None) -> bool:
return bool(value) and bool(_TOKEN_CHARS.fullmatch(value))
def _public_key() -> Ed25519PublicKey:
raw = config.settings.sso_public_key.strip()
return Ed25519PublicKey.from_public_bytes(base64.urlsafe_b64decode(raw + "=" * (-len(raw) % 4)))
def verify_assertion(token: str | None, expected_state: str | None) -> Identity:
if not token or len(token) > 4096:
raise SsoRejected("MISSING_ASSERTION")
try:
claims = jwt.decode(
token, _public_key(), algorithms=["EdDSA"], audience=AUDIENCE, issuer=ISSUER,
leeway=CLOCK_LEEWAY_SECONDS, options={"require": REQUIRED_CLAIMS},
)
except jwt.ExpiredSignatureError as error:
raise SsoRejected("EXPIRED") from error
except jwt.ImmatureSignatureError as error:
raise SsoRejected("NOT_YET_VALID") from error
except jwt.InvalidAudienceError as error:
raise SsoRejected("WRONG_AUDIENCE") from error
except jwt.InvalidIssuerError as error:
raise SsoRejected("WRONG_ISSUER") from error
except jwt.InvalidAlgorithmError as error:
raise SsoRejected("INVALID_ALGORITHM") from error
except jwt.InvalidSignatureError as error:
raise SsoRejected("INVALID_SIGNATURE") from error
except jwt.MissingRequiredClaimError as error:
raise SsoRejected("MISSING_CLAIM") from error
except jwt.InvalidTokenError as error:
raise SsoRejected("MALFORMED") from error
subject = claims["sub"]
if not isinstance(subject, str) or not _SUBJECT.fullmatch(subject):
raise SsoRejected("INVALID_SUBJECT")
if not isinstance(claims["iat"], int) or not isinstance(claims["exp"], int) or claims["exp"] - claims["iat"] > MAX_LIFETIME_SECONDS:
raise SsoRejected("LIFETIME_TOO_LONG", subject)
if not isinstance(claims["jti"], str) or not _TOKEN_CHARS.fullmatch(claims["jti"]) or len(claims["jti"]) > 64:
raise SsoRejected("INVALID_JTI", subject)
nonce = claims["nonce"]
if not valid_state(expected_state) or not isinstance(nonce, str) or not hmac.compare_digest(nonce, expected_state):
raise SsoRejected("STATE_MISMATCH", subject)
login, name, role = claims.get("login"), claims.get("name"), claims.get("perfil")
if not isinstance(login, str) or not login.strip() or not isinstance(name, str):
raise SsoRejected("MISSING_PROFILE", subject)
return Identity(
subject=subject, login=login.strip()[:128], name=name.strip()[:160] or login.strip()[:160],
role=role[:32] if isinstance(role, str) else None, jti=claims["jti"],
expires_at=datetime.fromtimestamp(claims["exp"], tz=timezone.utc),
)
def consume_jti(session, identity: Identity) -> None:
"""Records the jti; a second use of the same assertion is a replay."""
now = datetime.now(timezone.utc)
session.execute(delete(SsoUsedAssertion).where(SsoUsedAssertion.expires_at < now - timedelta(days=1)))
session.add(SsoUsedAssertion(jti=identity.jti, subject=identity.subject, expires_at=identity.expires_at))
try:
session.flush()
except IntegrityError as error:
session.rollback()
raise SsoRejected("REPLAY", identity.subject) from error
def provision(session, identity: Identity) -> AdminUser:
"""Creates the collaborator on first access, refreshes name/role afterwards."""
user = session.scalar(select(AdminUser).where(AdminUser.external_subject == identity.subject))
if user is None:
user = AdminUser(
username=f"gerenciador:{identity.subject.split(':', 1)[1]}", password_hash=None, auth_source=AUTH_SOURCE,
external_subject=identity.subject, display_name=identity.name, external_role=identity.role, is_active=True,
)
session.add(user)
session.flush()
return user
if user.auth_source != AUTH_SOURCE:
raise SsoRejected("SUBJECT_BOUND_TO_LOCAL_ACCOUNT", identity.subject)
if not user.is_active:
raise SsoRejected("DISABLED_IN_FISCAL", identity.subject)
user.display_name, user.external_role = identity.name, identity.role
return user