getstack 0.2.0__tar.gz → 0.3.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.
Files changed (27) hide show
  1. {getstack-0.2.0 → getstack-0.3.0}/CLAUDE.md +0 -1
  2. {getstack-0.2.0 → getstack-0.3.0}/PKG-INFO +36 -16
  3. {getstack-0.2.0 → getstack-0.3.0}/README.md +33 -15
  4. {getstack-0.2.0 → getstack-0.3.0}/pyproject.toml +9 -1
  5. {getstack-0.2.0 → getstack-0.3.0}/src/getstack/__init__.py +73 -11
  6. getstack-0.3.0/src/getstack/agent_auth.py +189 -0
  7. getstack-0.3.0/src/getstack/auth.py +157 -0
  8. getstack-0.2.0/src/getstack/auth.py +0 -78
  9. {getstack-0.2.0 → getstack-0.3.0}/.gitignore +0 -0
  10. {getstack-0.2.0 → getstack-0.3.0}/LICENSE +0 -0
  11. {getstack-0.2.0 → getstack-0.3.0}/src/getstack/agents.py +0 -0
  12. {getstack-0.2.0 → getstack-0.3.0}/src/getstack/audit.py +0 -0
  13. {getstack-0.2.0 → getstack-0.3.0}/src/getstack/client.py +0 -0
  14. {getstack-0.2.0 → getstack-0.3.0}/src/getstack/credentials.py +0 -0
  15. {getstack-0.2.0 → getstack-0.3.0}/src/getstack/dropoffs.py +0 -0
  16. {getstack-0.2.0 → getstack-0.3.0}/src/getstack/errors.py +0 -0
  17. {getstack-0.2.0 → getstack-0.3.0}/src/getstack/identity.py +0 -0
  18. {getstack-0.2.0 → getstack-0.3.0}/src/getstack/notifications.py +0 -0
  19. {getstack-0.2.0 → getstack-0.3.0}/src/getstack/passports.py +0 -0
  20. {getstack-0.2.0 → getstack-0.3.0}/src/getstack/proxy.py +0 -0
  21. {getstack-0.2.0 → getstack-0.3.0}/src/getstack/py.typed +0 -0
  22. {getstack-0.2.0 → getstack-0.3.0}/src/getstack/reviews.py +0 -0
  23. {getstack-0.2.0 → getstack-0.3.0}/src/getstack/scan.py +0 -0
  24. {getstack-0.2.0 → getstack-0.3.0}/src/getstack/security_events.py +0 -0
  25. {getstack-0.2.0 → getstack-0.3.0}/src/getstack/services.py +0 -0
  26. {getstack-0.2.0 → getstack-0.3.0}/src/getstack/skills.py +0 -0
  27. {getstack-0.2.0 → getstack-0.3.0}/src/getstack/types.py +0 -0
@@ -15,7 +15,6 @@ Published to PyPI as `getstack`. Not a workspace member of the main monorepo
15
15
 
16
16
  - Package metadata: `sdk-python/pyproject.toml`
17
17
  - Module entry: `sdk-python/src/getstack/`
18
- - Tests: `sdk-python/tests/`
19
18
  - User-facing quickstart + examples: `sdk-python/README.md`
20
19
  - License: `sdk-python/LICENSE`
21
20
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: getstack
3
- Version: 0.2.0
3
+ Version: 0.3.0
4
4
  Summary: Python SDK for STACK — trust infrastructure for AI agents
5
5
  Project-URL: Homepage, https://getstack.run
6
6
  Project-URL: Documentation, https://getstack.run/docs/sdk
@@ -19,7 +19,9 @@ Classifier: Programming Language :: Python :: 3.11
19
19
  Classifier: Programming Language :: Python :: 3.12
20
20
  Classifier: Topic :: Software Development :: Libraries
21
21
  Requires-Python: >=3.9
22
+ Requires-Dist: cryptography>=42.0.0
22
23
  Requires-Dist: httpx>=0.25.0
24
+ Requires-Dist: pyjwt>=2.8.0
23
25
  Description-Content-Type: text/markdown
24
26
 
25
27
  # getstack
@@ -34,16 +36,30 @@ pip install getstack
34
36
 
35
37
  ## Quick start
36
38
 
