getstack 0.9.0__tar.gz → 0.11.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {getstack-0.9.0 → getstack-0.11.0}/PKG-INFO +1 -1
- {getstack-0.9.0 → getstack-0.11.0}/pyproject.toml +1 -1
- {getstack-0.9.0 → getstack-0.11.0}/src/getstack/__init__.py +5 -0
- getstack-0.11.0/src/getstack/_async/partner_tenants.py +117 -0
- getstack-0.11.0/src/getstack/partner_tenants.py +129 -0
- getstack-0.11.0/src/getstack/passport_header_verify.py +109 -0
- {getstack-0.9.0 → getstack-0.11.0}/.gitignore +0 -0
- {getstack-0.9.0 → getstack-0.11.0}/CLAUDE.md +0 -0
- {getstack-0.9.0 → getstack-0.11.0}/LICENSE +0 -0
- {getstack-0.9.0 → getstack-0.11.0}/README.md +0 -0
- {getstack-0.9.0 → getstack-0.11.0}/src/getstack/_async/__init__.py +0 -0
- {getstack-0.9.0 → getstack-0.11.0}/src/getstack/_async/agents.py +0 -0
- {getstack-0.9.0 → getstack-0.11.0}/src/getstack/_async/audit.py +0 -0
- {getstack-0.9.0 → getstack-0.11.0}/src/getstack/_async/credentials.py +0 -0
- {getstack-0.9.0 → getstack-0.11.0}/src/getstack/_async/dropoffs.py +0 -0
- {getstack-0.9.0 → getstack-0.11.0}/src/getstack/_async/identity.py +0 -0
- {getstack-0.9.0 → getstack-0.11.0}/src/getstack/_async/inbound_webhooks.py +0 -0
- {getstack-0.9.0 → getstack-0.11.0}/src/getstack/_async/intents.py +0 -0
- {getstack-0.9.0 → getstack-0.11.0}/src/getstack/_async/llm.py +0 -0
- {getstack-0.9.0 → getstack-0.11.0}/src/getstack/_async/missions.py +0 -0
- {getstack-0.9.0 → getstack-0.11.0}/src/getstack/_async/notifications.py +0 -0
- {getstack-0.9.0 → getstack-0.11.0}/src/getstack/_async/passport_session.py +0 -0
- {getstack-0.9.0 → getstack-0.11.0}/src/getstack/_async/passports.py +0 -0
- {getstack-0.9.0 → getstack-0.11.0}/src/getstack/_async/proxy.py +0 -0
- {getstack-0.9.0 → getstack-0.11.0}/src/getstack/_async/reviews.py +0 -0
- {getstack-0.9.0 → getstack-0.11.0}/src/getstack/_async/scan.py +0 -0
- {getstack-0.9.0 → getstack-0.11.0}/src/getstack/_async/security_events.py +0 -0
- {getstack-0.9.0 → getstack-0.11.0}/src/getstack/_async/services.py +0 -0
- {getstack-0.9.0 → getstack-0.11.0}/src/getstack/_async/skills.py +0 -0
- {getstack-0.9.0 → getstack-0.11.0}/src/getstack/agent_auth.py +0 -0
- {getstack-0.9.0 → getstack-0.11.0}/src/getstack/agents.py +0 -0
- {getstack-0.9.0 → getstack-0.11.0}/src/getstack/audit.py +0 -0
- {getstack-0.9.0 → getstack-0.11.0}/src/getstack/auth.py +0 -0
- {getstack-0.9.0 → getstack-0.11.0}/src/getstack/browser_bootstrap.py +0 -0
- {getstack-0.9.0 → getstack-0.11.0}/src/getstack/client.py +0 -0
- {getstack-0.9.0 → getstack-0.11.0}/src/getstack/credentials.py +0 -0
- {getstack-0.9.0 → getstack-0.11.0}/src/getstack/dropoffs.py +0 -0
- {getstack-0.9.0 → getstack-0.11.0}/src/getstack/errors.py +0 -0
- {getstack-0.9.0 → getstack-0.11.0}/src/getstack/evidence_packs.py +0 -0
- {getstack-0.9.0 → getstack-0.11.0}/src/getstack/identity.py +0 -0
- {getstack-0.9.0 → getstack-0.11.0}/src/getstack/inbound_webhooks.py +0 -0
- {getstack-0.9.0 → getstack-0.11.0}/src/getstack/intents.py +0 -0
- {getstack-0.9.0 → getstack-0.11.0}/src/getstack/llm.py +0 -0
- {getstack-0.9.0 → getstack-0.11.0}/src/getstack/missions.py +0 -0
- {getstack-0.9.0 → getstack-0.11.0}/src/getstack/notifications.py +0 -0
- {getstack-0.9.0 → getstack-0.11.0}/src/getstack/passport_session.py +0 -0
- {getstack-0.9.0 → getstack-0.11.0}/src/getstack/passports.py +0 -0
- {getstack-0.9.0 → getstack-0.11.0}/src/getstack/proxy.py +0 -0
- {getstack-0.9.0 → getstack-0.11.0}/src/getstack/py.typed +0 -0
- {getstack-0.9.0 → getstack-0.11.0}/src/getstack/reviews.py +0 -0
- {getstack-0.9.0 → getstack-0.11.0}/src/getstack/scan.py +0 -0
- {getstack-0.9.0 → getstack-0.11.0}/src/getstack/security_events.py +0 -0
- {getstack-0.9.0 → getstack-0.11.0}/src/getstack/services.py +0 -0
- {getstack-0.9.0 → getstack-0.11.0}/src/getstack/skills.py +0 -0
- {getstack-0.9.0 → getstack-0.11.0}/src/getstack/types.py +0 -0
|
@@ -53,6 +53,8 @@ from .security_events import SecurityEventService as SecurityEventSvc
|
|
|
53
53
|
from .skills import SkillService
|
|
54
54
|
from .identity import IdentityService
|
|
55
55
|
from .inbound_webhooks import InboundWebhookService
|
|
56
|
+
from .partner_tenants import PartnerTenantsService
|
|
57
|
+
from .passport_header_verify import verify_passport_header
|
|
56
58
|
from .intents import IntentsService
|
|
57
59
|
from .missions import MissionsService
|
|
58
60
|
from .evidence_packs import EvidencePacksService
|
|
@@ -226,6 +228,7 @@ class Stack:
|
|
|
226
228
|
self.evidence_packs = EvidencePacksService(self._client)
|
|
227
229
|
self.llm = LlmService(self._client)
|
|
228
230
|
self.inbound_webhooks = InboundWebhookService(self._client)
|
|
231
|
+
self.partner_tenants = PartnerTenantsService(self._client)
|
|
229
232
|
|
|
230
233
|
def issue_passport(
|
|
231
234
|
self,
|
|
@@ -366,6 +369,7 @@ class AsyncStack:
|
|
|
366
369
|
from ._async.missions import AsyncMissionsService
|
|
367
370
|
from ._async.llm import AsyncLlmService
|
|
368
371
|
from ._async.inbound_webhooks import AsyncInboundWebhookService
|
|
372
|
+
from ._async.partner_tenants import AsyncPartnerTenantsService
|
|
369
373
|
from ._async.passport_session import AsyncPassportSession
|
|
370
374
|
|
|
371
375
|
self.agents = AsyncAgentService(self._client)
|
|
@@ -385,6 +389,7 @@ class AsyncStack:
|
|
|
385
389
|
self.missions = AsyncMissionsService(self._client)
|
|
386
390
|
self.llm = AsyncLlmService(self._client)
|
|
387
391
|
self.inbound_webhooks = AsyncInboundWebhookService(self._client)
|
|
392
|
+
self.partner_tenants = AsyncPartnerTenantsService(self._client)
|
|
388
393
|
# Stash for issue_passport() — Python doesn't easily expose the
|
|
389
394
|
# class binding without re-importing inside the method body.
|
|
390
395
|
self._AsyncPassportSession = AsyncPassportSession
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
"""Async mirror of PartnerTenantsService (Phase 5.6)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any, Literal, Optional
|
|
6
|
+
|
|
7
|
+
from ..client import AsyncHttpClient
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
Tier = Literal["free", "developer", "studio", "enterprise"]
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class AsyncPartnerTenantsService:
|
|
14
|
+
def __init__(self, client: AsyncHttpClient):
|
|
15
|
+
self._client = client
|
|
16
|
+
|
|
17
|
+
async def create(
|
|
18
|
+
self,
|
|
19
|
+
external_customer_id: Optional[str] = None,
|
|
20
|
+
tier: Optional[Tier] = None,
|
|
21
|
+
) -> dict[str, Any]:
|
|
22
|
+
body: dict[str, Any] = {}
|
|
23
|
+
if external_customer_id is not None:
|
|
24
|
+
body["external_customer_id"] = external_customer_id
|
|
25
|
+
if tier is not None:
|
|
26
|
+
body["tier"] = tier
|
|
27
|
+
return await self._client.post("/v1/partner/tenants", json=body)
|
|
28
|
+
|
|
29
|
+
async def list(self) -> list[dict[str, Any]]:
|
|
30
|
+
res = await self._client.get("/v1/partner/tenants")
|
|
31
|
+
return list(res.get("tenants", []))
|
|
32
|
+
|
|
33
|
+
async def get(self, tenant_id: str) -> dict[str, Any]:
|
|
34
|
+
return await self._client.get(f"/v1/partner/tenants/{tenant_id}")
|
|
35
|
+
|
|
36
|
+
async def revoke(self, tenant_id: str) -> dict[str, Any]:
|
|
37
|
+
return await self._client.delete(f"/v1/partner/tenants/{tenant_id}")
|
|
38
|
+
|
|
39
|
+
async def create_inbound_webhook(
|
|
40
|
+
self,
|
|
41
|
+
tenant_id: str,
|
|
42
|
+
provider: Literal["agentmail", "generic"],
|
|
43
|
+
agent_id: str,
|
|
44
|
+
forward_url: Optional[str] = None,
|
|
45
|
+
generic_config: Optional[dict[str, Any]] = None,
|
|
46
|
+
) -> dict[str, Any]:
|
|
47
|
+
body: dict[str, Any] = {"provider": provider, "agent_id": agent_id}
|
|
48
|
+
if forward_url is not None:
|
|
49
|
+
body["forward_url"] = forward_url
|
|
50
|
+
if generic_config is not None:
|
|
51
|
+
body["generic_config"] = generic_config
|
|
52
|
+
return await self._client.post(
|
|
53
|
+
f"/v1/partner/tenants/{tenant_id}/inbound-webhooks",
|
|
54
|
+
json=body,
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
async def issue_passport(
|
|
58
|
+
self,
|
|
59
|
+
tenant_id: str,
|
|
60
|
+
agent_id: str,
|
|
61
|
+
ttl_seconds: Optional[int] = None,
|
|
62
|
+
services: Optional[list[dict[str, Any]]] = None,
|
|
63
|
+
named_intents: Optional[list[str]] = None,
|
|
64
|
+
accountability_mode: Optional[Literal["enforced", "logged", "standard"]] = None,
|
|
65
|
+
) -> dict[str, Any]:
|
|
66
|
+
body: dict[str, Any] = {"agent_id": agent_id}
|
|
67
|
+
if ttl_seconds is not None:
|
|
68
|
+
body["ttl_seconds"] = ttl_seconds
|
|
69
|
+
if services is not None:
|
|
70
|
+
body["services"] = services
|
|
71
|
+
if named_intents is not None:
|
|
72
|
+
body["named_intents"] = named_intents
|
|
73
|
+
if accountability_mode is not None:
|
|
74
|
+
body["accountability_mode"] = accountability_mode
|
|
75
|
+
return await self._client.post(
|
|
76
|
+
f"/v1/partner/tenants/{tenant_id}/passports/issue",
|
|
77
|
+
json=body,
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
async def mint_outbound_passport(
|
|
81
|
+
self,
|
|
82
|
+
tenant_id: str,
|
|
83
|
+
agent_id: str,
|
|
84
|
+
scopes: Optional[list[str]] = None,
|
|
85
|
+
ttl_seconds: int = 900,
|
|
86
|
+
accountability_mode: Literal["enforced", "logged", "standard"] = "enforced",
|
|
87
|
+
named_intents: Optional[list[str]] = None,
|
|
88
|
+
) -> dict[str, Any]:
|
|
89
|
+
"""Phase 5.7 — convenience helper for the MIME-stamping pattern.
|
|
90
|
+
|
|
91
|
+
Async mirror of PartnerTenantsService.mint_outbound_passport.
|
|
92
|
+
"""
|
|
93
|
+
services = [{"scopes": scopes}] if scopes else None
|
|
94
|
+
issued = await self.issue_passport(
|
|
95
|
+
tenant_id,
|
|
96
|
+
agent_id,
|
|
97
|
+
ttl_seconds=ttl_seconds,
|
|
98
|
+
accountability_mode=accountability_mode,
|
|
99
|
+
services=services,
|
|
100
|
+
named_intents=named_intents,
|
|
101
|
+
)
|
|
102
|
+
token = issued.get("token") or issued.get("passport")
|
|
103
|
+
passport_id = issued.get("passport_id") or issued.get("jti")
|
|
104
|
+
if not token or not passport_id:
|
|
105
|
+
raise RuntimeError("mint_outbound_passport: API response missing token or passport_id")
|
|
106
|
+
expires_at = issued.get("expires_at")
|
|
107
|
+
if not expires_at:
|
|
108
|
+
exp = issued.get("exp")
|
|
109
|
+
if isinstance(exp, (int, float)):
|
|
110
|
+
from datetime import datetime, timezone
|
|
111
|
+
expires_at = datetime.fromtimestamp(exp, tz=timezone.utc).isoformat()
|
|
112
|
+
return {
|
|
113
|
+
"token": token,
|
|
114
|
+
"passport_id": passport_id,
|
|
115
|
+
"expires_at": expires_at,
|
|
116
|
+
"header_value": token,
|
|
117
|
+
}
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
"""Partner-tenancy surface (Phase 5.6).
|
|
2
|
+
|
|
3
|
+
Platform partners (AgentMail, Cofounder, etc.) with is_partner=True on
|
|
4
|
+
their operator row provision isolated child operators on behalf of
|
|
5
|
+
their customers via this service.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from typing import Any, Literal, Optional
|
|
11
|
+
|
|
12
|
+
from .client import HttpClient
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
Tier = Literal["free", "developer", "studio", "enterprise"]
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class PartnerTenantsService:
|
|
19
|
+
def __init__(self, client: HttpClient):
|
|
20
|
+
self._client = client
|
|
21
|
+
|
|
22
|
+
def create(
|
|
23
|
+
self,
|
|
24
|
+
external_customer_id: Optional[str] = None,
|
|
25
|
+
tier: Optional[Tier] = None,
|
|
26
|
+
) -> dict[str, Any]:
|
|
27
|
+
body: dict[str, Any] = {}
|
|
28
|
+
if external_customer_id is not None:
|
|
29
|
+
body["external_customer_id"] = external_customer_id
|
|
30
|
+
if tier is not None:
|
|
31
|
+
body["tier"] = tier
|
|
32
|
+
return self._client.post("/v1/partner/tenants", json=body)
|
|
33
|
+
|
|
34
|
+
def list(self) -> list[dict[str, Any]]:
|
|
35
|
+
res = self._client.get("/v1/partner/tenants")
|
|
36
|
+
return list(res.get("tenants", []))
|
|
37
|
+
|
|
38
|
+
def get(self, tenant_id: str) -> dict[str, Any]:
|
|
39
|
+
return self._client.get(f"/v1/partner/tenants/{tenant_id}")
|
|
40
|
+
|
|
41
|
+
def revoke(self, tenant_id: str) -> dict[str, Any]:
|
|
42
|
+
return self._client.delete(f"/v1/partner/tenants/{tenant_id}")
|
|
43
|
+
|
|
44
|
+
def create_inbound_webhook(
|
|
45
|
+
self,
|
|
46
|
+
tenant_id: str,
|
|
47
|
+
provider: Literal["agentmail", "generic"],
|
|
48
|
+
agent_id: str,
|
|
49
|
+
forward_url: Optional[str] = None,
|
|
50
|
+
generic_config: Optional[dict[str, Any]] = None,
|
|
51
|
+
) -> dict[str, Any]:
|
|
52
|
+
body: dict[str, Any] = {"provider": provider, "agent_id": agent_id}
|
|
53
|
+
if forward_url is not None:
|
|
54
|
+
body["forward_url"] = forward_url
|
|
55
|
+
if generic_config is not None:
|
|
56
|
+
body["generic_config"] = generic_config
|
|
57
|
+
return self._client.post(
|
|
58
|
+
f"/v1/partner/tenants/{tenant_id}/inbound-webhooks",
|
|
59
|
+
json=body,
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
def issue_passport(
|
|
63
|
+
self,
|
|
64
|
+
tenant_id: str,
|
|
65
|
+
agent_id: str,
|
|
66
|
+
ttl_seconds: Optional[int] = None,
|
|
67
|
+
services: Optional[list[dict[str, Any]]] = None,
|
|
68
|
+
named_intents: Optional[list[str]] = None,
|
|
69
|
+
accountability_mode: Optional[Literal["enforced", "logged", "standard"]] = None,
|
|
70
|
+
) -> dict[str, Any]:
|
|
71
|
+
body: dict[str, Any] = {"agent_id": agent_id}
|
|
72
|
+
if ttl_seconds is not None:
|
|
73
|
+
body["ttl_seconds"] = ttl_seconds
|
|
74
|
+
if services is not None:
|
|
75
|
+
body["services"] = services
|
|
76
|
+
if named_intents is not None:
|
|
77
|
+
body["named_intents"] = named_intents
|
|
78
|
+
if accountability_mode is not None:
|
|
79
|
+
body["accountability_mode"] = accountability_mode
|
|
80
|
+
return self._client.post(
|
|
81
|
+
f"/v1/partner/tenants/{tenant_id}/passports/issue",
|
|
82
|
+
json=body,
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
def mint_outbound_passport(
|
|
86
|
+
self,
|
|
87
|
+
tenant_id: str,
|
|
88
|
+
agent_id: str,
|
|
89
|
+
scopes: Optional[list[str]] = None,
|
|
90
|
+
ttl_seconds: int = 900,
|
|
91
|
+
accountability_mode: Literal["enforced", "logged", "standard"] = "enforced",
|
|
92
|
+
named_intents: Optional[list[str]] = None,
|
|
93
|
+
) -> dict[str, Any]:
|
|
94
|
+
"""Phase 5.7 — convenience helper for the MIME-stamping pattern.
|
|
95
|
+
|
|
96
|
+
Mints a passport under the tenant's per-operator signing key
|
|
97
|
+
with a default-narrow scope, returning the token along with
|
|
98
|
+
the value ready for direct header injection (`X-Stack-Passport:
|
|
99
|
+
<token>`). The receiver verifies offline via verify_passport_header.
|
|
100
|
+
|
|
101
|
+
Defaults: 15-minute TTL, accountability_mode='enforced'.
|
|
102
|
+
|
|
103
|
+
Returns dict with keys: token, passport_id, expires_at, header_value.
|
|
104
|
+
"""
|
|
105
|
+
services = [{"scopes": scopes}] if scopes else None
|
|
106
|
+
issued = self.issue_passport(
|
|
107
|
+
tenant_id,
|
|
108
|
+
agent_id,
|
|
109
|
+
ttl_seconds=ttl_seconds,
|
|
110
|
+
accountability_mode=accountability_mode,
|
|
111
|
+
services=services,
|
|
112
|
+
named_intents=named_intents,
|
|
113
|
+
)
|
|
114
|
+
token = issued.get("token") or issued.get("passport")
|
|
115
|
+
passport_id = issued.get("passport_id") or issued.get("jti")
|
|
116
|
+
if not token or not passport_id:
|
|
117
|
+
raise RuntimeError("mint_outbound_passport: API response missing token or passport_id")
|
|
118
|
+
expires_at = issued.get("expires_at")
|
|
119
|
+
if not expires_at:
|
|
120
|
+
exp = issued.get("exp")
|
|
121
|
+
if isinstance(exp, (int, float)):
|
|
122
|
+
from datetime import datetime, timezone
|
|
123
|
+
expires_at = datetime.fromtimestamp(exp, tz=timezone.utc).isoformat()
|
|
124
|
+
return {
|
|
125
|
+
"token": token,
|
|
126
|
+
"passport_id": passport_id,
|
|
127
|
+
"expires_at": expires_at,
|
|
128
|
+
"header_value": token,
|
|
129
|
+
}
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
"""Partner-side helper for the X-Stack-Passport convention (Phase 5.7).
|
|
2
|
+
|
|
3
|
+
Pulls the passport token from request headers (or accepts the raw
|
|
4
|
+
string), looks up the issuing tenant's JWKS, and verifies offline.
|
|
5
|
+
No network at verify time after first JWKS fetch per tenant.
|
|
6
|
+
|
|
7
|
+
Usage:
|
|
8
|
+
|
|
9
|
+
from getstack import verify_passport_header
|
|
10
|
+
|
|
11
|
+
claims = verify_passport_header(
|
|
12
|
+
request.headers.get('x-stack-passport'),
|
|
13
|
+
base_url='https://api.getstack.run',
|
|
14
|
+
)
|
|
15
|
+
tenant_id = claims['stk']['operator_id']
|
|
16
|
+
agent_id = claims['stk']['agent_id']
|
|
17
|
+
|
|
18
|
+
Does NOT check revocation. For revocation-aware verification, layer
|
|
19
|
+
a short-window cache on POST /v1/passports/verify.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
from __future__ import annotations
|
|
23
|
+
|
|
24
|
+
from typing import Any, Optional, Union
|
|
25
|
+
|
|
26
|
+
import jwt
|
|
27
|
+
from jwt import PyJWKClient
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
DEFAULT_BASE_URL = "https://api.getstack.run"
|
|
31
|
+
DEFAULT_ISSUER = "getstack.run"
|
|
32
|
+
DEFAULT_AUDIENCE = "stack:passport"
|
|
33
|
+
|
|
34
|
+
# Cache PyJWKClient per JWKS URL so repeat verifies share the key cache.
|
|
35
|
+
_jwks_client_cache: dict[str, PyJWKClient] = {}
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _get_jwks_client(jwks_url: str) -> PyJWKClient:
|
|
39
|
+
client = _jwks_client_cache.get(jwks_url)
|
|
40
|
+
if client is None:
|
|
41
|
+
client = PyJWKClient(jwks_url)
|
|
42
|
+
_jwks_client_cache[jwks_url] = client
|
|
43
|
+
return client
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def verify_passport_header(
|
|
47
|
+
header_value: Optional[Union[str, list[str]]],
|
|
48
|
+
*,
|
|
49
|
+
base_url: str = DEFAULT_BASE_URL,
|
|
50
|
+
issuer: str = DEFAULT_ISSUER,
|
|
51
|
+
audience: str = DEFAULT_AUDIENCE,
|
|
52
|
+
) -> dict[str, Any]:
|
|
53
|
+
"""Verify an X-Stack-Passport header value.
|
|
54
|
+
|
|
55
|
+
The header value is a STACK passport JWT signed under the issuing
|
|
56
|
+
tenant's per-operator signing key. This function decodes the JWT
|
|
57
|
+
(unverified) to read the sub claim (= tenant operator_id), resolves
|
|
58
|
+
the per-tenant JWKS URL, and verifies signature + issuer +
|
|
59
|
+
audience + expiry.
|
|
60
|
+
|
|
61
|
+
Raises ValueError on any failure — missing header, malformed JWT,
|
|
62
|
+
missing sub, signature failure, wrong issuer/audience, expired.
|
|
63
|
+
The caller should treat any raise as a 401 reject.
|
|
64
|
+
|
|
65
|
+
Returns the decoded claims dict on success.
|
|
66
|
+
"""
|
|
67
|
+
if header_value is None:
|
|
68
|
+
raise ValueError("verify_passport_header: missing header value")
|
|
69
|
+
if isinstance(header_value, list):
|
|
70
|
+
if not header_value:
|
|
71
|
+
raise ValueError("verify_passport_header: header value is empty list")
|
|
72
|
+
token = header_value[0]
|
|
73
|
+
else:
|
|
74
|
+
token = header_value
|
|
75
|
+
if not isinstance(token, str) or not token:
|
|
76
|
+
raise ValueError("verify_passport_header: header value is empty")
|
|
77
|
+
|
|
78
|
+
# Decode unverified to read the tenant operator_id. The signature
|
|
79
|
+
# check that follows binds the sub to the JWKS so an attacker can't
|
|
80
|
+
# substitute a different tenant's sub.
|
|
81
|
+
try:
|
|
82
|
+
unverified = jwt.decode(token, options={"verify_signature": False})
|
|
83
|
+
except jwt.PyJWTError as err:
|
|
84
|
+
raise ValueError(f"verify_passport_header: malformed JWT ({err})") from err
|
|
85
|
+
|
|
86
|
+
tenant_id = unverified.get("sub")
|
|
87
|
+
if not tenant_id or not isinstance(tenant_id, str):
|
|
88
|
+
raise ValueError("verify_passport_header: token missing sub claim")
|
|
89
|
+
|
|
90
|
+
jwks_url = f"{base_url.rstrip('/')}/v1/operators/{tenant_id}/jwks.json"
|
|
91
|
+
jwk_client = _get_jwks_client(jwks_url)
|
|
92
|
+
|
|
93
|
+
try:
|
|
94
|
+
signing_key = jwk_client.get_signing_key_from_jwt(token)
|
|
95
|
+
except jwt.PyJWKClientError as err:
|
|
96
|
+
raise ValueError(f"verify_passport_header: jwks lookup failed ({err})") from err
|
|
97
|
+
|
|
98
|
+
try:
|
|
99
|
+
verified = jwt.decode(
|
|
100
|
+
token,
|
|
101
|
+
signing_key.key,
|
|
102
|
+
algorithms=["EdDSA"],
|
|
103
|
+
issuer=issuer,
|
|
104
|
+
audience=audience,
|
|
105
|
+
)
|
|
106
|
+
except jwt.InvalidTokenError as err:
|
|
107
|
+
raise ValueError(f"verify_passport_header: signature/claims invalid ({err})") from err
|
|
108
|
+
|
|
109
|
+
return verified
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|