feat: standalone mpc-api deployment, implicit OAuth flow, E2E tests

- Separate mpc-api into its own Deployment with --cluster-url proxy to mpc-node-0
- Switch OAuth from authorization code flow to implicit flow (response_type=token)
- Add Playwright E2E tests for Lux ID login flow and OIDC discovery
- Update CI to apply api-deployment.yaml and wait for mpc-api rollout
This commit is contained in:
2025-12-27 10:59:11 -08:00
parent a11334d45a
commit 0232b7df72
9 changed files with 296 additions and 69 deletions
+2
View File
@@ -142,6 +142,7 @@ jobs:
- name: Apply MPC nodes and API
run: |
kubectl apply -f k8s/api-service.yaml
kubectl apply -f k8s/api-deployment.yaml
kubectl apply -f k8s/mpc-statefulset.yaml
- name: Apply dashboard
@@ -162,4 +163,5 @@ jobs:
- name: Wait for rollout
run: |
kubectl rollout status statefulset/mpc-node -n lux-mpc --timeout=300s
kubectl rollout status deployment/mpc-api -n lux-mpc --timeout=180s
kubectl rollout status deployment/mpc-dashboard -n lux-mpc --timeout=180s
+23 -41
View File
@@ -6,25 +6,33 @@ import { setTokens, setUserEmail } from '@/lib/auth'
import { getBranding } from '@/lib/branding'
import { api } from '@/lib/api'
function OidcCallbackInner() {
function CallbackInner() {
const router = useRouter()
const params = useSearchParams()
const searchParams = useSearchParams()
const [error, setError] = useState<string | null>(null)
const [status, setStatus] = useState('Completing sign in…')
useEffect(() => {
const code = params.get('code')
const state = params.get('state')
const oauthError = params.get('error')
const oauthErrorDesc = params.get('error_description')
// Check for error in query params (OAuth error redirects)
const oauthError = searchParams.get('error')
const oauthErrorDesc = searchParams.get('error_description')
if (oauthError) {
setError(oauthErrorDesc ?? oauthError)
return
}
if (!code) return
// Implicit flow: access_token is in the URL hash fragment
const hash = window.location.hash.substring(1)
const params = new URLSearchParams(hash)
const accessToken = params.get('access_token')
const state = params.get('state')
if (!accessToken) {
setError('No access token received from identity provider')
return
}
// Validate state to prevent CSRF
const savedState = sessionStorage.getItem('oidc_state')
if (savedState && state !== savedState) {
setError('Invalid state parameter — possible CSRF attack')
@@ -33,49 +41,23 @@ function OidcCallbackInner() {
sessionStorage.removeItem('oidc_state')
const branding = getBranding(window.location.hostname)
const redirectUri = `${window.location.origin}/auth/callback`
setStatus('Exchanging authorization code…')
setStatus('Authenticating with MPC API…')
// Step 1: Exchange code for Lux ID access token
fetch(`${branding.iamUrl}/api/login/oauth/access_token`, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
grant_type: 'authorization_code',
code,
redirect_uri: redirectUri,
client_id: branding.iamClientId,
}),
})
.then(async (res) => {
if (!res.ok) {
const body = await res.text()
throw new Error(`Token exchange failed: ${body}`)
}
return res.json() as Promise<{
access_token: string
refresh_token?: string
}>
})
.then(async (oidcData) => {
setStatus('Authenticating with MPC API…')
// Step 2: Exchange the OIDC token for a local MPC API JWT
const mpcAuth = await api.oidcExchange(oidcData.access_token, branding.iamUrl)
// Store the MPC API tokens (NOT the Lux ID token)
// Exchange the OIDC access token for a local MPC API JWT
api
.oidcExchange(accessToken, branding.iamUrl)
.then((mpcAuth) => {
setTokens(mpcAuth.access_token, mpcAuth.refresh_token)
if (mpcAuth.email) {
setUserEmail(mpcAuth.email)
}
router.replace('/dashboard')
})
.catch((err: unknown) => {
setError(err instanceof Error ? err.message : 'Authentication failed')
})
}, [params, router])
}, [searchParams, router])
if (error) {
return (
@@ -114,7 +96,7 @@ export default function OidcCallbackPage() {
</div>
}
>
<OidcCallbackInner />
<CallbackInner />
</Suspense>
)
}
+1 -1
View File
@@ -11,7 +11,7 @@ function buildLoginUrl(branding: Branding): string {
sessionStorage.setItem('oidc_state', state)
const params = new URLSearchParams({
response_type: 'code',
response_type: 'token',
client_id: branding.iamClientId,
redirect_uri: redirectUri,
scope: 'openid profile email',
+178
View File
@@ -0,0 +1,178 @@
import { test, expect } from '@playwright/test'
const IAM_URL = process.env.IAM_URL ?? 'https://lux.id'
/**
* E2E tests for the full Lux ID → MPC Dashboard OAuth login flow.
*
* Tests verify:
* 1. Unauthenticated users are redirected to /login
* 2. Login page renders correctly with Lux branding
* 3. OAuth implicit flow redirects to Lux ID
* 4. Lux ID login form accepts credentials and redirects back
* 5. MPC Dashboard receives the token and shows /dashboard
*
* Usage:
* TEST_PASSWORD='...' pnpm exec playwright test e2e/login-flow.spec.ts
*/
test.describe('Lux ID Login Flow', () => {
test('unauthenticated root redirects to /login', async ({ page }) => {
await page.goto('/')
await expect(page).toHaveURL(/\/login/)
})
test('login page shows Lux branding and Continue button', async ({ page }) => {
await page.goto('/login')
await expect(page.getByRole('heading')).toContainText(/Lux/)
const continueBtn = page.getByRole('button', { name: /Continue with/i })
await expect(continueBtn).toBeVisible()
await expect(continueBtn).toContainText(/Lux ID/i)
})
test('Continue button redirects to Lux ID authorize endpoint', async ({ page }) => {
await page.goto('/login')
const continueBtn = page.getByRole('button', { name: /Continue with/i })
await continueBtn.click()
await page.waitForURL(/lux\.id\/login\/oauth\/authorize/, { timeout: 15_000 })
const url = new URL(page.url())
expect(url.hostname).toBe('lux.id')
expect(url.pathname).toBe('/login/oauth/authorize')
expect(url.searchParams.get('response_type')).toBe('token')
expect(url.searchParams.get('client_id')).toBe('lux-mpc')
expect(url.searchParams.get('scope')).toContain('openid')
})
test('Lux ID login page loads with email and password fields', async ({ page }) => {
const authorizeUrl = new URL(`${IAM_URL}/login/oauth/authorize`)
authorizeUrl.searchParams.set('response_type', 'token')
authorizeUrl.searchParams.set('client_id', 'lux-mpc')
authorizeUrl.searchParams.set('redirect_uri', 'https://mpc.lux.network/auth/callback')
authorizeUrl.searchParams.set('scope', 'openid profile email')
authorizeUrl.searchParams.set('state', 'e2e-test')
await page.goto(authorizeUrl.toString())
// The Lux ID login form has "Sign in to Lux" heading
await expect(page.getByText('Sign in to Lux')).toBeVisible({ timeout: 15_000 })
// Email address and Password fields
const emailInput = page.getByPlaceholder('[email protected]')
const passwordInput = page.getByPlaceholder('Enter your password')
await expect(emailInput).toBeVisible()
await expect(passwordInput).toBeVisible()
// Sign in button
const signInBtn = page.getByRole('button', { name: /Sign in/i })
await expect(signInBtn).toBeVisible()
})
test('full login flow with [email protected]', async ({ page }) => {
const password = process.env.TEST_PASSWORD
test.skip(!password, 'TEST_PASSWORD env var required for login tests')
await page.goto('/login')
// Click continue to go to Lux ID
await page.getByRole('button', { name: /Continue with/i }).click()
// Wait for Lux ID login page
await page.waitForURL(/lux\.id/, { timeout: 15_000 })
await expect(page.getByText('Sign in to Lux')).toBeVisible({ timeout: 15_000 })
// Fill in credentials
await page.getByPlaceholder('[email protected]').fill('[email protected]')
await page.getByPlaceholder('Enter your password').fill(password!)
// Click Sign in
await page.getByRole('button', { name: /Sign in/i }).click()
// Should redirect back to MPC dashboard
await page.waitForURL(/mpc\.lux\.network/, { timeout: 30_000 })
// Wait for auth callback to process and redirect to dashboard
if (page.url().includes('/auth/callback')) {
await page.waitForURL(/\/dashboard/, { timeout: 15_000 })
}
await expect(page).toHaveURL(/\/dashboard/)
})
test('full login flow with [email protected]', async ({ page }) => {
const password = process.env.TEST_PASSWORD
test.skip(!password, 'TEST_PASSWORD env var required for login tests')
await page.goto('/login')
await page.getByRole('button', { name: /Continue with/i }).click()
await page.waitForURL(/lux\.id/, { timeout: 15_000 })
await expect(page.getByText('Sign in to Lux')).toBeVisible({ timeout: 15_000 })
await page.getByPlaceholder('[email protected]').fill('[email protected]')
await page.getByPlaceholder('Enter your password').fill(password!)
await page.getByRole('button', { name: /Sign in/i }).click()
await page.waitForURL(/mpc\.lux\.network/, { timeout: 30_000 })
if (page.url().includes('/auth/callback')) {
await page.waitForURL(/\/dashboard/, { timeout: 15_000 })
}
await expect(page).toHaveURL(/\/dashboard/)
})
})
test.describe('IAM OIDC Discovery', () => {
test('OIDC discovery endpoint returns valid config', async ({ request }) => {
// Use hanzo.id directly — lux.id sometimes returns login HTML on .well-known paths
const response = await request.get('https://hanzo.id/.well-known/openid-configuration')
expect(response.ok()).toBeTruthy()
const config = await response.json()
expect(config.issuer).toBeTruthy()
expect(config.authorization_endpoint).toContain('/login/oauth/authorize')
expect(config.token_endpoint).toContain('/oauth/token')
expect(config.userinfo_endpoint).toContain('/oauth/userinfo')
expect(config.jwks_uri).toContain('/.well-known/jwks')
expect(config.response_types_supported).toContain('token')
})
test('JWKS endpoint returns valid keys', async ({ request }) => {
// Use hanzo.id for JWKS since lux.id may proxy via login frontend
const response = await request.get('https://hanzo.id/.well-known/jwks')
expect(response.ok()).toBeTruthy()
const jwks = await response.json()
expect(jwks.keys).toBeDefined()
expect(jwks.keys.length).toBeGreaterThan(0)
expect(jwks.keys[0].kty).toBeTruthy()
})
test('app-lux-mpc application is accessible', async ({ request }) => {
const response = await request.get('https://hanzo.id/api/get-application?id=admin/app-lux-mpc')
expect(response.ok()).toBeTruthy()
const body = await response.json()
expect(body.data).toBeTruthy()
expect(body.data.clientId).toBe('lux-mpc')
expect(body.data.organization).toBe('lux')
})
})
test.describe('MPC API Health', () => {
test('healthz endpoint returns ok', async ({ request }) => {
const baseUrl = process.env.BASE_URL ?? 'https://mpc.lux.network'
const response = await request.get(`${baseUrl}/healthz`)
expect(response.ok()).toBeTruthy()
const body = await response.json()
expect(body.status).toBe('ok')
})
})
+9 -8
View File
@@ -9,22 +9,23 @@
"lint": "next lint"
},
"dependencies": {
"@tanstack/react-query": "^5.60.0",
"geist": "^1.3.0",
"next": "^14.2.0",
"react": "^18.3.0",
"react-dom": "^18.3.0",
"@tanstack/react-query": "^5.60.0",
"zustand": "^5.0.0",
"geist": "^1.3.0"
"zustand": "^5.0.0"
},
"devDependencies": {
"typescript": "^5.6.0",
"@playwright/test": "^1.58.2",
"@types/node": "^22.0.0",
"@types/react": "^18.3.0",
"@types/react-dom": "^18.3.0",
"@types/node": "^22.0.0",
"tailwindcss": "^3.4.0",
"postcss": "^8.4.0",
"autoprefixer": "^10.4.0",
"eslint": "^8.57.0",
"eslint-config-next": "^14.2.0"
"eslint-config-next": "^14.2.0",
"postcss": "^8.4.0",
"tailwindcss": "^3.4.0",
"typescript": "^5.6.0"
}
}
+21
View File
@@ -0,0 +1,21 @@
import { defineConfig, devices } from '@playwright/test'
export default defineConfig({
testDir: './e2e',
timeout: 60_000,
expect: { timeout: 10_000 },
fullyParallel: false,
retries: 1,
reporter: 'html',
use: {
baseURL: process.env.BASE_URL ?? 'https://mpc.lux.network',
trace: 'on-first-retry',
screenshot: 'only-on-failure',
},
projects: [
{
name: 'chromium',
use: { ...devices['Desktop Chrome'] },
},
],
})
+56 -5
View File
@@ -13,10 +13,10 @@ importers:
version: 5.90.21([email protected])
geist:
specifier: ^1.3.0
version: 1.7.0([email protected]([email protected]([email protected]))([email protected]))
version: 1.7.0([email protected](@playwright/[email protected])([email protected]([email protected]))([email protected]))
next:
specifier: ^14.2.0
version: 14.2.35([email protected]([email protected]))([email protected])
version: 14.2.35(@playwright/[email protected])([email protected]([email protected]))([email protected])
react:
specifier: ^18.3.0
version: 18.3.1
@@ -27,6 +27,9 @@ importers:
specifier: ^5.0.0
version: 5.0.11(@types/[email protected])([email protected])
devDependencies:
'@playwright/test':
specifier: ^1.58.2
version: 1.58.2
'@types/node':
specifier: ^22.0.0
version: 22.19.13
@@ -144,24 +147,28 @@ packages:
engines: {node: '>= 10'}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@next/[email protected]':
resolution: {integrity: sha512-Bm+QulsAItD/x6Ih8wGIMfRJy4G73tu1HJsrccPW6AfqdZd0Sfm5Imhgkgq2+kly065rYMnCOxTBvmvFY1BKfg==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [linux]
libc: [musl]
'@next/[email protected]':
resolution: {integrity: sha512-FnFn+ZBgsVMbGDsTqo8zsnRzydvsGV8vfiWwUo1LD8FTmPTdV+otGSWKc4LJec0oSexFnCYVO4hX8P8qQKaSlg==}
engines: {node: '>= 10'}
cpu: [x64]
os: [linux]
libc: [glibc]
'@next/[email protected]':
resolution: {integrity: sha512-345tsIWMzoXaQndUTDv1qypDRiebFxGYx9pYkhwY4hBRaOLt8UGfiWKr9FSSHs25dFIf8ZqIFaPdy5MljdoawA==}
engines: {node: '>= 10'}
cpu: [x64]
os: [linux]
libc: [musl]
'@next/[email protected]':
resolution: {integrity: sha512-nscpt0G6UCTkrT2ppnJnFsYbPDQwmum4GNXYTeoTIdsmMydSKFz9Iny2jpaRupTb+Wl298+Rh82WKzt9LCcqSQ==}
@@ -201,6 +208,11 @@ packages:
resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==}
engines: {node: '>=14'}
'@playwright/[email protected]':
resolution: {integrity: sha512-akea+6bHYBBfA9uQqSYmlJXn61cTa+jbO87xVLCWbTqbWadRVmhxlXATaOjOgcBaWU4ePo0wB41KMFv3o35IXA==}
engines: {node: '>=18'}
hasBin: true
'@rtsao/[email protected]':
resolution: {integrity: sha512-zt6OdqaDoOnJ1ZYsCYGt9YmWzDXl4vQdKTyJev62gFhRGKdx7mcT54V9KIjg+d2wi9EXsPvAPKe7i7WjfVWB8g==}
@@ -342,41 +354,49 @@ packages:
resolution: {integrity: sha512-34gw7PjDGB9JgePJEmhEqBhWvCiiWCuXsL9hYphDF7crW7UgI05gyBAi6MF58uGcMOiOqSJ2ybEeCvHcq0BCmQ==}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@unrs/[email protected]':
resolution: {integrity: sha512-RyMIx6Uf53hhOtJDIamSbTskA99sPHS96wxVE/bJtePJJtpdKGXO1wY90oRdXuYOGOTuqjT8ACccMc4K6QmT3w==}
cpu: [arm64]
os: [linux]
libc: [musl]
'@unrs/[email protected]':
resolution: {integrity: sha512-D8Vae74A4/a+mZH0FbOkFJL9DSK2R6TFPC9M+jCWYia/q2einCubX10pecpDiTmkJVUH+y8K3BZClycD8nCShA==}
cpu: [ppc64]
os: [linux]
libc: [glibc]
'@unrs/[email protected]':
resolution: {integrity: sha512-frxL4OrzOWVVsOc96+V3aqTIQl1O2TjgExV4EKgRY09AJ9leZpEg8Ak9phadbuX0BA4k8U5qtvMSQQGGmaJqcQ==}
cpu: [riscv64]
os: [linux]
libc: [glibc]
'@unrs/[email protected]':
resolution: {integrity: sha512-mJ5vuDaIZ+l/acv01sHoXfpnyrNKOk/3aDoEdLO/Xtn9HuZlDD6jKxHlkN8ZhWyLJsRBxfv9GYM2utQ1SChKew==}
cpu: [riscv64]
os: [linux]
libc: [musl]
'@unrs/[email protected]':
resolution: {integrity: sha512-kELo8ebBVtb9sA7rMe1Cph4QHreByhaZ2QEADd9NzIQsYNQpt9UkM9iqr2lhGr5afh885d/cB5QeTXSbZHTYPg==}
cpu: [s390x]
os: [linux]
libc: [glibc]
'@unrs/[email protected]':
resolution: {integrity: sha512-C3ZAHugKgovV5YvAMsxhq0gtXuwESUKc5MhEtjBpLoHPLYM+iuwSj3lflFwK3DPm68660rZ7G8BMcwSro7hD5w==}
cpu: [x64]
os: [linux]
libc: [glibc]
'@unrs/[email protected]':
resolution: {integrity: sha512-rV0YSoyhK2nZ4vEswT/QwqzqQXw5I6CjoaYMOX0TqBlWhojUf8P94mvI7nuJTeaCkkds3QE4+zS8Ko+GdXuZtA==}
cpu: [x64]
os: [linux]
libc: [musl]
'@unrs/[email protected]':
resolution: {integrity: sha512-5u4RkfxJm+Ng7IWgkzi3qrFOvLvQYnPBmjmZQ8+szTK/b31fQCnleNl1GgEt7nIsZRIf5PLhPwT0WM+q45x/UQ==}
@@ -884,6 +904,11 @@ packages:
[email protected]:
resolution: {integrity: sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==}
[email protected]:
resolution: {integrity: sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==}
engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
os: [darwin]
[email protected]:
resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==}
engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
@@ -1380,6 +1405,16 @@ packages:
resolution: {integrity: sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==}
engines: {node: '>= 6'}
[email protected]:
resolution: {integrity: sha512-yZkEtftgwS8CsfYo7nm0KE8jsvm6i/PTgVtB8DL726wNf6H2IMsDuxCpJj59KDaxCtSnrWan2AeDqM7JBaultg==}
engines: {node: '>=18'}
hasBin: true
[email protected]:
resolution: {integrity: sha512-vA30H8Nvkq/cPBnNw4Q8TWz1EJyqgpuinBcHET0YVJVFldr8JDNiU9LaWAE1KqSkRYazuaBhTpB5ZzShOezQ6A==}
engines: {node: '>=18'}
hasBin: true
[email protected]:
resolution: {integrity: sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==}
engines: {node: '>= 0.4'}
@@ -1939,6 +1974,10 @@ snapshots:
'@pkgjs/[email protected]':
optional: true
'@playwright/[email protected]':
dependencies:
playwright: 1.58.2
'@rtsao/[email protected]': {}
'@rushstack/[email protected]': {}
@@ -2781,6 +2820,9 @@ snapshots:
[email protected]: {}
[email protected]:
optional: true
[email protected]:
optional: true
@@ -2797,9 +2839,9 @@ snapshots:
[email protected]: {}
[email protected]([email protected]([email protected]([email protected]))([email protected])):
[email protected]([email protected](@playwright/[email protected])([email protected]([email protected]))([email protected])):
dependencies:
next: 14.2.35([email protected]([email protected]))([email protected])
next: 14.2.35(@playwright/[email protected])([email protected]([email protected]))([email protected])
[email protected]: {}
@@ -3151,7 +3193,7 @@ snapshots:
[email protected]: {}
[email protected]([email protected]([email protected]))([email protected]):
[email protected](@playwright/[email protected])([email protected]([email protected]))([email protected]):
dependencies:
'@next/env': 14.2.35
'@swc/helpers': 0.5.5
@@ -3172,6 +3214,7 @@ snapshots:
'@next/swc-win32-arm64-msvc': 14.2.33
'@next/swc-win32-ia32-msvc': 14.2.33
'@next/swc-win32-x64-msvc': 14.2.33
'@playwright/test': 1.58.2
transitivePeerDependencies:
- '@babel/core'
- babel-plugin-macros
@@ -3285,6 +3328,14 @@ snapshots:
[email protected]: {}
[email protected]: {}
[email protected]:
dependencies:
playwright-core: 1.58.2
optionalDependencies:
fsevents: 2.3.2
[email protected]: {}
[email protected]([email protected]):
+5 -12
View File
@@ -23,17 +23,15 @@ spec:
- name: mpc-api
image: ghcr.io/luxfi/mpc-api:latest
args:
- start
- --mode
- consensus
- --api-db
- api
- --db
- $(DATABASE_URL)
- --api-kv
- $(KV_URL)
- --api-listen
- --listen
- ":8081"
- --jwt-secret
- $(JWT_SECRET)
- --cluster-url
- http://mpc-node-0.mpc-node-headless.lux-mpc.svc:8081
ports:
- containerPort: 8081
name: api
@@ -49,11 +47,6 @@ spec:
secretKeyRef:
name: mpc-secrets
key: jwt-secret
- name: KV_URL
valueFrom:
secretKeyRef:
name: mpc-secrets
key: KV_URL
livenessProbe:
httpGet:
path: /healthz
+1 -2
View File
@@ -14,5 +14,4 @@ spec:
protocol: TCP
name: api
selector:
app: mpc-node
statefulset.kubernetes.io/pod-name: mpc-node-0
app: mpc-api