mirror of
https://github.com/Sea-Haven-Industries/open-swe.git
synced 2026-09-30 19:43:15 +00:00
Some checks are pending
CI / Lint (push) Waiting to run
CI / Format check (push) Waiting to run
CI / Typecheck (push) Waiting to run
CI / Unit tests (push) Waiting to run
CI / Playwright E2E (push) Waiting to run
CI / Docker build smoke (push) Waiting to run
CI / Triage ledger up to date (push) Waiting to run
CI / ui bun.lock in sync (push) Waiting to run
* feat(open-swe): add Jira tool plane (Phase 1)
Curated Jira Cloud REST v3 toolset for the agent, mirroring the Linear
tools:
- utils/jira.py: service-account REST client (Basic auth) with get/
create/update issue, comments, list projects, trace comment; issue and
comment bodies normalized to markdown.
- utils/adf.py: minimal ADF <-> markdown conversion (read paths convert
Jira ADF to markdown; agent comments convert prose to ADF).
- tools/jira_{comment,get_issue,get_issue_comments,create_issue,
update_issue,list_projects}.py wired into the tool registry and the
main agent tool list.
- tests/test_jira_utils.py: ADF conversion + mocked-transport util tests.
Reads JIRA_BASE_URL / JIRA_SERVICE_EMAIL / JIRA_API_TOKEN; unset env
returns a clean error, so this is safe to land dark. Trigger plane,
prompt guidance, and config plumbing follow in Phase 2.
* feat(open-swe): add Confluence tool plane (Phase 3)
Curated Confluence Cloud REST toolset for the agent, mirroring the Jira
tools:
- utils/confluence.py: service-account REST client (Basic auth) with
get/create/update page, add comment, CQL search. Page bodies are XHTML
storage format (not ADF), with minimal storage<->text converters;
update_page reads the current version and bumps it, as Confluence
requires.
- tools/confluence_{get_page,create_page,update_page,comment,search}.py
registered in the tool registry.
- tests/test_confluence_utils.py: converter + mocked-transport tests
including the version-bump path.
Reads CONFLUENCE_BASE_URL / CONFLUENCE_EMAIL / CONFLUENCE_API_TOKEN;
unset env returns a clean error. Activation in the agent tool list lands
with the Phase 2 server.py wiring.
* feat(open-swe): add Jira trigger plane (Phase 2)
Make an @openswe comment on a Jira issue spawn an agent run, mirroring
the Linear trigger plane:
- webhooks/jira.py: process_jira_issue clones process_linear_issue —
deterministic thread id, full-issue fetch, actor accountId->email
attribution feeding resolve_login_from_email_async (PRs open as the
human), multimodal image handling, source="jira" + jira_issue config.
- webapp.py: POST/GET /webhooks/jira, verify_jira_secret (constant-time
X-Automation-Webhook-Token check, fails closed), repo-resolution
cascade, get_repo_config_from_jira_mapping.
- utils/jira_project_repo_map.py: JIRA_PROJECT_TO_REPO (placeholder
entry — real project->repo mappings still needed).
- utils/jira.py: get_user_email (accountId -> email) for attribution.
- completion.py: source=="jira" failure-reply branch.
- prompt.py: Jira-triggered notify guidance + Refs:/branch key from
{jira_project_key}-{jira_issue_number}.
- server.py: read jira_issue config + pass jira key to the system
prompt; also activates the Phase 3 Confluence tools in the agent list.
Jira Automation lacks native webhook HMAC signing, so trust is a shared
secret header (decision D2); replay protection is weaker than Linear's
HMAC+timestamp. /sh-security-review + an Atlassian IP allowlist are the
outstanding gate/hardening before push.
* fix(open-swe): harden Jira webhook trust (sh-security-review)
Resolves findings from the Phase 2 security review (detector fan-out +
proof-or-kill verifier). The unsigned Jira Automation webhook body was
trusted for identity, comment content, repo routing, and issue
existence; a JIRA_WEBHOOK_SECRET holder could forge those fields.
- Corroborate against the real Jira record: the webhook body is now only
a pointer (issue_key + required comment_id). The triggering comment's
author and text are re-fetched server-side via get_comment/fetch_jira_
comment, and identity, the @openswe check, prompt text, and project
key are derived from that authoritative record — never payload author/
body fields. An uncorroborated comment is rejected. (closes the
account-id impersonation, unsigned-body prompt injection, and
fabricated-issue findings)
- Validate issue_key against the Jira key format and percent-encode all
untrusted path segments (_seg) so a crafted key can't traverse to a
different Jira REST endpoint or inject query params. (closes the path-
traversal / query-injection findings)
- Route source=="jira" through the bot-token-default / author_prs_as_
user opt-in path in resolve_github_token, matching Linear, instead of
unconditionally resolving a per-user OAuth token from a payload email.
- Gate attribution on an active user mapping (is_login_mapped) so a
pending/unconfirmed mapping can't drive PR authorship.
Adds regression tests: server-corroboration wins over payload, malformed
issue_key rejected, uncorroborated comment rejected, path-segment
encoding, project-key derivation, active-mapping gate.
Remaining (non-blocking, deployment/hardening): set ALLOWED_GITHUB_ORGS/
REPOS so the shared allowlist isn't fail-open; consider HMAC-over-body +
timestamp on the Automation payload to close the residual replay gap.
* harden(open-swe): opt-in Jira webhook replay/IP + fail-closed allowlist
Folds the two deployment-hardening items from the Phase 2 security review
into code (all opt-in / default-off, so existing and upstream deployments
are unaffected):
- JIRA_WEBHOOK_REQUIRE_SIGNATURE: when set, the Automation payload must
carry X-Openswe-Signature (hex HMAC-SHA256 of the raw body keyed by
JIRA_WEBHOOK_SECRET) plus a fresh timestamp, verified by
verify_jira_signature / _jira_timestamp_is_fresh (mirrors the Linear
HMAC+freshness model). Closes the static-token model's replay/forgery
gap when enabled.
- JIRA_WEBHOOK_IP_ALLOWLIST: optional CIDR allowlist on the webhook's
direct client IP (verify_jira_source_ip). Documented as direct-peer
only; behind a proxy/LB, allowlist Atlassian's ranges at that layer.
- REQUIRE_REPO_ALLOWLIST: makes an empty ALLOWED_GITHUB_ORGS/REPOS fail
CLOSED instead of the back-compat allow-all, plus a startup fail-open
warning. Applies to all channels for consistency.
Documents all new vars (and a Jira section) in .env.example. Adds tests
for signature on/off + valid/missing/wrong/stale, IP allow/deny/off, and
the fail-closed allowlist.
* feat(open-swe): Confluence Atlassian Connect trigger (Phase 4)
Adds the @openswe-on-a-Confluence-comment trigger via a private Atlassian
Connect app. Designed and adversarially verified with the ultracode
workflow (3 divergent Opus designs + judge; 3 proof-or-kill Opus
skeptics on the implemented crypto).
- utils/atlassian_connect.py: hand-rolled qsh (pinned to Atlassian's
official test vector), PyJWT HS256 webhook verifier with alg-pinning,
issuer binding, and qsh-verified-last ordering; RS256 signed-install
lifecycle verifier against Atlassian's published keys; installation
store keyed by clientKey with the sharedSecret encrypted at rest
(TOKEN_ENCRYPTION_KEY / Fernet). No new dependency (PyJWT already pinned).
- webhooks/confluence.py: install/uninstall lifecycle + comment handler.
The JWT-signed webhook body is only a pointer; the comment's real
author/text/container are re-fetched server-side via the Basic-auth
service account (Phase-2 corroboration lesson), with active-only login
attribution and the repo allowlist.
- utils/confluence.py: get_comment / get_user_email (path-encoded).
- webapp.py: GET /connect/atlassian-connect.json (served dynamically),
POST /connect/{installed,uninstalled,webhook/comment-created}, the
space->repo resolver, thread-id, and fetch helpers.
- completion.py: source=="confluence" failure-reply branch.
Security: the sh-security-review verify pass confirmed one HIGH — the
symmetric signed-install=false first-install was trust-on-first-use gated
only by the public Confluence hostname (webhook-auth bypass). Fixed by
switching to signed-install=true + RS256 verification of lifecycle
callbacks, which cryptographically authenticates the first install. All
other attack lenses (forgery/replay/alg-confusion/overwrite/uninstall
DoS/corroboration/injection) were defeated; residuals are deployment
config (REQUIRE_REPO_ALLOWLIST) or accepted-by-design (qsh cannot cover
bodies; comment-trigger prompt injection, shared with all sources).
New env (documented in .env.example): CONFLUENCE_BASE_URL/EMAIL/API_TOKEN,
CONNECT_BASE_URL, CONNECT_EXPECTED_BASE_URL (optional). Install secrets
require the durable Postgres LangGraph store in prod.
Outstanding before push: /sh-security-review on the real diff and the
GPT-4.1 cross-family review (auth boundary); README/CLAUDE.md + memory.
* docs(open-swe): Phase 5 — Confluence prompt guidance + architecture docs
- prompt.py: Confluence-triggered runs notify via confluence_comment on
the triggering page; add Confluence to the shared-base source list.
- CLAUDE.md: document the Jira + Confluence tool planes and the Atlassian
triggers (Jira Automation shared-secret webhook; Confluence Connect app
with HS256 webhook + qsh and RS256 signed-install lifecycle), plus the
server-side corroboration + encrypted install store.
Phase 5 also verified the trigger surface end-to-end against a running
uvicorn app (descriptor served; /connect/* and /webhooks/jira fail closed
without valid auth) and recorded the integration in project memory.
* fix(open-swe): resolve /sh-security-review findings on the Atlassian surface
Formal sh-security-review (detector fan-out + verifier) over the Phase-4
Connect surface (esp. the new RS256 signed-install code, unseen by the
earlier adversarial verify) and the Phase-2 opt-in hardening.
CRITICAL — cross-tenant install (origin validation, CWE-346): signed-
install proves the caller is *an* Atlassian tenant, not *ours*, and the
descriptor is served publicly, so any attacker could install the app on
their own Confluence site and drive agent runs against our allowlisted
repos. The baseUrl body field is attacker-controlled and cannot bind the
tenant; only the signature-verified clientKey (JWT iss) can. Added a
MANDATORY, fail-closed CONNECT_EXPECTED_CLIENT_KEYS allowlist checked in
process_install after signature+iss verification.
HIGH — cross-tenant thread-id collision (CWE-330/863): Confluence comment
ids are per-instance, so generate_thread_id_from_confluence_comment now
salts the hash with the verified clientKey (plumbed from the webhook JWT
iss) to prevent thread hijack across tenants.
HIGH/MEDIUM — path/query injection (CWE-22/88): get_page and update_page
interpolated page_id into the REST path unencoded (update_page on a
mutating PUT with no params= backstop). Now _seg()-encoded, matching the
rest of the module.
MEDIUM — self-trigger loop (CWE-405): process_confluence_comment had no
bot-authorship early-out. Added an optional CONFLUENCE_BOT_ACCOUNT_ID
guard mirroring the Linear botActor / Jira comment_author_is_bot checks.
LOW — corrected the CONNECT_EXPECTED_BASE_URL comment to document it as
opt-in defense-in-depth (the clientKey allowlist is the real gate).
Verified clean by the detectors: RS256/HS256 alg-pinning, aud/iss/exp,
kid-fetch SSRF (host-pinned + quote-encoded), at-rest secret encryption,
constant-time comparisons, and the Phase-2 hardening. New regression
tests for each fix; full suite green (1602).
* harden(open-swe): GPT-4.1 cross-family review follow-ups
Cross-family review (GPT-4.1 via orchestrator cross_reviewer) found no
critical/high issues and confirmed the auth boundary is fail-closed and
correct. Two low-cost defense-in-depth items applied:
- Validate the signed-install JWT 'kid' against a strict charset before
the public-key fetch, so a malformed kid fails fast with no network
call (on top of the existing fixed host + percent-encoding).
- Make JWT nbf verification explicit (verify_nbf) on both the RS256
lifecycle and HS256 webhook decodes.
Other suggestions triaged as already-handled (aud cross-app replay is
blocked by the per-tenant iss->secret lookup; documented static-token/IP/
baseUrl tradeoffs; qsh pinned to Atlassian's vector) or ops/infra
(Fernet rotation via MultiFernet; rate limiting at the gateway).
* docs(open-swe): document Jira + Confluence in installation & customization guides
- INSTALLATION.md §5: add Jira (Automation-rule webhook + shared secret,
service account, JIRA_PROJECT_TO_REPO) and Confluence (Atlassian Connect
app install, CONNECT_EXPECTED_CLIENT_KEYS bootstrap, durable-store note,
CONFLUENCE_SPACE_TO_REPO) trigger setup; §6: add the new env vars +
REQUIRE_REPO_ALLOWLIST.
- CUSTOMIZATION.md: jira_*/confluence_* in the tools table; repo-extraction
note covers all four sources.
- AGENTS.md: match CLAUDE.md (triggers, webhooks, tool list, auth).
- README.md: invocation section, tools table, and overview line.
395 lines
14 KiB
Python
395 lines
14 KiB
Python
"""Atlassian Connect (Confluence) trust surface: qsh, JWT verify, secret store.
|
|
|
|
Private Connect app, symmetric (HS256) shared-secret flow. Atlassian mints a
|
|
``sharedSecret`` per install and signs each request with it; we verify the
|
|
signature, the ``exp``, and the ``qsh`` (query-string-hash) claim that binds a
|
|
token to one exact method+path+query (defeating cross-endpoint replay). The
|
|
shared secret is stored encrypted-at-rest in the LangGraph store, keyed by the
|
|
tenant ``clientKey``.
|
|
|
|
``qsh`` is Connect-specific (no JWT library provides it) so it is hand-rolled
|
|
here from the Atlassian spec and pinned to the official test vector in
|
|
``tests/test_atlassian_connect.py``. A subtle bug here is a silent auth bypass.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import hashlib
|
|
import hmac
|
|
import logging
|
|
import os
|
|
import re
|
|
import time
|
|
from typing import Any
|
|
from urllib.parse import parse_qs, quote
|
|
|
|
import httpx
|
|
import jwt
|
|
from langgraph_sdk import get_client
|
|
|
|
from ..encryption import decrypt_token, encrypt_token
|
|
from .http import DEFAULT_HTTP_TIMEOUT
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
LANGGRAPH_URL = os.environ.get("LANGGRAPH_URL") or os.environ.get(
|
|
"LANGGRAPH_URL_PROD", "http://localhost:2024"
|
|
)
|
|
# The app's public origin — must equal the descriptor baseUrl and the `aud` in
|
|
# Atlassian's signed-install lifecycle JWTs.
|
|
CONNECT_BASE_URL = os.environ.get("CONNECT_BASE_URL", "").rstrip("/")
|
|
# Atlassian's CDN of public keys for signed-install (asymmetric) lifecycle JWTs.
|
|
_CONNECT_INSTALL_KEYS_BASE = "https://connect-install-keys.atlassian.com"
|
|
# Tenant binding (REQUIRED): the signature-verified clientKey(s) — i.e. the JWT
|
|
# `iss` — we accept installs from. signed-install proves the caller is *an*
|
|
# Atlassian tenant, not *ours*, and the descriptor is served publicly, so
|
|
# without this any tenant could install the app and drive runs. Empty => reject
|
|
# ALL installs (fail closed). The install `baseUrl` is an untrusted body field
|
|
# and is NOT a valid binding; only the signed `iss` is. Bootstrap: attempt an
|
|
# install, read the rejected clientKey from the logs, add it here, re-install.
|
|
CONNECT_EXPECTED_CLIENT_KEYS: frozenset[str] = frozenset(
|
|
key.strip()
|
|
for key in os.environ.get("CONNECT_EXPECTED_CLIENT_KEYS", "").replace(",", " ").split()
|
|
if key.strip()
|
|
)
|
|
# Optional defense-in-depth on the (untrusted) install baseUrl host. Enforced
|
|
# only when set — the clientKey allowlist above is the real tenant gate.
|
|
CONNECT_EXPECTED_BASE_URL_HOSTS: frozenset[str] = frozenset(
|
|
host.strip().lower()
|
|
for host in os.environ.get("CONNECT_EXPECTED_BASE_URL", "").replace(",", " ").split()
|
|
if host.strip()
|
|
)
|
|
|
|
_JWT_LEEWAY_SECONDS = 10
|
|
_INSTALL_NS = ("atlassian_connect", "installations")
|
|
|
|
|
|
# --- qsh (query-string hash) -----------------------------------------------
|
|
|
|
|
|
def _encode(value: Any) -> str:
|
|
"""RFC-3986 encode a single component (Atlassian encodeRfc3986: space->%20)."""
|
|
return quote(str(value), safe="")
|
|
|
|
|
|
def _canonical_uri(path: str) -> str:
|
|
if not path:
|
|
return "/"
|
|
if len(path) > 1 and path.endswith("/"):
|
|
path = path[:-1]
|
|
return path.replace("&", "%26")
|
|
|
|
|
|
def _canonical_query(query: str) -> str:
|
|
if not query:
|
|
return ""
|
|
parsed = parse_qs(query, keep_blank_values=True)
|
|
parsed.pop("jwt", None)
|
|
pairs = [
|
|
f"{_encode(key)}={','.join(sorted(_encode(v) for v in values))}"
|
|
for key, values in parsed.items()
|
|
]
|
|
pairs.sort()
|
|
return "&".join(pairs)
|
|
|
|
|
|
def canonical_request(method: str, path: str, query: str = "") -> str:
|
|
return "&".join([method.upper(), _canonical_uri(path), _canonical_query(query)])
|
|
|
|
|
|
def compute_qsh(method: str, path: str, query: str = "") -> str:
|
|
"""SHA-256 hex of the canonical `METHOD&path&query` request string."""
|
|
return hashlib.sha256(canonical_request(method, path, query).encode()).hexdigest()
|
|
|
|
|
|
# --- token extraction + JWT verification -----------------------------------
|
|
|
|
|
|
def extract_connect_token(request: Any) -> str | None:
|
|
"""Pull the Connect JWT from `Authorization: JWT <t>` or the `?jwt=` param."""
|
|
header = request.headers.get("Authorization") or request.headers.get("authorization") or ""
|
|
if header[:4].upper() == "JWT ":
|
|
token = header[4:].strip()
|
|
if token:
|
|
return token
|
|
query_token = request.query_params.get("jwt") if hasattr(request, "query_params") else None
|
|
return query_token or None
|
|
|
|
|
|
def verify_connect_jwt(
|
|
request: Any,
|
|
*,
|
|
shared_secret: str | None = None,
|
|
expected_client_key: str | None = None,
|
|
qsh_required: bool = True,
|
|
) -> dict[str, Any] | None:
|
|
"""Verify a Connect JWT. Returns the claims on success, None on any failure.
|
|
|
|
``shared_secret`` is passed explicitly by the lifecycle routes (the stored
|
|
secret); the webhook resolves it from the token's ``iss`` via the store.
|
|
``qsh_required`` is True for the webhook and False (verify-if-present) for
|
|
lifecycle callbacks, whose auth strength comes from the signature + issuer
|
|
binding rather than qsh. Every failure path returns None with no side effect.
|
|
"""
|
|
token = extract_connect_token(request)
|
|
if not token:
|
|
logger.warning("Connect JWT missing — rejecting")
|
|
return None
|
|
|
|
# Fail-fast alg pin before any lookup: blocks alg=none and RS256/ES256
|
|
# algorithm-confusion against a symmetric secret.
|
|
try:
|
|
alg = jwt.get_unverified_header(token).get("alg")
|
|
except jwt.PyJWTError:
|
|
logger.warning("Connect JWT header undecodable — rejecting")
|
|
return None
|
|
if alg != "HS256":
|
|
logger.warning("Connect JWT alg %r is not HS256 — rejecting", alg)
|
|
return None
|
|
|
|
# Read the (untrusted) issuer to resolve the secret; trust nothing yet.
|
|
try:
|
|
unverified = jwt.decode(token, options={"verify_signature": False})
|
|
except jwt.PyJWTError:
|
|
logger.warning("Connect JWT undecodable — rejecting")
|
|
return None
|
|
issuer = unverified.get("iss")
|
|
if not issuer:
|
|
logger.warning("Connect JWT missing iss — rejecting")
|
|
return None
|
|
|
|
# The caller always supplies the secret: lifecycle passes the stored secret;
|
|
# the webhook resolves it from `iss` via get_shared_secret and passes it.
|
|
# A missing secret fails closed (never verify against nothing / a default).
|
|
if not shared_secret:
|
|
logger.warning("No shared secret provided for Connect JWT — rejecting")
|
|
return None
|
|
|
|
try:
|
|
claims = jwt.decode(
|
|
token,
|
|
shared_secret,
|
|
algorithms=["HS256"],
|
|
options={
|
|
"require": ["exp", "iss"],
|
|
"verify_signature": True,
|
|
"verify_exp": True,
|
|
"verify_nbf": True,
|
|
},
|
|
leeway=_JWT_LEEWAY_SECONDS,
|
|
)
|
|
except jwt.PyJWTError as exc:
|
|
logger.warning("Connect JWT signature/claims invalid: %s", exc.__class__.__name__)
|
|
return None
|
|
|
|
if expected_client_key is not None and claims.get("iss") != expected_client_key:
|
|
logger.warning("Connect JWT iss does not match expected client key — rejecting")
|
|
return None
|
|
|
|
# qsh verified LAST — it is a signed claim, only trustworthy post-signature.
|
|
qsh_claim = claims.get("qsh")
|
|
if qsh_claim == "context-qsh":
|
|
logger.warning("Connect JWT carries context-qsh (iframe token) — rejecting")
|
|
return None
|
|
if qsh_claim is None:
|
|
if qsh_required:
|
|
logger.warning("Connect JWT missing required qsh — rejecting")
|
|
return None
|
|
else:
|
|
expected_qsh = compute_qsh(request.method, request.url.path, request.url.query)
|
|
if not hmac.compare_digest(expected_qsh, qsh_claim):
|
|
logger.warning("Connect JWT qsh mismatch — rejecting")
|
|
return None
|
|
|
|
return claims
|
|
|
|
|
|
_KID_RE = re.compile(r"^[A-Za-z0-9._-]+$")
|
|
|
|
|
|
async def _fetch_atlassian_public_key(kid: str) -> str | None:
|
|
"""Fetch Atlassian's PEM public key for a signed-install ``kid``.
|
|
|
|
``kid`` is validated to a strict charset before use (defense-in-depth on top
|
|
of the fixed host + percent-encoding) so a malformed kid fails fast without
|
|
a network call and can never influence the request path.
|
|
"""
|
|
if not _KID_RE.match(kid):
|
|
logger.warning("Rejecting Connect install: malformed kid")
|
|
return None
|
|
async with httpx.AsyncClient(timeout=DEFAULT_HTTP_TIMEOUT) as client:
|
|
try:
|
|
response = await client.get(f"{_CONNECT_INSTALL_KEYS_BASE}/{quote(kid, safe='')}")
|
|
response.raise_for_status()
|
|
return response.text
|
|
except Exception as exc: # noqa: BLE001
|
|
logger.warning("Failed to fetch Atlassian install public key: %s", exc)
|
|
return None
|
|
|
|
|
|
async def verify_asymmetric_install_jwt(
|
|
request: Any, *, expected_client_key: str | None = None
|
|
) -> dict[str, Any] | None:
|
|
"""Verify a signed-install (RS256) lifecycle JWT against Atlassian's keys.
|
|
|
|
signed-install cryptographically authenticates the install/uninstall
|
|
callbacks — including the FIRST install — so first install is not
|
|
trust-on-first-use. The token is RS256-signed by Atlassian; its ``kid``
|
|
selects a published public key, and its ``aud`` must equal this app's
|
|
baseUrl (blocking tokens minted for another Connect app). qsh is
|
|
verify-if-present on lifecycle. Returns claims on success, None otherwise.
|
|
"""
|
|
if not CONNECT_BASE_URL:
|
|
logger.warning("CONNECT_BASE_URL unset — cannot verify signed-install aud; rejecting")
|
|
return None
|
|
token = extract_connect_token(request)
|
|
if not token:
|
|
return None
|
|
try:
|
|
header = jwt.get_unverified_header(token)
|
|
except jwt.PyJWTError:
|
|
return None
|
|
if header.get("alg") != "RS256":
|
|
logger.warning("Signed-install JWT alg %r is not RS256 — rejecting", header.get("alg"))
|
|
return None
|
|
kid = header.get("kid")
|
|
if not kid:
|
|
return None
|
|
public_key = await _fetch_atlassian_public_key(kid)
|
|
if not public_key:
|
|
return None
|
|
try:
|
|
claims = jwt.decode(
|
|
token,
|
|
public_key,
|
|
algorithms=["RS256"],
|
|
audience=CONNECT_BASE_URL,
|
|
options={
|
|
"require": ["exp", "iss", "aud"],
|
|
"verify_signature": True,
|
|
"verify_exp": True,
|
|
"verify_nbf": True,
|
|
"verify_aud": True,
|
|
},
|
|
leeway=_JWT_LEEWAY_SECONDS,
|
|
)
|
|
except jwt.PyJWTError as exc:
|
|
logger.warning("Signed-install JWT invalid: %s", exc.__class__.__name__)
|
|
return None
|
|
if expected_client_key is not None and claims.get("iss") != expected_client_key:
|
|
logger.warning("Signed-install JWT iss does not match clientKey — rejecting")
|
|
return None
|
|
qsh_claim = claims.get("qsh")
|
|
if qsh_claim == "context-qsh":
|
|
return None
|
|
if qsh_claim is not None:
|
|
expected_qsh = compute_qsh(request.method, request.url.path, request.url.query)
|
|
if not hmac.compare_digest(expected_qsh, qsh_claim):
|
|
logger.warning("Signed-install JWT qsh mismatch — rejecting")
|
|
return None
|
|
return claims
|
|
|
|
|
|
async def verify_connect_webhook(request: Any) -> dict[str, Any] | None:
|
|
"""Resolve the shared secret from the token's issuer, then verify (qsh required).
|
|
|
|
The async counterpart to verify_connect_jwt for the webhook route, where the
|
|
secret must be looked up from the store by the (untrusted-until-verified)
|
|
issuer. Returns claims on success, None on any failure.
|
|
"""
|
|
token = extract_connect_token(request)
|
|
if not token:
|
|
return None
|
|
try:
|
|
issuer = jwt.decode(token, options={"verify_signature": False}).get("iss")
|
|
except jwt.PyJWTError:
|
|
return None
|
|
if not issuer:
|
|
return None
|
|
secret = await get_shared_secret(issuer)
|
|
if not secret:
|
|
logger.warning("No installation for Connect issuer — rejecting webhook")
|
|
return None
|
|
return verify_connect_jwt(
|
|
request, shared_secret=secret, expected_client_key=issuer, qsh_required=True
|
|
)
|
|
|
|
|
|
# --- encrypted installation store ------------------------------------------
|
|
|
|
|
|
def _client() -> Any:
|
|
return get_client(url=LANGGRAPH_URL)
|
|
|
|
|
|
async def get_installation(client_key: str) -> dict[str, Any] | None:
|
|
"""Return the stored installation record (secret decrypted), or None."""
|
|
item = await _client().store.get_item(_INSTALL_NS, client_key)
|
|
if not item:
|
|
return None
|
|
value = item.get("value")
|
|
if not isinstance(value, dict):
|
|
return None
|
|
record = dict(value)
|
|
try:
|
|
record["shared_secret"] = decrypt_token(record["shared_secret_enc"])
|
|
except Exception: # noqa: BLE001 — fail closed on decrypt/missing-key failure
|
|
logger.warning("Failed to decrypt stored Connect shared secret for %s", client_key)
|
|
return None
|
|
return record
|
|
|
|
|
|
async def get_shared_secret(client_key: str) -> str | None:
|
|
record = await get_installation(client_key)
|
|
return (record or {}).get("shared_secret") or None
|
|
|
|
|
|
async def put_installation(
|
|
client_key: str,
|
|
shared_secret: str,
|
|
base_url: str,
|
|
product_type: str,
|
|
*,
|
|
first_install: bool,
|
|
) -> None:
|
|
now_ms = int(time.time() * 1000)
|
|
installed_at = now_ms
|
|
if not first_install:
|
|
existing = await get_installation(client_key)
|
|
installed_at = (existing or {}).get("installed_at_ms", now_ms)
|
|
record = {
|
|
"client_key": client_key,
|
|
"shared_secret_enc": encrypt_token(shared_secret),
|
|
"base_url": base_url,
|
|
"product_type": product_type,
|
|
"installed_at_ms": installed_at,
|
|
"updated_at_ms": now_ms,
|
|
}
|
|
await _client().store.put_item(_INSTALL_NS, client_key, record)
|
|
|
|
|
|
async def delete_installation(client_key: str) -> None:
|
|
await _client().store.delete_item(_INSTALL_NS, client_key)
|
|
|
|
|
|
def client_key_allowed(client_key: str) -> bool:
|
|
"""Whether a signature-verified clientKey (JWT iss) is an accepted tenant.
|
|
|
|
Fail closed: an empty allowlist accepts no installs (the mandatory tenant
|
|
binding — see CONNECT_EXPECTED_CLIENT_KEYS).
|
|
"""
|
|
return bool(client_key) and client_key in CONNECT_EXPECTED_CLIENT_KEYS
|
|
|
|
|
|
def base_url_host_allowed(base_url: str) -> bool:
|
|
"""Whether an install baseUrl's host is in the optional CONNECT_EXPECTED_BASE_URL allowlist.
|
|
|
|
Defense-in-depth only (the baseUrl is an untrusted body field); the real
|
|
tenant gate is client_key_allowed. Returns False when the allowlist is empty.
|
|
"""
|
|
if not CONNECT_EXPECTED_BASE_URL_HOSTS:
|
|
return False
|
|
from urllib.parse import urlparse
|
|
|
|
host = (urlparse(base_url).hostname or "").lower()
|
|
return bool(host) and host in CONNECT_EXPECTED_BASE_URL_HOSTS
|