"""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