open-swe/tests/auth/test_atlassian_connect.py
Adam Moussa ae1f883b4c
refactor: move tests into tests/<domain>/ layout
Applies the plan's C5 step: git mv every test per the domain-reorg
move-map (movemap-m50.txt) into tests/{agent,analyzer,auth,dashboard,
github,middleware,models,reviewer,sandbox,slack,tools,webhooks}/, plus
the 13 fork-only placements from the scoping report §2c (Atlassian
webhook tests -> tests/webhooks/, test_atlassian_connect.py and
test_auth_error_leak.py -> tests/auth/, jira/confluence util tests ->
tests/tools/, test_repo_binding_isolation.py -> tests/sandbox/,
bot-identity/autofix tests -> tests/github/).

Path-only move: the only content edits are parents[1] -> parents[2]
fixes in test_e2b_integration.py and test_daytona_integration.py,
required because their __file__-relative ROOT path gained one more
directory level in the move.

Monkeypatch retargets for these files were already completed in C4;
none remained outstanding here.
2026-07-17 14:42:45 -04:00

305 lines
11 KiB
Python

"""Atlassian Connect qsh vectors + JWT verification (auth boundary).
qsh correctness is a silent-auth-bypass surface, so the official Atlassian test
vector and the three endpoint vectors are pinned here. The JWT tests exercise
alg-pinning, signature, exp, issuer binding, and qsh binding.
"""
from __future__ import annotations
import time
from types import SimpleNamespace
import jwt
import pytest
from agent.utils import atlassian_connect as ac
_SECRET = "connect-shared-secret"
def _request(method: str, path: str, query: str = "", *, token: str | None = None) -> object:
headers = {"Authorization": f"JWT {token}"} if token else {}
return SimpleNamespace(
method=method,
url=SimpleNamespace(path=path, query=query),
headers=headers,
query_params={},
)
def _make_token(
*,
secret: str = _SECRET,
iss: str = "tenant-1",
alg: str = "HS256",
exp_delta: int = 180,
qsh: str | None = "auto",
method: str = "POST",
path: str = "/connect/webhook/comment-created",
query: str = "",
drop_exp: bool = False,
) -> str:
claims: dict = {"iss": iss}
if not drop_exp:
claims["exp"] = int(time.time()) + exp_delta
if qsh == "auto":
claims["qsh"] = ac.compute_qsh(method, path, query)
elif qsh is not None:
claims["qsh"] = qsh
return jwt.encode(claims, secret, algorithm=alg)
# --- qsh vectors -----------------------------------------------------------
def test_official_atlassian_qsh_vector() -> None:
canon = ac.canonical_request(
"GET",
"/path/to/service",
"zee_last=param&repeated=parameter 1&first=param&repeated=parameter 2",
)
assert canon == (
"GET&/path/to/service&first=param&repeated=parameter%201,parameter%202&zee_last=param"
)
@pytest.mark.parametrize(
("path", "expected"),
[
("/connect/installed", "72c0a77bd4d709a202e9b2561ed003fdb400318f7a1cfabe47576d1e1d5b5dd7"),
(
"/connect/uninstalled",
"ef0c0673ed4cf59a823d82cdc5c397c8643d79db724ce7d567342ea15e02acfe",
),
(
"/connect/webhook/comment-created",
"72e058a8906e894732718ec80dbbbf073640341b9ac6ed7cd86f887f23a00b4d",
),
],
)
def test_endpoint_qsh_vectors(path: str, expected: str) -> None:
assert ac.compute_qsh("POST", path, "") == expected
def test_qsh_drops_jwt_param_and_encodes_space_not_plus() -> None:
# jwt param is excluded; space must be %20 (never +).
with_jwt = ac.compute_qsh("GET", "/x", "a=b c&jwt=zzz")
without = ac.compute_qsh("GET", "/x", "a=b c")
assert with_jwt == without
assert "%20" in ac.canonical_request("GET", "/x", "a=b c")
assert "+" not in ac.canonical_request("GET", "/x", "a=b c")
# --- JWT verification ------------------------------------------------------
def test_valid_token_accepted() -> None:
token = _make_token()
req = _request("POST", "/connect/webhook/comment-created", token=token)
claims = ac.verify_connect_jwt(req, shared_secret=_SECRET)
assert claims is not None
assert claims["iss"] == "tenant-1"
def test_missing_token_rejected() -> None:
req = _request("POST", "/connect/webhook/comment-created")
assert ac.verify_connect_jwt(req, shared_secret=_SECRET) is None
def test_alg_none_rejected() -> None:
token = jwt.encode({"iss": "t", "exp": int(time.time()) + 60}, "", algorithm="none")
req = _request("POST", "/connect/webhook/comment-created", token=token)
assert ac.verify_connect_jwt(req, shared_secret=_SECRET) is None
def test_wrong_secret_rejected() -> None:
token = _make_token(secret="attacker-secret")
req = _request("POST", "/connect/webhook/comment-created", token=token)
assert ac.verify_connect_jwt(req, shared_secret=_SECRET) is None
def test_expired_token_rejected() -> None:
token = _make_token(exp_delta=-3600)
req = _request("POST", "/connect/webhook/comment-created", token=token)
assert ac.verify_connect_jwt(req, shared_secret=_SECRET) is None
def test_missing_secret_fails_closed() -> None:
token = _make_token()
req = _request("POST", "/connect/webhook/comment-created", token=token)
assert ac.verify_connect_jwt(req, shared_secret=None) is None
def test_issuer_binding_mismatch_rejected() -> None:
token = _make_token(iss="tenant-1")
req = _request("POST", "/connect/webhook/comment-created", token=token)
assert ac.verify_connect_jwt(req, shared_secret=_SECRET, expected_client_key="tenant-2") is None
def test_qsh_mismatch_rejected_cross_endpoint_replay() -> None:
# Token signed with the qsh for /installed, replayed at the webhook endpoint.
token = _make_token(path="/connect/installed")
req = _request("POST", "/connect/webhook/comment-created", token=token)
assert ac.verify_connect_jwt(req, shared_secret=_SECRET) is None
def test_missing_qsh_rejected_when_required() -> None:
token = _make_token(qsh=None)
req = _request("POST", "/connect/webhook/comment-created", token=token)
assert ac.verify_connect_jwt(req, shared_secret=_SECRET, qsh_required=True) is None
def test_missing_qsh_allowed_on_lifecycle_when_not_required() -> None:
token = _make_token(qsh=None)
req = _request("POST", "/connect/installed", token=token)
assert ac.verify_connect_jwt(req, shared_secret=_SECRET, qsh_required=False) is not None
def test_context_qsh_rejected() -> None:
token = _make_token(qsh="context-qsh")
req = _request("POST", "/connect/webhook/comment-created", token=token)
assert ac.verify_connect_jwt(req, shared_secret=_SECRET, qsh_required=False) is None
def test_missing_exp_rejected() -> None:
token = _make_token(drop_exp=True)
req = _request("POST", "/connect/webhook/comment-created", token=token)
assert ac.verify_connect_jwt(req, shared_secret=_SECRET) is None
# --- baseUrl host allowlist (first-install gate) ---------------------------
def test_base_url_allowlist(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(
ac, "CONNECT_EXPECTED_BASE_URL_HOSTS", frozenset({"seahaven.atlassian.net"})
)
assert ac.base_url_host_allowed("https://seahaven.atlassian.net/wiki") is True
assert ac.base_url_host_allowed("https://evil.example.com/wiki") is False
def test_base_url_allowlist_empty_fails_closed(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(ac, "CONNECT_EXPECTED_BASE_URL_HOSTS", frozenset())
assert ac.base_url_host_allowed("https://seahaven.atlassian.net/wiki") is False
# --- signed-install (asymmetric RS256) lifecycle verification ---------------
_AUD = "https://openswe.example.com"
def _rsa_keypair() -> tuple[str, str]:
from cryptography.hazmat.primitives import serialization
from cryptography.hazmat.primitives.asymmetric import rsa
key = rsa.generate_private_key(public_exponent=65537, key_size=2048)
priv = key.private_bytes(
serialization.Encoding.PEM,
serialization.PrivateFormat.PKCS8,
serialization.NoEncryption(),
).decode()
pub = (
key.public_key()
.public_bytes(serialization.Encoding.PEM, serialization.PublicFormat.SubjectPublicKeyInfo)
.decode()
)
return priv, pub
def _install_token(priv_pem: str, *, iss="tenant-1", aud=_AUD, exp_delta=180) -> str:
return jwt.encode(
{"iss": iss, "aud": aud, "exp": int(time.time()) + exp_delta},
priv_pem,
algorithm="RS256",
headers={"kid": "install-key-1"},
)
def _install_req(token: str) -> object:
return SimpleNamespace(
method="POST",
url=SimpleNamespace(path="/connect/installed", query=""),
headers={"Authorization": f"JWT {token}"},
query_params={},
)
def _run_install_verify(token, pub_pem, monkeypatch, *, expected_client_key=None):
import asyncio
from unittest.mock import AsyncMock, patch
monkeypatch.setattr(ac, "CONNECT_BASE_URL", _AUD)
with patch.object(ac, "_fetch_atlassian_public_key", new=AsyncMock(return_value=pub_pem)):
return asyncio.run(
ac.verify_asymmetric_install_jwt(
_install_req(token), expected_client_key=expected_client_key
)
)
def test_signed_install_valid_accepted(monkeypatch: pytest.MonkeyPatch) -> None:
priv, pub = _rsa_keypair()
claims = _run_install_verify(_install_token(priv), pub, monkeypatch)
assert claims is not None and claims["iss"] == "tenant-1"
def test_signed_install_hs256_rejected(monkeypatch: pytest.MonkeyPatch) -> None:
# An HS256 token (symmetric alg-confusion) must not pass asymmetric verify.
_priv, pub = _rsa_keypair()
hs = jwt.encode({"iss": "t", "aud": _AUD, "exp": int(time.time()) + 60}, "x", algorithm="HS256")
assert _run_install_verify(hs, pub, monkeypatch) is None
def test_signed_install_wrong_audience_rejected(monkeypatch: pytest.MonkeyPatch) -> None:
priv, pub = _rsa_keypair()
token = _install_token(priv, aud="https://some-other-app.example.com")
assert _run_install_verify(token, pub, monkeypatch) is None
def test_signed_install_wrong_key_rejected(monkeypatch: pytest.MonkeyPatch) -> None:
priv, _pub = _rsa_keypair()
_priv2, pub2 = _rsa_keypair()
assert _run_install_verify(_install_token(priv), pub2, monkeypatch) is None
def test_signed_install_expired_rejected(monkeypatch: pytest.MonkeyPatch) -> None:
priv, pub = _rsa_keypair()
assert _run_install_verify(_install_token(priv, exp_delta=-3600), pub, monkeypatch) is None
def test_signed_install_issuer_binding(monkeypatch: pytest.MonkeyPatch) -> None:
priv, pub = _rsa_keypair()
token = _install_token(priv, iss="tenant-1")
assert _run_install_verify(token, pub, monkeypatch, expected_client_key="tenant-2") is None
def test_signed_install_key_fetch_failure_rejected(monkeypatch: pytest.MonkeyPatch) -> None:
priv, _pub = _rsa_keypair()
assert _run_install_verify(_install_token(priv), None, monkeypatch) is None
async def test_fetch_public_key_rejects_malformed_kid() -> None:
# Defense-in-depth: a kid with path/URL chars is rejected before any fetch.
for bad in ["../../evil", "a/b", "http://evil.com", "a b", ""]:
assert await ac._fetch_atlassian_public_key(bad) is None
def test_client_key_allowed_fails_closed(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(ac, "CONNECT_EXPECTED_CLIENT_KEYS", frozenset())
assert ac.client_key_allowed("anything") is False
monkeypatch.setattr(ac, "CONNECT_EXPECTED_CLIENT_KEYS", frozenset({"ours"}))
assert ac.client_key_allowed("ours") is True
assert ac.client_key_allowed("attacker") is False
assert ac.client_key_allowed("") is False
def test_signed_install_no_base_url_rejected(monkeypatch: pytest.MonkeyPatch) -> None:
import asyncio
from unittest.mock import AsyncMock, patch
priv, pub = _rsa_keypair()
monkeypatch.setattr(ac, "CONNECT_BASE_URL", "")
with patch.object(ac, "_fetch_atlassian_public_key", new=AsyncMock(return_value=pub)):
result = asyncio.run(ac.verify_asymmetric_install_jwt(_install_req(_install_token(priv))))
assert result is None