Move browser authentication behind a generated-contract BFF
This commit is contained in:
parent
6f0f20f5cc
commit
fa7220ee33
4 changed files with 7259 additions and 2825 deletions
61
web/tests/e2e/accessibility.spec.ts
Normal file
61
web/tests/e2e/accessibility.spec.ts
Normal 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()
|
||||
})
|
||||
|
|
@ -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' &&
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
|
|
|
|||
Loading…
Reference in a new issue