getstack 0.2.0__tar.gz → 0.4.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 (28) hide show
  1. {getstack-0.2.0 → getstack-0.4.0}/CLAUDE.md +0 -1
  2. {getstack-0.2.0 → getstack-0.4.0}/PKG-INFO +36 -16
  3. {getstack-0.2.0 → getstack-0.4.0}/README.md +33 -15
  4. {getstack-0.2.0 → getstack-0.4.0}/pyproject.toml +9 -1
  5. {getstack-0.2.0 → getstack-0.4.0}/src/getstack/__init__.py +80 -11
  6. getstack-0.4.0/src/getstack/agent_auth.py +331 -0
  7. getstack-0.4.0/src/getstack/auth.py +157 -0
  8. getstack-0.4.0/src/getstack/browser_bootstrap.py +212 -0
  9. getstack-0.2.0/src/getstack/auth.py +0 -78
  10. {getstack-0.2.0 → getstack-0.4.0}/.gitignore +0 -0
  11. {getstack-0.2.0 → getstack-0.4.0}/LICENSE +0 -0
  12. {getstack-0.2.0 → getstack-0.4.0}/src/getstack/agents.py +0 -0
  13. {getstack-0.2.0 → getstack-0.4.0}/src/getstack/audit.py +0 -0
  14. {getstack-0.2.0 → getstack-0.4.0}/src/getstack/client.py +0 -0
  15. {getstack-0.2.0 → getstack-0.4.0}/src/getstack/credentials.py +0 -0
  16. {getstack-0.2.0 → getstack-0.4.0}/src/getstack/dropoffs.py +0 -0
  17. {getstack-0.2.0 → getstack-0.4.0}/src/getstack/errors.py +0 -0
  18. {getstack-0.2.0 → getstack-0.4.0}/src/getstack/identity.py +0 -0
  19. {getstack-0.2.0 → getstack-0.4.0}/src/getstack/notifications.py +0 -0
  20. {getstack-0.2.0 → getstack-0.4.0}/src/getstack/passports.py +0 -0
  21. {getstack-0.2.0 → getstack-0.4.0}/src/getstack/proxy.py +0 -0
  22. {getstack-0.2.0 → getstack-0.4.0}/src/getstack/py.typed +0 -0
  23. {getstack-0.2.0 → getstack-0.4.0}/src/getstack/reviews.py +0 -0
  24. {getstack-0.2.0 → getstack-0.4.0}/src/getstack/scan.py +0 -0
  25. {getstack-0.2.0 → getstack-0.4.0}/src/getstack/security_events.py +0 -0
  26. {getstack-0.2.0 → getstack-0.4.0}/src/getstack/services.py +0 -0
  27. {getstack-0.2.0 → getstack-0.4.0}/src/getstack/skills.py +0 -0
  28. {getstack-0.2.0 → getstack-0.4.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.4.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.4.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,48 @@ 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
+ return _resolve_creds_or_bootstrap(base_url)
102
+
103
+
104
+ def _resolve_creds_or_bootstrap(base_url: str) -> CredentialsFileAuth:
105
+ """Read ~/.stack/credentials.json — and if it's missing AND the
106
+ process looks interactive (TTY + not CI), spawn the browser-spawn
107
+ OAuth bootstrap to create it. Raises ValueError when neither path
108
+ resolves.
109
+ """
110
+ try:
111
+ return CredentialsFileAuth(api_base_url=base_url)
112
+ except RuntimeError:
113
+ from .browser_bootstrap import browser_bootstrap, should_run_browser_bootstrap
114
+ if should_run_browser_bootstrap():
115
+ browser_bootstrap(base_url)
116
+ try:
117
+ return CredentialsFileAuth(api_base_url=base_url)
118
+ except RuntimeError as e:
119
+ raise ValueError(
120
+ "STACK SDK: browser bootstrap reported success but "
121
+ "credentials file still missing."
122
+ ) from e
123
+ raise ValueError(
124
+ "No STACK credentials found. Pass api_key=, set "
125
+ "STACK_API_KEY, or run `stack-cli auth login`."
126
+ )
127
+
83
128
  __all__ = [
84
129
  "Stack",
85
130
  "AsyncStack",
@@ -129,16 +174,31 @@ class Stack:
129
174
  self,
130
175
  api_key: str | None = None,
131
176
  *,
177
+ agent_id: str | None = None,
132
178
  base_url: str = DEFAULT_BASE_URL,
133
179
  timeout: float = 30.0,
134
- _auth: ApiKeyAuth | SessionAuth | OAuthAuth | None = None,
180
+ _auth: ApiKeyAuth | SessionAuth | OAuthAuth | CredentialsFileAuth | AgentKeypairAuth | None = None,
135
181
  ):
182
+ # Four-source resolution, in priority order:
183
+ # 1. Explicit _auth (callers that pre-built a strategy).
184
+ # 2. agent_id (Phase 2 — agent-keypair runtime mode). Static
185
+ # bearer is auto-resolved for enrollment via api_key/env/file.
186
+ # 3. api_key arg or STACK_API_KEY env var (legacy sk_live_*).
187
+ # 4. ~/.stack/credentials.json — OAuth refresh token written
188
+ # by `stack-cli auth login`.
136
189
  if _auth:
137
- auth = _auth
138
- elif api_key:
139
- auth = ApiKeyAuth(api_key)
190
+ auth: ApiKeyAuth | SessionAuth | OAuthAuth | CredentialsFileAuth | AgentKeypairAuth = _auth
191
+ elif agent_id:
192
+ static_auth = _build_static_auth(api_key=api_key, base_url=base_url)
193
+ auth = AgentKeypairAuth(
194
+ agent_id=agent_id,
195
+ api_base_url=base_url,
196
+ bearer_provider=lambda: static_auth.get_headers()["Authorization"].split(" ", 1)[1],
197
+ )
198
+ elif api_key or os.environ.get("STACK_API_KEY"):
199
+ auth = ApiKeyAuth(api_key or os.environ["STACK_API_KEY"])
140
200
  else:
141
- raise ValueError("Either api_key or _auth must be provided")
201
+ auth = _resolve_creds_or_bootstrap(base_url)
142
202
 
143
203
  self._client = HttpClient(auth, base_url=base_url, timeout=timeout)
144
204
  self.agents = AgentService(self._client)
@@ -209,16 +269,25 @@ class AsyncStack:
209
269
  self,
210
270
  api_key: str | None = None,
211
271
  *,
272
+ agent_id: str | None = None,
212
273
  base_url: str = DEFAULT_BASE_URL,
213
274
  timeout: float = 30.0,
214
- _auth: ApiKeyAuth | SessionAuth | OAuthAuth | None = None,
275
+ _auth: ApiKeyAuth | SessionAuth | OAuthAuth | CredentialsFileAuth | AgentKeypairAuth | None = None,
215
276
  ):
