"""Canonical JWT bypass-attack regressions for :class:`ClerkSessionVerifier`.

Marked ``regression``: each test pins a classic auth-bypass attack and
asserts the verifier REJECTS it (raises :class:`InvalidSessionError`,
which carries ``http_status == 401``). The verifier pins
``algorithms=["RS256"]`` in ``pyjwt.decode`` (clerk_session.py:141), so
every asymmetric→symmetric / unsigned trick below must be refused at the
PyJWT layer before any principal is resolved.

These complement ``tests/test_clerk_session.py`` (valid token, wrong
signing key, expired, wrong issuer, missing claims) — none of those
exercise the hostile-header / algorithm-confusion class of attack.
"""

from __future__ import annotations

import base64
import hashlib
import hmac
import json
import time
from datetime import UTC, datetime, timedelta
from uuid import uuid4

import jwt as pyjwt
import pytest
from cryptography.hazmat.primitives import serialization
from cryptography.hazmat.primitives.asymmetric import rsa
from tests.conftest import Principal

from mgf.auth import InvalidSessionError
from mgf.auth.clerk_session import ClerkClaims, ClerkSessionVerifier

pytestmark = pytest.mark.regression

_ISSUER = "https://clerk.test.example"


# ---------------------------------------------------------------------
# Helpers (mirror tests/test_clerk_session.py so the attack suite is
# self-contained; the keypair/sign shape is identical).
# ---------------------------------------------------------------------


def _make_keypair() -> tuple[bytes, bytes]:
    """Return ``(private_pem, public_pem)`` as bytes."""
    private_key = rsa.generate_private_key(public_exponent=65537, key_size=2048)
    private_pem = private_key.private_bytes(
        encoding=serialization.Encoding.PEM,
        format=serialization.PrivateFormat.PKCS8,
        encryption_algorithm=serialization.NoEncryption(),
    )
    public_pem = private_key.public_key().public_bytes(
        encoding=serialization.Encoding.PEM,
        format=serialization.PublicFormat.SubjectPublicKeyInfo,
    )
    return private_pem, public_pem


def _default_claims(now: int | None = None) -> dict[str, object]:
    now = now if now is not None else int(time.time())
    return {
        "iss": _ISSUER,
        "sub": "user_2_faraj",
        "org_id": "org_acme",
        "org_role": "operator",
        "email": "faraj@acme.example",
        "locale": "ar-IQ",
        "iat": now,
        "exp": now + 3600,
    }


def _seeded_principal() -> Principal:
    now = datetime.now(UTC)
    return Principal(
        user_id=uuid4(),
        tenant_id=uuid4(),
        role="operator",
        email="faraj@acme.example",
        locale="ar-IQ",
        issued_at=now,
        expires_at=now + timedelta(hours=1),
    )


def _b64url(raw: bytes) -> str:
    return base64.urlsafe_b64encode(raw).rstrip(b"=").decode("ascii")


def _b64url_json(obj: dict[str, object]) -> str:
    return _b64url(json.dumps(obj, separators=(",", ":")).encode())


def _forge_hs_token(claims: dict[str, object], hmac_secret: bytes, alg: str) -> str:
    """Hand-craft an HS* JWT signed with ``hmac_secret``.

    PyJWT's high-level ``encode`` refuses to use an RSA PEM as an HMAC
    key (a client-side guard), so a real algorithm-confusion attacker
    constructs the token byte-for-byte. ``alg`` is one of HS256/384/512.
    """
    digestmod = {
        "HS256": hashlib.sha256,
        "HS384": hashlib.sha384,
        "HS512": hashlib.sha512,
    }[alg]
    header = _b64url_json({"alg": alg, "typ": "JWT"})
    payload = _b64url_json(claims)
    signing_input = f"{header}.{payload}".encode("ascii")
    sig = hmac.new(hmac_secret, signing_input, digestmod).digest()
    return f"{header}.{payload}.{_b64url(sig)}"


