Skip to content

Commit 0258227

Browse files
authored
Add --interactive-auth for OIDC login (#17)
* Document authentication modes for different IdPs * Add --interactive-auth flag for OIDC login Launches a headed Chromium browser before test collection, navigates to Connect's login page (which redirects to the IdP), waits for the user to authenticate, then captures the storage state and injects it into all subsequent Playwright browser contexts. - src/vip/auth.py: interactive auth module (sync_playwright) - src/vip/plugin.py: --interactive-auth option, lazy import, skip credential prereq when active - tests/conftest.py: browser_context_args override for storage state * Fix ruff formatting in plugin.py * Mint Connect API key via UI during interactive auth After OIDC login, navigates the Connect UI to create a temporary API key (_vip_interactive). The key is injected into the ConnectClient so httpx-based API tests work alongside Playwright browser tests. The browser and Playwright instance are closed before pytest starts its own Playwright to avoid async loop conflicts. At session end the API key is deleted via httpx using the key itself for auth. * Update README auth section with API key minting details * Address review feedback on interactive auth - Fix stale docstrings/comments about browser staying open - Add login timeout error instead of silently continuing - Remove unused _find_key_id function and _key_id field - Clean up temp directory in cleanup() - Restrict temp dir permissions to 0o700 - Remove debug screenshots left from development - Simplify login URL detection * Harden interactive auth based on review feedback - Wrap Playwright lifecycle in try/finally for cleanup on failure - Use unique per-run key name (_vip_interactive_{timestamp}) to avoid collisions and stale key deletion - Delete orphaned VIP keys from previous runs on the API Keys page - Warn (not silently continue) if API key creation fails - Remove double-stash pattern (_auth_state_key); use session object - More precise test skip matching (path + name, not string contains) - Better deletion logging (show endpoint and status) - Add docstring noting UI automation fragility * Add selftests for --interactive-auth option - Verify the flag is registered in --help output - Verify it fails fast when Connect URL is not configured * Remove redundant interactive-auth selftest The test_interactive_auth_option test from #19 passes --interactive-auth without a Connect URL, which now fails validation added in #17. The existing test_interactive_auth_option_registered and test_interactive_auth_requires_connect_url tests already cover this. * Fix trailing newline in selftests
1 parent 6109f1d commit 0258227

File tree

5 files changed

+424
-19
lines changed

5 files changed

+424
-19
lines changed

README.md

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,62 @@ You can also point to the config file explicitly:
7373
pytest --vip-config=/path/to/vip.toml
7474
```
7575

76+
## Authentication
77+
78+
VIP tests that verify login flows and authenticated functionality need user
79+
credentials. How you provide them depends on the deployment's identity
80+
provider.
81+
82+
### Password / LDAP / Keycloak (headless)
83+
84+
Set credentials via environment variables and run normally:
85+
86+
```bash
87+
export VIP_TEST_USERNAME="test-user"
88+
export VIP_TEST_PASSWORD="test-password"
89+
uv run pytest
90+
```
91+
92+
For PTD deployments with Keycloak, `ptd verify` handles this automatically —
93+
it provisions a test user and passes credentials to VIP.
94+
95+
### Okta / external OIDC provider (interactive)
96+
97+
External identity providers require a real browser login. Use
98+
`--interactive-auth` to launch a visible browser, authenticate through the
99+
IdP, and then run the remaining tests headlessly with the captured session:
100+
101+
```bash
102+
uv run pytest --interactive-auth
103+
```
104+
105+
This will:
106+
107+
1. Open a Chromium window and navigate to the Connect login page
108+
2. Wait for you to complete the OIDC login flow (Okta, Azure AD, etc.)
109+
3. Navigate the Connect UI to mint a temporary API key (`_vip_interactive`)
110+
4. Capture the browser session state (cookies, localStorage)
111+
5. Close the browser and run all tests headlessly
112+
6. Delete the API key when the session finishes
113+
114+
Both Playwright browser tests (using the saved session state) and httpx API
115+
tests (using the minted key) work with a single interactive login.
116+
117+
> **Note**: `--interactive-auth` is not available in container/CI
118+
> environments. For automated runs against OIDC deployments, pre-provision
119+
> credentials and set the environment variables above.
120+
121+
### PTD integration
122+
123+
When using `ptd verify`, auth mode is selected automatically based on the
124+
Site CR:
125+
126+
| Deployment auth | ptd verify mode | What happens |
127+
|-----------------|-----------------|--------------|
128+
| Keycloak | `ptd verify <target>` (K8s Job) | Test user auto-provisioned |
129+
| Okta / OIDC | `ptd verify <target> --local --interactive-auth` | Browser popup for login |
130+
| Any | `ptd verify <target> --local` | Uses pre-existing credentials from Secret or env vars |
131+
76132
## Test categories
77133

78134
| Category | Marker | Description |

selftests/test_plugin.py

Lines changed: 10 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -129,17 +129,21 @@ def test_markers_registered(self, selftest_pytester):
129129
]
130130
)
131131

132-
def test_interactive_auth_option(self, selftest_pytester):
132+
def test_interactive_auth_option_registered(self, selftest_pytester):
133+
"""--interactive-auth appears in help output."""
134+
result = selftest_pytester.runpytest("--help")
135+
result.stdout.fnmatch_lines(["*--interactive-auth*"])
136+
137+
def test_interactive_auth_requires_connect_url(self, selftest_pytester):
138+
"""--interactive-auth fails fast when Connect URL is not configured."""
133139
selftest_pytester.makepyfile(
134140
"""
135-
def test_reads_interactive_auth(request):
136-
val = request.config.getoption("--interactive-auth")
137-
assert val is True
141+
def test_placeholder():
142+
assert True
138143
"""
139144
)
140145
result = selftest_pytester.runpytest(
141146
"--vip-config=vip.toml",
142147
"--interactive-auth",
143-
"-v",
144148
)
145-
result.stdout.fnmatch_lines(["*test_reads_interactive_auth*PASSED*"])
149+
result.stderr.fnmatch_lines(["*--interactive-auth requires Connect URL*"])

src/vip/auth.py

Lines changed: 289 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,289 @@
1+
"""Interactive browser authentication for OIDC providers.
2+
3+
Opens a headed Chromium browser for the user to complete an OIDC login
4+
flow, mints a temporary Connect API key via the UI, saves the browser
5+
storage state, then closes the browser before tests start.
6+
7+
.. warning::
8+
9+
The UI automation in ``_create_api_key_via_ui`` is inherently fragile
10+
and may break across Connect versions. If Connect gains a programmatic
11+
endpoint for temporary key creation, this should be replaced.
12+
"""
13+
14+
from __future__ import annotations
15+
16+
import os
17+
import shutil
18+
import tempfile
19+
import time
20+
from dataclasses import dataclass, field
21+
from pathlib import Path
22+
23+
from playwright.sync_api import Page, sync_playwright
24+
25+
# Prefix for VIP-managed API keys. A timestamp is appended per run.
26+
_KEY_NAME_PREFIX = "_vip_interactive_"
27+
28+
29+
@dataclass
30+
class InteractiveAuthSession:
31+
"""Result of an interactive OIDC authentication flow.
32+
33+
Holds the saved browser storage state (for Playwright tests) and a
34+
minted Connect API key (for httpx API tests). Call ``cleanup()``
35+
after the test session to delete the temporary API key.
36+
"""
37+
38+
storage_state_path: Path
39+
api_key: str | None = None
40+
key_name: str = ""
41+
42+
_connect_url: str = field(default="", repr=False)
43+
_tmpdir: str = field(default="", repr=False)
44+
45+
def cleanup(self) -> None:
46+
"""Delete the minted API key and remove the temp directory."""
47+
if self.api_key and self._connect_url:
48+
try:
49+
_delete_api_key(self._connect_url, self.api_key, self.key_name)
50+
except Exception as exc:
51+
print(f">>> Warning: Could not delete API key: {exc}")
52+
53+
if self._tmpdir and os.path.isdir(self._tmpdir):
54+
shutil.rmtree(self._tmpdir, ignore_errors=True)
55+
56+
57+
def start_interactive_auth(connect_url: str) -> InteractiveAuthSession:
58+
"""Launch a headed browser, authenticate via OIDC, and mint a
59+
Connect API key through the UI.
60+
61+
The browser is closed before this function returns. pytest-playwright
62+
creates its own browser instance using the saved storage state.
63+
"""
64+
tmpdir = tempfile.mkdtemp(prefix="vip-auth-")
65+
storage_state_path = Path(tmpdir) / "vip-auth-state.json"
66+
os.chmod(tmpdir, 0o700)
67+
68+
key_name = f"{_KEY_NAME_PREFIX}{int(time.time())}"
69+
70+
pw = None
71+
browser = None
72+
try:
73+
pw = sync_playwright().start()
74+
browser = pw.chromium.launch(headless=False)
75+
context = browser.new_context()
76+
page = context.new_page()
77+
78+
page.goto(f"{connect_url}/__login__")
79+
80+
print(f"\n>>> A browser window has opened at {connect_url}")
81+
print(">>> Please log in through your identity provider.")
82+
print(">>> The browser will close automatically after login.\n")
83+
84+
# Poll until login completes
85+
base = connect_url.rstrip("/")
86+
deadline = time.monotonic() + 300
87+
login_completed = False
88+
while time.monotonic() < deadline:
89+
try:
90+
url = page.url
91+
except Exception:
92+
break
93+
if base in url and "/__login__" not in url:
94+
login_completed = True
95+
break
96+
try:
97+
page.wait_for_timeout(500)
98+
except Exception:
99+
break
100+
101+
if not login_completed:
102+
raise RuntimeError(
103+
"Login did not complete within 5 minutes. "
104+
"Please rerun and complete authentication in the browser window."
105+
)
106+
107+
api_key = _create_api_key_via_ui(page, connect_url, key_name)
108+
context.storage_state(path=str(storage_state_path))
109+
110+
return InteractiveAuthSession(
111+
storage_state_path=storage_state_path,
112+
api_key=api_key,
113+
key_name=key_name,
114+
_connect_url=connect_url,
115+
_tmpdir=tmpdir,
116+
)
117+
except Exception:
118+
if tmpdir and os.path.isdir(tmpdir):
119+
shutil.rmtree(tmpdir, ignore_errors=True)
120+
raise
121+
finally:
122+
if browser is not None:
123+
try:
124+
browser.close()
125+
except Exception:
126+
pass
127+
if pw is not None:
128+
try:
129+
pw.stop()
130+
except Exception:
131+
pass
132+
133+
134+
def _create_api_key_via_ui(page: Page, connect_url: str, key_name: str) -> str | None:
135+
"""Navigate the Connect UI to create an API key.
136+
137+
Also deletes any orphaned ``_vip_interactive_*`` keys left over from
138+
previous runs that crashed before cleanup.
139+
140+
Returns the API key string, or None on failure.
141+
"""
142+
base = connect_url.rstrip("/")
143+
144+
try:
145+
# Navigate to the Connect dashboard
146+
page.goto(f"{base}/connect/#/")
147+
page.wait_for_load_state("networkidle")
148+
page.wait_for_timeout(2_000)
149+
150+
# Open user dropdown by clicking the user panel area (top-right).
151+
# Uses JS to find the element by position since Connect versions
152+
# vary in their markup.
153+
page.evaluate(
154+
"""() => {
155+
const els = document.querySelectorAll('a, button, [role="button"], span');
156+
for (const el of els) {
157+
const rect = el.getBoundingClientRect();
158+
if (rect.right > window.innerWidth - 200 && rect.top < 60) {
159+
const text = el.textContent || '';
160+
if (text.includes('.') && text.length < 30) {
161+
el.click();
162+
return;
163+
}
164+
}
165+
}
166+
}"""
167+
)
168+
page.wait_for_timeout(1_000)
169+
170+
page.get_by_text("Manage Your API Keys").click(timeout=5_000)
171+
page.wait_for_load_state("networkidle")
172+
page.wait_for_timeout(1_000)
173+
174+
# Delete orphaned VIP keys from previous runs
175+
_delete_orphaned_keys(page)
176+
177+
# Click "+ New API Key"
178+
page.locator("text=New API Key").first.click(timeout=5_000)
179+
page.wait_for_timeout(1_000)
180+
181+
# Fill in the key name
182+
name_input = page.locator("input[type='text']").first
183+
name_input.fill(key_name)
184+
page.wait_for_timeout(300)
185+
186+
# Click Create button
187+
page.locator("button:has-text('Create'),button[type='submit']").first.click(timeout=5_000)
188+
page.wait_for_timeout(1_000)
189+
190+
# Extract the generated key — Connect shows it in a read-only
191+
# input, a code block, or a text element in the dialog
192+
api_key = None
193+
for selector in [
194+
"input[readonly]",
195+
"code",
196+
".api-key-value",
197+
"pre",
198+
"[data-automation='api-key-value']",
199+
]:
200+
el = page.locator(selector).first
201+
try:
202+
val = el.input_value(timeout=2_000)
203+
except Exception:
204+
try:
205+
val = el.text_content(timeout=2_000)
206+
except Exception:
207+
continue
208+
if val and len(val) > 20:
209+
api_key = val.strip()
210+
break
211+
212+
if not api_key:
213+
print(">>> Warning: Could not read API key from Connect UI.")
214+
print(">>> Set VIP_CONNECT_API_KEY manually for API-based tests.\n")
215+
return None
216+
217+
print(">>> Connect API key created via UI.\n")
218+
219+
# Close the dialog
220+
try:
221+
page.locator(
222+
"button:has-text('Close'),"
223+
"button:has-text('Done'),"
224+
"button:has-text('OK'),"
225+
"[aria-label='Close']"
226+
).first.click(timeout=3_000)
227+
except Exception:
228+
page.keyboard.press("Escape")
229+
230+
return api_key
231+
except Exception as exc:
232+
print(f">>> Warning: Could not create API key via UI: {exc}")
233+
print(">>> Set VIP_CONNECT_API_KEY manually for API-based tests.\n")
234+
return None
235+
236+
237+
def _delete_orphaned_keys(page: Page) -> None:
238+
"""Delete any leftover _vip_interactive_* keys visible on the API Keys page."""
239+
try:
240+
rows = page.locator("tr, [role='row']").all()
241+
for row in rows:
242+
text = row.text_content() or ""
243+
if _KEY_NAME_PREFIX in text:
244+
delete_btn = row.locator(
245+
"button[aria-label='Delete'], button:has-text('Delete'), [title='Delete']"
246+
).first
247+
try:
248+
delete_btn.click(timeout=2_000)
249+
# Confirm deletion if a dialog appears
250+
page.locator("button:has-text('Yes'),button:has-text('Delete')").first.click(
251+
timeout=2_000
252+
)
253+
page.wait_for_timeout(500)
254+
except Exception:
255+
pass
256+
except Exception:
257+
pass # Best-effort cleanup of orphans
258+
259+
260+
def _delete_api_key(connect_url: str, api_key: str, key_name: str) -> None:
261+
"""Delete the VIP API key using the key itself for authentication."""
262+
import httpx
263+
264+
base = connect_url.rstrip("/")
265+
with httpx.Client(
266+
base_url=f"{base}/__api__",
267+
headers={"Authorization": f"Key {api_key}"},
268+
timeout=10.0,
269+
) as client:
270+
for keys_path in ("/v1/user/api_keys", "/keys"):
271+
resp = client.get(keys_path)
272+
if resp.status_code == 404:
273+
continue
274+
if not resp.is_success:
275+
print(f">>> Warning: {keys_path} returned HTTP {resp.status_code}")
276+
continue
277+
for k in resp.json():
278+
if k.get("name") == key_name:
279+
del_resp = client.delete(f"{keys_path}/{k['id']}")
280+
if del_resp.is_success:
281+
print(">>> API key deleted.\n")
282+
else:
283+
print(
284+
f">>> Warning: DELETE {keys_path}/{k['id']}"
285+
f" returned {del_resp.status_code}"
286+
)
287+
return
288+
break
289+
print(">>> Warning: Could not find API key to delete.\n")

0 commit comments

Comments
 (0)