import assert from 'node:assert/strict'; import { readFile, writeFile } from 'node:fs/promises'; import { createHash, randomBytes } from 'node:crypto'; import { chromium } from './node_modules/playwright/index.mjs'; const input = JSON.parse(await readFile('/run/fixture/app-browser-input.json', 'utf8')); const auth = 'https://auth.recovery.test:9091'; const issuer = 'http://127.0.0.1:8080'; const app = 'http://127.0.0.1:8000'; const portal = 'http://127.0.0.1:8089'; const fixture = 'http://127.0.0.1:8088'; const allowed = new Set([auth, issuer, app, portal, fixture]); let browser; let stage = 'native_app_browser_startup'; let outside = 0; try { browser = await chromium.launch({ executablePath: '/run/fixture/browser/chrome-headless-shell-linux64/chrome-headless-shell', headless: true, args: ['--host-resolver-rules=MAP auth.recovery.test 127.0.0.1', '--no-proxy-server', '--disable-background-networking'], }); const context = await browser.newContext({ignoreHTTPSErrors: true, serviceWorkers: 'block'}); context.on('request', request => { const u = new URL(request.url()); if (['http:', 'https:'].includes(u.protocol) && !allowed.has(u.origin)) outside += 1; }); await context.route('**/*', route => { const u = new URL(route.request().url()); if (!allowed.has(u.origin)) { return route.abort(); } return route.continue(); }); const page = await context.newPage(); page.setDefaultTimeout(20000); let callback; page.on('request', request => { const u = new URL(request.url()); if (u.origin === app && u.pathname === '/auth/callback/' && u.searchParams.has('code')) { callback = u.href; // private, never emitted } }); stage = 'native_app_login_redirect'; await page.goto(app + '/auth/login/'); await page.locator('#username-textfield').fill('alice'); stage = 'native_old_password_refusal'; await page.locator('#password-textfield').fill(input.old_password); const rejected = page.waitForResponse(r => r.url() === auth + '/api/firstfactor'); await page.locator('#sign-in-button').click(); const denied = await rejected; assert(denied.status() >= 400 || (await denied.json()).status !== 'OK'); await page.getByText('Incorrect username or password', {exact: false}).first().waitFor(); stage = 'native_replacement_password_login'; await page.locator('#password-textfield').fill(input.new_password); await page.locator('#sign-in-button').click(); await page.waitForURL(app + '/app/'); assert(callback); await page.goto(app + '/account/session/'); await page.getByRole('heading', {name: 'Session details', exact: true}).waitFor(); const value = label => page.locator('dt').filter({hasText: label}).locator('xpath=following-sibling::dd[1]'); assert.equal((await value(/^Issuer$/).innerText()).trim(), issuer); assert.equal((await value(/^Subject$/).innerText()).trim(), 'uid=alice,ou=people,dc=example,dc=test'); assert.equal((await value(/^Tenant$/).innerText()).trim(), 'tenant:coulomb'); assert.equal((await value(/^user-engine source$/).innerText()).trim(), 'http'); stage = 'native_app_callback_replay_refusal'; const replay = await context.request.get(callback, {maxRedirects: 0}); assert.equal(replay.status(), 400); stage = 'native_unknown_client_refusal'; const deniedQuery = {redirect_uri: app + '/auth/callback/', response_type: 'code', scope: 'openid profile email groups', state: randomBytes(32).toString('base64url'), code_challenge_method: 'S256', code_challenge: randomBytes(32).toString('base64url')}; const invalid = await context.request.get(issuer + '/authorize?' + new URLSearchParams({...deniedQuery, client_id: 'not-registered'}), {maxRedirects: 0}); assert.equal(invalid.status(), 400); assert((await invalid.text()).includes('unknown client_id')); const wrongRedirect = await context.request.get(issuer + '/authorize?' + new URLSearchParams({...deniedQuery, client_id: 'coulomb-recovery-test', redirect_uri: app + '/wrong'}), {maxRedirects: 0}); assert.equal(wrongRedirect.status(), 400); assert((await wrongRedirect.text()).includes('redirect_uri does not match')); stage = 'native_foreign_tenant_client_refusal'; const verifier = randomBytes(32).toString('base64url'); const foreignState = randomBytes(32).toString('base64url'); const query = new URLSearchParams({client_id: 'foreign-recovery-test', redirect_uri: fixture + '/client-callback', response_type: 'code', scope: 'openid profile email groups', state: foreignState, code_challenge_method: 'S256', code_challenge: createHash('sha256').update(verifier).digest('base64url')}); await page.goto(issuer + '/authorize?' + query); await page.waitForURL(u => u.origin === fixture && u.pathname === '/client-callback'); const foreignCapture = new URL(page.url()); assert.equal(foreignCapture.searchParams.get('state'), foreignState); if (!foreignCapture?.searchParams.get('code')) { throw new Error('Foreign callback metadata: ' + JSON.stringify({captured: !!foreignCapture, parameters: foreignCapture ? [...foreignCapture.searchParams.keys()] : [], error: foreignCapture?.searchParams.get('error'), tenant_error: /tenant/i.test(foreignCapture?.searchParams.get('error_description') || '')})); } const foreign = await context.request.post(issuer + '/token', {maxRedirects: 0, form: {grant_type: 'authorization_code', client_id: 'foreign-recovery-test', redirect_uri: fixture + '/client-callback', code: foreignCapture.searchParams.get('code'), code_verifier: verifier}}); assert.equal(foreign.status(), 403); assert((await foreign.text()).includes('tenant binding conflict')); const state = await context.request.get(portal + '/fixture/state', {maxRedirects: 0}); assert.equal((await state.json()).users, 1); assert.equal(outside, 0); console.log(JSON.stringify({status: 'native_app_journey_passed', old_password_login_refused: true, replacement_password_login_completed: true, native_keycape_code_and_app_session: true, real_owner_http_identity_link: true, app_callback_replay_refused: true, native_unknown_client_refused: true, native_wrong_redirect_refused: true, native_foreign_tenant_token_refused: true, negative_client_callback_is_fixture: true, issuer_subject_tenant_provenance_verified: true, owner_users: 1, requests_outside_fixture_origins: outside})); await context.close(); } catch (error) { await writeFile('/run/fixture/browser-diagnostic.txt', String(error.message), {mode: 0o600}); console.log(JSON.stringify({status: 'native_app_journey_failed', stage, failure_type: error.name})); process.exitCode = 1; } finally { if (browser) await browser.close(); }