def _build_verifier(
    public_pem: bytes,
) -> tuple[ClerkSessionVerifier[Principal], list[ClerkClaims]]:
    """Verifier + a capture list. If the resolver is ever invoked, the
    attack slipped past JWT verification — so an empty capture list is
    itself an assertion that no principal leaked."""
    captured: list[ClerkClaims] = []
    principal = _seeded_principal()

    async def resolver(claims: ClerkClaims) -> Principal:
        captured.append(claims)
        return principal

    verifier: ClerkSessionVerifier[Principal] = ClerkSessionVerifier(
        jwt_public_key=public_pem.decode(),
        issuer=_ISSUER,
        principal_resolver=resolver,
    )
    return verifier, captured


# ---------------------------------------------------------------------
# Attack 1 — alg=none (unsigned token)
# ---------------------------------------------------------------------


class TestAlgNone:
    @pytest.mark.asyncio
    async def test_alg_none_token_rejected(self) -> None:
        # CRITICAL bypass: a token with header alg "none" and an empty
        # signature. PyJWT's `algorithms=["RS256"]` pin must refuse it.
        _, public = _make_keypair()
        verifier, captured = _build_verifier(public)

        # `pyjwt.encode(..., algorithm="none")` refuses to emit an
        # unsigned token, so craft it by hand to be a genuine attacker.
        token = pyjwt.encode(
            _default_claims(),
            key="",
            algorithm="none",
        )

        with pytest.raises(InvalidSessionError):
            await verifier.verify_session(token)
        # The resolver must never have seen these forged claims.
        assert captured == []

    @pytest.mark.asyncio
    async def test_alg_none_uppercase_variant_rejected(self) -> None:
        # Header-casing tricks ("None" / "NONE") are the same attack;
        # they must not slip past the RS256 pin either.
        _, public = _make_keypair()
        verifier, captured = _build_verifier(public)

        header = _b64url_json({"alg": "None", "typ": "JWT"})
        payload = _b64url_json(_default_claims())
        token = f"{header}.{payload}."  # empty signature segment

        with pytest.raises(InvalidSessionError):
            await verifier.verify_session(token)
        assert captured == []


# ---------------------------------------------------------------------
# Attack 2 — RS256 -> HS256 algorithm confusion (THE critical one)
# ---------------------------------------------------------------------


class TestAlgorithmConfusion:
    @pytest.mark.asyncio
    async def test_rs256_to_hs256_confusion_rejected(self) -> None:
        # The attacker takes the verifier's RSA PUBLIC key (public by
        # design) and uses its PEM bytes as the HMAC secret to sign an
        # HS256 token with otherwise-valid claims. A naive verifier that
        # doesn't pin the algorithm would HMAC-verify using the public
        # key it holds and ACCEPT this forgery. The RS256 pin
        # (clerk_session.py:141) must reject it.
        _, public = _make_keypair()
        verifier, captured = _build_verifier(public)

        forged = _forge_hs_token(_default_claims(), public, "HS256")

        with pytest.raises(InvalidSessionError):
            await verifier.verify_session(forged)
        assert captured == []

    @pytest.mark.asyncio
    async def test_hs384_hs512_confusion_rejected(self) -> None:
        # Same family, wider HMAC variants — also blocked by the pin.
        _, public = _make_keypair()
        verifier, captured = _build_verifier(public)

        for alg in ("HS384", "HS512"):
            forged = _forge_hs_token(_default_claims(), public, alg)
            with pytest.raises(InvalidSessionError):
                await verifier.verify_session(forged)
        assert captured == []


# ---------------------------------------------------------------------
# Attack 3 — tampered signature (bit-flip)
# ---------------------------------------------------------------------


