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:
+23
-12
@@ -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 }) => {
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user