39
+ Sign in once on your machine — the SDK reads credentials from `~/.stack/credentials.json` automatically:
40
+
41
+ ```bash
42
+ npx -y @getstackrun/cli auth login
43
+ ```
44
+
37
45
  ```python
38
46
  from getstack import Stack
39
47
 
40
- stack = Stack(api_key="sk_live_...")
48
+ # Zero-arg constructor reads ~/.stack/credentials.json (OAuth refresh token).
49
+ # Falls back to STACK_API_KEY env var, then constructor api_key= for CI.
50
+ stack = Stack()
41
51
 
42
- # Register an agent
52
+ # 1. Register an agent (one-time)
43
53
  agent = stack.agents.register("my-agent", accountability_mode="enforced")
44
54
 
45
- # Run a mission with automatic checkpoints and checkout
46
- with stack.passports.mission(
55
+ # 2. In your agent runtime, switch to per-agent keypair mode
56
+ agent_stack = Stack(agent_id=agent.id)
57
+ # First run: generates an Ed25519 keypair locally + enrolls the public
58
+ # half via /v1/agents/<id>/enroll. Persisted at ~/.stack/agents/<id>.json
59
+ # (mode 0600). Every subsequent call signs a fresh 60-second JWT.
60
+
61
+ # 3. Run a mission with automatic checkpoints and checkout
62
+ with agent_stack.passports.mission(
47
63
  agent_id=agent.id,
48
64
  intent="Process invoices from Slack",
49
65
  services=["slack", "stripe"],
@@ -57,22 +73,26 @@ with stack.passports.mission(
57
73
 
58
74
  ## Authentication
59
75
 
76
+ Four sources, resolved in priority order:
77
+
60
78
  ```python
61
- # API key (most common)
62
- stack = Stack(api_key="sk_live_...")
79
+ # 1. Explicit auth strategy
80
+ stack = Stack.from_oauth(client_id="...", client_secret="", access_token="...", refresh_token="...")
81
+ stack = Stack.from_session(session_token="...")
63
82
 
64
- # OAuth with auto-refresh
65
- stack = Stack.from_oauth(
66
- client_id="...",
67
- client_secret="...",
68
- access_token="...",
69
- refresh_token="...",
70
- )
83
+ # 2. agent_id (Phase 2 — recommended for production runtimes)
84
+ stack = Stack(agent_id="agt_xxx")
71
85
 
72
- # Session JWT (dashboard integrations)
73
- stack = Stack.from_session(session_token="...")
86
+ # 3. api_key (legacy sk_live_*; for CI without a browser)
87
+ stack = Stack(api_key="sk_live_...")
88
+ # or set STACK_API_KEY in the environment
89
+
90
+ # 4. ~/.stack/credentials.json (Phase 1 — `stack-cli auth login` writes it)
91
+ stack = Stack()
74
92
  ```
75
93
 
94
+ See [/docs/security/stack-auth](https://getstack.run/docs/security/stack-auth) for the full auth model and [/docs/security/agent-keys](https://getstack.run/docs/security/agent-keys) for the per-agent keypair story.
95
+
76
96
  ## Continuous missions (24/7 agents)
77
97
 
78
98
  ```python
@@ -10,16 +10,30 @@ pip install getstack
10
10
 
11
11
  ## Quick start
12
12
 
13
+ Sign in once on your machine — the SDK reads credentials from `~/.stack/credentials.json` automatically:
14
+
15
+ ```bash
16
+ npx -y @getstackrun/cli auth login
17
+ ```
18
+
13
19
  ```python
14
20
  from getstack import Stack
15
21
 
16
- stack = Stack(api_key="sk_live_...")
22
+ # Zero-arg constructor reads ~/.stack/credentials.json (OAuth refresh token).
23
+ # Falls back to STACK_API_KEY env var, then constructor api_key= for CI.
24
+ stack = Stack()
17
25
 
18
- # Register an agent
26
+ # 1. Register an agent (one-time)
19
27
  agent = stack.agents.register("my-agent", accountability_mode="enforced")
20
28
 
21
- # Run a mission with automatic checkpoints and checkout
22
- with stack.passports.mission(
29
+ # 2. In your agent runtime, switch to per-agent keypair mode
30
+ agent_stack = Stack(agent_id=agent.id)
31
+ # First run: generates an Ed25519 keypair locally + enrolls the public
32
+ # half via /v1/agents/<id>/enroll. Persisted at ~/.stack/agents/<id>.json
33
+ # (mode 0600). Every subsequent call signs a fresh 60-second JWT.
34
+
35
+ # 3. Run a mission with automatic checkpoints and checkout
36
+ with agent_stack.passports.mission(
23
37
  agent_id=agent.id,
24
38
  intent="Process invoices from Slack",
25
39
  services=["slack", "stripe"],
@@ -33,22 +47,26 @@ with stack.passports.mission(
33
47
 
34
48
  ## Authentication
35
49
 
50
+ Four sources, resolved in priority order:
51
+
36
52
  ```python