class TestTamperedSignature:
    @pytest.mark.asyncio
    async def test_flipped_signature_byte_rejected(self) -> None:
        private, public = _make_keypair()
        verifier, captured = _build_verifier(public)

        token = pyjwt.encode(_default_claims(), private, algorithm="RS256")
        header, payload, signature = token.split(".")
        # Flip one character in the (base64url) signature segment.
        flipped = "B" if signature[0] != "B" else "C"
        tampered = f"{header}.{payload}.{flipped}{signature[1:]}"

        with pytest.raises(InvalidSessionError):
            await verifier.verify_session(tampered)
        assert captured == []


# ---------------------------------------------------------------------
# Attack 4 — forged claims signed by an attacker key (privilege escalation)
# ---------------------------------------------------------------------


class TestForgedClaims:
    @pytest.mark.asyncio
    async def test_attacker_key_with_escalated_claims_rejected(self) -> None:
        # Verifier trusts key A. Attacker mints a *structurally valid*
        # RS256 token with their own key B, carrying escalated claims
        # (different org + owner role). Signature won't verify against
        # A's public key -> reject. No privilege may leak to the
        # resolver.
        _, public_a = _make_keypair()
        private_b, _ = _make_keypair()
        verifier, captured = _build_verifier(public_a)

        claims = _default_claims()
        claims["org_id"] = "org_victim"
        claims["org_role"] = "owner"
        claims["sub"] = "user_attacker"
        forged = pyjwt.encode(claims, private_b, algorithm="RS256")

        with pytest.raises(InvalidSessionError):
            await verifier.verify_session(forged)
        # No escalated principal reached the resolver.
        assert captured == []


# ---------------------------------------------------------------------
# Attack 5 — timestamp edges
# ---------------------------------------------------------------------


class TestTimestampEdges:
    @pytest.mark.asyncio
    async def test_exp_equals_iat_already_expired_rejected(self) -> None:
        # exp == iat means the token expires at the instant it's issued;
        # by verification time it is already past `exp`.
        private, public = _make_keypair()
        verifier, captured = _build_verifier(public)

        past = int(time.time()) - 60
        claims = _default_claims(now=past)
        claims["exp"] = claims["iat"]  # exp == iat, both in the past
        token = pyjwt.encode(claims, private, algorithm="RS256")

        with pytest.raises(InvalidSessionError, match="expired"):
            await verifier.verify_session(token)
        assert captured == []

    @pytest.mark.asyncio
    async def test_exp_slightly_in_past_rejected(self) -> None:
        private, public = _make_keypair()
        verifier, captured = _build_verifier(public)

        now = int(time.time())
        claims = _default_claims()
        claims["iat"] = now - 120
        claims["exp"] = now - 30  # expired 30s ago, beyond PyJWT's leeway
        token = pyjwt.encode(claims, private, algorithm="RS256")

        with pytest.raises(InvalidSessionError, match="expired"):
            await verifier.verify_session(token)
        assert captured == []

    @pytest.mark.asyncio
    async def test_far_future_exp_with_wrong_issuer_still_rejected(self) -> None:
        # A token that's nowhere near expiry must STILL be rejected when
        # the issuer is wrong — freshness can't buy past an `iss` check.
        private, public = _make_keypair()
        verifier, captured = _build_verifier(public)

        now = int(time.time())
        claims = _default_claims()
        claims["exp"] = now + 10 * 365 * 24 * 3600  # ~10 years out
        claims["iss"] = "https://clerk.attacker.example"
        token = pyjwt.encode(claims, private, algorithm="RS256")

        with pytest.raises(InvalidSessionError, match="issuer"):
            await verifier.verify_session(token)
        assert captured == []


# ---------------------------------------------------------------------
# `aud` is NOT validated by ClerkSessionVerifier (clerk_session.py:138-144
# passes no `audience=` to pyjwt.decode and does not require an `aud`
# claim). This is a deliberate non-test: potential hardening if Clerk
# templates start emitting `aud`, but inventing an aud-rejection test
# here would assert behaviour the verifier does not implement.
# ---------------------------------------------------------------------
