e2e: one way to build an IAM authorize URL; kill the silent-pass

IAM routes exactly ONE authorize path (iam internal/oidc/oidc.go
PathAuthorize) = /v1/iam/oauth/authorize. Two specs hand-built
`${iam}/oauth/authorize` instead, and the IdP SPA answers ANY unrouted
path with 200 text/html — so both specs asserted a 2xx/3xx and PASSED
on a dead endpoint.

Measured on lux.id 2026-07-27, same query string on each:
  /v1/iam/oauth/authorize  302                              <- REAL (redirect to login)
  /v1/iam/oauth/authorize  400 text/plain  "invalid redirect_uri" (unregistered uri)
  /oauth/authorize         200 text/html   (SPA shell)
  /oauth2/authorize        200 text/html   (SPA shell)
  /zzz-control             200 text/html   (SPA shell)      <- CONTROL: 200 proves nothing
  /v1/iam/.well-known/jwks 200 application/json (real keys)

env.ts now owns the path (OIDC.authorizePath/tokenPath/jwksPath) and
exports authorizeUrl() — the same "one way" that orgRpc() already gives
RPC URLs. isRealAuthorizeResponse() rejects the shell: a 200 text/html
can no longer pass.

Proof both directions, playwright 13-iam '-g OAuth authorize endpoint':
  canonical path  -> 6 passed, 3 skipped (Zoo DNS); logs "400 text/plain"
  NEGATIVE CONTROL, authorizePath flipped back to /oauth/authorize
                  -> 2 failed (Hanzo, Lux)
The old assertion passed on that identical response.

05-cloud also gains a positive DOM assertion (a visible password input)
instead of "current URL contains lux.id", which the shell satisfies.

Also: cm/exchange-config's gateway URL. The live ConfigMap in ns
lux-mainnet still carries /mainnet/ext/bc/C/rpc and this source had been
half-converted to /mainnet/v1/bc/C/rpc — still dead. Measured:
  api.lux.network/mainnet/ext/bc/C/rpc 404
  api.lux.network/mainnet/v1/bc/C/rpc  404   <- the /mainnet/ segment is dead too
  api.lux.network/v1/bc/C/rpc          200 eth_chainId 0x17871 (96369)