277
+ # Same four-source resolution as Stack — see Stack.__init__.
216
278
  if _auth:
217
- auth = _auth
218
- elif api_key:
219
- auth = ApiKeyAuth(api_key)
279
+ auth: ApiKeyAuth | SessionAuth | OAuthAuth | CredentialsFileAuth | AgentKeypairAuth = _auth
280
+ elif agent_id:
281
+ static_auth = _build_static_auth(api_key=api_key, base_url=base_url)
282
+ auth = AgentKeypairAuth(
283
+ agent_id=agent_id,
284
+ api_base_url=base_url,
285
+ bearer_provider=lambda: static_auth.get_headers()["Authorization"].split(" ", 1)[1],
286
+ )
287
+ elif api_key or os.environ.get("STACK_API_KEY"):
288
+ auth = ApiKeyAuth(api_key or os.environ["STACK_API_KEY"])
220
289
  else:
221
- raise ValueError("Either api_key or _auth must be provided")
290
+ auth = _resolve_creds_or_bootstrap(base_url)
222
291
 
223
292
  self._client = AsyncHttpClient(auth, base_url=base_url, timeout=timeout)
224
293
 
@@ -0,0 +1,331 @@
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: open the dashboard /sdk-bootstrap page in the user's browser
9
+ for explicit approval (interactive only — TTY + not CI), generate the
10
+ Ed25519 keypair locally, sign the API's challenge, POST /enroll with
11
+ the operator-approved enrollment ticket as Bearer. Persist privkey
12
+ locally (mode 0600). Subsequent runs read from disk.
13
+
14
+ Headless / CI runs (no TTY, or STACK_AUTH_INTERACTIVE=false) skip the
15
+ browser step and fall through to the developer's OAuth bearer (or
16
+ sk_live_* via STACK_API_KEY) for silent enrollment — same endpoints,
17
+ same proof-of-possession, no human in the loop.
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ import base64
23
+ import http.server
24
+ import json
25
+ import os
26
+ import secrets
27
+ import socket
28
+ import socketserver
29
+ import sys
30
+ import threading
31
+ import time
32
+ import urllib.parse
33
+ import webbrowser
34
+ from pathlib import Path
35
+ from typing import Callable
36
+
37
+ import httpx
38
+ import jwt
39
+ from cryptography.hazmat.primitives.asymmetric.ed25519 import (
40
+ Ed25519PrivateKey,
41
+ Ed25519PublicKey,
42
+ )
43
+ from cryptography.hazmat.primitives.serialization import (
44
+ Encoding,
45
+ NoEncryption,
46
+ PrivateFormat,
47
+ PublicFormat,
48
+ )
49
+
50
+ from .auth import AuthStrategy
51
+ from .browser_bootstrap import should_run_browser_bootstrap
52
+
53
+ AGENT_JWT_TTL = 60
54
+ AGENT_JWT_ISSUER = "stack-sdk"
55
+ AGENT_JWT_AUDIENCE = "stack:agent"
56
+
57
+ ENROLLMENT_AUTH_TIMEOUT_S = 5 * 60 # matches enrollment-ticket TTL
58
+
59
+
60
+ def _dashboard_url(api_base_url: str) -> str:
61
+ """Origin to which the SDK opens the browser for the approval screen.
62
+
63
+ Override via STACK_DASHBOARD_URL for self-hosted/test environments.
64
+ Dev shortcut: api on localhost ⇒ dashboard on :3100.
65
+ """
66
+ override = os.environ.get("STACK_DASHBOARD_URL")
67
+ if override:
68
+ return override
69
+ if api_base_url.startswith(("http://127.0.0.1", "http://localhost")):
70
+ return "http://localhost:3100"
71
+ return "https://getstack.run"
72
+
73
+
74
+ def _ticket_via_browser_approval(agent_id: str, api_base_url: str) -> str:
75
+ """Spawn a one-shot loopback HTTP listener, open the dashboard
76
+ /sdk-bootstrap page in the user's browser, and return the ticket
77
+ once the user approves. Raises RuntimeError on deny / timeout / any
78
+ failure — caller decides whether to surface or fall back.
79
+ """
80
+ state = base64.urlsafe_b64encode(secrets.token_bytes(16)).rstrip(b"=").decode("ascii")
81
+ machine = socket.gethostname()[:80]
82
+ proc_label = f"python {Path(sys.argv[0]).name}"[:80] if sys.argv else "python"
83
+
84
+ holder: dict[str, object | None] = {"ticket": None, "error": None}
85
+ handler_event = threading.Event()
86
+
87
+ class Handler(http.server.BaseHTTPRequestHandler):
88
+ def do_GET(self): # noqa: N802
89
+ parsed = urllib.parse.urlparse(self.path)
90
+ if parsed.path != "/cb":
91
+ self.send_response(404)
92
+ self.end_headers()
93
+ return
94
+ params = urllib.parse.parse_qs(parsed.query)
95
+ cb_state = params.get("state", [None])[0]
96
+ err = params.get("error", [None])[0]
97
+ ticket = params.get("ticket", [None])[0]
98
+ if cb_state != state:
99
+ holder["error"] = "state mismatch"
100
+ self.send_response(400)
101
+ self.send_header("Content-Type", "text/html")
102
+ self.end_headers()
103
+ self.wfile.write(b"<html><body style='font-family:system-ui;padding:40px'><h1>State mismatch</h1></body></html>")
104
+ elif err:
105
+ holder["error"] = err
106
+ self.send_response(403)
107
+ self.send_header("Content-Type", "text/html")
108
+ self.end_headers()
109
+ self.wfile.write(b"<html><body style='font-family:system-ui;padding:40px'><h1>Enrollment denied</h1><p>You can close this tab.</p></body></html>")
110
+ elif not ticket:
111
+ holder["error"] = "missing ticket"
112
+ self.send_response(400)
113
+ self.send_header("Content-Type", "text/html")
114
+ self.end_headers()
115
+ self.wfile.write(b"<html><body style='font-family:system-ui;padding:40px'><h1>Missing ticket</h1></body></html>")
116
+ else:
117
+ holder["ticket"] = ticket
118
+ self.send_response(200)
119
+ self.send_header("Content-Type", "text/html")
120
+ self.end_headers()
121
+ self.wfile.write(b"<html><body style='font-family:system-ui;padding:40px;text-align:center'><h1>Approved</h1><p>You can close this tab and return to your terminal.</p></body></html>")
122
+ handler_event.set()
123
+
124
+ def log_message(self, *_a, **_kw):
125
+ return
126
+
127
+ server = socketserver.TCPServer(("127.0.0.1", 0), Handler)
128
+ port = server.server_address[1]
129
+ thread = threading.Thread(target=server.serve_forever, daemon=True)
130
+ thread.start()
131
+
132
+ try:
133
+ callback = f"http://127.0.0.1:{port}/cb"
134
+ dash = _dashboard_url(api_base_url)
135
+ url = f"{dash}/sdk-bootstrap?" + urllib.parse.urlencode({
136
+ "agent_id": agent_id,
137
+ "callback": callback,
138
+ "state": state,
139
+ "machine": machine,
140
+ "process": proc_label,
141
+ })
142
+
143
+ sys.stderr.write("\n STACK SDK agent enrollment\n")
144
+ sys.stderr.write(" ──────────────────────────\n\n")
145
+ sys.stderr.write(f" Approve in your browser: {url}\n\n")
146
+
147
+ try:
148
+ webbrowser.open(url)
149
+ except Exception:
150
+ pass
151
+
152
+ if not handler_event.wait(timeout=ENROLLMENT_AUTH_TIMEOUT_S):
153
+ raise RuntimeError("enrollment approval timed out (5 min). Re-run to try again.")
154
+ if holder["error"]:
155
+ raise RuntimeError(f"enrollment {holder['error']}")
156
+ ticket = holder["ticket"]
157
+ if not isinstance(ticket, str):
158
+ raise RuntimeError("enrollment callback produced no ticket")
159
+ return ticket
160
+ finally:
161
+ server.shutdown()
162
+ server.server_close()
163
+
164
+
165
+ def _b64url_no_pad(data: bytes) -> str:
166
+ return base64.urlsafe_b64encode(data).rstrip(b"=").decode("ascii")
167
+
168
+
169
+ def _key_path(agent_id: str) -> Path:
170
+ return Path(os.path.expanduser("~")) / ".stack" / "agents" / f"{agent_id}.json"
171
+
172
+
173
+ def _load_stored(agent_id: str) -> dict | None:
174
+ path = _key_path(agent_id)
175
+ if not path.exists():
176
+ return None
177
+ try:
178
+ return json.loads(path.read_text(encoding="utf8"))
179
+ except (OSError, json.JSONDecodeError):
180
+ return None
181
+
182
+
183
+ def _persist_stored(agent_id: str, payload: dict) -> None:
184
+ path = _key_path(agent_id)
185
+ path.parent.mkdir(parents=True, exist_ok=True)
186
+ path.write_text(json.dumps(payload, indent=2), encoding="utf8")
187
+ try:
188
+ os.chmod(path, 0o600)
189
+ except OSError:
190
+ # Windows / unsupported FS — ignore.
191
+ pass
192
+
193
+
194
+ def _generate_keypair() -> tuple[Ed25519PrivateKey, dict, dict]:
195
+ """Generate an Ed25519 keypair and return (privkey_obj, public_jwk,
196
+ private_jwk_with_d)."""
197
+ priv = Ed25519PrivateKey.generate()
198
+ pub = priv.public_key()
199
+ pub_raw = pub.public_bytes(Encoding.Raw, PublicFormat.Raw)
200
+ priv_raw = priv.private_bytes(Encoding.Raw, PrivateFormat.Raw, NoEncryption())
201
+ public_jwk = {"kty": "OKP", "crv": "Ed25519", "x": _b64url_no_pad(pub_raw)}
202
+ private_jwk = {**public_jwk, "d": _b64url_no_pad(priv_raw)}
203
+ return priv, public_jwk, private_jwk
204
+
205
+
206
+ def _privkey_from_jwk(jwk: dict) -> Ed25519PrivateKey:
207
+ pad = "=" * (-len(jwk["d"]) % 4)
208
+ raw = base64.urlsafe_b64decode(jwk["d"] + pad)
209
+ return Ed25519PrivateKey.from_private_bytes(raw)
210
+
211
+
212
+ def _enroll(
213
+ agent_id: str,
214
+ base_url: str,
215
+ bearer_provider: Callable[[], str],
216
+ ) -> dict:
217
+ """Run the proof-of-possession enrollment dance. Returns the stored
218
+ payload (with private JWK) ready to persist.
219
+
220
+ Two paths converge on the same /enrollment-challenge + /enroll calls:
221
+
222
+ - Interactive: open the dashboard /sdk-bootstrap page in the user's
223
+ browser, get a single-use enrollment ticket, use it as the bearer.
224
+ - Headless: call ``bearer_provider`` (developer's OAuth or
225
+ STACK_API_KEY) directly. No browser, no human in the loop.
226
+ """
227
+ if should_run_browser_bootstrap():
228
+ try:
229
+ bearer = _ticket_via_browser_approval(agent_id, base_url)
230
+ except RuntimeError as e:
231
+ raise RuntimeError(
232
+ f"Browser approval for SDK agent enrollment failed ({e}). "
233
+ f"Either fix the issue and re-run, or set STACK_API_KEY "
234
+ f"(or STACK_AUTH_INTERACTIVE=false) to use the silent "
235
+ f"enrollment path."
236
+ ) from e
237
+ else:
238
+ bearer = bearer_provider()
239
+ base = base_url.rstrip("/")
240
+
241
+ # Step 1 — request challenge.
242
+ r = httpx.post(
243
+ f"{base}/v1/agents/{agent_id}/enrollment-challenge",
244
+ headers={"Authorization": f"Bearer {bearer}", "Content-Type": "application/json"},
245
+ timeout=15.0,
246
+ )
247
+ r.raise_for_status()
248
+ challenge_body = r.json()
249
+ challenge: str = challenge_body["challenge"]
250
+ challenge_id: str = challenge_body["challenge_id"]
251
+
252
+ # Step 2 — generate keypair locally.
253
+ priv_obj, public_jwk, private_jwk = _generate_keypair()
254
+
255
+ # Step 3 — sign the challenge bytes.
256
+ sig = priv_obj.sign(challenge.encode("utf8"))
257
+ signed_challenge = _b64url_no_pad(sig)
258
+
259
+ # Step 4 — POST /enroll.
260
+ r2 = httpx.post(
261
+ f"{base}/v1/agents/{agent_id}/enroll",
262
+ headers={"Authorization": f"Bearer {bearer}", "Content-Type": "application/json"},
263
+ json={
264
+ "public_key": public_jwk,
265
+ "challenge_id": challenge_id,
266
+ "signed_challenge": signed_challenge,
267
+ },
268
+ timeout=15.0,
269
+ )
270
+ r2.raise_for_status()
271
+ enrolled = r2.json()
272
+
273
+ return {
274
+ "agent_id": agent_id,
275
+ "publicKey": public_jwk,
276
+ "privateKey": private_jwk,
277
+ "enrolled_at": enrolled.get("enrolled_at"),
278
+ }
279
+
280
+
281
+ def _sign_agent_jwt(stored: dict, agent_id: str) -> str:
282
+ """Mint a fresh 60-second EdDSA JWT signed with the agent privkey."""
283
+ priv_obj = _privkey_from_jwk(stored["privateKey"])
284
+ now = int(time.time())
285
+ payload = {
286
+ "iss": AGENT_JWT_ISSUER,
287
+ "sub": agent_id,
288
+ "aud": AGENT_JWT_AUDIENCE,
289
+ "iat": now,
290
+ "nbf": now,
291
+ "exp": now + AGENT_JWT_TTL,
292
+ "jti": f"aj_{now}_{os.urandom(6).hex()}",
293
+ }
294
+ # PyJWT accepts a raw cryptography Ed25519 key when algorithm='EdDSA'.
295
+ return jwt.encode(payload, priv_obj, algorithm="EdDSA")
296
+
297
+
298
+ class AgentKeypairAuth(AuthStrategy):
299
+ """Phase 2 agent-runtime auth strategy.
300
+
301
+ Constructed with an agent_id and a fallback bearer provider (used
302
+ only for the one-time enrollment dance). Once enrolled, every
303
+ request mints a fresh 60-second JWT signed by the locally-stored
304
+ privkey — the bearer provider is no longer consulted.
305
+ """
306
+
307
+ def __init__(
308
+ self,
309
+ agent_id: str,
310
+ api_base_url: str,
311
+ bearer_provider: Callable[[], str],
312
+ ):
313
+ self.agent_id = agent_id
314
+ self.api_base_url = api_base_url
315
+ self.bearer_provider = bearer_provider
316
+ self._stored: dict | None = None
317
+
318
+ def _ensure_stored(self) -> dict:
319
+ if self._stored is not None:
320
+ return self._stored
321
+ stored = _load_stored(self.agent_id)
322
+ if stored is None:
323
+ stored = _enroll(self.agent_id, self.api_base_url, self.bearer_provider)
324
+ _persist_stored(self.agent_id, stored)
325
+ self._stored = stored
326
+ return stored
327
+
328
+ def get_headers(self) -> dict[str, str]:
329
+ stored = self._ensure_stored()
330
+ token = _sign_agent_jwt(stored, self.agent_id)
331
+ 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}"}
@@ -0,0 +1,212 @@
1
+ """Phase 1 SDK first-run browser-spawn bootstrap (Python).
2
+
3
+ Mirror of packages/sdk/src/browser-bootstrap.ts. When ``Stack()`` is
4
+ constructed with no api_key, no STACK_API_KEY env, and no
5
+ ~/.stack/credentials.json — instead of raising, the SDK can spawn the
6
+ user's browser and run the OAuth Authorization Code + PKCE dance
7
+ itself.
8
+
9
+ Triggered ONLY when the process is interactive (TTY + not CI). In
10
+ production agent runtimes (Lambda, Vercel, K8s) the SDK falls through
11
+ to the standard "no credentials" error and the operator must set
12
+ STACK_API_KEY (or use agent_id mode).
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import base64
18
+ import hashlib
19
+ import http.server
20
+ import json
21
+ import os
22
+ import secrets
23
+ import socket
24
+ import socketserver
25
+ import sys
26
+ import threading
27
+ import time
28
+ import urllib.parse
29
+ import webbrowser
30
+ from pathlib import Path
31
+
32
+ import httpx
33
+
34
+ DEFAULT_AUTH_TIMEOUT_S = 5 * 60 # matches the auth-code TTL
35
+
36
+
37
+ def _credentials_path() -> Path:
38
+ return Path(os.path.expanduser("~")) / ".stack" / "credentials.json"
39
+
40
+
41
+ def _b64url_no_pad(data: bytes) -> str:
42
+ return base64.urlsafe_b64encode(data).rstrip(b"=").decode("ascii")
43
+
44
+
45
+ def should_run_browser_bootstrap() -> bool:
46
+ """Conservative gate: only spawn if we're at an interactive terminal."""
47
+ if not sys.stdout.isatty():
48
+ return False
49
+ if os.environ.get("STACK_AUTH_INTERACTIVE", "").lower() == "false":
50
+ return False
51
+ if os.environ.get("CI"):
52
+ return False
53
+ return True
54
+
55
+
56
+ def browser_bootstrap(base_url: str) -> dict:
57
+ """Run the full OAuth Authorization-Code + PKCE flow.
58
+
59
+ Returns ``{"access_token", "refresh_token", "expires_in", "client_id"}``.
60
+ Raises RuntimeError on any step failure.
61
+ """
62
+ base = base_url.rstrip("/")
63
+
64
+ # 1. DCR a public PKCE client. We re-register with the loopback URI
65
+ # once we know the bound port (DCR is open; the second register
66
+ # supersedes for redirect_uri matching).
67
+ name = f"STACK Python SDK on {socket.gethostname()[:40]}"
68
+
69
+ # 2. Pick a free port + spin up the callback server first, so we can
70
+ # register the client with the actual loopback URI.
71
+ code_holder: dict = {"code": None, "error": None, "expected_state": None}
72
+ handler_event = threading.Event()
73
+
74
+ class Handler(http.server.BaseHTTPRequestHandler):
75
+ def do_GET(self): # noqa: N802
76
+ parsed = urllib.parse.urlparse(self.path)
77
+ if parsed.path != "/callback":
78
+ self.send_response(404)
79
+ self.end_headers()
80
+ return
81
+ params = urllib.parse.parse_qs(parsed.query)
82
+ err = params.get("error", [None])[0]
83
+ code = params.get("code", [None])[0]
84
+ state = params.get("state", [None])[0]
85
+ if err:
86
+ code_holder["error"] = err
87
+ self.send_response(400)
88
+ self.send_header("Content-Type", "text/html")
89
+ self.end_headers()
90
+ self.wfile.write(b"<html><body style='font-family:system-ui;padding:40px'><h1>Sign-in failed</h1><p>You can close this tab.</p></body></html>")
91
+ elif not code or state != code_holder["expected_state"]:
92
+ code_holder["error"] = "invalid callback (state mismatch or missing code)"
93
+ self.send_response(400)
94
+ self.send_header("Content-Type", "text/html")
95
+ self.end_headers()
96
+ self.wfile.write(b"<html><body style='font-family:system-ui;padding:40px'><h1>Invalid callback</h1></body></html>")
97
+ else:
98
+ code_holder["code"] = code
99
+ self.send_response(200)
100
+ self.send_header("Content-Type", "text/html")
101
+ self.end_headers()
102
+ self.wfile.write(b"<html><body style='font-family:system-ui;padding:40px;text-align:center'><h1>Signed in</h1><p>You can close this tab and return to your terminal.</p></body></html>")
103
+ handler_event.set()
104
+
105
+ def log_message(self, *_args, **_kwargs):
106
+ return # silence access logs
107
+
108
+ # Bind to port 0 to get a free port.
109
+ server = socketserver.TCPServer(("127.0.0.1", 0), Handler)
110
+ port = server.server_address[1]
111
+ server_thread = threading.Thread(target=server.serve_forever, daemon=True)
112
+ server_thread.start()
113
+
114
+ try:
115
+ redirect_uri = f"http://127.0.0.1:{port}/callback"
116
+
117
+ # DCR with the actual loopback URI.
118
+ r = httpx.post(
119
+ f"{base}/oauth/register",
120
+ json={
121
+ "client_name": name,
122
+ "redirect_uris": [redirect_uri],
123
+ "grant_types": ["authorization_code", "refresh_token"],
124
+ "software_id": "getstackrun.sdk-python.bootstrap",
125
+ },
126
+ timeout=15.0,
127
+ )
128
+ if r.status_code != 201:
129
+ raise RuntimeError(
130
+ f"STACK SDK: client registration failed ({r.status_code}). "
131
+ f"Try setting STACK_API_KEY or running `stack-cli auth login`."
132
+ )
133
+ client_id = r.json()["client_id"]
134
+
135
+ # 3. PKCE pair + state.
136
+ verifier = _b64url_no_pad(secrets.token_bytes(32))
137
+ challenge = _b64url_no_pad(hashlib.sha256(verifier.encode()).digest())
138
+ state = _b64url_no_pad(secrets.token_bytes(16))
139
+ code_holder["expected_state"] = state
140
+
141
+ scope = "passports:read passports:write agents:read agents:write services:read services:connect credentials:read proxy:read proxy:write audit:read"
142
+ authorize_url = f"{base}/oauth/authorize?" + urllib.parse.urlencode({
143
+ "response_type": "code",
144
+ "client_id": client_id,
145
+ "redirect_uri": redirect_uri,
146
+ "scope": scope,
147
+ "resource": "https://api.getstack.run",
148
+ "code_challenge": challenge,
149
+ "code_challenge_method": "S256",
150
+ "state": state,
151
+ })
152
+
153
+ sys.stderr.write("\n STACK SDK first-run sign-in\n")
154
+ sys.stderr.write(" ──────────────────────────\n\n")
155
+ sys.stderr.write(" Opening your browser to approve.\n")
156
+ sys.stderr.write(f" If it doesn't open, paste this URL:\n\n {authorize_url}\n\n")
157
+
158
+ try:
159
+ webbrowser.open(authorize_url)
160
+ except Exception:
161
+ pass # best-effort
162
+
163
+ # 4. Wait for the callback.
164
+ if not handler_event.wait(timeout=DEFAULT_AUTH_TIMEOUT_S):
165
+ raise RuntimeError("Sign-in timed out (5 min). Re-run to try again.")
166
+ if code_holder["error"]:
167
+ raise RuntimeError(f"OAuth error: {code_holder['error']}")
168
+ code = code_holder["code"]
169
+
170
+ # 5. Exchange code for tokens.
171
+ r2 = httpx.post(
172
+ f"{base}/oauth/token",
173
+ data={
174
+ "grant_type": "authorization_code",
175
+ "code": code,
176
+ "redirect_uri": redirect_uri,
177
+ "client_id": client_id,
178
+ "code_verifier": verifier,
179
+ },
180
+ timeout=15.0,
181
+ )
182
+ if r2.status_code != 200:
183
+ raise RuntimeError(f"STACK SDK: token exchange failed ({r2.status_code}) {r2.text}")
184
+ tokens = r2.json()
185
+
186
+ # 6. Persist for subsequent runs. Same file the CLI + the TS SDK write.
187
+ path = _credentials_path()
188
+ path.parent.mkdir(parents=True, exist_ok=True)
189
+ stored = {
190
+ "client_id": client_id,
191
+ "refresh_token": tokens["refresh_token"],
192
+ "issued_at": int(time.time()),
193
+ "refresh_expires_at": int(time.time()) + 30 * 24 * 60 * 60,
194
+ "scope": scope,
195
+ }
196
+ path.write_text(json.dumps(stored, indent=2), encoding="utf8")
197
+ try:
198
+ os.chmod(path, 0o600)
199
+ except OSError:
200
+ pass
201
+
202
+ sys.stderr.write(f" Signed in. Credentials saved to {path}\n\n")
203
+
204
+ return {
205
+ "access_token": tokens["access_token"],
206
+ "refresh_token": tokens["refresh_token"],
207
+ "expires_in": tokens.get("expires_in", 300),
208
+ "client_id": client_id,
209
+ }
210
+ finally:
211
+ server.shutdown()
212
+ server.server_close()
@@ -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