Move browser authentication behind a generated-contract BFF

This commit is contained in:
ms2k 2026-07-19 18:21:17 +09:00
commit fa7220ee33
4 changed files with 7259 additions and 2825 deletions

View file

@ -0,0 +1,61 @@
import AxeBuilder from '@axe-core/playwright'
import { expect, test } from '@playwright/test'
test.beforeEach(async ({ page }) => {
await page.route('**/auth/session', (route) =>
route.fulfill({
contentType: 'application/json',
body: JSON.stringify({
kind: 'authenticated',
identity: 'issuer\u0000subject',
csrfToken: 'csrf-value',
}),
}),
)
await page.route(/\/bff\/api\//, (route) => {
const path = new URL(route.request().url()).pathname
if (path.endsWith('/jobs'))
return route.fulfill({
contentType: 'application/json',
body: JSON.stringify({
items: [],
next_cursor: null,
event_cursor: 'cursor-snapshot-0001',
management_capabilities: { revoke_execution_grants: false },
}),
})
return route.fulfill({
status: 201,
contentType: 'application/json',
body: JSON.stringify({
ticket: 'ticket_00001',
expires_at: '2026-07-20T00:00:00Z',
}),
})
})
await page.routeWebSocket(/\/bff\/events/, () => {})
})
test('meets automated WCAG A and AA checks', async ({ page }) => {
await page.goto('/')
await expect(page.getByRole('heading', { name: 'Remora' })).toBeVisible()
const results = await new AxeBuilder({ page })
.withTags(['wcag2a', 'wcag2aa', 'wcag21aa', 'wcag22aa'])
.analyze()
expect(results.violations).toEqual([])
})
test('reflows at 320 CSS pixels and preserves keyboard focus', async ({
page,
}) => {
await page.setViewportSize({ width: 320, height: 720 })
await page.goto('/')
await expect(page.getByRole('heading', { name: 'Remora' })).toBeVisible()
expect(
await page.evaluate(() => document.documentElement.scrollWidth),
).toBeLessThanOrEqual(320)
await page.keyboard.press('Tab')
await expect(page.getByRole('button', { name: 'Sign out' })).toBeFocused()
await page.keyboard.press('Tab')
await expect(page.getByRole('button', { name: 'Jobs' })).toBeFocused()
})

View file