Co-authored-by: Hanzo Dev <[email protected]>
This commit is contained in:
2026-07-27 18:08:38 -07:00
co-authored by Hanzo Dev
parent 3eaeaf13d7
commit 0ad6ac563d
4 changed files with 66 additions and 18 deletions
+23 -12
View File
@@ -1,5 +1,6 @@
import { test, expect } from '@playwright/test'
import { getUrls, httpStatus, httpGetJson, rpcCall, getCurrentValidators, snap } from './helpers'
import { OIDC, authorizeUrl, isRealAuthorizeResponse } from './env'
const urls = getUrls()
const CLOUD_URL = urls.cloud
@@ -90,21 +91,31 @@ test.describe('Cloud — Login Flow', () => {
})
test('OAuth authorize URL works with lux-cloud client', async ({ page }) => {
const callbackUrl = encodeURIComponent(`${CLOUD_URL}/auth/callback`)
const authUrl = `https://lux.id/oauth/authorize?client_id=lux-cloud&response_type=code&redirect_uri=${callbackUrl}&scope=openid+profile+email`
// Built through authorizeUrl() so the path can only ever be IAM's one
// routed authorize path. The old version hand-built /oauth/authorize and
// asserted status 200 + "url contains lux.id" — both of which the IdP's
// SPA catch-all satisfies for ANY dead path, so it passed on a 404 in
// disguise. Measured 2026-07-27: lux.id/oauth/authorize -> 200 text/html
// (shell), same as the control lux.id/zzz-control.
const authUrl = authorizeUrl('https://lux.id', {
client_id: OIDC.clientId,
response_type: 'code',
redirect_uri: `${CLOUD_URL}${OIDC.callbackPath}`,
scope: 'openid profile email',
})
const res = await page.goto(authUrl, { timeout: 30_000, waitUntil: 'domcontentloaded' })
const status = res?.status() || 0
// Should show login form or redirect (200, 302, 307)
expect([200, 301, 302, 307]).toContain(status)
await page.waitForTimeout(2000)
const currentUrl = page.url()
console.log(` OAuth page: ${currentUrl}`)
const ctype = res?.headers()['content-type'] || ''
console.log(` OAuth authorize: ${status} ${ctype} -> ${page.url()}`)
await snap(page, 'cloud-oauth')
// Should be on hanzo.id login page
const onAuthPage = currentUrl.includes('hanzo.id') ||
currentUrl.includes('lux.id') ||
currentUrl.includes('login')
expect(onAuthPage).toBe(true)
expect(
isRealAuthorizeResponse(status, ctype),
`authorize answered the SPA shell (${status} ${ctype}) — the endpoint is not routed`,
).toBe(true)
// Positive DOM assertion: a real authorize lands on a login form, not a
// hostname that merely looks right.
await expect(page.locator('input[type="password"], form[action*="login"]').first())
.toBeVisible({ timeout: 15_000 })
})
test('auth callback page exists', async ({ page }) => {
+15 -4
View File
@@ -1,5 +1,6 @@
import { test, expect } from '@playwright/test'
import { httpStatus } from './helpers'
import { authorizeUrl, isRealAuthorizeResponse } from './env'
// Per-universe IAM + commerce config — Hanzo / Lux / Zoo only.
interface Universe {
@@ -148,13 +149,23 @@ test.describe('Per-Universe IAM — Auth Flow Smoke', () => {
redirect_uri: `${u.iamUrl}/callback`,
state: 'e2e-smoke',
})
const res = await request.get(`${u.iamUrl}/oauth/authorize?${params}`, {
const res = await request.get(authorizeUrl(u.iamUrl, Object.fromEntries(params)), {
maxRedirects: 0,
timeout: 15_000,
})
// Expect redirect (to login or to callback with code/error) — NOT 500
console.log(` ${u.name}: ${res.status()}`)
expect([200, 302, 303, 307, 308]).toContain(res.status())
// A 200 is NOT evidence: the IdP SPA answers any unrouted path with 200
// text/html, so the previous [200,302,303,307,308] assertion passed on a
// dead endpoint. Measured 2026-07-27 on lux.id — /oauth/authorize,
// /oauth2/authorize and the control /zzz-control all return 200
// text/html, while the routed /v1/iam/oauth/authorize returns 400
// text/plain "authorization error: invalid redirect_uri".
const ctype = res.headers()['content-type'] || ''
console.log(` ${u.name}: ${res.status()} ${ctype}`)
expect(
isRealAuthorizeResponse(res.status(), ctype),
`${u.name} authorize answered the SPA shell (${res.status()} ${ctype})`,
).toBe(true)
expect(res.status()).toBeLessThan(500)
})
}
})
+26
View File
@@ -262,8 +262,34 @@ export const OIDC = {
clientId: 'lux-cloud',
callbackPath: '/auth/callback',
challengeMethod: 'S256',
// IAM routes ONE authorize path (iam internal/oidc/oidc.go PathAuthorize).
authorizePath: '/v1/iam/oauth/authorize',
tokenPath: '/v1/iam/oauth/token',
jwksPath: '/v1/iam/.well-known/jwks',
} as const
// The one way to build an IAM authorize URL. Hand-building `${iam}/oauth/
// authorize` or `/oauth2/authorize` is the trap: the IdP SPA answers ANY
// unrouted path with 200 text/html, so a hand-built URL fails SILENTLY and a
// test that merely asserts a 2xx/3xx passes on a dead endpoint.
//
// Measured on lux.id 2026-07-27 (same query on each):
// /v1/iam/oauth/authorize 400 text/plain "authorization error: invalid redirect_uri" <- REAL
// /oauth/authorize 200 text/html (SPA shell)
// /oauth2/authorize 200 text/html (SPA shell)
// /zzz-control 200 text/html (SPA shell) <- control: 200 proves nothing
export function authorizeUrl(iamBase: string, params: Record<string, string>): string {
return `${iamBase.replace(/\/$/, '')}${OIDC.authorizePath}?${new URLSearchParams(params)}`
}
// An authorize response is REAL only when it is not the SPA catch-all: either a
// redirect to the login page, or a text/plain OAuth error from the endpoint
// itself. A 200 text/html is the shell — always a failure.
export function isRealAuthorizeResponse(status: number, contentType: string): boolean {
if (status >= 300 && status < 400) return true // redirect to the login page
return !/text\/html/i.test(contentType) // anything else must not be the shell
}
// ── Concurrency the front door tolerates ───────────────────────────────────
// MEASURED, not guessed. Mainnet's api host is CDN-fronted (dig → 172.67.x) and
// served the whole gate at 4 workers with 342/342 green. Testnet resolves
+2 -2
View File
@@ -75,8 +75,8 @@ data:
NODE_ENV: "production"
PORT: "3000"
NEXT_TELEMETRY_DISABLED: "1"
NEXT_PUBLIC_LXD_GATEWAY_URL: "https://api.lux.network/mainnet/v1/bc/C/rpc"
REACT_APP_LXD_GATEWAY_URL: "https://api.lux.network/mainnet/v1/bc/C/rpc"
NEXT_PUBLIC_LXD_GATEWAY_URL: "https://api.lux.network/v1/bc/C/rpc"
REACT_APP_LXD_GATEWAY_URL: "https://api.lux.network/v1/bc/C/rpc"
REACT_APP_AWS_API_ENDPOINT: "/api"
REACT_APP_PUBLIC_API_ENDPOINT: "/api"
REACT_APP_SENTRY_ENABLED: "false"