Files
id-worker/test/e2e_canonical_paths.py
zeekayandGitHub 5ed75779e8 fix(auth): migrate login worker /api/* → canonical /v1/iam/* (HIP-0111) (#1)
* corona→corona: academic Corona now only in lp-220-p3q-corona

* chore: restore upstream attribution + NOTICE (OSS compliance)

* auth: migrate front-door worker IAM calls to canonical /v1/iam/*

Every IAM-targeted path the hanzo.id/lux.id/zoo.id/pars.id front-door
worker emits now uses the canonical /v1/iam/* surface (HIP-0111), off the
legacy /api/*, /login/oauth/*, and bare /oauth/* shapes. Proven against
the IAM router: PathRewriteFilter collapses /api/<ep>->/v1/iam/<ep> and
the oauth aliases ->/v1/iam/oauth/<ep>; every target route exists.

Emitted-path changes (worker.js):
- inline login JS:  /api/get-app-login->/v1/iam/get-app-login;
  social ->/v1/iam/oauth/authorize; password+web3 ->/v1/iam/login
- inline signup JS: get-app-login, social ->/v1/iam/oauth/authorize;
  web3 ->/v1/iam/login; /api/signup->/v1/iam/signup
- inline forgot JS: get-app-login; /api/send-verification-code
  ->/v1/iam/send-verification-code
- platform+MPC IAM callback token exchange: /api/login/oauth/access_token
  ->/v1/iam/oauth/token
- /oauth/{token,introspect,revoke,userinfo,logout} public facades now hop
  to /v1/iam/oauth/{token,introspect,revoke,userinfo,logout} internally
- /callback social handler: /api/login->/v1/iam/login
- provider-aware authorize handler now matches the canonical
  /v1/iam/oauth/authorize (and legacy /login/oauth/authorize), targets
  IAM /v1/iam/oauth/authorize, keeps the _oauth_ctx cookie + branded page
- /logout: /api/logout->/v1/iam/logout

Kept (worker public contract; IAM canonicalizes inbound): the worker own
/oauth/* facade routes, the OIDC discovery rewrite, and IAM_PATHS legacy
inbound tolerance. Untouched (separate concern, flagged): the github.com
platform OAuth bridge.

Verified per brand (hanzo/lux/zoo/pars) with Playwright network
interception against worker.js under workerd (wrangler dev, local):
login renders -> password POST hits /v1/iam/login -> Google button
-> /v1/iam/oauth/authorize?provider=provider-google; zero /api/* and
zero bare /oauth/authorize across the session. /oauth/token facade probe
returns IAM token-endpoint JSON (invalid_client), proving the
/v1/iam/oauth/token hop is live. 36/36 + 7/7 checks pass.

---------

Co-authored-by: zeekay <[email protected]>
2026-06-24 19:30:40 -07:00

132 lines
5.5 KiB
Python

#!/usr/bin/env python3
"""
E2E proof for the /api/* -> /v1/iam/* canonical-path migration (id-worker).
Runs against the real worker.js executed under workerd (`wrangler dev`,
local mode) — never production. Per brand, via Playwright network
interception, it asserts the branded login page emits ONLY canonical
/v1/iam/* IAM calls:
1. login page renders (brand-specific <title>)
2. password form POSTs to /v1/iam/login (exact path asserted)
3. Google social button navigates to
/v1/iam/oauth/authorize?...&provider=provider-google
4. across the WHOLE session, NO request path is /api/* or bare /oauth/authorize
The brand under test is selected by the worker's IAM_DEFAULT_ORG var
(set per `wrangler dev` invocation); the localhost host has no tenant
catalog entry so it falls back to that default.
Usage: python3 e2e_canonical_paths.py <base-url> <brand>
"""
import sys
from urllib.parse import urlparse
from playwright.sync_api import sync_playwright
BASE = sys.argv[1] if len(sys.argv) > 1 else "http://127.0.0.1:8787"
BRAND = sys.argv[2] if len(sys.argv) > 2 else "hanzo"
BRAND_TITLE = {"hanzo": "Hanzo", "lux": "Lux", "zoo": "Zoo", "pars": "Pars"}.get(BRAND, "Hanzo")
results = []
def check(name, cond, detail=""):
results.append((name, bool(cond)))
print(f" {'PASS' if cond else 'FAIL'} {name}" + (f" -- {detail}" if detail else ""))
def is_canonical_login(p): return p == "/v1/iam/login"
def is_canonical_authorize(p): return p == "/v1/iam/oauth/authorize"
def is_legacy_api(p): return p.startswith("/api/")
def is_bare_authorize(p): return p == "/oauth/authorize"
client_id = f"{BRAND}-app-client-id"
redirect_uri = f"{BASE}/callback"
login_url = (
f"{BASE}/login?client_id={client_id}"
f"&redirect_uri={redirect_uri}"
f"&response_type=code&scope=openid+profile+email&state=e2e&prompt=login"
)
print(f"\n=== BRAND: {BRAND} ({BRAND_TITLE}) @ {BASE} ===")
with sync_playwright() as p:
browser = p.chromium.launch(headless=True)
page = browser.new_page()
seen = [] # every request the browser issues
page.on("request", lambda req: seen.append((req.method, urlparse(req.url).path, req.url)))
state = {"login_req": None, "legacy_login": False, "authorize_req": None, "bare_authorize": False}
# Intercept canonical login: assert path, fulfill locally (no real auth).
def handle_login(route):
state["login_req"] = urlparse(route.request.url)
route.fulfill(status=200, content_type="application/json",
body='{"status":"error","msg":"e2e-intercepted"}')
page.route("**/v1/iam/login*", handle_login)
# Legacy login must NOT fire.
def handle_legacy_login(route):
state["legacy_login"] = True
route.fulfill(status=200, content_type="application/json",
body='{"status":"error","msg":"LEGACY"}')
page.route("**/api/login*", handle_legacy_login)
# (1) render
page.goto(login_url, wait_until="domcontentloaded")
title = page.title()
content = page.content()
check("login page renders", "Sign In" in title or "Sign in" in content, f'title="{title}"')
check(f"brand pane is {BRAND_TITLE}", BRAND_TITLE in title or BRAND_TITLE in content, f'title="{title}"')
# (2) password form -> /v1/iam/login
page.fill("#email", "[email protected]")
page.fill("#password", "not-a-real-password")
page.click("#submitBtn")
page.wait_for_timeout(1500)
lr = state["login_req"]
check("password POST hit /v1/iam/login", lr and is_canonical_login(lr.path),
f"path={lr.path}" if lr else "no /v1/iam/login observed")
check("password POST did NOT hit /api/login", not state["legacy_login"])
# (3) Google social button -> /v1/iam/oauth/authorize?...&provider=provider-google
def handle_authorize(route):
pa = urlparse(route.request.url)
if pa.path == "/v1/iam/oauth/authorize":
state["authorize_req"] = pa
if is_bare_authorize(pa.path):
state["bare_authorize"] = True
route.fulfill(status=200, content_type="text/plain", body="e2e")
# One glob matches both /oauth/authorize and /v1/iam/oauth/authorize; classify inside.
page.route("**/oauth/authorize*", handle_authorize)
page.click("#btn-google")
page.wait_for_timeout(1500)
ar = state["authorize_req"]
qs = {}
if ar:
from urllib.parse import parse_qs
qs = parse_qs(ar.query)
check("Google button -> /v1/iam/oauth/authorize", ar and is_canonical_authorize(ar.path),
f"path={ar.path}" if ar else "no authorize observed")
check("authorize carries provider=provider-google",
ar and qs.get("provider", [""])[0] == "provider-google",
f'provider={qs.get("provider", [""])[0]}' if ar else "")
check("did NOT hit bare /oauth/authorize", not state["bare_authorize"])
# (4) session-wide leakage assertions
api_leaks = [f"{m} {pa}" for (m, pa, _u) in seen if is_legacy_api(pa)]
bare_leaks = [pa for (m, pa, _u) in seen if is_bare_authorize(pa)]
check("zero /api/* requests across session", len(api_leaks) == 0,
", ".join(api_leaks) if api_leaks else "clean")
check("zero bare /oauth/authorize across session", len(bare_leaks) == 0,
", ".join(bare_leaks) if bare_leaks else "clean")
browser.close()
passed = sum(1 for _n, ok in results if ok)
print(f"\n {passed}/{len(results)} checks passed for brand={BRAND}")
failed = [n for n, ok in results if not ok]
if failed:
print(" FAILURES: " + "; ".join(failed))
sys.exit(1)
sys.exit(0)