"""SSO from GTI Gerenciador into the Hub (central/app/sso.py, web.py).""" import base64 import dataclasses import time import uuid from datetime import datetime, timedelta, timezone from urllib.parse import parse_qs, urlsplit import jwt import pytest from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey from cryptography.hazmat.primitives.serialization import Encoding, PublicFormat from fastapi.testclient import TestClient from sqlalchemy import select from central.app import config, sso from central.app.human_auth import verify_password from central.app.main import create_app from central.app.models import AdminUser, AuditEvent, SsoUsedAssertion, WebSession from central.tests.test_control_plane import seed, setup_db START_URL = "https://gtigerenciador.gtigrupo.com.br/sso/fiscal" PRIVATE = Ed25519PrivateKey.generate() PUBLIC_B64 = base64.urlsafe_b64encode(PRIVATE.public_key().public_bytes(Encoding.Raw, PublicFormat.Raw)).decode().rstrip("=") @pytest.fixture(autouse=True) def sso_settings(monkeypatch): monkeypatch.setattr(config, "settings", dataclasses.replace(config.settings, sso_public_key=PUBLIC_B64, sso_start_url=START_URL, web_cookie_secure=True, federated_session_seconds=3600)) @pytest.fixture def hub(): factory = setup_db() seed(factory) # creates the LOCAL break-glass admin "operator" client = TestClient(create_app(database_probe=lambda: True, session_factory=factory, bootstrap_token=None), base_url="https://testserver") return client, factory def assertion(state, *, sub="funcionario:7", key=PRIVATE, algorithm="EdDSA", aud=sso.AUDIENCE, iss=sso.ISSUER, iat=None, lifetime=60, jti=None, **extra): now = int(iat if iat is not None else time.time()) claims = {"iss": iss, "aud": aud, "sub": sub, "iat": now, "nbf": now, "exp": now + lifetime, "jti": jti or str(uuid.uuid4()), "nonce": state, "login": "maria", "name": "Maria Teste", "perfil": "PADRAO", **extra} return jwt.encode(claims, key, algorithm=algorithm) def start(client, next_path="/"): response = client.get("/login", params={"next": next_path}, follow_redirects=False) return response, response.cookies.get(sso.STATE_COOKIE) or client.cookies.get(sso.STATE_COOKIE) def callback(client, token, next_path="/"): return client.post("/auth/sso/callback", data={"assertion": token, "next": next_path}, follow_redirects=False) def audits(factory, action): with factory() as session: return session.scalars(select(AuditEvent).where(AuditEvent.action == action)).all() def test_login_starts_sso_at_the_panel_with_a_browser_bound_state(hub): client, _ = hub response, state = start(client, "/agents") assert response.status_code == 303 target = urlsplit(response.headers["location"]) assert f"{target.scheme}://{target.netloc}{target.path}" == START_URL query = parse_qs(target.query) assert query["next"] == ["/agents"] and query["state"] == [state] and len(state) >= 16 cookie = next(h for h in response.headers.get_list("set-cookie") if h.startswith(sso.STATE_COOKIE)) assert "HttpOnly" in cookie and "Secure" in cookie and "SameSite=none" in cookie and "Path=/auth/sso" in cookie def test_active_employee_enters_without_password_and_is_provisioned(hub): client, factory = hub _, state = start(client, "/agents") response = callback(client, assertion(state), "/agents") assert response.status_code == 303 and response.headers["location"] == "/agents" cookies = response.headers.get_list("set-cookie") session_cookie = next(h for h in cookies if h.startswith(config.settings.web_cookie_name)) assert "HttpOnly" in session_cookie and "Secure" in session_cookie and "SameSite=lax" in session_cookie csrf_cookie = next(h for h in cookies if h.startswith("gti_csrf")) assert "Secure" in csrf_cookie and "HttpOnly" not in csrf_cookie page = client.get("/agents") assert page.status_code == 200 and "Maria Teste" in page.text and 'name="password"' not in page.text with factory() as session: user = session.scalar(select(AdminUser).where(AdminUser.external_subject == "funcionario:7")) assert user.auth_source == "GTI_GERENCIADOR" and user.password_hash is None and user.external_role == "PADRAO" web_session = session.scalar(select(WebSession).where(WebSession.admin_user_id == user.id)) lifetime = web_session.expires_at.replace(tzinfo=timezone.utc) - datetime.now(timezone.utc) assert timedelta(minutes=55) < lifetime <= timedelta(hours=1) success = audits(factory, "SSO_LOGIN_SUCCESS") assert len(success) == 1 and success[0].target_id == "funcionario:7" and "ip" in success[0].context def test_second_login_reuses_the_provisioned_user_and_refreshes_its_name(hub): client, factory = hub _, state = start(client) callback(client, assertion(state)) _, state = start(client) callback(client, assertion(state, name="Maria T. Souza", perfil="ADMIN")) with factory() as session: users = session.scalars(select(AdminUser).where(AdminUser.auth_source == "GTI_GERENCIADOR")).all() assert len(users) == 1 and users[0].display_name == "Maria T. Souza" and users[0].external_role == "ADMIN" @pytest.mark.parametrize("builder,reason", [ (lambda s: assertion(s, iat=time.time() - 600), "EXPIRED"), (lambda s: assertion(s, key=Ed25519PrivateKey.generate()), "INVALID_SIGNATURE"), (lambda s: assertion(s, aud="outro-sistema"), "WRONG_AUDIENCE"), (lambda s: assertion(s, iss="impostor"), "WRONG_ISSUER"), (lambda s: jwt.encode({"iss": sso.ISSUER, "aud": sso.AUDIENCE, "sub": "funcionario:7", "nonce": s}, "segredo", algorithm="HS256"), "INVALID_ALGORITHM"), (lambda s: assertion(s, lifetime=3600), "LIFETIME_TOO_LONG"), (lambda s: assertion(s, sub="admin"), "INVALID_SUBJECT"), (lambda s: assertion("outro-navegador-0123456789"), "STATE_MISMATCH"), (lambda s: "nao-e-um-jwt", "MALFORMED"), ]) def test_invalid_assertions_are_rejected_and_audited(hub, builder, reason): client, factory = hub _, state = start(client) token = builder(state) response = callback(client, token) assert response.status_code == 401 and config.settings.web_cookie_name not in response.cookies rejected = audits(factory, "SSO_LOGIN_REJECTED") assert [event.context["reason"] for event in rejected] == [reason] assert token not in str(rejected[0].context) with factory() as session: assert session.scalar(select(AdminUser).where(AdminUser.auth_source == "GTI_GERENCIADOR")) is None def test_assertion_without_the_state_cookie_is_rejected(hub): client, factory = hub _, state = start(client) client.cookies.delete(sso.STATE_COOKIE) assert callback(client, assertion(state)).status_code == 401 assert audits(factory, "SSO_LOGIN_REJECTED")[0].context["reason"] == "STATE_MISMATCH" def test_replayed_assertion_is_rejected(hub): client, factory = hub _, state = start(client) token = assertion(state) assert callback(client, token).status_code == 303 client.cookies.clear() client.cookies.set(sso.STATE_COOKIE, state) # an attacker replaying with the matching state assert callback(client, token).status_code == 401 assert audits(factory, "SSO_LOGIN_REJECTED")[0].context["reason"] == "REPLAY" with factory() as session: assert len(session.scalars(select(SsoUsedAssertion)).all()) == 1 @pytest.mark.parametrize("malicious", ["//evil.com/x", "https://evil.com", "javascript:alert(1)", "/\\evil.com", "data:text/html,x", "agents"]) def test_next_never_leaves_the_hub(hub, malicious): client, _ = hub _, state = start(client) response = callback(client, assertion(state), malicious) assert response.status_code == 303 and response.headers["location"] == "/" login = client.get("/login", params={"next": malicious}, follow_redirects=False) assert parse_qs(urlsplit(login.headers["location"]).query)["next"] == ["/"] def test_user_disabled_in_the_hub_cannot_sso(hub): client, factory = hub _, state = start(client) callback(client, assertion(state)) with factory() as session: user = session.scalar(select(AdminUser).where(AdminUser.external_subject == "funcionario:7")) user.is_active = False; session.commit() client.cookies.clear() _, state = start(client) assert callback(client, assertion(state)).status_code == 401 assert audits(factory, "SSO_LOGIN_REJECTED")[0].context["reason"] == "DISABLED_IN_FISCAL" assert client.get("/", follow_redirects=False).status_code == 303 def test_federated_account_has_no_password_login(hub): client, _ = hub _, state = start(client) callback(client, assertion(state)) client.cookies.clear() response = client.post("/login", data={"username": "gerenciador:7", "password": "qualquer-coisa-longa"}, follow_redirects=False) assert response.status_code == 401 assert verify_password("x", None) is False def test_break_glass_local_admin_still_works_and_is_audited(hub): client, factory = hub assert client.get("/login/local").status_code == 200 response = client.post("/login", data={"username": "operator", "password": "a-secure-password"}, follow_redirects=False) assert response.status_code == 303 assert client.get("/").status_code == 200 assert len(audits(factory, "LOGIN_LOCAL_SUCCESS")) == 1 client.cookies.clear() client.post("/login", data={"username": "operator", "password": "errada-mas-longa"}) assert len(audits(factory, "LOGIN_LOCAL_FAILURE")) == 1 def test_unauthenticated_page_goes_to_sso_and_keeps_the_destination(hub): client, _ = hub response = client.get("/blockers?status=open", follow_redirects=False) assert response.status_code == 303 assert parse_qs(urlsplit(response.headers["location"]).query)["next"] == ["/blockers?status=open"] def test_logout_ends_only_the_hub_session_and_csrf_is_enforced(hub): client, factory = hub _, state = start(client) callback(client, assertion(state)) assert client.post("/logout", data={}).status_code == 403 # no CSRF token assert client.get("/").status_code == 200 response = client.post("/logout", data={"csrf_token": client.cookies.get("gti_csrf")}) assert response.status_code == 200 and "VocĂȘ saiu do GTI Fiscal" in response.text and "gtigerenciador.gtigrupo.com.br" in response.text assert client.get("/", follow_redirects=False).status_code == 303 assert len(audits(factory, "LOGOUT")) == 1 _, state = start(client) # coming back through the panel signs in again, no password assert callback(client, assertion(state)).status_code == 303 def test_expired_federated_session_sends_back_through_sso(hub): client, factory = hub _, state = start(client) callback(client, assertion(state)) with factory() as session: for row in session.scalars(select(WebSession)).all(): row.expires_at = datetime.now(timezone.utc) - timedelta(seconds=1) session.commit() response = client.get("/jobs", follow_redirects=False) assert response.status_code == 303 and response.headers["location"].startswith("/login?") def test_without_sso_configuration_the_hub_keeps_the_local_login(hub, monkeypatch): monkeypatch.setattr(config, "settings", dataclasses.replace(config.settings, sso_public_key="")) client, factory = hub assert client.get("/login").status_code == 200 assert callback(client, assertion("x" * 20)).status_code == 401 assert audits(factory, "SSO_LOGIN_REJECTED")[0].context["reason"] == "SSO_DISABLED"