steppy-dev 0.3.0__py3-none-any.whl
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.
- steppy/CHANGELOG.md +862 -0
- steppy/__init__.py +9 -0
- steppy/__main__.py +13 -0
- steppy/_cli_common.py +183 -0
- steppy/_docker/Dockerfile.installed +53 -0
- steppy/_parallel/__init__.py +7 -0
- steppy/_parallel/compose.py +349 -0
- steppy/_parallel/event_bus.py +418 -0
- steppy/_parallel/failures.py +267 -0
- steppy/_parallel/fingerprint.py +141 -0
- steppy/_parallel/image.py +380 -0
- steppy/_parallel/lastfailed.py +125 -0
- steppy/_parallel/lifecycle.py +90 -0
- steppy/_parallel/preauth.py +109 -0
- steppy/_parallel/progress.py +101 -0
- steppy/_parallel/queue.py +385 -0
- steppy/_parallel/renderer.py +489 -0
- steppy/_parallel/retry.py +316 -0
- steppy/_player_bundle/THIRD_PARTY_LICENSES.txt +158 -0
- steppy/_player_bundle/__init__.py +100 -0
- steppy/_player_bundle/steppy-cursor-legacy.js +2 -0
- steppy/_player_bundle/steppy-cursor-legacy.js.map +1 -0
- steppy/_player_bundle/steppy-player.js +5419 -0
- steppy/_player_bundle/steppy-player.js.map +1 -0
- steppy/_player_bundle/steppy-video.js +472 -0
- steppy/_player_bundle/steppy-video.js.map +1 -0
- steppy/_templating.py +117 -0
- steppy/assertions.py +251 -0
- steppy/auth.py +493 -0
- steppy/build.py +2798 -0
- steppy/build_models.py +669 -0
- steppy/buildid.py +134 -0
- steppy/bundle/__init__.py +47 -0
- steppy/bundle/archive.py +239 -0
- steppy/bundle/classify.py +329 -0
- steppy/bundle/create.py +490 -0
- steppy/bundle/hashing.py +28 -0
- steppy/bundle/manifest.py +334 -0
- steppy/bundle/results.py +546 -0
- steppy/bundle_cli.py +455 -0
- steppy/captures.py +207 -0
- steppy/cli.py +2489 -0
- steppy/cloud/__init__.py +58 -0
- steppy/cloud/client.py +267 -0
- steppy/cloud/upload.py +497 -0
- steppy/compat.py +109 -0
- steppy/config.py +231 -0
- steppy/config_schema.py +87 -0
- steppy/contracts/__init__.py +54 -0
- steppy/contracts/bundle.py +215 -0
- steppy/contracts/report.py +346 -0
- steppy/contracts/tours.py +505 -0
- steppy/coordinator.py +1352 -0
- steppy/coverage/__init__.py +9 -0
- steppy/coverage/gaps.py +190 -0
- steppy/coverage/manifest.py +193 -0
- steppy/coverage/merge.py +363 -0
- steppy/coverage/normalize.py +174 -0
- steppy/coverage/report.py +178 -0
- steppy/coverage/sourcemap.py +219 -0
- steppy/coverage/store.py +473 -0
- steppy/coverage/triage.py +151 -0
- steppy/coverage_cli.py +265 -0
- steppy/durations.py +48 -0
- steppy/exclusive.py +178 -0
- steppy/flaky_cli.py +169 -0
- steppy/generator.py +633 -0
- steppy/guide.py +1467 -0
- steppy/history.py +352 -0
- steppy/init_cli.py +330 -0
- steppy/locales.py +282 -0
- steppy/migrate/__init__.py +35 -0
- steppy/migrate/apply.py +194 -0
- steppy/migrate/plan.py +199 -0
- steppy/migrate/rules.py +465 -0
- steppy/migrate/verify.py +163 -0
- steppy/migrate_cli.py +152 -0
- steppy/models.py +2539 -0
- steppy/naming.py +37 -0
- steppy/parser.py +475 -0
- steppy/pdf.py +193 -0
- steppy/presets.py +284 -0
- steppy/record_cli.py +796 -0
- steppy/report/__init__.py +37 -0
- steppy/report/assets.py +197 -0
- steppy/report/build.py +261 -0
- steppy/report/coverage_source.py +277 -0
- steppy/report/model.py +423 -0
- steppy/report/render.py +303 -0
- steppy/report/source.py +238 -0
- steppy/report_cli.py +173 -0
- steppy/runmeta.py +114 -0
- steppy/selection.py +183 -0
- steppy/sitemap.py +205 -0
- steppy/skills/steppy-app-tours/SKILL.md +448 -0
- steppy/skills/steppy-ci-report/SKILL.md +567 -0
- steppy/skills/steppy-scenario-author/SKILL.md +2176 -0
- steppy/skills/steppy-template/SKILL.md +676 -0
- steppy/templates/_steppy_events.py +291 -0
- steppy/templates/_steppy_tours.py +404 -0
- steppy/templates/angular/component-interactive.ts.j2 +109 -0
- steppy/templates/angular/component-video.ts.j2 +112 -0
- steppy/templates/angular/component.ts.j2 +229 -0
- steppy/templates/angular/content.ts.j2 +31 -0
- steppy/templates/angular/gitignore.j2 +3 -0
- steppy/templates/angular/group.component.ts.j2 +39 -0
- steppy/templates/angular/onboarding.component.ts.j2 +43 -0
- steppy/templates/angular/routes.ts.j2 +34 -0
- steppy/templates/ci/github-actions.yml +284 -0
- steppy/templates/ci/gitlab-ci.yml +178 -0
- steppy/templates/conftest.py.j2 +2405 -0
- steppy/templates/guide.book.html.j2 +145 -0
- steppy/templates/guide.fragment.html.j2 +257 -0
- steppy/templates/guide.html.j2 +301 -0
- steppy/templates/guide.md.j2 +18 -0
- steppy/templates/init/config.yaml +124 -0
- steppy/templates/init/credentials.yaml.example +30 -0
- steppy/templates/init/scenarios/example--first-flow.md +53 -0
- steppy/templates/report/_head.html.j2 +14 -0
- steppy/templates/report/_mark.html.j2 +3 -0
- steppy/templates/report/coverage.html.j2 +124 -0
- steppy/templates/report/index.html.j2 +203 -0
- steppy/templates/report/report.css +401 -0
- steppy/templates/report/scenario.html.j2 +137 -0
- steppy/templates/static/_headers.j2 +30 -0
- steppy/templates/static/_mode_links.html.j2 +22 -0
- steppy/templates/static/_seo_head.html.j2 +37 -0
- steppy/templates/static/cursor.js.j2 +10 -0
- steppy/templates/static/group.html.j2 +78 -0
- steppy/templates/static/guides.css +678 -0
- steppy/templates/static/index.html.j2 +79 -0
- steppy/templates/static/interactive.html.j2 +100 -0
- steppy/templates/static/onboarding.html.j2 +64 -0
- steppy/templates/static/page.html.j2 +145 -0
- steppy/templates/static/robots.txt.j2 +7 -0
- steppy/templates/static/video.html.j2 +135 -0
- steppy/templates/test_scenario.py.j2 +767 -0
- steppy/testids.py +166 -0
- steppy/totp.py +26 -0
- steppy/tour_spec.py +163 -0
- steppy/tours/__init__.py +22 -0
- steppy/tours/actions.py +402 -0
- steppy/tours/compiler.py +144 -0
- steppy/tours/diagnostics.py +137 -0
- steppy/tours/emit.py +239 -0
- steppy/tours/ids.py +40 -0
- steppy/tours/inference.py +399 -0
- steppy/tours/model.py +120 -0
- steppy/tours/paths.py +126 -0
- steppy/tours/pipeline.py +277 -0
- steppy/tours/publish.py +124 -0
- steppy/tours/replay.py +234 -0
- steppy/tours/routes.py +175 -0
- steppy/tours/settings.py +164 -0
- steppy/tours/text.py +237 -0
- steppy/tours_cli.py +219 -0
- steppy/tts/__init__.py +280 -0
- steppy/tts/cache.py +185 -0
- steppy/tts/presets.py +125 -0
- steppy/tts/providers/__init__.py +6 -0
- steppy/tts/providers/http.py +184 -0
- steppy/tts/providers/local.py +207 -0
- steppy/tts/providers/null.py +28 -0
- steppy/tts/registry.py +201 -0
- steppy/tts/track.py +158 -0
- steppy/tts/types.py +94 -0
- steppy/update_cli.py +460 -0
- steppy/video.py +730 -0
- steppy/video_cache.py +239 -0
- steppy/video_cli.py +271 -0
- steppy/worker.py +301 -0
- steppy_dev-0.3.0.dist-info/METADATA +1335 -0
- steppy_dev-0.3.0.dist-info/RECORD +177 -0
- steppy_dev-0.3.0.dist-info/WHEEL +4 -0
- steppy_dev-0.3.0.dist-info/entry_points.txt +2 -0
- steppy_dev-0.3.0.dist-info/licenses/LICENSE +202 -0
- steppy_dev-0.3.0.dist-info/licenses/NOTICE +13 -0
steppy/auth.py
ADDED
|
@@ -0,0 +1,493 @@
|
|
|
1
|
+
"""Authentication management for Steppy."""
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import stat
|
|
5
|
+
import time
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Optional
|
|
8
|
+
from urllib.parse import urlparse
|
|
9
|
+
|
|
10
|
+
import httpx
|
|
11
|
+
from playwright.sync_api import Browser, BrowserContext, Page
|
|
12
|
+
|
|
13
|
+
from .models import SteppyConfig, AppConfig
|
|
14
|
+
from .totp import _totp_now
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class AuthenticationError(Exception):
|
|
18
|
+
"""Raised when authentication fails."""
|
|
19
|
+
|
|
20
|
+
pass
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _fill_totp_if_configured(page: Page, form, credentials) -> None:
|
|
24
|
+
"""Fill a TOTP/MFA code after the password submit, when configured (FLAWS §6.2).
|
|
25
|
+
|
|
26
|
+
No-op unless both a ``totp_selector`` (on ``auth.form``) and a ``totp_secret``
|
|
27
|
+
(on the app credentials) are set. The selector wait tolerates absence with a
|
|
28
|
+
short timeout so a non-MFA session (already trusted device) falls straight
|
|
29
|
+
through. ``totp_submit_selector`` defaults to the form's ``submit_selector``.
|
|
30
|
+
"""
|
|
31
|
+
totp_selector = getattr(form, "totp_selector", None)
|
|
32
|
+
totp_secret = getattr(credentials, "totp_secret", None)
|
|
33
|
+
if not totp_selector or not totp_secret:
|
|
34
|
+
return
|
|
35
|
+
try:
|
|
36
|
+
page.wait_for_selector(totp_selector, timeout=10000)
|
|
37
|
+
except Exception:
|
|
38
|
+
return # no MFA prompt this session — nothing to fill
|
|
39
|
+
page.fill(totp_selector, _totp_now(totp_secret))
|
|
40
|
+
submit = getattr(form, "totp_submit_selector", None) or form.submit_selector
|
|
41
|
+
page.click(submit)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def get_storage_state_path(role: str, auth_dir: Path) -> Path:
|
|
45
|
+
"""Get the path to the storage state file for a role."""
|
|
46
|
+
return auth_dir / f"{role}.json"
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _restrict_permissions(path: Path) -> None:
|
|
50
|
+
"""Best-effort chmod 0o600 on a saved storage-state file.
|
|
51
|
+
|
|
52
|
+
``.auth/<role>.json`` holds cleartext cookies + JWTs, so restrict it to the
|
|
53
|
+
owner. A no-op on platforms (e.g. Windows) where POSIX modes don't apply;
|
|
54
|
+
failures are swallowed so auth never breaks over a permissions quirk.
|
|
55
|
+
"""
|
|
56
|
+
try:
|
|
57
|
+
os.chmod(path, stat.S_IRUSR | stat.S_IWUSR) # 0o600
|
|
58
|
+
except OSError:
|
|
59
|
+
pass
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def is_storage_state_valid(role: str, auth_dir: Path, ttl: int) -> bool:
|
|
63
|
+
"""
|
|
64
|
+
Check if a cached storage state exists and is still valid.
|
|
65
|
+
|
|
66
|
+
Args:
|
|
67
|
+
role: Role name (e.g., "editor", "reviewer")
|
|
68
|
+
auth_dir: Directory containing storage state files
|
|
69
|
+
ttl: Time to live in seconds
|
|
70
|
+
|
|
71
|
+
Returns:
|
|
72
|
+
True if valid cache exists, False otherwise
|
|
73
|
+
"""
|
|
74
|
+
state_path = get_storage_state_path(role, auth_dir)
|
|
75
|
+
|
|
76
|
+
if not state_path.exists():
|
|
77
|
+
return False
|
|
78
|
+
|
|
79
|
+
age = time.time() - state_path.stat().st_mtime
|
|
80
|
+
return age < ttl
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def authenticate_auth0(
|
|
84
|
+
browser: Browser,
|
|
85
|
+
role: str,
|
|
86
|
+
app_config: AppConfig,
|
|
87
|
+
config: SteppyConfig,
|
|
88
|
+
) -> BrowserContext:
|
|
89
|
+
"""
|
|
90
|
+
Authenticate using Auth0 and save storage state.
|
|
91
|
+
|
|
92
|
+
Args:
|
|
93
|
+
browser: Playwright browser instance
|
|
94
|
+
role: Role name
|
|
95
|
+
app_config: App configuration for this role
|
|
96
|
+
config: Global Steppy configuration
|
|
97
|
+
|
|
98
|
+
Returns:
|
|
99
|
+
Authenticated browser context
|
|
100
|
+
|
|
101
|
+
Raises:
|
|
102
|
+
AuthenticationError: If authentication fails
|
|
103
|
+
"""
|
|
104
|
+
auth_dir = Path(config.auth.storage_state_dir)
|
|
105
|
+
state_path = get_storage_state_path(role, auth_dir)
|
|
106
|
+
|
|
107
|
+
# Check cache first
|
|
108
|
+
if is_storage_state_valid(role, auth_dir, config.auth.storage_state_ttl):
|
|
109
|
+
return browser.new_context(
|
|
110
|
+
storage_state=str(state_path),
|
|
111
|
+
viewport={"width": config.screenshot.viewport.width, "height": config.screenshot.viewport.height},
|
|
112
|
+
base_url=app_config.url,
|
|
113
|
+
)
|
|
114
|
+
|
|
115
|
+
# Fresh login required — guard the missing-credentials case with a clear,
|
|
116
|
+
# non-wrapped message before launching the browser (FLAWS §5.4).
|
|
117
|
+
if not app_config.auth or not app_config.auth.credentials:
|
|
118
|
+
raise AuthenticationError(
|
|
119
|
+
f"Form login for role '{role}' requires apps.{role}.auth.credentials (username/password)."
|
|
120
|
+
)
|
|
121
|
+
|
|
122
|
+
context = browser.new_context(
|
|
123
|
+
viewport={"width": config.screenshot.viewport.width, "height": config.screenshot.viewport.height},
|
|
124
|
+
base_url=app_config.url,
|
|
125
|
+
)
|
|
126
|
+
page = context.new_page()
|
|
127
|
+
|
|
128
|
+
try:
|
|
129
|
+
# Navigate to app → triggers Auth0 redirect
|
|
130
|
+
page.goto("/", timeout=30000)
|
|
131
|
+
|
|
132
|
+
# Wait for the hosted login page
|
|
133
|
+
form = config.auth.form
|
|
134
|
+
if not form:
|
|
135
|
+
raise AuthenticationError("Login-form configuration (auth.form) is missing")
|
|
136
|
+
|
|
137
|
+
page.wait_for_selector(form.username_selector, timeout=15000)
|
|
138
|
+
|
|
139
|
+
# Fill credentials
|
|
140
|
+
page.fill(form.username_selector, app_config.auth.credentials.username)
|
|
141
|
+
page.fill(form.password_selector, app_config.auth.credentials.password)
|
|
142
|
+
page.click(form.submit_selector)
|
|
143
|
+
|
|
144
|
+
# Optional TOTP/MFA step (no-op unless configured)
|
|
145
|
+
_fill_totp_if_configured(page, form, app_config.auth.credentials)
|
|
146
|
+
|
|
147
|
+
# Handle optional consent screen
|
|
148
|
+
if form.consent_selector:
|
|
149
|
+
try:
|
|
150
|
+
page.wait_for_selector(form.consent_selector, timeout=3000)
|
|
151
|
+
page.click(form.consent_selector)
|
|
152
|
+
except Exception:
|
|
153
|
+
pass # No consent screen, continue
|
|
154
|
+
|
|
155
|
+
# Wait for redirect back to the app
|
|
156
|
+
page.wait_for_url(f"{app_config.url}/**", timeout=15000)
|
|
157
|
+
page.wait_for_load_state("networkidle", timeout=30000)
|
|
158
|
+
|
|
159
|
+
# Cache the authenticated state
|
|
160
|
+
auth_dir.mkdir(parents=True, exist_ok=True)
|
|
161
|
+
context.storage_state(path=str(state_path))
|
|
162
|
+
_restrict_permissions(state_path)
|
|
163
|
+
|
|
164
|
+
page.close()
|
|
165
|
+
return context
|
|
166
|
+
|
|
167
|
+
except Exception as e:
|
|
168
|
+
page.close()
|
|
169
|
+
context.close()
|
|
170
|
+
raise AuthenticationError(f"Form authentication failed for {role}: {e}")
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
# Preferred name for the generic hosted-login form-fill (FLAWS §6.2); the
|
|
174
|
+
# historical ``authenticate_auth0`` name is kept as the implementation.
|
|
175
|
+
authenticate_form = authenticate_auth0
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def authenticate_storage_state(
|
|
179
|
+
browser: Browser,
|
|
180
|
+
app_config: AppConfig,
|
|
181
|
+
config: SteppyConfig,
|
|
182
|
+
) -> BrowserContext:
|
|
183
|
+
"""
|
|
184
|
+
Load authentication from an existing storage state file.
|
|
185
|
+
|
|
186
|
+
Args:
|
|
187
|
+
browser: Playwright browser instance
|
|
188
|
+
app_config: App configuration
|
|
189
|
+
config: Global Steppy configuration
|
|
190
|
+
|
|
191
|
+
Returns:
|
|
192
|
+
Authenticated browser context
|
|
193
|
+
|
|
194
|
+
Raises:
|
|
195
|
+
AuthenticationError: If storage state file doesn't exist
|
|
196
|
+
"""
|
|
197
|
+
state_path = app_config.auth.storage_state_path
|
|
198
|
+
if not state_path or not Path(state_path).exists():
|
|
199
|
+
raise AuthenticationError(f"Storage state file not found: {state_path}")
|
|
200
|
+
|
|
201
|
+
return browser.new_context(
|
|
202
|
+
storage_state=state_path,
|
|
203
|
+
viewport={"width": config.screenshot.viewport.width, "height": config.screenshot.viewport.height},
|
|
204
|
+
base_url=app_config.url,
|
|
205
|
+
)
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def authenticate_bypass(
|
|
209
|
+
browser: Browser,
|
|
210
|
+
app_config: AppConfig,
|
|
211
|
+
config: SteppyConfig,
|
|
212
|
+
) -> BrowserContext:
|
|
213
|
+
"""
|
|
214
|
+
Create context with bypass authentication (no real login).
|
|
215
|
+
|
|
216
|
+
Args:
|
|
217
|
+
browser: Playwright browser instance
|
|
218
|
+
app_config: App configuration
|
|
219
|
+
config: Global Steppy configuration
|
|
220
|
+
|
|
221
|
+
Returns:
|
|
222
|
+
Browser context with injected auth credentials
|
|
223
|
+
"""
|
|
224
|
+
context = browser.new_context(
|
|
225
|
+
viewport={"width": config.screenshot.viewport.width, "height": config.screenshot.viewport.height},
|
|
226
|
+
base_url=app_config.url,
|
|
227
|
+
)
|
|
228
|
+
|
|
229
|
+
bypass = app_config.auth.bypass
|
|
230
|
+
if not bypass:
|
|
231
|
+
return context
|
|
232
|
+
|
|
233
|
+
# Inject cookie if configured
|
|
234
|
+
if bypass.cookie:
|
|
235
|
+
context.add_cookies([bypass.cookie])
|
|
236
|
+
|
|
237
|
+
# Inject localStorage if configured
|
|
238
|
+
if bypass.local_storage:
|
|
239
|
+
page = context.new_page()
|
|
240
|
+
page.goto(app_config.url)
|
|
241
|
+
for key, value in bypass.local_storage.items():
|
|
242
|
+
# Pass key/value as arguments rather than interpolating into the JS
|
|
243
|
+
# source, so a value containing a quote or backslash can't break or
|
|
244
|
+
# inject into the script.
|
|
245
|
+
page.evaluate("([k, v]) => localStorage.setItem(k, v)", [key, value])
|
|
246
|
+
page.close()
|
|
247
|
+
|
|
248
|
+
return context
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
def authenticate_token(
|
|
252
|
+
browser: Browser,
|
|
253
|
+
role: str,
|
|
254
|
+
app_config: AppConfig,
|
|
255
|
+
config: SteppyConfig,
|
|
256
|
+
) -> BrowserContext:
|
|
257
|
+
"""
|
|
258
|
+
Authenticate by calling a token endpoint and injecting the JWT.
|
|
259
|
+
|
|
260
|
+
Args:
|
|
261
|
+
browser: Playwright browser instance
|
|
262
|
+
role: Role name
|
|
263
|
+
app_config: App configuration
|
|
264
|
+
config: Global Steppy configuration
|
|
265
|
+
|
|
266
|
+
Returns:
|
|
267
|
+
Authenticated browser context
|
|
268
|
+
|
|
269
|
+
Raises:
|
|
270
|
+
AuthenticationError: If token retrieval fails
|
|
271
|
+
"""
|
|
272
|
+
token_config = app_config.auth.token
|
|
273
|
+
if not token_config:
|
|
274
|
+
raise AuthenticationError("Token configuration is missing")
|
|
275
|
+
|
|
276
|
+
# Call the token endpoint
|
|
277
|
+
try:
|
|
278
|
+
response = httpx.request(
|
|
279
|
+
method=token_config.method,
|
|
280
|
+
url=token_config.endpoint,
|
|
281
|
+
json=token_config.body,
|
|
282
|
+
timeout=10.0,
|
|
283
|
+
)
|
|
284
|
+
response.raise_for_status()
|
|
285
|
+
token_data = response.json()
|
|
286
|
+
|
|
287
|
+
# Extract token (assume it's in the response root or in a "token" field)
|
|
288
|
+
token = token_data.get("token") or token_data.get("access_token") or token_data
|
|
289
|
+
|
|
290
|
+
if not isinstance(token, str):
|
|
291
|
+
raise AuthenticationError(f"Token endpoint returned invalid data: {token_data}")
|
|
292
|
+
|
|
293
|
+
except Exception as e:
|
|
294
|
+
raise AuthenticationError(f"Failed to retrieve token for {role}: {e}")
|
|
295
|
+
|
|
296
|
+
# Create context and inject token
|
|
297
|
+
context = browser.new_context(
|
|
298
|
+
viewport={"width": config.screenshot.viewport.width, "height": config.screenshot.viewport.height},
|
|
299
|
+
base_url=app_config.url,
|
|
300
|
+
)
|
|
301
|
+
|
|
302
|
+
inject_as = token_config.inject_as
|
|
303
|
+
key = token_config.key
|
|
304
|
+
|
|
305
|
+
if inject_as == "localStorage":
|
|
306
|
+
page = context.new_page()
|
|
307
|
+
page.goto(app_config.url)
|
|
308
|
+
# Pass key/token as arguments (not string-interpolated) — the token is
|
|
309
|
+
# network-sourced and could contain a quote that would otherwise break
|
|
310
|
+
# or inject into the JS.
|
|
311
|
+
page.evaluate("([k, v]) => localStorage.setItem(k, v)", [key, token])
|
|
312
|
+
page.close()
|
|
313
|
+
elif inject_as == "cookie":
|
|
314
|
+
# Extract the host safely — urlparse tolerates ports/paths and won't
|
|
315
|
+
# IndexError on a URL missing "://" (FLAWS §5.4).
|
|
316
|
+
domain = urlparse(app_config.url).hostname
|
|
317
|
+
if not domain:
|
|
318
|
+
raise AuthenticationError(
|
|
319
|
+
f"Cannot derive a cookie domain from apps.{role}.url "
|
|
320
|
+
f"({app_config.url!r}); expected a URL like https://host[:port]."
|
|
321
|
+
)
|
|
322
|
+
context.add_cookies(
|
|
323
|
+
[
|
|
324
|
+
{
|
|
325
|
+
"name": key,
|
|
326
|
+
"value": token,
|
|
327
|
+
"domain": domain,
|
|
328
|
+
"path": "/",
|
|
329
|
+
}
|
|
330
|
+
]
|
|
331
|
+
)
|
|
332
|
+
elif inject_as == "header":
|
|
333
|
+
# Inject the token as a default request header on the context. `key` is
|
|
334
|
+
# the header name (e.g. "Authorization"); the token endpoint should
|
|
335
|
+
# return the full header value (e.g. "Bearer <jwt>") when a scheme is
|
|
336
|
+
# required (FLAWS §6.2).
|
|
337
|
+
context.set_extra_http_headers({key: token})
|
|
338
|
+
|
|
339
|
+
return context
|
|
340
|
+
|
|
341
|
+
|
|
342
|
+
def get_authenticated_context(
|
|
343
|
+
browser: Browser,
|
|
344
|
+
role: str,
|
|
345
|
+
config: SteppyConfig,
|
|
346
|
+
) -> BrowserContext:
|
|
347
|
+
"""
|
|
348
|
+
Get an authenticated browser context for a role using the configured auth method.
|
|
349
|
+
|
|
350
|
+
Args:
|
|
351
|
+
browser: Playwright browser instance
|
|
352
|
+
role: Role name (e.g., "editor", "reviewer")
|
|
353
|
+
config: Global Steppy configuration
|
|
354
|
+
|
|
355
|
+
Returns:
|
|
356
|
+
Authenticated browser context
|
|
357
|
+
|
|
358
|
+
Raises:
|
|
359
|
+
AuthenticationError: If authentication fails
|
|
360
|
+
ValueError: If role or auth mode is unknown
|
|
361
|
+
"""
|
|
362
|
+
if role not in config.apps:
|
|
363
|
+
raise ValueError(f"Unknown role: {role}. Available roles: {list(config.apps.keys())}")
|
|
364
|
+
|
|
365
|
+
app_config = config.apps[role]
|
|
366
|
+
mode = app_config.auth.mode
|
|
367
|
+
|
|
368
|
+
if mode in ("form", "auth0"):
|
|
369
|
+
return authenticate_auth0(browser, role, app_config, config)
|
|
370
|
+
elif mode == "storage-state":
|
|
371
|
+
return authenticate_storage_state(browser, app_config, config)
|
|
372
|
+
elif mode == "bypass":
|
|
373
|
+
return authenticate_bypass(browser, app_config, config)
|
|
374
|
+
elif mode == "token":
|
|
375
|
+
return authenticate_token(browser, role, app_config, config)
|
|
376
|
+
else:
|
|
377
|
+
raise ValueError(f"Unknown auth mode: {mode}")
|
|
378
|
+
|
|
379
|
+
|
|
380
|
+
def interactive_login(
|
|
381
|
+
role: str,
|
|
382
|
+
config: SteppyConfig,
|
|
383
|
+
browser: Browser,
|
|
384
|
+
) -> None:
|
|
385
|
+
"""
|
|
386
|
+
Open a headed browser and wait for the user to log in manually.
|
|
387
|
+
Save the storage state when done.
|
|
388
|
+
|
|
389
|
+
Args:
|
|
390
|
+
role: Role name
|
|
391
|
+
config: Global Steppy configuration
|
|
392
|
+
browser: Playwright browser instance (should be headed)
|
|
393
|
+
|
|
394
|
+
Raises:
|
|
395
|
+
ValueError: If role is unknown
|
|
396
|
+
"""
|
|
397
|
+
if role not in config.apps:
|
|
398
|
+
raise ValueError(f"Unknown role: {role}. Available roles: {list(config.apps.keys())}")
|
|
399
|
+
|
|
400
|
+
app_config = config.apps[role]
|
|
401
|
+
auth_dir = Path(config.auth.storage_state_dir)
|
|
402
|
+
state_path = get_storage_state_path(role, auth_dir)
|
|
403
|
+
|
|
404
|
+
context = browser.new_context(
|
|
405
|
+
viewport={"width": config.screenshot.viewport.width, "height": config.screenshot.viewport.height},
|
|
406
|
+
base_url=app_config.url,
|
|
407
|
+
)
|
|
408
|
+
page = context.new_page()
|
|
409
|
+
|
|
410
|
+
# Navigate to the app
|
|
411
|
+
page.goto("/", timeout=60000)
|
|
412
|
+
|
|
413
|
+
# Wait for user to log in - just wait for the URL to change from login page
|
|
414
|
+
# This is a simple heuristic - user should close the browser when done
|
|
415
|
+
print(f"\nPlease log in to {app_config.name} in the browser window.")
|
|
416
|
+
print("Press Enter when you have successfully logged in...")
|
|
417
|
+
input()
|
|
418
|
+
|
|
419
|
+
# Save storage state
|
|
420
|
+
auth_dir.mkdir(parents=True, exist_ok=True)
|
|
421
|
+
context.storage_state(path=str(state_path))
|
|
422
|
+
_restrict_permissions(state_path)
|
|
423
|
+
|
|
424
|
+
page.close()
|
|
425
|
+
context.close()
|
|
426
|
+
|
|
427
|
+
|
|
428
|
+
def clear_storage_state(role: Optional[str], auth_dir: Path) -> list[str]:
|
|
429
|
+
"""
|
|
430
|
+
Clear cached storage state(s).
|
|
431
|
+
|
|
432
|
+
Args:
|
|
433
|
+
role: Role name to clear, or None to clear all
|
|
434
|
+
auth_dir: Directory containing storage state files
|
|
435
|
+
|
|
436
|
+
Returns:
|
|
437
|
+
List of cleared role names
|
|
438
|
+
"""
|
|
439
|
+
cleared = []
|
|
440
|
+
|
|
441
|
+
if role:
|
|
442
|
+
# Clear specific role
|
|
443
|
+
state_path = get_storage_state_path(role, auth_dir)
|
|
444
|
+
if state_path.exists():
|
|
445
|
+
state_path.unlink()
|
|
446
|
+
cleared.append(role)
|
|
447
|
+
else:
|
|
448
|
+
# Clear all
|
|
449
|
+
for state_file in auth_dir.glob("*.json"):
|
|
450
|
+
role_name = state_file.stem
|
|
451
|
+
state_file.unlink()
|
|
452
|
+
cleared.append(role_name)
|
|
453
|
+
|
|
454
|
+
return cleared
|
|
455
|
+
|
|
456
|
+
|
|
457
|
+
def get_storage_state_status(config: SteppyConfig) -> dict[str, dict]:
|
|
458
|
+
"""
|
|
459
|
+
Get status of all storage states.
|
|
460
|
+
|
|
461
|
+
Args:
|
|
462
|
+
config: Global Steppy configuration
|
|
463
|
+
|
|
464
|
+
Returns:
|
|
465
|
+
Dict mapping role names to status info (exists, age, valid)
|
|
466
|
+
"""
|
|
467
|
+
auth_dir = Path(config.auth.storage_state_dir)
|
|
468
|
+
ttl = config.auth.storage_state_ttl
|
|
469
|
+
|
|
470
|
+
status = {}
|
|
471
|
+
for role in config.apps.keys():
|
|
472
|
+
state_path = get_storage_state_path(role, auth_dir)
|
|
473
|
+
|
|
474
|
+
if state_path.exists():
|
|
475
|
+
age = time.time() - state_path.stat().st_mtime
|
|
476
|
+
valid = age < ttl
|
|
477
|
+
status[role] = {
|
|
478
|
+
"exists": True,
|
|
479
|
+
"age_seconds": int(age),
|
|
480
|
+
"age_minutes": int(age / 60),
|
|
481
|
+
"valid": valid,
|
|
482
|
+
"path": str(state_path),
|
|
483
|
+
}
|
|
484
|
+
else:
|
|
485
|
+
status[role] = {
|
|
486
|
+
"exists": False,
|
|
487
|
+
"age_seconds": None,
|
|
488
|
+
"age_minutes": None,
|
|
489
|
+
"valid": False,
|
|
490
|
+
"path": str(state_path),
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
return status
|