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.
- {getstack-0.2.0 → getstack-0.4.0}/CLAUDE.md +0 -1
- {getstack-0.2.0 → getstack-0.4.0}/PKG-INFO +36 -16
- {getstack-0.2.0 → getstack-0.4.0}/README.md +33 -15
- {getstack-0.2.0 → getstack-0.4.0}/pyproject.toml +9 -1
- {getstack-0.2.0 → getstack-0.4.0}/src/getstack/__init__.py +80 -11
- getstack-0.4.0/src/getstack/agent_auth.py +331 -0
- getstack-0.4.0/src/getstack/auth.py +157 -0
- getstack-0.4.0/src/getstack/browser_bootstrap.py +212 -0
- getstack-0.2.0/src/getstack/auth.py +0 -78
- {getstack-0.2.0 → getstack-0.4.0}/.gitignore +0 -0
- {getstack-0.2.0 → getstack-0.4.0}/LICENSE +0 -0
- {getstack-0.2.0 → getstack-0.4.0}/src/getstack/agents.py +0 -0
- {getstack-0.2.0 → getstack-0.4.0}/src/getstack/audit.py +0 -0
- {getstack-0.2.0 → getstack-0.4.0}/src/getstack/client.py +0 -0
- {getstack-0.2.0 → getstack-0.4.0}/src/getstack/credentials.py +0 -0
- {getstack-0.2.0 → getstack-0.4.0}/src/getstack/dropoffs.py +0 -0
- {getstack-0.2.0 → getstack-0.4.0}/src/getstack/errors.py +0 -0
- {getstack-0.2.0 → getstack-0.4.0}/src/getstack/identity.py +0 -0
- {getstack-0.2.0 → getstack-0.4.0}/src/getstack/notifications.py +0 -0
- {getstack-0.2.0 → getstack-0.4.0}/src/getstack/passports.py +0 -0
- {getstack-0.2.0 → getstack-0.4.0}/src/getstack/proxy.py +0 -0
- {getstack-0.2.0 → getstack-0.4.0}/src/getstack/py.typed +0 -0
- {getstack-0.2.0 → getstack-0.4.0}/src/getstack/reviews.py +0 -0
- {getstack-0.2.0 → getstack-0.4.0}/src/getstack/scan.py +0 -0
- {getstack-0.2.0 → getstack-0.4.0}/src/getstack/security_events.py +0 -0
- {getstack-0.2.0 → getstack-0.4.0}/src/getstack/services.py +0 -0
- {getstack-0.2.0 → getstack-0.4.0}/src/getstack/skills.py +0 -0
- {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.
|
|
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
|
|
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
|
-
#
|
|
46
|
-
|
|
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
|
-
#
|
|
62
|
-
stack = Stack(
|
|
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
|
-
#
|
|
65
|
-
stack = Stack
|
|
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
|
-
#
|
|
73
|
-
stack = Stack
|
|
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
|
|
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
|
-
#
|
|
22
|
-
|
|
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
|
-
#
|
|
38
|
-
stack = Stack(
|
|
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
|
-
#
|
|
41
|
-
stack = Stack
|
|
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
|
-
#
|
|
49
|
-
stack = Stack
|
|
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.
|
|
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
|
-
|
|
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
|
|
139
|
-
|
|
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
|
-
|
|
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
|
|
219
|
-
|
|
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
|
-
|
|
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
|
|
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
|