37
- # API key (most common)
38
- stack = Stack(api_key="sk_live_...")
53
+ # 1. Explicit auth strategy
54
+ stack = Stack.from_oauth(client_id="...", client_secret="", access_token="...", refresh_token="...")
55
+ stack = Stack.from_session(session_token="...")
39
56
 
40
- # OAuth with auto-refresh
41
- stack = Stack.from_oauth(
42
- client_id="...",
43
- client_secret="...",
44
- access_token="...",
45
- refresh_token="...",
46
- )
57
+ # 2. agent_id (Phase 2 — recommended for production runtimes)
58
+ stack = Stack(agent_id="agt_xxx")
47
59
 
48
- # Session JWT (dashboard integrations)
49
- stack = Stack.from_session(session_token="...")
60
+ # 3. api_key (legacy sk_live_*; for CI without a browser)
61
+ stack = Stack(api_key="sk_live_...")
62
+ # or set STACK_API_KEY in the environment
63
+
64
+ # 4. ~/.stack/credentials.json (Phase 1 — `stack-cli auth login` writes it)
65
+ stack = Stack()
50
66
  ```
51
67
 
68
+ See [/docs/security/stack-auth](https://getstack.run/docs/security/stack-auth) for the full auth model and [/docs/security/agent-keys](https://getstack.run/docs/security/agent-keys) for the per-agent keypair story.
69
+
52
70
  ## Continuous missions (24/7 agents)
53
71
 
54
72
  ```python
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "getstack"
7
- version = "0.2.0"
7
+ version = "0.3.0"
8
8
  description = "Python SDK for STACK — trust infrastructure for AI agents"
9
9
  readme = "README.md"
10
10
  license = "MIT"
@@ -24,6 +24,14 @@ classifiers = [
24
24
  ]
25
25
  dependencies = [
26
26
  "httpx>=0.25.0",
27
+ # Phase 2 — agent-keypair runtime needs Ed25519 sign + verify and
28
+ # JWT mint. cryptography is the de-facto standalone Ed25519 lib in
29
+ # the Python ecosystem; ~10MB install. Pinned to a major to avoid
30
+ # surprise breakage but minor floats.
31
+ "cryptography>=42.0.0",
32
+ # PyJWT for agent JWT minting; pure-Python, tiny (~50KB), no native
33
+ # deps beyond cryptography (which we already need).
34
+ "pyjwt>=2.8.0",
27
35
  ]
28
36
 
29
37
  [project.urls]
@@ -33,7 +33,10 @@ For async usage::
33
33
 
34
34
  from __future__ import annotations
35
35
 
36
- from .auth import ApiKeyAuth, SessionAuth, OAuthAuth
36
+ import os
37
+
38
+ from .auth import ApiKeyAuth, SessionAuth, OAuthAuth, CredentialsFileAuth
39
+ from .agent_auth import AgentKeypairAuth
37
40
  from .client import HttpClient, AsyncHttpClient, DEFAULT_BASE_URL
38
41
  from .agents import AgentService
39
42
  from .passports import PassportService, Mission
