diff --git a/e2e/README.md b/e2e/README.md index 63c7f91..e946d2c 100644 --- a/e2e/README.md +++ b/e2e/README.md @@ -135,7 +135,7 @@ This creates an in-browser authenticator that: | Variable | Description | Default | |----------|-------------|---------| -| `BASE_URL` | Server URL | `http://localhost:4401` | +| `BASE_URL` | Server URL | `http://localhost:4404` | | `CI` | CI environment flag | - | | `CLEANUP_TEST_DB` | Remove test DB after run | `false` | diff --git a/e2e/playwright.config.js b/e2e/playwright.config.js index 11f5540..eab7aba 100644 --- a/e2e/playwright.config.js +++ b/e2e/playwright.config.js @@ -24,7 +24,7 @@ export default defineConfig({ use: { // Base URL for the Paskia server - baseURL: process.env.BASE_URL || 'http://localhost:4401', + baseURL: process.env.BASE_URL || 'http://localhost:4404', // Collect trace on failure for debugging trace: 'on-first-retry', diff --git a/e2e/tests/passkey.spec.ts b/e2e/tests/10-passkey.spec.ts similarity index 90% rename from e2e/tests/passkey.spec.ts rename to e2e/tests/10-passkey.spec.ts index b5d4927..ba1cb9b 100644 --- a/e2e/tests/passkey.spec.ts +++ b/e2e/tests/10-passkey.spec.ts @@ -10,6 +10,7 @@ import { getSessionCookieName, saveSessionToken, getSavedSessionToken, + saveDeviceTokens, } from './fixtures/passkey-helpers' import type { Page, BrowserContext } from '@playwright/test' @@ -44,7 +45,7 @@ async function setupSessionCookie(page: Page, sessionToken: string): Promise { - const baseUrl = process.env.BASE_URL || 'http://localhost:4401' + const baseUrl = process.env.BASE_URL || 'http://localhost:4404' test.describe.configure({ mode: 'serial' }) @@ -72,6 +73,10 @@ test.describe('Passkey Authentication E2E', () => { // Page should load - 401 errors are expected since user is not logged in await page.waitForTimeout(500) + // Take screenshot of the login view + await page.screenshot({ path: 'test-results/login-view.png' }) + console.log('✓ Screenshot saved: test-results/login-view.png') + // Just verify the page loaded without JS errors (network 401s are OK) console.log('✓ Auth page loaded successfully') }) @@ -109,6 +114,23 @@ test.describe('Passkey Authentication E2E', () => { console.log(`✓ Session token: ${sessionToken.substring(0, 4)}...`) }) + test('should create device tokens for other tests', async ({ page }) => { + test.skip(!sessionToken, 'Requires successful registration') + + // Create a batch of device tokens for API tests to use + // Each API test needs its own token to register a passkey in its virtual authenticator + const tokenCount = 15 // Enough for all API tests + const tokens: string[] = [] + + for (let i = 0; i < tokenCount; i++) { + const deviceLink = await createDeviceLink(page, baseUrl, sessionToken) + tokens.push(deviceLink.token) + } + + saveDeviceTokens(tokens) + console.log(`✓ Created ${tokens.length} device tokens for API tests`) + }) + test('should validate the session token', async ({ page }) => { // Skip if registration didn't run test.skip(!sessionToken, 'Requires successful registration') @@ -131,6 +153,22 @@ test.describe('Passkey Authentication E2E', () => { expect(userInfo.credentials).toBeDefined() expect(userInfo.credentials.length).toBeGreaterThanOrEqual(1) + // Navigate to profile and take screenshot + const cookieName = getSessionCookieName() + await page.context().addCookies([{ + name: cookieName, + value: sessionToken, + domain: 'localhost', + path: '/', + secure: true, + httpOnly: true, + sameSite: 'Strict' as const, + }]) + await page.goto('/auth/') + await page.waitForSelector('[data-view="profile"]', { timeout: 5000 }) + await page.screenshot({ path: 'test-results/profile-view.png' }) + console.log('✓ Screenshot saved: test-results/profile-view.png') + console.log(`✓ User info retrieved: ${userInfo.user.user_name}`) console.log(`✓ Credentials count: ${userInfo.credentials.length}`) }) @@ -190,7 +228,7 @@ test.describe('Passkey Authentication E2E', () => { }) test.describe('Session Management', () => { - const baseUrl = process.env.BASE_URL || 'http://localhost:4401' + const baseUrl = process.env.BASE_URL || 'http://localhost:4404' test('should reject invalid session token', async ({ page }) => { const cookieName = getSessionCookieName() @@ -217,7 +255,7 @@ test.describe('Session Management', () => { }) test.describe('Device Addition Dialog', () => { - const baseUrl = process.env.BASE_URL || 'http://localhost:4401' + const baseUrl = process.env.BASE_URL || 'http://localhost:4404' test.describe.configure({ mode: 'serial' }) @@ -269,12 +307,16 @@ test.describe('Device Addition Dialog', () => { const qrCanvas = dialog.locator('.qr-code') await expect(qrCanvas).toBeVisible() - // Verify the link is displayed - const linkText = dialog.locator('.qr-link p') - await expect(linkText).toBeVisible() - const linkContent = await linkText.textContent() - expect(linkContent).toContain('localhost/auth/') - console.log(`✓ Device link displayed: ${linkContent}`) + // Verify the link is displayed (text strips scheme, but href has it) + const linkElement = dialog.locator('a.qr-link') + await expect(linkElement).toBeVisible() + const linkText = await linkElement.textContent() + const linkHref = await linkElement.getAttribute('href') + // Text shows hostname without scheme + expect(linkText).toContain('localhost:4404/auth/') + // Href includes full URL with scheme + expect(linkHref).toContain('http://localhost:4404/auth/') + console.log(`✓ Device link displayed: ${linkText} (href: ${linkHref})`) // Verify expiration warning is shown await expect(dialog.locator('.reg-help')).toContainText('Expires') @@ -364,7 +406,7 @@ test.describe('Device Addition Dialog', () => { }) test.describe('ProfileView - Add New Passkey', () => { - const baseUrl = process.env.BASE_URL || 'http://localhost:4401' + const baseUrl = process.env.BASE_URL || 'http://localhost:4404' test('should show credentials list in profile', async ({ page }) => { const sessionToken = getSavedSessionToken() @@ -474,7 +516,7 @@ test.describe('ProfileView - Add New Passkey', () => { }) test.describe('ProfileView - Multi-Authenticator', () => { - const baseUrl = process.env.BASE_URL || 'http://localhost:4401' + const baseUrl = process.env.BASE_URL || 'http://localhost:4404' test('should add passkey from different authenticator', async ({ page }) => { const sessionToken = getSavedSessionToken() @@ -591,26 +633,3 @@ test.describe('ProfileView - Multi-Authenticator', () => { } }) }) - -test.describe('Logout', () => { - const baseUrl = process.env.BASE_URL || 'http://localhost:4401' - - test('should logout successfully', async ({ page }) => { - const sessionToken = getSavedSessionToken() - test.skip(!sessionToken, 'Requires saved session token') - - await logout(page, baseUrl, sessionToken!) - - // Session should no longer be valid - const cookieName = getSessionCookieName() - const response = await page.request.post(`${baseUrl}/auth/api/validate`, { - headers: { - 'Cookie': `${cookieName}=${sessionToken}`, - }, - failOnStatusCode: false, - }) - - expect(response.status()).toBe(401) - console.log(`✓ Logout successful, session invalidated`) - }) -}) diff --git a/e2e/tests/20-api-auth.spec.ts b/e2e/tests/20-api-auth.spec.ts new file mode 100644 index 0000000..af79770 --- /dev/null +++ b/e2e/tests/20-api-auth.spec.ts @@ -0,0 +1,606 @@ +import { test, expect, createVirtualAuthenticator } from './fixtures/virtual-authenticator' +import { + getSessionCookieName, + getSavedSessionToken, + saveSessionToken, + registerPasskey, + authenticatePasskey, + popDeviceToken, + getDeviceTokenCount, + logout, +} from './fixtures/passkey-helpers' +import type { Page, Frame } from '@playwright/test' + +/** + * E2E tests for API mode authentication flows. + * + * These tests simulate the flow used by SPAs when making API calls: + * 1. API call returns 401/403 with auth.iframe URL + * 2. App shows auth iframe overlay + * 3. User authenticates in iframe + * 4. Iframe posts 'auth-success' message to parent + * 5. App retries original API call + * + * Note: These tests depend on 10-passkey.spec.ts running first to create device tokens. + * Each test that needs authentication uses popDeviceToken() to get a fresh token + * and registers its own credential in its virtual authenticator. + */ + +const baseUrl = process.env.BASE_URL || 'http://localhost:4404' + +/** + * Helper to set up session cookie for a page. + */ +async function setupSessionCookie(page: Page, sessionToken: string): Promise { + const cookieName = getSessionCookieName() + await page.context().addCookies([{ + name: cookieName, + value: sessionToken, + domain: 'localhost', + path: '/', + secure: true, + httpOnly: true, + sameSite: 'Strict' as const, + }]) +} + +/** + * Helper to clear session cookie. + */ +async function clearSessionCookie(page: Page): Promise { + const cookieName = getSessionCookieName() + await page.context().clearCookies({ name: cookieName }) +} + +/** + * Set up the test page using the examples page directly. + * The examples page already has iframe handling - we just add a Promise wrapper. + */ +async function setupTestHarness(page: Page): Promise { + // Navigate to the examples page which already has the auth iframe handling + await page.goto(`${baseUrl}/auth/examples/`) +} + +/** + * Make an API call through the examples page, returning a Promise. + * Wraps the page's apiCall and listens for auth-success/auth-back messages. + * Returns { status, data } on success, or throws on cancellation. + * + * Note: If auth is not needed (request succeeds without 401/403), this will + * resolve after a timeout with the direct fetch result. + */ +async function makeApiCall(page: Page, url: string, method = 'GET'): Promise<{ status: number; data?: any }> { + return page.evaluate(({ url, method }) => { + return new Promise((resolve, reject) => { + let resolved = false; + + // Listen for auth messages + const handler = (event: MessageEvent) => { + const { type } = event.data || {}; + if (type === 'auth-success') { + if (resolved) return; + resolved = true; + window.removeEventListener('message', handler); + // Wait a tick for the page's handler to retry, then make our own call + setTimeout(async () => { + try { + const response = await fetch(url, { method, credentials: 'include' }); + if (response.status === 204) { + resolve({ status: 204 }); + } else if (response.ok) { + const data = await response.json(); + resolve({ status: response.status, data }); + } else { + resolve({ status: response.status }); + } + } catch (e) { + resolve({ status: 0 }); + } + }, 200); + } else if (type === 'auth-back') { + if (resolved) return; + resolved = true; + window.removeEventListener('message', handler); + reject(new Error('cancelled')); + } + }; + window.addEventListener('message', handler); + + // Also make a direct fetch to handle the case where no auth is needed + // (the page's apiCall won't send any message if the request succeeds) + setTimeout(async () => { + if (resolved) return; + try { + const response = await fetch(url, { method, credentials: 'include' }); + // Only resolve if this is a success or non-auth error + if (response.status !== 401 && response.status !== 403) { + if (resolved) return; + resolved = true; + window.removeEventListener('message', handler); + if (response.status === 204) { + resolve({ status: 204 }); + } else if (response.ok) { + const data = await response.json(); + resolve({ status: response.status, data }); + } else { + resolve({ status: response.status }); + } + } + // If 401/403, the auth iframe will appear and we wait for the message + } catch (e) { + // Network error - let the message handler deal with it + } + }, 100); + + // Call the page's existing apiCall function + // It will show the iframe on 401/403 + (window as any).apiCall(url, method); + }); + }, { url, method }); +} + +/** + * Wait for auth iframe to appear and return a reference to it. + */ +async function waitForAuthIframe(page: Page, timeout = 5000): Promise { + await page.waitForSelector('#auth-iframe', { timeout }) + const iframe = page.frameLocator('#auth-iframe') + // Wait for iframe content to load + await iframe.locator('.view-root').waitFor({ timeout }) + return page.frame({ url: /\/auth\/restricted\// })! +} + +/** + * Wait for auth iframe to disappear. + */ +async function waitForAuthIframeHidden(page: Page, timeout = 5000): Promise { + await page.waitForSelector('#auth-iframe', { state: 'detached', timeout }) +} + +/** + * Click Back button in auth iframe. + */ +async function clickBackInIframe(page: Page): Promise { + const iframe = page.frameLocator('#auth-iframe') + await iframe.getByRole('button', { name: 'Back' }).click() +} + +/** + * Click Login button in auth iframe. + */ +async function clickLoginInIframe(page: Page): Promise { + const iframe = page.frameLocator('#auth-iframe') + await iframe.getByRole('button', { name: 'Login' }).click() +} + +/** + * Click Verify button in auth iframe (for reauth mode). + */ +async function clickVerifyInIframe(page: Page): Promise { + const iframe = page.frameLocator('#auth-iframe') + await iframe.getByRole('button', { name: 'Verify' }).click() +} + +/** + * Click Logout button in auth iframe (for forbidden mode). + */ +async function clickLogoutInIframe(page: Page): Promise { + const iframe = page.frameLocator('#auth-iframe') + await iframe.getByRole('button', { name: 'Logout' }).click() +} + +test.describe('API Mode - 401 Login Flow', () => { + test.describe.configure({ mode: 'serial' }) + + test('should show auth iframe on 401 and allow cancellation (Back)', async ({ page }) => { + // Set up test harness (injects our API flow handler) + await setupTestHarness(page) + + // Clear any existing session cookie + await clearSessionCookie(page) + + // Make API call that triggers 401 (don't await - it blocks until iframe resolves) + const apiCallPromise = makeApiCall(page, '/auth/api/user-info', 'POST').catch(e => e) + console.log('✓ Auth iframe appeared on 401') + + // Verify it's in login mode (not reauth) + const iframe = page.frameLocator('#auth-iframe') + await expect(iframe.locator('h1')).toContainText('🔐') + await expect(iframe.getByRole('button', { name: 'Login' })).toBeVisible() + + // Take screenshot of the login iframe + await page.screenshot({ path: 'test-results/api-401-login-iframe.png' }) + console.log('✓ Screenshot saved: test-results/api-401-login-iframe.png') + + // Click Back to cancel authentication + await clickBackInIframe(page) + + // Iframe should close + await waitForAuthIframeHidden(page) + console.log('✓ Auth iframe closed on Back button') + + // Wait for the API call promise to reject + const result = await apiCallPromise + expect(result).toBeInstanceOf(Error) + expect(result.message).toContain('cancelled') + + // Output should show cancellation + const output = page.locator('#output') + await expect(output).toContainText('cancelled') + console.log('✓ API call was cancelled') + }) + + test('should show auth iframe on 401 and complete login', async ({ page, virtualAuthenticator }) => { + // Get a device token from the pool (created by 10-passkey.spec.ts) + const deviceToken = popDeviceToken() + test.skip(!deviceToken, 'Requires device token from passkey tests') + console.log(`✓ Got device token: ${deviceToken} (${getDeviceTokenCount()} remaining)`) + + // Navigate and register credential using device token + await page.goto(`${baseUrl}/auth/`) + const regResult = await registerPasskey(page, baseUrl, { + resetToken: deviceToken, + displayName: 'API Test Device', + }) + console.log(`✓ Registered credential: ${regResult.credential_uuid}`) + + // Logout to clear session (but keep the passkey in virtual authenticator) + await logout(page, baseUrl, regResult.session_token) + console.log('✓ Logged out') + + // Set up test harness + await setupTestHarness(page) + + // Make API call that triggers 401 + const apiCallPromise = makeApiCall(page, '/auth/api/user-info', 'POST') + + // Wait for auth iframe to appear + await waitForAuthIframe(page) + console.log('✓ Auth iframe appeared on 401') + + // Click Login button - virtual authenticator will handle the passkey + await clickLoginInIframe(page) + + // Wait for authentication to complete - iframe should close + await waitForAuthIframeHidden(page, 10000) + console.log('✓ Authentication completed, iframe closed') + + // Wait for API call to complete and verify result + const result = await apiCallPromise + expect(result.status).toBe(200) + expect(result.data.user).toBeDefined() + console.log('✓ API call succeeded after authentication') + + // Save the session for other tests + const cookies = await page.context().cookies() + const sessionCookie = cookies.find(c => c.name === getSessionCookieName()) + if (sessionCookie) { + saveSessionToken(sessionCookie.value) + console.log(`✓ Saved session token for other tests`) + } + }) +}) + +test.describe('API Mode - 401 Reauth Flow', () => { + test.describe.configure({ mode: 'serial' }) + + test('should show reauth iframe on max_age violation and allow cancellation', async ({ page, virtualAuthenticator }) => { + // Get a device token from the pool (created by 10-passkey.spec.ts) + const deviceToken = popDeviceToken() + test.skip(!deviceToken, 'Requires device token from passkey tests') + console.log(`✓ Got device token: ${deviceToken} (${getDeviceTokenCount()} remaining)`) + + // Navigate and register a credential + await page.goto(`${baseUrl}/auth/`) + const regResult = await registerPasskey(page, baseUrl, { + resetToken: deviceToken, + displayName: 'Reauth Cancel Test Device', + }) + saveSessionToken(regResult.session_token) + + // Wait for session to age past max_age threshold + console.log('Waiting 3s for session to age...') + await page.waitForTimeout(3000) + + // Set up test harness with the session + await setupSessionCookie(page, regResult.session_token) + await setupTestHarness(page) + + // Make API call with max_age=1s (session is now > 1s old) + const apiCallPromise = makeApiCall(page, '/auth/api/forward?max_age=1s', 'GET').catch(e => e) + + // Wait for auth iframe to appear + await waitForAuthIframe(page) + console.log('✓ Reauth iframe appeared (session older than max_age)') + + // Verify it's in reauth mode + const iframe = page.frameLocator('#auth-iframe') + await expect(iframe.locator('h1')).toContainText('Additional Authentication') + await expect(iframe.getByRole('button', { name: 'Verify' })).toBeVisible() + + // Take screenshot of reauth iframe + await page.screenshot({ path: 'test-results/api-401-reauth-iframe.png' }) + console.log('✓ Screenshot saved: test-results/api-401-reauth-iframe.png') + + // Click Back to cancel + await clickBackInIframe(page) + await waitForAuthIframeHidden(page) + console.log('✓ Reauth cancelled via Back button') + + const result = await apiCallPromise + expect(result).toBeInstanceOf(Error) + }) + + test('should complete reauth flow with passkey', async ({ page, virtualAuthenticator }) => { + // Get a device token from the pool (created by 10-passkey.spec.ts) + const deviceToken = popDeviceToken() + test.skip(!deviceToken, 'Requires device token from passkey tests') + console.log(`✓ Got device token: ${deviceToken} (${getDeviceTokenCount()} remaining)`) + + // Navigate and register a credential + await page.goto(`${baseUrl}/auth/`) + const regResult = await registerPasskey(page, baseUrl, { + resetToken: deviceToken, + displayName: 'Reauth Test Device', + }) + + // Save the new session + saveSessionToken(regResult.session_token) + + // Wait for the session to be "old" (>2s for max_age=2s test) + console.log('Waiting 3s for session to age...') + await page.waitForTimeout(3000) + + // Set up test harness with the session + await setupSessionCookie(page, regResult.session_token) + await setupTestHarness(page) + + // Make API call with max_age=2s + const apiCallPromise = makeApiCall(page, '/auth/api/forward?max_age=2s', 'GET') + + // Auth iframe should appear in reauth mode + await waitForAuthIframe(page) + console.log('✓ Reauth iframe appeared') + + const iframe = page.frameLocator('#auth-iframe') + await expect(iframe.locator('h1')).toContainText('Additional Authentication') + + // Click Verify - virtual authenticator handles passkey + await clickVerifyInIframe(page) + + // Wait for completion + await waitForAuthIframeHidden(page, 10000) + console.log('✓ Reauth completed') + + // Wait for API call result + const result = await apiCallPromise + expect(result.status).toBe(204) + console.log('✓ Forward endpoint returned 204 after reauth') + }) +}) + +test.describe('API Mode - 403 Forbidden Flow', () => { + test.describe.configure({ mode: 'serial' }) + + test('should show forbidden view and allow going back', async ({ page }) => { + const sessionToken = getSavedSessionToken() + test.skip(!sessionToken, 'Requires saved session token') + + // Set up test harness with valid session + await setupSessionCookie(page, sessionToken!) + await setupTestHarness(page) + + // Make API call requiring admin permission + const apiCallPromise = makeApiCall(page, '/auth/api/forward?perm=auth:admin', 'GET').catch(e => e) + + // Check if auth iframe appeared + const iframeAppeared = await page.waitForSelector('#auth-iframe', { timeout: 3000 }).then(() => true).catch(() => false) + + if (!iframeAppeared) { + // User might already have admin permission + const result = await apiCallPromise + if (result.status === 204) { + console.log('✓ User has admin permission, got 204 (skipping forbidden test)') + return + } + } + + await waitForAuthIframe(page) + console.log('✓ Auth iframe appeared on permission check') + + // Wait for view to stabilize and check mode + await page.waitForTimeout(500) + const iframe = page.frameLocator('#auth-iframe') + const headingText = await iframe.locator('h1').textContent() + console.log(` Heading: ${headingText}`) + + if (headingText?.includes('Forbidden')) { + console.log('✓ Forbidden view displayed (user lacks admin permission)') + + // Should show Logout button in forbidden mode + await expect(iframe.getByRole('button', { name: 'Logout' })).toBeVisible() + + // Take screenshot of forbidden view + await page.screenshot({ path: 'test-results/api-403-forbidden-iframe.png' }) + console.log('✓ Screenshot saved: test-results/api-403-forbidden-iframe.png') + + // Click Back to close + await clickBackInIframe(page) + await waitForAuthIframeHidden(page) + console.log('✓ Forbidden dialog closed via Back') + + const result = await apiCallPromise + expect(result).toBeInstanceOf(Error) + } else { + // User has admin permission, so they got through + console.log('✓ User has admin permission, no forbidden view') + } + }) + + test('should allow logout from forbidden view and then login', async ({ page, virtualAuthenticator }) => { + // Get a device token from the pool (created by 10-passkey.spec.ts) + const deviceToken = popDeviceToken() + test.skip(!deviceToken, 'Requires device token from passkey tests') + console.log(`✓ Got device token: ${deviceToken} (${getDeviceTokenCount()} remaining)`) + + // Navigate and register credential for later login + await page.goto(`${baseUrl}/auth/`) + const regResult = await registerPasskey(page, baseUrl, { + resetToken: deviceToken, + displayName: 'Forbidden Test Device', + }) + saveSessionToken(regResult.session_token) + + // Set up test harness with the session + await setupSessionCookie(page, regResult.session_token) + await setupTestHarness(page) + + // Make API call requiring admin permission + const apiCallPromise = makeApiCall(page, '/auth/api/forward?perm=auth:admin', 'GET').catch(e => e) + + // Check if auth iframe appeared + const iframeAppeared = await page.waitForSelector('#auth-iframe', { timeout: 3000 }).then(() => true).catch(() => false) + + if (!iframeAppeared) { + const result = await apiCallPromise + if (result.status === 204) { + console.log('✓ User has admin permission, skipping forbidden->login test') + return + } + } + + await waitForAuthIframe(page) + const iframe = page.frameLocator('#auth-iframe') + await page.waitForTimeout(500) + + const headingText = await iframe.locator('h1').textContent() + + if (headingText?.includes('Forbidden')) { + console.log('✓ Forbidden view displayed') + + // Take screenshot of forbidden view before logout + await page.screenshot({ path: 'test-results/api-403-forbidden-before-logout.png' }) + console.log('✓ Screenshot saved: test-results/api-403-forbidden-before-logout.png') + + // Click Logout in the iframe + await clickLogoutInIframe(page) + + // After logout, the view should switch to login mode and show a toast + await page.waitForTimeout(1000) + await expect(iframe.getByRole('button', { name: 'Login' })).toBeVisible({ timeout: 5000 }) + console.log('✓ Switched to login view after logout') + + // Verify status message appears indicating user can login with another account + const statusMessage = iframe.locator('.global-status .status') + await expect(statusMessage).toBeVisible({ timeout: 3000 }) + const statusText = await statusMessage.textContent() + expect(statusText).toContain('sign in with a different account') + console.log(`✓ Status message: ${statusText}`) + + // Take screenshot showing login view with status message (after forbidden logout) + await page.screenshot({ path: 'test-results/api-403-after-logout-login.png' }) + console.log('✓ Screenshot saved: test-results/api-403-after-logout-login.png') + + // Now login with the passkey + await clickLoginInIframe(page) + + // Wait for auth to complete + await waitForAuthIframeHidden(page, 10000) + console.log('✓ Logged in successfully') + + // The API call should have completed (but may still fail with 403 since same user) + const result = await apiCallPromise + console.log(` Final result status: ${result.status || 'error'}`) + } else { + console.log('✓ Not in forbidden mode, closing dialog') + await clickBackInIframe(page) + await waitForAuthIframeHidden(page) + } + }) +}) + +test.describe('API Mode - Direct API Response Format', () => { + test('should return JSON with auth.iframe on 401 (unauthenticated)', async ({ page }) => { + // Make direct API call without session + const response = await page.request.get(`${baseUrl}/auth/api/forward`, { + headers: { + 'Accept': 'application/json', + }, + }) + + expect(response.status()).toBe(401) + + const data = await response.json() + expect(data.auth).toBeDefined() + expect(data.auth.iframe).toBeDefined() + expect(data.auth.mode).toBe('login') + expect(data.auth.iframe).toContain('/auth/restricted/') + + console.log(`✓ 401 response includes auth.iframe: ${data.auth.iframe}`) + }) + + test('should return JSON with auth.mode=forbidden on 403', async ({ page }) => { + const sessionToken = getSavedSessionToken() + test.skip(!sessionToken, 'Requires saved session token') + + const cookieName = getSessionCookieName() + + // Make API call with session but requesting admin permission + const response = await page.request.get(`${baseUrl}/auth/api/forward?perm=auth:admin`, { + headers: { + 'Accept': 'application/json', + 'Cookie': `${cookieName}=${sessionToken}`, + }, + }) + + // Could be 403 (forbidden) or 204 (user is admin) + if (response.status() === 403) { + const data = await response.json() + expect(data.auth).toBeDefined() + expect(data.auth.mode).toBe('forbidden') + console.log(`✓ 403 response auth.mode: ${data.auth.mode}`) + } else if (response.status() === 204) { + console.log('✓ User has admin permission, got 204') + } else { + console.log(` Unexpected status: ${response.status()}`) + } + }) + + test('should return JSON with auth.mode=reauth on max_age violation', async ({ page, virtualAuthenticator }) => { + // Get a device token from the pool (created by 10-passkey.spec.ts) + const deviceToken = popDeviceToken() + test.skip(!deviceToken, 'Requires device token from passkey tests') + console.log(`✓ Got device token: ${deviceToken} (${getDeviceTokenCount()} remaining)`) + + // Navigate and create fresh session + await page.goto(`${baseUrl}/auth/`) + const regResult = await registerPasskey(page, baseUrl, { + resetToken: deviceToken, + displayName: 'Max Age Test Device', + }) + + // Wait for session to be older than 1s + await page.waitForTimeout(2000) + + const cookieName = getSessionCookieName() + + // Make API call with max_age=1s (session is now > 1s old) + const response = await page.request.get(`${baseUrl}/auth/api/forward?max_age=1s`, { + headers: { + 'Accept': 'application/json', + 'Cookie': `${cookieName}=${regResult.session_token}`, + }, + }) + + expect(response.status()).toBe(401) + + const data = await response.json() + expect(data.auth).toBeDefined() + expect(data.auth.mode).toBe('reauth') + + console.log(`✓ 401 response auth.mode: ${data.auth.mode}`) + + // Save session for cleanup + saveSessionToken(regResult.session_token) + }) +}) diff --git a/e2e/tests/99-logout.spec.ts b/e2e/tests/99-logout.spec.ts new file mode 100644 index 0000000..12db40b --- /dev/null +++ b/e2e/tests/99-logout.spec.ts @@ -0,0 +1,33 @@ +import { test, expect } from './fixtures/virtual-authenticator' +import { + logout, + getSessionCookieName, + getSavedSessionToken, +} from './fixtures/passkey-helpers' + +/** + * Logout test - runs last to clean up the session. + * The "99-" prefix ensures this runs after all other tests. + */ +test.describe('Logout', () => { + const baseUrl = process.env.BASE_URL || 'http://localhost:4404' + + test('should logout successfully', async ({ page }) => { + const sessionToken = getSavedSessionToken() + test.skip(!sessionToken, 'Requires saved session token') + + await logout(page, baseUrl, sessionToken!) + + // Session should no longer be valid + const cookieName = getSessionCookieName() + const response = await page.request.post(`${baseUrl}/auth/api/validate`, { + headers: { + 'Cookie': `${cookieName}=${sessionToken}`, + }, + failOnStatusCode: false, + }) + + expect(response.status()).toBe(401) + console.log(`✓ Logout successful, session invalidated`) + }) +}) diff --git a/e2e/tests/fixtures/passkey-helpers.ts b/e2e/tests/fixtures/passkey-helpers.ts index 07981de..b2e7d6b 100644 --- a/e2e/tests/fixtures/passkey-helpers.ts +++ b/e2e/tests/fixtures/passkey-helpers.ts @@ -68,6 +68,22 @@ export function saveSessionToken(sessionToken: string): void { } } +/** + * Clear the saved session token from the test state file. + * Call this after logout to prevent accidental reuse of invalidated sessions. + */ +export function clearSavedSessionToken(): void { + if (existsSync(stateFile)) { + try { + const state = JSON.parse(readFileSync(stateFile, 'utf-8')) + delete state.savedSessionToken + writeFileSync(stateFile, JSON.stringify(state, null, 2)) + } catch { + // Ignore errors + } + } +} + /** * Get a saved session token from the test state file. */ @@ -83,6 +99,57 @@ export function getSavedSessionToken(): string | undefined { return undefined } +/** + * Save device tokens to the test state file for use by other tests. + * These tokens allow tests to register their own passkeys. + */ +export function saveDeviceTokens(tokens: string[]): void { + if (existsSync(stateFile)) { + try { + const state = JSON.parse(readFileSync(stateFile, 'utf-8')) + state.deviceTokens = tokens + writeFileSync(stateFile, JSON.stringify(state, null, 2)) + } catch { + // Ignore errors + } + } +} + +/** + * Get and consume a device token from the pool. + * Returns undefined if no tokens are available. + */ +export function popDeviceToken(): string | undefined { + if (existsSync(stateFile)) { + try { + const state = JSON.parse(readFileSync(stateFile, 'utf-8')) + if (state.deviceTokens && state.deviceTokens.length > 0) { + const token = state.deviceTokens.pop() + writeFileSync(stateFile, JSON.stringify(state, null, 2)) + return token + } + } catch { + return undefined + } + } + return undefined +} + +/** + * Get the count of remaining device tokens. + */ +export function getDeviceTokenCount(): number { + if (existsSync(stateFile)) { + try { + const state = JSON.parse(readFileSync(stateFile, 'utf-8')) + return state.deviceTokens?.length || 0 + } catch { + return 0 + } + } + return 0 +} + /** * Perform passkey registration via WebSocket. * This runs in the browser context using the virtual authenticator. @@ -338,6 +405,7 @@ export async function getUserInfo( /** * Logout via the API. + * If the session being logged out matches the saved session token, clears it. */ export async function logout( page: Page, @@ -350,6 +418,11 @@ export async function logout( 'Cookie': `${cookieName}=${sessionToken}`, }, }) + // Clear saved session token if it matches the one being logged out + const savedToken = getSavedSessionToken() + if (savedToken === sessionToken) { + clearSavedSessionToken() + } } /** @@ -366,7 +439,13 @@ export async function createDeviceLink( 'Cookie': `${cookieName}=${sessionToken}`, }, }) + if (!response.ok()) { + throw new Error(`Failed to create device link: ${response.status()} - ${await response.text()}`) + } const data = await response.json() + if (!data.url) { + throw new Error(`No URL in response: ${JSON.stringify(data)}`) + } // Extract token from URL (last path segment) const url = new URL(data.url) const token = url.pathname.split('/').pop() || '' diff --git a/e2e/tests/global-setup.ts b/e2e/tests/global-setup.ts index f9ddf33..128a12e 100644 --- a/e2e/tests/global-setup.ts +++ b/e2e/tests/global-setup.ts @@ -42,14 +42,12 @@ export default async function globalSetup() { const serverArgs = COLLECT_COVERAGE ? [ 'run', 'coverage', 'run', '--parallel-mode', - '-m', 'paskia.fastapi', 'serve', ':4401', - '--rp-id', 'localhost', - '--origin', 'http://localhost:4401' + '-m', 'paskia.fastapi', 'serve', 'localhost:4404', + '--rp-id', 'localhost' ] : [ - 'run', 'paskia', 'serve', ':4401', - '--rp-id', 'localhost', - '--origin', 'http://localhost:4401' + 'run', 'paskia', 'serve', 'localhost:4404', + '--rp-id', 'localhost' ] // Start the server using Node's spawn @@ -80,7 +78,7 @@ export default async function globalSetup() { process.stdout.write(text) // Echo to console // Look for the reset token URL in the output - // Format: https://localhost/auth/{token} or http://localhost:4401/auth/{token} + // Format: https://localhost/auth/{token} or http://localhost:4404/auth/{token} // where token is word.word.word.word.word (dot separated) const match = output.match(/https?:\/\/localhost(?::\d+)?\/auth\/([a-z]+(?:\.[a-z]+)+)/) if (match) { @@ -117,7 +115,7 @@ export default async function globalSetup() { // Fetch session cookie name from server settings try { - const response = await fetch('http://localhost:4401/auth/api/settings') + const response = await fetch('http://localhost:4404/auth/api/settings') const settings = await response.json() state.sessionCookie = settings.session_cookie console.log(` ✅ Session cookie name: ${state.sessionCookie}\n`)