@ -17,23 +17,12 @@ const result: MockResult = {
}
test.beforeEach(async ({ page }) => {
await page.routeWebSocket(/\/v1\/events/, () => {})
await page.routeWebSocket(/\/bff\/events/, () => {})
})
test('completes credential, submission, result, history, and lifecycle workflows', async ({
page,
}) => {
test('creates, rotates, and revokes a credential', async ({ page }) => {
const state = await mockApi(page)
await authenticatedSession(page)
await expect
.poll(() =>
page.evaluate(() =>
sessionStorage.getItem(
`oidc.user:${window.location.origin}/oidc:remora-web`,
),
),
)
.not.toBeNull()
await expect(page.getByRole('heading', { name: 'Remora' })).toBeVisible()
await expect(page.getByRole('button', { name: 'Sign out' })).toBeVisible()
await expect(page.getByLabel('Synchronization status')).toHaveText('live')
@ -55,7 +44,19 @@ test('completes credential, submission, result, history, and lifecycle workflows
await page.getByLabel('Replacement token').fill('replacement-token')
await page.getByRole('button', { name: 'Rotate credential' }).click()
await expect(page.getByLabel('Replacement token')).toHaveValue('')
await page.getByRole('button', { name: 'Revoke credential' }).click()
await expect(page.getByRole('alertdialog')).toContainText('future execution')
await page.getByRole('button', { name: 'Confirm' }).click()
await expect(
page.getByText('Credential revoked for future grants'),
).toBeVisible()
expect(state.mutations).toEqual([])
expect(new Set(state.idempotencyKeys).size).toBe(state.idempotencyKeys.length)
})
test('submits a job and browses its history and result', async ({ page }) => {
const state = await mockApi(page)
await authenticatedSession(page)
await page.getByRole('button', { name: 'Submit job' }).click()
await page.getByLabel('Label').fill('Imported archive')
await page
@ -75,7 +76,15 @@ test('completes credential, submission, result, history, and lifecycle workflows
await expect(page.getByText('AttemptCommitted')).toBeVisible()
await page.getByRole('button', { name: 'Browse result' }).click()
await expect(page.getByText('archive.bin')).toBeVisible()
expect(new Set(state.idempotencyKeys).size).toBe(state.idempotencyKeys.length)
})
test('confirms reversible and destructive job lifecycle actions', async ({
page,
}) => {
const state = await mockApi(page)
await authenticatedSession(page)
await page.getByRole('button', { name: /Existing archive/ }).click()
const softDelete = page.getByRole('button', { name: 'Soft delete' })
await softDelete.click()
await expect(page.getByRole('alertdialog')).toContainText(
@ -89,7 +98,7 @@ test('completes credential, submission, result, history, and lifecycle workflows
await page.getByRole('button', { name: 'Confirm' }).click()
await page.reload()
await expect(page.getByLabel('Synchronization status')).toHaveText('live')
await page.getByRole('button', { name: /Imported archive/ }).click()
await page.getByRole('button', { name: /Existing archive/ }).click()
expect(state.includeDeletedRequests).toBeGreaterThan(1)
await expect(page.getByRole('button', { name: 'Browse result' })).toBeHidden()
await page.getByRole('button', { name: 'Restore' }).click()
@ -103,15 +112,6 @@ test('completes credential, submission, result, history, and lifecycle workflows
page.getByRole('list', { name: 'Deletion operations' }).getByText('queued'),
).toBeVisible()
await page.getByRole('button', { name: 'Credentials' }).click()
await page.getByRole('button', { name: /Archive token/ }).click()
await page.getByRole('button', { name: 'Revoke credential' }).click()
await expect(page.getByRole('alertdialog')).toContainText('future execution')
await page.getByRole('button', { name: 'Confirm' }).click()
await expect(
page.getByText('Credential revoked for future grants'),
).toBeVisible()
expect(state.mutations).toEqual([
'soft-delete',
'restore',
@ -122,22 +122,14 @@ test('completes credential, submission, result, history, and lifecycle workflows
})
async function authenticatedSession(page: Page): Promise<void> {
await page.route('**/auth/session', (route) =>
json(route, {
kind: 'authenticated',
identity: 'issuer\u0000subject-0001',
csrfToken: 'csrf-value',
}),
)
await page.goto('/')
await page.evaluate(() => {
const authority = `${window.location.origin}/oidc`
sessionStorage.setItem(
`oidc.user:${authority}:remora-web`,
JSON.stringify({
id_token: 'id-token',
access_token: 'access-token',
token_type: 'Bearer',
scope: 'openid profile',
profile: { iss: authority, sub: 'subject-0001' },
expires_at: Math.floor(Date.now() / 1000) + 3600,
}),
)
})
await page.reload()
}
async function mockApi(page: Page): Promise<{
@ -162,10 +154,10 @@ async function mockApi(page: Page): Promise<{
}
let credential = sourceCredential('active')
let currentJob = job('Existing archive')
await page.route(/\/v1\//, async (route) => {
await page.route(/\/bff\/api\//, async (route) => {
const request = route.request()
const url = new URL(request.url())
const path = url.pathname.replace('/v1/', '')
const path = url.pathname.replace('/bff/api/', '')
state.paths.push(path)
if (
path === 'jobs' &&

View file

@ -1,6 +1,7 @@
import { createPrivateKey, sign } from 'node:crypto'
import { createPrivateKey, randomBytes, sign } from 'node:crypto'
import { expect, test } from '@playwright/test'
import { createClient } from 'redis'
test('authenticates and completes a deployed job lifecycle through ingress', async ({
page,
@ -8,28 +9,49 @@ test('authenticates and completes a deployed job lifecycle through ingress', asy
const token = await mintZitadelToken()
const claims = jwtClaims(token)
const issuer = requiredEnv('REMORA_OIDC_ISSUER')
const sessionId = randomBytes(32).toString('base64url')
const csrfToken = randomBytes(24).toString('base64url')
const coordination = createClient({
url: requiredEnv('REMORA_COORDINATION_URL'),
})
await coordination.connect()
await coordination.set(
`${requiredEnv('REMORA_COORDINATION_NAMESPACE')}web-session:${sessionId}`,
JSON.stringify({
kind: 'authenticated',
identity: `${issuer}\u0000${stringValue(claims.sub, 'subject')}`,
csrfToken,
accessToken: token,
refreshToken: null,
idToken: 'qualification-session',
accessExpiresAt: Number(claims.exp),
}),
{ EX: 3600 },
)
await coordination.quit()
await page.context().addCookies([
{
name: 'remora-qualification-session',
value: sessionId,
url: requiredEnv('REMORA_SYSTEM_BASE_URL'),
sameSite: 'Strict',
},
])
const assetResponse = page.waitForResponse((response) => {
const path = new URL(response.url()).pathname
return path.startsWith('/assets/') && path.endsWith('.js')
})
await page.goto('/')
await page.evaluate(
({ accessToken, authority, profile }) => {
sessionStorage.setItem(
`oidc.user:${authority}:remora-web`,
JSON.stringify({
access_token: accessToken,
token_type: 'Bearer',
scope: 'openid profile',
profile,
expires_at: Number(profile.exp),
}),
)
},
{ accessToken: token, authority: issuer, profile: claims },
expect(['br', 'gzip']).toContain(
(await assetResponse).headers()['content-encoding'],
)
await page.reload()
await expect(page.getByRole('heading', { name: 'Remora' })).toBeVisible()
await expect(page.getByRole('button', { name: 'Sign out' })).toBeVisible()
await expect(page.getByLabel('Synchronization status')).toHaveText('live')
await expect(page.getByLabel('Synchronization status')).toHaveText('live', {
timeout: 30_000,
})
await expect(page.getByText('deployment-coordination-rebuild')).toBeVisible()
await page.getByRole('button', { name: 'Submit job' }).click()
@ -39,7 +61,7 @@ test('authenticates and completes a deployed job lifecycle through ingress', asy
const submitted = page.waitForResponse(
(response) =>
response.request().method() === 'POST' &&
new URL(response.url()).pathname === '/v1/jobs',
new URL(response.url()).pathname === '/bff/api/jobs',
)
await page.getByRole('button', { name: 'Submit job now' }).click()
const response = await submitted

View file

@ -5,13 +5,14 @@ const upstreamHost = 'qualification-ingress'
const upstreamPort = 80
const server = http.createServer((request, response) => {
const headers = upstreamHeaders(request.headers)
const upstream = http.request(
{
host: upstreamHost,
port: upstreamPort,
method: request.method,
path: request.url,
headers: { ...request.headers, host: upstreamHost },
headers,
},
(upstreamResponse) => {
response.writeHead(
@ -30,7 +31,7 @@ const server = http.createServer((request, response) => {
server.on('upgrade', (request, socket, head) => {
const upstream = net.connect(upstreamPort, upstreamHost, () => {
const headers = Object.entries(request.headers)
const headers = Object.entries(upstreamHeaders(request.headers))
.filter(([name]) => name.toLowerCase() !== 'host')
.map(([name, value]) => `${name}: ${String(value)}`)
.join('\r\n')
@ -41,6 +42,25 @@ server.on('upgrade', (request, socket, head) => {
socket.pipe(upstream).pipe(socket)
})
upstream.on('error', () => socket.destroy())
socket.on('error', () => upstream.destroy())
})
function upstreamHeaders(incoming) {
const headers = { ...incoming, host: upstreamHost }
const session = qualificationSession(headers.cookie)
if (typeof session === 'string' && /^[A-Za-z0-9_-]{43}$/.test(session))
headers.cookie = `__Host-remora-session=${session}`
else delete headers.cookie
return headers
}
function qualificationSession(cookieHeader) {
if (typeof cookieHeader !== 'string') return null
for (const cookie of cookieHeader.split(';')) {
const [name, value] = cookie.trim().split('=', 2)
if (name === 'remora-qualification-session') return value
}
return null
}
server.listen(4174, '127.0.0.1')