@@ -80,6 +83,29 @@ from .errors import (
80
83
 
81
84
  __version__ = "0.2.0"
82
85
 
86
+
87
+ def _build_static_auth(
88
+ *,
89
+ api_key: str | None,
90
+ base_url: str,
91
+ ) -> ApiKeyAuth | CredentialsFileAuth:
92
+ """Phase 2 helper. Resolve a static-bearer auth strategy for the
93
+ one-time agent-enrollment dance — explicit api_key, STACK_API_KEY
94
+ env var, or ~/.stack/credentials.json (CLI-managed OAuth refresh).
95
+ """
96
+ if api_key:
97
+ return ApiKeyAuth(api_key)
98
+ env_key = os.environ.get("STACK_API_KEY")
99
+ if env_key:
100
+ return ApiKeyAuth(env_key)
101
+ try:
102
+ return CredentialsFileAuth(api_base_url=base_url)
103
+ except RuntimeError as e:
104
+ raise ValueError(
105
+ "Agent enrollment needs a static bearer (api_key=, "
106
+ "STACK_API_KEY, or `stack-cli auth login`) — none found."
107
+ ) from e
108
+
83
109
  __all__ = [
84
110
  "Stack",
85
111
  "AsyncStack",
@@ -129,16 +155,37 @@ class Stack:
129
155
  self,
130
156
  api_key: str | None = None,
131
157
  *,
158
+ agent_id: str | None = None,
132
159
  base_url: str = DEFAULT_BASE_URL,
133
160
  timeout: float = 30.0,
134
- _auth: ApiKeyAuth | SessionAuth | OAuthAuth | None = None,
161
+ _auth: ApiKeyAuth | SessionAuth | OAuthAuth | CredentialsFileAuth | AgentKeypairAuth | None = None,
135
162
  ):
163
+ # Four-source resolution, in priority order:
164
+ # 1. Explicit _auth (callers that pre-built a strategy).
165
+ # 2. agent_id (Phase 2 — agent-keypair runtime mode). Static
166
+ # bearer is auto-resolved for enrollment via api_key/env/file.
167
+ # 3. api_key arg or STACK_API_KEY env var (legacy sk_live_*).
168
+ # 4. ~/.stack/credentials.json — OAuth refresh token written
169
+ # by `stack-cli auth login`.
136
170
  if _auth:
137
- auth = _auth
138
- elif api_key:
139
- auth = ApiKeyAuth(api_key)
171
+ auth: ApiKeyAuth | SessionAuth | OAuthAuth | CredentialsFileAuth | AgentKeypairAuth = _auth
172
+ elif agent_id:
173
+ static_auth = _build_static_auth(api_key=api_key, base_url=base_url)
174
+ auth = AgentKeypairAuth(
175
+ agent_id=agent_id,
176
+ api_base_url=base_url,
177
+ bearer_provider=lambda: static_auth.get_headers()["Authorization"].split(" ", 1)[1],
178
+ )
179
+ elif api_key or os.environ.get("STACK_API_KEY"):
180
+ auth = ApiKeyAuth(api_key or os.environ["STACK_API_KEY"])
140
181
  else:
141
- raise ValueError("Either api_key or _auth must be provided")
182
+ try:
183
+ auth = CredentialsFileAuth(api_base_url=base_url)
184
+ except RuntimeError as e:
185
+ raise ValueError(
186
+ "No STACK credentials found. Pass api_key=, set "
187
+ "STACK_API_KEY, or run `stack-cli auth login`."
188
+ ) from e
142
189
 
143
190
  self._client = HttpClient(auth, base_url=base_url, timeout=timeout)
144
191
  self.agents = AgentService(self._client)
@@ -209,16 +256,31 @@ class AsyncStack:
209
256
  self,
210
257
  api_key: str | None = None,
211
258
  *,
259
+ agent_id: str | None = None,
212
260
  base_url: str = DEFAULT_BASE_URL,
213
261
  timeout: float = 30.0,
214
- _auth: ApiKeyAuth | SessionAuth | OAuthAuth | None = None,
262
+ _auth: ApiKeyAuth | SessionAuth | OAuthAuth | CredentialsFileAuth | AgentKeypairAuth | None = None,
215
263
  ):
264
+ # Same four-source resolution as Stack — see Stack.__init__.
216
265
  if _auth:
217
- auth = _auth
218
- elif api_key:
219
- auth = ApiKeyAuth(api_key)
266
+ auth: ApiKeyAuth | SessionAuth | OAuthAuth | CredentialsFileAuth | AgentKeypairAuth = _auth
267
+ elif agent_id:
268
+ static_auth = _build_static_auth(api_key=api_key, base_url=base_url)
269
+ auth = AgentKeypairAuth(
270
+ agent_id=agent_id,
271
+ api_base_url=base_url,
272
+ bearer_provider=lambda: static_auth.get_headers()["Authorization"].split(" ", 1)[1],
273
+ )
274
+ elif api_key or os.environ.get("STACK_API_KEY"):
275
+ auth = ApiKeyAuth(api_key or os.environ["STACK_API_KEY"])
220
276
  else:
221
- raise ValueError("Either api_key or _auth must be provided")
277
+ try:
278
+ auth = CredentialsFileAuth(api_base_url=base_url)
279
+ except RuntimeError as e:
280
+ raise ValueError(
281
+ "No STACK credentials found. Pass api_key=, set "
282
+ "STACK_API_KEY, or run `stack-cli auth login`."
283
+ ) from e
222
284
 
223
285
  self._client = AsyncHttpClient(auth, base_url=base_url, timeout=timeout)
224
286
 
@@ -0,0 +1,189 @@
1
+ """Phase 2 — agent-keypair runtime auth (Python).
2
+
3
+ Mirror of packages/sdk/src/agent-auth.ts. When ``Stack(agent_id=...)`` is
4
+ constructed, the SDK enters agent-runtime mode: every request is signed
5
+ with a fresh 60-second EdDSA JWT minted from the agent's local privkey
6
+ at ~/.stack/agents/<agent_id>.json.
7
+
8
+ First run: generate Ed25519 keypair, request enrollment challenge from
9
+ the API, sign challenge with privkey, POST /enroll. Persist privkey
10
+ locally (mode 0600). Subsequent runs read from disk.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import base64
16
+ import json
17
+ import os
18
+ import time
19
+ from pathlib import Path
20
+ from typing import Callable
21
+
22
+ import httpx
23
+ import jwt
24
+ from cryptography.hazmat.primitives.asymmetric.ed25519 import (
25
+ Ed25519PrivateKey,
26
+ Ed25519PublicKey,
27
+ )
28
+ from cryptography.hazmat.primitives.serialization import (
29
+ Encoding,
30
+ NoEncryption,
31
+ PrivateFormat,
32
+ PublicFormat,
33
+ )
34
+
35
+ from .auth import AuthStrategy
36
+
37
+ AGENT_JWT_TTL = 60
38
+ AGENT_JWT_ISSUER = "stack-sdk"
39
+ AGENT_JWT_AUDIENCE = "stack:agent"
40
+
41
+
42
+ def _b64url_no_pad(data: bytes) -> str:
43
+ return base64.urlsafe_b64encode(data).rstrip(b"=").decode("ascii")
44
+
45
+
46
+ def _key_path(agent_id: str) -> Path:
47
+ return Path(os.path.expanduser("~")) / ".stack" / "agents" / f"{agent_id}.json"
48
+
49
+
50
+ def _load_stored(agent_id: str) -> dict | None:
51
+ path = _key_path(agent_id)
52
+ if not path.exists():
53
+ return None
54
+ try:
55
+ return json.loads(path.read_text(encoding="utf8"))
56
+ except (OSError, json.JSONDecodeError):
57
+ return None
58
+
59
+
60
+ def _persist_stored(agent_id: str, payload: dict) -> None:
61
+ path = _key_path(agent_id)
62
+ path.parent.mkdir(parents=True, exist_ok=True)
63
+ path.write_text(json.dumps(payload, indent=2), encoding="utf8")
64
+ try:
65
+ os.chmod(path, 0o600)
66
+ except OSError:
67
+ # Windows / unsupported FS — ignore.
68
+ pass
69
+
70
+
71
+ def _generate_keypair() -> tuple[Ed25519PrivateKey, dict, dict]:
72
+ """Generate an Ed25519 keypair and return (privkey_obj, public_jwk,
73
+ private_jwk_with_d)."""
74
+ priv = Ed25519PrivateKey.generate()
75
+ pub = priv.public_key()
76
+ pub_raw = pub.public_bytes(Encoding.Raw, PublicFormat.Raw)
77
+ priv_raw = priv.private_bytes(Encoding.Raw, PrivateFormat.Raw, NoEncryption())
78
+ public_jwk = {"kty": "OKP", "crv": "Ed25519", "x": _b64url_no_pad(pub_raw)}
79
+ private_jwk = {**public_jwk, "d": _b64url_no_pad(priv_raw)}
80
+ return priv, public_jwk, private_jwk
81
+
82
+
83
+ def _privkey_from_jwk(jwk: dict) -> Ed25519PrivateKey:
84
+ pad = "=" * (-len(jwk["d"]) % 4)
85
+ raw = base64.urlsafe_b64decode(jwk["d"] + pad)
86
+ return Ed25519PrivateKey.from_private_bytes(raw)
87
+
88
+
89
+ def _enroll(
90
+ agent_id: str,
91
+ base_url: str,
92
+ bearer_provider: Callable[[], str],
93
+ ) -> dict:
94
+ """Run the proof-of-possession enrollment dance. Returns the stored
95
+ payload (with private JWK) ready to persist."""
96
+ bearer = bearer_provider()
97
+ base = base_url.rstrip("/")
98
+
99
+ # Step 1 — request challenge.
100
+ r = httpx.post(
101
+ f"{base}/v1/agents/{agent_id}/enrollment-challenge",
102
+ headers={"Authorization": f"Bearer {bearer}", "Content-Type": "application/json"},
103
+ timeout=15.0,
104
+ )
105
+ r.raise_for_status()
106
+ challenge_body = r.json()
107
+ challenge: str = challenge_body["challenge"]
108
+ challenge_id: str = challenge_body["challenge_id"]
109
+
110
+ # Step 2 — generate keypair locally.
111
+ priv_obj, public_jwk, private_jwk = _generate_keypair()
112
+
113
+ # Step 3 — sign the challenge bytes.
114
+ sig = priv_obj.sign(challenge.encode("utf8"))
115
+ signed_challenge = _b64url_no_pad(sig)
116
+
117
+ # Step 4 — POST /enroll.
118
+ r2 = httpx.post(
119
+ f"{base}/v1/agents/{agent_id}/enroll",
120
+ headers={"Authorization": f"Bearer {bearer}", "Content-Type": "application/json"},
121
+ json={
122
+ "public_key": public_jwk,
123
+ "challenge_id": challenge_id,
124
+ "signed_challenge": signed_challenge,
125
+ },
126
+ timeout=15.0,
127
+ )
128
+ r2.raise_for_status()
129
+ enrolled = r2.json()
130
+
131
+ return {
132
+ "agent_id": agent_id,
133
+ "publicKey": public_jwk,
134
+ "privateKey": private_jwk,
135
+ "enrolled_at": enrolled.get("enrolled_at"),
136
+ }
137
+
138
+
139
+ def _sign_agent_jwt(stored: dict, agent_id: str) -> str:
140
+ """Mint a fresh 60-second EdDSA JWT signed with the agent privkey."""
141
+ priv_obj = _privkey_from_jwk(stored["privateKey"])
142
+ now = int(time.time())
143
+ payload = {
144
+ "iss": AGENT_JWT_ISSUER,
145
+ "sub": agent_id,
146
+ "aud": AGENT_JWT_AUDIENCE,
147
+ "iat": now,
148
+ "nbf": now,
149
+ "exp": now + AGENT_JWT_TTL,
150
+ "jti": f"aj_{now}_{os.urandom(6).hex()}",
151
+ }
152
+ # PyJWT accepts a raw cryptography Ed25519 key when algorithm='EdDSA'.
153
+ return jwt.encode(payload, priv_obj, algorithm="EdDSA")
154
+
155
+
156
+ class AgentKeypairAuth(AuthStrategy):
157
+ """Phase 2 agent-runtime auth strategy.
158
+
159
+ Constructed with an agent_id and a fallback bearer provider (used
160
+ only for the one-time enrollment dance). Once enrolled, every
161
+ request mints a fresh 60-second JWT signed by the locally-stored
162
+ privkey — the bearer provider is no longer consulted.
163
+ """
164
+
165
+ def __init__(
166
+ self,
167
+ agent_id: str,
168
+ api_base_url: str,
169
+ bearer_provider: Callable[[], str],
170
+ ):
171
+ self.agent_id = agent_id
172
+ self.api_base_url = api_base_url
173
+ self.bearer_provider = bearer_provider
174
+ self._stored: dict | None = None
175
+
176
+ def _ensure_stored(self) -> dict:
177
+ if self._stored is not None:
178
+ return self._stored
179
+ stored = _load_stored(self.agent_id)
180
+ if stored is None:
181
+ stored = _enroll(self.agent_id, self.api_base_url, self.bearer_provider)
182
+ _persist_stored(self.agent_id, stored)
183
+ self._stored = stored
184
+ return stored
185
+
186
+ def get_headers(self) -> dict[str, str]:
187
+ stored = self._ensure_stored()
188
+ token = _sign_agent_jwt(stored, self.agent_id)
189
+ return {"Authorization": f"Bearer {token}"}
@@ -0,0 +1,157 @@
1
+ """Authentication strategies for the STACK SDK."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import os
7
+ import time
8
+ from abc import ABC, abstractmethod
9
+ from pathlib import Path
10
+
11
+ import httpx
12
+
13
+
14
+ class AuthStrategy(ABC):
15
+ """Base class for auth strategies."""
16
+
17
+ @abstractmethod
18
+ def get_headers(self) -> dict[str, str]:
19
+ """Return auth headers for API requests."""
20
+ ...
21
+
22
+
23
+ class ApiKeyAuth(AuthStrategy):
24
+ """Authenticate with a STACK API key (sk_live_...)."""
25
+
26
+ def __init__(self, api_key: str):
27
+ self.api_key = api_key
28
+
29
+ def get_headers(self) -> dict[str, str]:
30
+ return {"Authorization": f"Bearer {self.api_key}"}
31
+
32
+
33
+ class SessionAuth(AuthStrategy):
34
+ """Authenticate with a session JWT (dashboard integrations)."""
35
+
36
+ def __init__(self, session_token: str):
37
+ self.session_token = session_token
38
+
39
+ def get_headers(self) -> dict[str, str]:
40
+ return {"Authorization": f"Bearer {self.session_token}"}
41
+
42
+
43
+ class OAuthAuth(AuthStrategy):
44
+ """Authenticate with OAuth tokens. Auto-refreshes when expired.
45
+
46
+ For OAuth 2.1 public PKCE clients (the Phase 1 default), pass
47
+ ``client_secret=""``. The token endpoint accepts public clients with
48
+ no secret.
49
+ """
50
+
51
+ def __init__(
52
+ self,
53
+ client_id: str,
54
+ client_secret: str,
55
+ access_token: str,
56
+ refresh_token: str,
57
+ token_endpoint: str = "https://api.getstack.run/oauth/token",
58
+ ):
59
+ self.client_id = client_id
60
+ self.client_secret = client_secret
61
+ self.access_token = access_token
62
+ self.refresh_token = refresh_token
63
+ self.token_endpoint = token_endpoint
64
+ self.expires_at: float | None = None
65
+
66
+ def get_headers(self) -> dict[str, str]:
67
+ if self.expires_at and time.time() > self.expires_at - 30:
68
+ self._refresh()
69
+ return {"Authorization": f"Bearer {self.access_token}"}
70
+
71
+ def _refresh(self) -> None:
72
+ """Exchange refresh_token for a new access_token."""
73
+ body: dict[str, str] = {
74
+ "grant_type": "refresh_token",
75
+ "refresh_token": self.refresh_token,
76
+ "client_id": self.client_id,
77
+ }
78
+ if self.client_secret:
79
+ body["client_secret"] = self.client_secret
80
+ resp = httpx.post(self.token_endpoint, data=body)
81
+ resp.raise_for_status()
82
+ data = resp.json()
83
+ self.access_token = data["access_token"]
84
+ self.refresh_token = data.get("refresh_token", self.refresh_token)
85
+ self.expires_at = time.time() + data.get("expires_in", 300)
86
+
87
+
88
+ class CredentialsFileAuth(AuthStrategy):
89
+ """Read OAuth credentials from ~/.stack/credentials.json.
90
+
91
+ Mirror of the TS SDK's lazy refresh-from-file path. The CLI's
92
+ ``stack-cli auth login`` (Device flow) writes this file; both SDKs
93
+ read it. On every request we exchange the stored refresh for a fresh
94
+ access token (5-minute TTL, rotation-on-use), then cache it in
95
+ memory until 30 seconds before expiry.
96
+
97
+ Raises ``FileNotFoundError`` (re-raised as ``RuntimeError``) on
98
+ construction if the file is missing — callers should fall back to
99
+ ``ApiKeyAuth`` from ``STACK_API_KEY`` first.
100
+ """
101
+
102
+ def __init__(
103
+ self,
104
+ credentials_path: Path | None = None,
105
+ api_base_url: str = "https://api.getstack.run",
106
+ ):
107
+ self.path = credentials_path or (Path(os.path.expanduser("~")) / ".stack" / "credentials.json")
108
+ self.token_endpoint = f"{api_base_url.rstrip('/')}/oauth/token"
109
+ if not self.path.exists():
110
+ raise RuntimeError(
111
+ f"No STACK credentials at {self.path}. "
112
+ "Run `stack-cli auth login` or set STACK_API_KEY."
113
+ )
114
+ self._load()
115
+ self._access_token: str | None = None
116
+ self._access_expires_at: float = 0.0
117
+
118
+ def _load(self) -> None:
119
+ with self.path.open("r", encoding="utf8") as f:
120
+ data = json.load(f)
121
+ self.client_id: str = data["client_id"]
122
+ self.refresh_token: str = data["refresh_token"]
123
+ self.scope: str = data.get("scope", "")
124
+
125
+ def _refresh(self) -> None:
126
+ resp = httpx.post(
127
+ self.token_endpoint,
128
+ data={
129
+ "grant_type": "refresh_token",
130
+ "refresh_token": self.refresh_token,
131
+ "client_id": self.client_id,
132
+ },
133
+ )
134
+ resp.raise_for_status()
135
+ data = resp.json()
136
+ self._access_token = data["access_token"]
137
+ self._access_expires_at = time.time() + data.get("expires_in", 300)
138
+ self.refresh_token = data.get("refresh_token", self.refresh_token)
139
+ # Persist the rotated refresh.
140
+ try:
141
+ current = json.loads(self.path.read_text(encoding="utf8"))
142
+ current["refresh_token"] = self.refresh_token
143
+ current["issued_at"] = int(time.time())
144
+ current["refresh_expires_at"] = int(time.time()) + 30 * 24 * 60 * 60
145
+ self.path.write_text(json.dumps(current, indent=2), encoding="utf8")
146
+ try:
147
+ os.chmod(self.path, 0o600)
148
+ except OSError:
149
+ pass
150
+ except OSError:
151
+ pass
152
+
153
+ def get_headers(self) -> dict[str, str]:
154
+ if not self._access_token or time.time() > self._access_expires_at - 30:
155
+ self._refresh()
156
+ assert self._access_token is not None
157
+ return {"Authorization": f"Bearer {self._access_token}"}
@@ -1,78 +0,0 @@
1
- """Authentication strategies for the STACK SDK."""
2
-
3
- from __future__ import annotations
4
-
5
- import time
6
- from abc import ABC, abstractmethod
7
-
8
- import httpx
9
-
10
-
11
- class AuthStrategy(ABC):
12
- """Base class for auth strategies."""
13
-
14
- @abstractmethod
15
- def get_headers(self) -> dict[str, str]:
16
- """Return auth headers for API requests."""
17
- ...
18
-
19
-
20
- class ApiKeyAuth(AuthStrategy):
21
- """Authenticate with a STACK API key (sk_live_...)."""
22
-
23
- def __init__(self, api_key: str):
24
- self.api_key = api_key
25
-
26
- def get_headers(self) -> dict[str, str]:
27
- return {"Authorization": f"Bearer {self.api_key}"}
28
-
29
-
30
- class SessionAuth(AuthStrategy):
31
- """Authenticate with a session JWT (dashboard integrations)."""
32
-
33
- def __init__(self, session_token: str):
34
- self.session_token = session_token
35
-
36
- def get_headers(self) -> dict[str, str]:
37
- return {"Authorization": f"Bearer {self.session_token}"}
38
-
39
-
40
- class OAuthAuth(AuthStrategy):
41
- """Authenticate with OAuth tokens. Auto-refreshes when expired."""
42
-
43
- def __init__(
44
- self,
45
- client_id: str,
46
- client_secret: str,
47
- access_token: str,
48
- refresh_token: str,
49
- token_endpoint: str = "https://api.getstack.run/v1/oauth/token",
50
- ):
51
- self.client_id = client_id
52
- self.client_secret = client_secret
53
- self.access_token = access_token
54
- self.refresh_token = refresh_token
55
- self.token_endpoint = token_endpoint
56
- self.expires_at: float | None = None
57
-
58
- def get_headers(self) -> dict[str, str]:
59
- if self.expires_at and time.time() > self.expires_at - 30:
60
- self._refresh()
61
- return {"Authorization": f"Bearer {self.access_token}"}
62
-
63
- def _refresh(self) -> None:
64
- """Exchange refresh_token for a new access_token."""
65
- resp = httpx.post(
66
- self.token_endpoint,
67
- data={
68
- "grant_type": "refresh_token",
69
- "refresh_token": self.refresh_token,
70
- "client_id": self.client_id,
71
- "client_secret": self.client_secret,
72
- },
73
- )
74
- resp.raise_for_status()
75
- data = resp.json()
76
- self.access_token = data["access_token"]
77
- self.refresh_token = data.get("refresh_token", self.refresh_token)
78
- self.expires_at = time.time() + data.get("expires_in", 3600)
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes