Compare commits

...
11 Commits
Author SHA1 Message Date
LeoVasanko 1bed2c39d8 Implement code word based remote authentication (#1)
Add comprehensive remote authentication system allowing users to log in from one device by authenticating from another trusted device. Features include:

- Proof of Work (PoW) protection using PBKDF2-SHA512 to prevent abuse
- Simple pairing codes (3 words) protected by dynamic PoW difficulty
- Autocomplete pairing code input with error checking
- Real-time WebSocket communication between devices

Unlike device addition links and reset links with QR codes that only allow adding an authentication method, and that work offline over the duration of several days, this mechanism is strictly online, with 5 minute time limit.
2025-12-08 23:56:48 +00:00
LeoVasanko 83419d1845 API tests added with near-complete coverage over user and admin APIs. 60% overall backend. (not including E2E test in coverage) 2025-12-06 04:45:26 +00:00
LeoVasanko a2fe0b6f1a Added E2E restricted API flow tests and fixed earlier failing tests. All passing. Coverage 51% backend, 74% frontend. 2025-12-06 03:43:28 +00:00
LeoVasanko a1b73711e6 Cleanup of origins handling. Added site_url and site_path such that these can be determined reliably, and we print it in the startbox. 2025-12-06 03:39:05 +00:00
LeoVasanko df5c176bcd Fixed and updated E2E test suite. Added user credential registration tests. Coverage for backend and frontend. 2025-12-06 00:52:35 +00:00
LeoVasanko 8937905c9c Changed origin config to take multiple origins and if any are configured, restrict access to these. Removed bootstrap name options of created org and user (both can be easily renamed from web ui). Cleanup. 2025-12-06 00:51:18 +00:00
LeoVasanko 127e06179b More robust server startup, startup logo and info screen, renewed devmode script. 2025-12-05 19:06:42 +00:00
LeoVasanko c1204ca020 Updated documentation. 2025-12-05 16:15:50 +00:00
LeoVasanko 208115ebc3 Project renamed to Paskia. 2025-12-05 13:17:52 +00:00
LeoVasanko 8609f2fe69 Refactor dev mode into a source repo script (remove dev subcommand from package). 2025-12-05 18:36:13 +00:00
LeoVasanko 0355c55fc0 Updated E2E tests. 2025-12-04 04:44:58 +00:00
87 changed files with 9923 additions and 1173 deletions
+6 -4
View File
@@ -3,7 +3,9 @@ dist/
.* .*
!.gitignore !.gitignore
*.lock *.lock
passkey-auth.sqlite package-lock.json
/passkey/frontend-build paskia.sqlite
/test_*.py /paskia/frontend-build
passkey/_version.py /paskia/_version.py
coverage-html/
e2e/coverage-frontend/
+2 -2
View File
@@ -1,6 +1,6 @@
# PassKey Auth API Documentation # Paskia API Documentation
This document lists the HTTP and WebSocket endpoints exposed by the PassKey Auth This document lists the HTTP and WebSocket endpoints exposed by the Paskia
service and how they behave depending on whether a dedicated authentication host service and how they behave depending on whether a dedicated authentication host
(`--auth-host` / environment `PASSKEY_AUTH_HOST`) is configured. (`--auth-host` / environment `PASSKEY_AUTH_HOST`) is configured.
+51 -108
View File
@@ -1,129 +1,72 @@
# PasskeyAuth # Paskia
A minimal FastAPI WebAuthn server with WebSocket support for passkey registration. This project demonstrates WebAuthn registration flow with Resident Keys (discoverable credentials) using modern Python tooling. An easy to install passkey-based authentication service that protects any web application with strong passwordless login.
## Features ## What is Paskia?
- 🔐 WebAuthn registration with Resident Keys support - Easy to use fully featured auth&auth system (login and permissions)
- 🔌 WebSocket-based communication for real-time interaction - Organization and role-based access control (optional)
- 🚀 Modern Python packaging with `pyproject.toml` * Org admins control their users and roles
- 🎨 Clean, responsive HTML interface using @simplewebauthn/browser * Master admin can create multiple independent orgs
- 📦 No database required - challenges stored locally per connection * Master admin makes permissions available for orgs to assign
- 🛠️ Development tools: `ruff` for linting and formatting - User Profile and Administration by API and web interface.
- 🧹 Clean architecture with local challenge management under `/auth/` or `auth.example.com`
- Reset tokens and additional device linking via QR code or codewords.
- Pure Python, FastAPI, packaged with prebuilt Vue frontend
## Docs Two interfaces:
- API fetch: auth checks and login without leaving your app
- Forward-auth proxy: protect any unprotected site or service (Caddy, Nginx)
- Caddy integration: see `CADDY.md` for short, copy-paste snippets to secure your site with Caddy. The API mode is useful for applications that can be customized to run with Paskia. Forward auth can also protect your javascript and other assets. Each provides fine-grained permission control and reauthentication requests where needed, and both can be mixed where needed.
## Requirements Single Sign-On (SSO): Users register once and authenticate across all applications under your domain name (configured rp-id).
- Python 3.9+
- A WebAuthn-compatible authenticator (security key, biometric device, etc.)
## Quick Start ## Quick Start
### Install (editable dev mode) Install [UV](https://docs.astral.sh/uv/getting-started/installation/) and run:
```fish ```fish
uv pip install -e .[dev] uvx paskia serve --rp-id example.com
``` ```
### Run (new CLI) On the first run it downloads the software and prints a registration link for the Admin. If you are going to be connecting `localhost` directly, for testing, leave out the rp-id.
`passkey-auth` now provides subcommands: The server will start up on [localhost:4401](http://localhost:4401) "for authentication required", serving for `*.example.com`.
Otherwise you will need a web server such as [Caddy](https://caddyserver.com/) to serve HTTPS on your actual domain names and proxy requests to Paskia and your backend apps.
A quick example without any config file:
```fish
sudo caddy reverse-proxy --from example.com --to :4401
```
For a permanent install of `paskia` CLI command, not needing `uvx`:
```fish
uv tool install paskia
```
## Configuration
There is no config file. Pass only the options on CLI:
```text ```text
passkey-auth serve [host:port] [--options] paskia serve [options]
passkey-auth dev [--options]
``` ```
Examples (fish shell shown): Optional options:
```fish - Listen address (one of):
# Production style (no reload) * `[host]:port`: Address and port (default: `localhost:4401`)
passkey-auth serve * `unix:/path.sock`: Unix socket
passkey-auth serve 0.0.0.0:8080 --rp-id example.com --origin https://example.com - `--rp-id <domain>`: Main domain (required for production)
- `--rp-name "<text>"`: Name of your company or site (default: same as rp-id)
- `--origin <url>`: Explicit single site (default: `https://<rp-id>`)
- `--auth-host <domain>`: Dedicated authentication site (e.g., `auth.example.com`)
# Development (auto-reload) ## Documentation
passkey-auth dev # localhost:4401
passkey-auth dev :5500 # localhost on port 5500
passkey-auth dev 127.0.0.1 # host only, default port 4401
```
Available options (both subcommands): - `API.md`: Complete HTTP and WebSocket API reference
- `Caddy.md`: Caddy configuration examples
```text - `Headers.md`: HTTP headers passed to protected applications
--rp-id <id> Relying Party ID (default: localhost)
--rp-name <name> Relying Party name (default: same as rp-id)
--origin <url> Explicit origin (default: https://<rp-id>)
```
### Legacy Invocation
If you previously used `python -m passkey.fastapi --dev --host ...`, switch to the new form above. The old flags `--host`, `--port`, and `--dev` are replaced by the `[host:port]` positional and the `dev` subcommand.
## Usage (Web)
1. Start the server with one of the commands above
2. Open your browser to `http://localhost:4401/auth/` (or your chosen host/port)
3. Enter a username (or use the default)
4. Click "Register Passkey"
5. Follow your authenticator's prompts
Real-time status updates stream over WebSocket.
## Development
### Code Quality
```fish
# Run linting and formatting with ruff
uv run ruff check .
uv run ruff format .
# Or with hatch
hatch run ruff check .
hatch run ruff format .
```
### Project Structure
```
passkeyauth/
├── passkeyauth/
│ ├── __init__.py
│ └── main.py # FastAPI server with WebSocket support
├── static/
│ └── index.html # Frontend interface
├── pyproject.toml # Modern Python packaging configuration
└── README.md
```
## Technical Details
### WebAuthn Configuration
- **Relying Party ID**: `localhost` (for development)
- **Resident Keys**: Required (enables discoverable credentials)
- **User Verification**: Preferred
- **Supported Algorithms**: ECDSA-SHA256, RSASSA-PKCS1-v1_5-SHA256
### WebSocket Message Flow
1. Client connects to `/ws/{client_id}`
2. Client sends `registration_challenge` message
3. Server responds with `registration_challenge_response`
4. Client completes WebAuthn ceremony and sends `registration_response`
5. Server verifies and responds with `registration_success` or `error`
### Security Notes
- This is a minimal demo - challenges are stored locally per WebSocket connection
- For production use, implement proper user storage and session management
- Consider using Redis or similar for challenge storage in production with multiple server instances
- Ensure HTTPS in production environments
## License
MIT License - feel free to use this as a starting point for your own WebAuthn implementations!
+15 -5
View File
@@ -1,6 +1,6 @@
# PasskeyAuth E2E Tests # Paskia E2E Tests
End-to-end tests for PasskeyAuth using [Playwright](https://playwright.dev/) with Chrome's **Virtual Authenticator**. End-to-end tests for Paskia using [Playwright](https://playwright.dev/) with Chrome's **Virtual Authenticator**.
## Overview ## Overview
@@ -33,10 +33,20 @@ npm test
``` ```
This will: This will:
1. Start a fresh PasskeyAuth server with a test database 1. Start a fresh Paskia server with a test database
2. Run all E2E tests against it 2. Run all E2E tests against it
3. Clean up the server when done 3. Clean up the server when done
### With Coverage
```bash
npm run test:coverage
```
Runs tests and collects coverage for both:
- **Python backend** (via `coverage.py`) - HTML report in `coverage-html/`
- **Frontend JavaScript** (via Chrome V8 coverage) - JSON data in `e2e/coverage-frontend/`
### Interactive Mode ### Interactive Mode
```bash ```bash
@@ -86,7 +96,7 @@ e2e/
- WebSocket challenge-response with virtual authenticator - WebSocket challenge-response with virtual authenticator
- Session token creation and validation - Session token creation and validation
### Authentication Flow ### Authentication Flow
- Passkey authentication via WebSocket - Passkey authentication via WebSocket
- Credential verification - Credential verification
- Session management - Session management
@@ -125,7 +135,7 @@ This creates an in-browser authenticator that:
| Variable | Description | Default | | Variable | Description | Default |
|----------|-------------|---------| |----------|-------------|---------|
| `BASE_URL` | Server URL | `http://localhost:4401` | | `BASE_URL` | Server URL | `http://localhost:4404` |
| `CI` | CI environment flag | - | | `CI` | CI environment flag | - |
| `CLEANUP_TEST_DB` | Remove test DB after run | `false` | | `CLEANUP_TEST_DB` | Remove test DB after run | `false` |
+1127
View File
File diff suppressed because it is too large Load Diff
+5 -3
View File
@@ -1,20 +1,22 @@
{ {
"name": "passkey-auth-e2e", "name": "paskia-e2e",
"version": "1.0.0", "version": "1.0.0",
"private": true, "private": true,
"description": "E2E tests for PasskeyAuth using Playwright with Virtual Authenticator", "description": "E2E tests for Paskia using Playwright with Virtual Authenticator",
"type": "module", "type": "module",
"scripts": { "scripts": {
"test": "bunx playwright test", "test": "bunx playwright test",
"test:headed": "bunx playwright test --headed", "test:headed": "bunx playwright test --headed",
"test:debug": "bunx playwright test --debug", "test:debug": "bunx playwright test --debug",
"test:ui": "bunx playwright test --ui", "test:ui": "bunx playwright test --ui",
"test:coverage": "COVERAGE=1 bunx playwright test",
"report": "bunx playwright show-report", "report": "bunx playwright show-report",
"install:browsers": "bunx playwright install chromium" "install:browsers": "bunx playwright install chromium"
}, },
"devDependencies": { "devDependencies": {
"@playwright/test": "^1.49.0", "@playwright/test": "^1.49.0",
"@simplewebauthn/browser": "^13.1.2", "@simplewebauthn/browser": "^13.1.2",
"@types/bun": "^1.3.3" "@types/bun": "^1.3.3",
"c8": "^10.1.3"
} }
} }
+50
View File
@@ -0,0 +1,50 @@
import { defineConfig, devices } from '@playwright/test'
/**
* Playwright configuration for Paskia E2E tests.
* Uses Chrome's Virtual Authenticator for automated passkey testing.
*
* Run with: bun run test
*/
export default defineConfig({
testDir: './tests',
fullyParallel: false, // Run tests sequentially for passkey state consistency
forbidOnly: !!process.env.CI,
retries: process.env.CI ? 2 : 0,
workers: 1, // Single worker for database state consistency
reporter: [
['html', { open: 'never' }],
['list']
],
// Global setup/teardown for test database and server
globalSetup: './tests/global-setup.ts',
globalTeardown: './tests/global-teardown.ts',
use: {
// Base URL for the Paskia server
baseURL: process.env.BASE_URL || 'http://localhost:4404',
// Collect trace on failure for debugging
trace: 'on-first-retry',
// Screenshot on failure
screenshot: 'only-on-failure',
},
projects: [
{
name: 'chromium',
use: {
...devices['Desktop Chrome'],
// Chrome-specific settings for virtual authenticator
launchOptions: {
args: [
'--enable-features=WebAuthenticationEnterpriseAttestation',
],
},
},
},
],
})
@@ -1,9 +1,9 @@
import { defineConfig, devices } from '@playwright/test' import { defineConfig, devices } from '@playwright/test'
/** /**
* Playwright configuration for PasskeyAuth E2E tests. * Playwright configuration for Paskia E2E tests.
* Uses Chrome's Virtual Authenticator for automated passkey testing. * Uses Chrome's Virtual Authenticator for automated passkey testing.
* *
* Run with: bun run test * Run with: bun run test
*/ */
@@ -17,18 +17,18 @@ export default defineConfig({
['html', { open: 'never' }], ['html', { open: 'never' }],
['list'] ['list']
], ],
// Global setup/teardown for test database and server // Global setup/teardown for test database and server
globalSetup: './tests/global-setup.ts', globalSetup: './tests/global-setup.ts',
globalTeardown: './tests/global-teardown.ts', globalTeardown: './tests/global-teardown.ts',
use: { use: {
// Base URL for the passkey-auth server // Base URL for the Paskia server
baseURL: process.env.BASE_URL || 'http://localhost:4401', baseURL: process.env.BASE_URL || 'http://localhost:4401',
// Collect trace on failure for debugging // Collect trace on failure for debugging
trace: 'on-first-retry', trace: 'on-first-retry',
// Screenshot on failure // Screenshot on failure
screenshot: 'only-on-failure', screenshot: 'only-on-failure',
}, },
+635
View File
@@ -0,0 +1,635 @@
import { test, expect, createVirtualAuthenticator } from './fixtures/virtual-authenticator'
import {
registerPasskey,
authenticatePasskey,
validateSession,
getUserInfo,
logout,
getBootstrapResetToken,
createDeviceLink,
getSessionCookieName,
saveSessionToken,
getSavedSessionToken,
saveDeviceTokens,
} from './fixtures/passkey-helpers'
import type { Page, BrowserContext } from '@playwright/test'
/**
* Helper to set up session cookie for a page.
*/
async function setupSessionCookie(page: Page, sessionToken: string): Promise<void> {
const cookieName = getSessionCookieName()
await page.context().addCookies([{
name: cookieName,
value: sessionToken,
domain: 'localhost',
path: '/',
secure: true,
httpOnly: true,
sameSite: 'Strict' as const,
}])
}
/**
* E2E tests for Paskia using Chrome's Virtual Authenticator.
*
* These tests exercise the complete WebAuthn flow:
* 1. Registration via WebSocket using bootstrap reset token
* 2. Authentication via WebSocket
* 3. Session validation
* 4. User info retrieval
* 5. Logout
*
* The virtual authenticator simulates a hardware passkey device,
* allowing fully automated testing without physical hardware.
*/
test.describe('Passkey Authentication E2E', () => {
const baseUrl = process.env.BASE_URL || 'http://localhost:4404'
test.describe.configure({ mode: 'serial' })
// Shared state across tests in this describe block
let sessionToken: string
let userUuid: string
let credentialUuid: string
let resetToken: string | undefined
test.beforeAll(() => {
// Get the bootstrap reset token from global setup
resetToken = getBootstrapResetToken()
if (!resetToken) {
console.warn('⚠️ No reset token found - registration test may fail')
} else {
console.log(`📝 Using reset token: ${resetToken}`)
}
})
test('should load the auth page', async ({ page }) => {
// Navigate to auth page to establish origin for WebAuthn
await page.goto('/auth/')
await expect(page).toHaveTitle(/.*/)
// 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')
})
test('should register admin passkey via WebSocket using reset token', async ({ page, virtualAuthenticator }) => {
test.skip(!resetToken, 'No reset token available from bootstrap')
// Must visit the page first to establish origin
await page.goto('/auth/')
// Perform registration via WebSocket with virtual authenticator
// Using the bootstrap reset token for the admin user
const result = await registerPasskey(page, baseUrl, {
resetToken: resetToken,
displayName: 'Admin User',
})
// Verify registration result
expect(result.session_token).toBeDefined()
expect(result.session_token).toHaveLength(16)
expect(result.user_uuid).toBeDefined()
expect(result.credential_uuid).toBeDefined()
expect(result.message).toContain('successfully')
// Store for subsequent tests
sessionToken = result.session_token
userUuid = result.user_uuid
credentialUuid = result.credential_uuid
// Save session token for other test groups to use
saveSessionToken(sessionToken)
console.log(`✓ Registered user: ${userUuid}`)
console.log(`✓ Credential: ${credentialUuid}`)
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')
const validation = await validateSession(page, baseUrl, sessionToken)
expect(validation.valid).toBe(true)
expect(validation.user_uuid).toBe(userUuid)
console.log(`✓ Session validated for user: ${validation.user_uuid}`)
})
test('should retrieve user info', async ({ page }) => {
test.skip(!sessionToken, 'Requires successful registration')
const userInfo = await getUserInfo(page, baseUrl, sessionToken)
expect(userInfo.user.user_uuid).toBe(userUuid)
expect(userInfo.user.user_name).toBe('Admin User')
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}`)
})
test('should authenticate with existing passkey', async ({ page, virtualAuthenticator }) => {
test.skip(!sessionToken, 'Requires successful registration')
// Navigate to page (required for WebAuthn origin)
await page.goto('/auth/')
// The virtual authenticator in this context is new and doesn't have credentials.
// Create a device link using the current session, then register a new credential.
const deviceLink = await createDeviceLink(page, baseUrl, sessionToken)
console.log(`✓ Created device link with token: ${deviceLink.token}`)
// Register a new credential using the device link
const regResult = await registerPasskey(page, baseUrl, {
resetToken: deviceLink.token,
displayName: 'Admin User (test device)'
})
console.log(`✓ Added test credential: ${regResult.credential_uuid}`)
// Now logout and authenticate with the fresh credential
await logout(page, baseUrl, regResult.session_token)
console.log('✓ Logged out')
// Authenticate with the virtual authenticator (now has a valid credential)
const result = await authenticatePasskey(page, baseUrl)
expect(result.session_token).toBeDefined()
expect(result.session_token).toHaveLength(16)
expect(result.user_uuid).toBe(userUuid)
// Update session token for subsequent tests
sessionToken = result.session_token
// Save session token for other test groups to use
saveSessionToken(sessionToken)
console.log(`✓ Authenticated as user: ${result.user_uuid}`)
console.log(`✓ New session token: ${sessionToken.substring(0, 4)}...`)
})
test('should validate new session after authentication', async ({ page }) => {
test.skip(!sessionToken, 'Requires successful authentication')
const validation = await validateSession(page, baseUrl, sessionToken)
expect(validation.valid).toBe(true)
expect(validation.user_uuid).toBe(userUuid)
console.log(`✓ New session validated`)
})
// Note: Logout test moved to the end so other test groups can use the session
})
test.describe('Session Management', () => {
const baseUrl = process.env.BASE_URL || 'http://localhost:4404'
test('should reject invalid session token', async ({ page }) => {
const cookieName = getSessionCookieName()
const response = await page.request.post(`${baseUrl}/auth/api/validate`, {
headers: {
'Cookie': `${cookieName}=invalid_token_123`,
},
failOnStatusCode: false,
})
// Server may return 400 (bad format) or 401 (unauthorized)
expect([400, 401]).toContain(response.status())
console.log(`✓ Invalid token correctly rejected`)
})
test('should reject missing session token', async ({ page }) => {
const response = await page.request.post(`${baseUrl}/auth/api/validate`, {
failOnStatusCode: false,
})
expect(response.status()).toBe(401)
console.log(`✓ Missing token correctly rejected`)
})
})
test.describe('Device Addition Dialog', () => {
const baseUrl = process.env.BASE_URL || 'http://localhost:4404'
test.describe.configure({ mode: 'serial' })
let sessionToken: string
test.beforeAll(() => {
// Get the session token saved by the previous test group
// Note: This runs before the logout test, so the session should still be valid
const saved = getSavedSessionToken()
if (saved) {
sessionToken = saved
}
})
test('should open device addition dialog and show QR code', async ({ page }) => {
test.skip(!sessionToken, 'Requires saved session token from previous tests')
// Set the session cookie for this test context
const cookieName = getSessionCookieName()
await page.context().addCookies([{
name: cookieName,
value: sessionToken,
domain: 'localhost',
path: '/',
secure: true,
httpOnly: true,
sameSite: 'Strict',
}])
// Navigate to auth page (which should show profile when logged in)
await page.goto('/auth/')
// Wait for the profile view to load
await page.waitForSelector('[data-view="profile"]', { timeout: 5000 })
// Click the "Add Another Device" button
const addDeviceButton = page.getByRole('button', { name: 'Add Another Device' })
await expect(addDeviceButton).toBeVisible()
await addDeviceButton.click()
// Wait for the registration link modal to appear
const dialog = page.locator('.device-dialog')
await expect(dialog).toBeVisible({ timeout: 5000 })
// Verify dialog contains expected elements
await expect(dialog.locator('h2')).toContainText('Device Registration Link')
// Wait for QR code to be generated (canvas should have content)
const qrCanvas = dialog.locator('.qr-code')
await expect(qrCanvas).toBeVisible()
// 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')
// Take screenshot of the dialog
await dialog.screenshot({ path: 'test-results/device-addition-dialog.png' })
console.log(`✓ Screenshot saved: test-results/device-addition-dialog.png`)
// Verify Copy Link button exists
const copyButton = dialog.getByRole('button', { name: 'Copy Link' })
await expect(copyButton).toBeVisible()
// Close the dialog (use the text button, not the icon button)
const closeButton = dialog.locator('button.btn-secondary', { hasText: 'Close' })
await closeButton.click()
await expect(dialog).not.toBeVisible()
console.log(`✓ Device addition dialog test complete`)
})
test('should extract valid reset token from dialog', async ({ page }) => {
test.skip(!sessionToken, 'Requires successful registration')
// Set the session cookie
// __Host- cookies require: secure=true, path=/, no domain (but we set domain for localhost)
const cookieName = getSessionCookieName()
await page.context().addCookies([{
name: cookieName,
value: sessionToken,
domain: 'localhost',
path: '/',
secure: true,
httpOnly: true,
sameSite: 'Strict',
}])
await page.goto('/auth/')
await page.waitForSelector('[data-view="profile"]', { timeout: 5000 })
// Open the dialog
await page.getByRole('button', { name: 'Add Another Device' }).click()
const dialog = page.locator('.device-dialog')
await expect(dialog).toBeVisible({ timeout: 5000 })
// Extract the reset token from the displayed URL
const linkText = dialog.locator('.qr-link p')
const linkContent = await linkText.textContent()
// URL format: localhost/auth/word1.word2.word3.word4.word5
const tokenMatch = linkContent?.match(/\/auth\/([a-z]+\.[a-z]+\.[a-z]+\.[a-z]+\.[a-z]+)/)
expect(tokenMatch).toBeTruthy()
const extractedToken = tokenMatch![1]
console.log(`✓ Extracted reset token: ${extractedToken}`)
// Close the dialog (use the text button, not the icon button)
await dialog.locator('button.btn-secondary', { hasText: 'Close' }).click()
// Verify the token can be used for registration via API
// (We won't complete registration, just verify the WebSocket accepts it)
const wsUrl = `${baseUrl.replace('http', 'ws')}/auth/ws/register?reset=${encodeURIComponent(extractedToken)}&name=Test`
// Use page.evaluate to test WebSocket connection
const wsResult = await page.evaluate(async (wsUrl) => {
return new Promise<{ success: boolean; hasOptions: boolean }>((resolve) => {
const ws = new WebSocket(wsUrl)
ws.onmessage = (event) => {
const data = JSON.parse(event.data)
ws.close()
// Check if we got registration options (not an error)
resolve({
success: !data.status && !data.detail,
hasOptions: !!data.optionsJSON?.challenge
})
}
ws.onerror = () => resolve({ success: false, hasOptions: false })
setTimeout(() => {
ws.close()
resolve({ success: false, hasOptions: false })
}, 5000)
})
}, wsUrl)
expect(wsResult.success).toBe(true)
expect(wsResult.hasOptions).toBe(true)
console.log(`✓ Reset token is valid and accepted by server`)
})
})
test.describe('ProfileView - Add New Passkey', () => {
const baseUrl = process.env.BASE_URL || 'http://localhost:4404'
test('should show credentials list in profile', async ({ page }) => {
const sessionToken = getSavedSessionToken()
test.skip(!sessionToken, 'Requires saved session token')
await setupSessionCookie(page, sessionToken!)
// Navigate to profile page
await page.goto(`${baseUrl}/auth/`)
await page.waitForLoadState('networkidle')
// Wait for credentials to load
await page.waitForSelector('.credential-list', { timeout: 10000 })
// Should have at least one credential from initial registration
const credentialItems = await page.locator('.credential-item').count()
expect(credentialItems).toBeGreaterThanOrEqual(1)
console.log(`✓ Profile shows ${credentialItems} credential(s) in list`)
})
test('should add a new passkey using Add New Passkey button', async ({ page }) => {
const sessionToken = getSavedSessionToken()
test.skip(!sessionToken, 'Requires saved session token')
// Create virtual authenticator for this page
await createVirtualAuthenticator(page)
await setupSessionCookie(page, sessionToken!)
// Navigate to profile page
await page.goto(`${baseUrl}/auth/`)
await page.waitForLoadState('networkidle')
// Wait for credentials list and get initial count
await page.waitForSelector('.credential-list', { timeout: 10000 })
const initialCredentialCount = await page.locator('.credential-item').count()
console.log(`Initial credential count: ${initialCredentialCount}`)
// Click "Add New Passkey" button
const addPasskeyBtn = page.locator('button:has-text("Add New Passkey")')
await expect(addPasskeyBtn).toBeVisible()
await addPasskeyBtn.click()
// Wait for WebAuthn registration to complete (virtual authenticator handles it automatically)
// The button might show loading state or there might be a success message
await page.waitForTimeout(2000) // Give time for WebSocket registration to complete
// Refresh the page to ensure we see updated credentials
await page.reload()
await page.waitForLoadState('networkidle')
await page.waitForSelector('.credential-list', { timeout: 10000 })
// Should now have one more credential
const newCredentialCount = await page.locator('.credential-item').count()
expect(newCredentialCount).toBe(initialCredentialCount + 1)
console.log(`✓ Successfully added new passkey. Credentials: ${initialCredentialCount} -> ${newCredentialCount}`)
})
test('should reject duplicate passkey from same authenticator', async ({ page }) => {
const sessionToken = getSavedSessionToken()
test.skip(!sessionToken, 'Requires saved session token')
// Create virtual authenticator with resident key support
// Using same authenticator configuration - credentials stored on authenticator
await createVirtualAuthenticator(page, {
protocol: 'ctap2',
transport: 'internal',
hasResidentKey: true,
hasUserVerification: true,
isUserVerified: true,
})
await setupSessionCookie(page, sessionToken!)
// Navigate to profile page
await page.goto(`${baseUrl}/auth/`)
await page.waitForLoadState('networkidle')
// Wait for credentials list
await page.waitForSelector('.credential-list', { timeout: 10000 })
const initialCredentialCount = await page.locator('.credential-item').count()
// Try to add a passkey - with excludeCredentials the authenticator should
// prevent re-registration of the same credential
const addPasskeyBtn = page.locator('button:has-text("Add New Passkey")')
await expect(addPasskeyBtn).toBeVisible()
await addPasskeyBtn.click()
// Wait for response - could be success (new credential) or error (duplicate)
await page.waitForTimeout(3000)
// Check for error message or status message
const statusMessage = page.locator('.status-message')
const hasError = await statusMessage.locator('.error, .status-error').isVisible().catch(() => false)
// Reload to check final credential count
await page.reload()
await page.waitForLoadState('networkidle')
await page.waitForSelector('.credential-list', { timeout: 10000 })
const finalCredentialCount = await page.locator('.credential-item').count()
// The test passes if either:
// 1. An error was shown (duplicate rejected by excludeCredentials)
// 2. A new credential was added (fresh authenticator has no stored credential)
console.log(`Credentials: ${initialCredentialCount} -> ${finalCredentialCount}, error shown: ${hasError}`)
console.log(`✓ Add passkey flow completed (new authenticator creates new credential)`)
})
})
test.describe('ProfileView - Multi-Authenticator', () => {
const baseUrl = process.env.BASE_URL || 'http://localhost:4404'
test('should add passkey from different authenticator', async ({ page }) => {
const sessionToken = getSavedSessionToken()
test.skip(!sessionToken, 'Requires saved session token')
// Create a different virtual authenticator (simulating a different device)
await createVirtualAuthenticator(page, {
protocol: 'ctap2',
transport: 'usb', // Different transport - like a USB security key
hasResidentKey: true,
hasUserVerification: true,
isUserVerified: true,
})
await setupSessionCookie(page, sessionToken!)
// Navigate to profile page
await page.goto(`${baseUrl}/auth/`)
await page.waitForLoadState('networkidle')
// Wait for credentials list and get initial count
await page.waitForSelector('.credential-list', { timeout: 10000 })
const initialCredentialCount = await page.locator('.credential-item').count()
// Click "Add New Passkey" button
const addPasskeyBtn = page.locator('button:has-text("Add New Passkey")')
await expect(addPasskeyBtn).toBeVisible()
await addPasskeyBtn.click()
// Wait for registration to complete
await page.waitForTimeout(2000)
// Refresh to see updated list
await page.reload()
await page.waitForLoadState('networkidle')
await page.waitForSelector('.credential-list', { timeout: 10000 })
const newCredentialCount = await page.locator('.credential-item').count()
expect(newCredentialCount).toBe(initialCredentialCount + 1)
console.log(`✓ Added passkey from USB authenticator. Credentials: ${initialCredentialCount} -> ${newCredentialCount}`)
})
test('should display multiple credentials with details', async ({ page }) => {
const sessionToken = getSavedSessionToken()
test.skip(!sessionToken, 'Requires saved session token')
await setupSessionCookie(page, sessionToken!)
// Navigate to profile page
await page.goto(`${baseUrl}/auth/`)
await page.waitForLoadState('networkidle')
await page.waitForSelector('.credential-list', { timeout: 10000 })
// Should have multiple credentials now from previous tests
const credentialItems = page.locator('.credential-item')
const count = await credentialItems.count()
// Verify each credential has required elements
for (let i = 0; i < count; i++) {
const item = credentialItems.nth(i)
// Should have title/name
const title = item.locator('.item-title')
await expect(title).toBeVisible()
// Should have date information
const dates = item.locator('.credential-dates')
await expect(dates).toBeVisible()
// Should have created date
const createdDate = item.locator('.date-label:has-text("Created:")')
await expect(createdDate).toBeVisible()
}
console.log(`✓ All ${count} credentials displayed with proper details`)
// Take screenshot of credentials list
await page.screenshot({
path: 'test-results/credentials-list.png',
fullPage: false,
})
console.log(`✓ Screenshot saved: test-results/credentials-list.png`)
})
test('should show current session badge', async ({ page }) => {
const sessionToken = getSavedSessionToken()
test.skip(!sessionToken, 'Requires saved session token')
await setupSessionCookie(page, sessionToken!)
// Navigate to profile page
await page.goto(`${baseUrl}/auth/`)
await page.waitForLoadState('networkidle')
await page.waitForSelector('.credential-list', { timeout: 10000 })
// Look for the "Current" badge indicating current session's credential
const currentBadge = page.locator('.badge-current:has-text("Current")')
const hasCurrent = await currentBadge.isVisible().catch(() => false)
if (hasCurrent) {
console.log(`✓ Current session credential is marked with "Current" badge`)
// The current credential should have delete disabled
const currentItem = page.locator('.credential-item.current-session')
if (await currentItem.isVisible()) {
const deleteBtn = currentItem.locator('.btn-card-delete')
if (await deleteBtn.isVisible()) {
await expect(deleteBtn).toBeDisabled()
console.log(`✓ Delete button is disabled for current session credential`)
}
}
} else {
console.log(` No credential marked as current (may be using different auth method)`)
}
})
})
+606
View File
@@ -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<void> {
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<void> {
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<void> {
// 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<Frame> {
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<void> {
await page.waitForSelector('#auth-iframe', { state: 'detached', timeout })
}
/**
* Click Back button in auth iframe.
*/
async function clickBackInIframe(page: Page): Promise<void> {
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<void> {
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<void> {
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<void> {
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)
})
})
+33
View File
@@ -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`)
})
})
+147
View File
@@ -0,0 +1,147 @@
import { test as base, type Page, type CDPSession } from '@playwright/test'
import { existsSync, mkdirSync, writeFileSync, readFileSync } from 'fs'
import { join, dirname } from 'path'
import { fileURLToPath } from 'url'
const __dirname = dirname(fileURLToPath(import.meta.url))
const coverageDir = join(__dirname, '..', '..', 'coverage-frontend')
// Check if frontend coverage is enabled
const COLLECT_COVERAGE = process.env.COVERAGE === '1' || process.env.COVERAGE === 'true'
interface CoverageEntry {
url: string
scriptId: string
source?: string
functions: Array<{
functionName: string
ranges: Array<{
startOffset: number
endOffset: number
count: number
}>
isBlockCoverage: boolean
}>
}
/**
* Collect V8 JavaScript coverage from the page.
*/
async function startCoverage(page: Page): Promise<CDPSession | null> {
if (!COLLECT_COVERAGE) return null
try {
const cdp = await page.context().newCDPSession(page)
await cdp.send('Profiler.enable')
await cdp.send('Profiler.startPreciseCoverage', {
callCount: true,
detailed: true,
})
return cdp
} catch {
return null
}
}
async function stopCoverage(cdp: CDPSession | null, testName: string): Promise<void> {
if (!cdp) return
try {
const { result } = await cdp.send('Profiler.takePreciseCoverage')
await cdp.send('Profiler.stopPreciseCoverage')
await cdp.send('Profiler.disable')
// Filter to only include our app's JavaScript files
const appCoverage = result.filter((entry: CoverageEntry) =>
entry.url.includes('/auth/') &&
entry.url.endsWith('.js') &&
!entry.url.includes('node_modules')
)
if (appCoverage.length > 0) {
// Ensure coverage directory exists
if (!existsSync(coverageDir)) {
mkdirSync(coverageDir, { recursive: true })
}
// Save coverage data for this test
const safeName = testName.replace(/[^a-z0-9]/gi, '_').substring(0, 50)
const coverageFile = join(coverageDir, `coverage-${safeName}-${Date.now()}.json`)
writeFileSync(coverageFile, JSON.stringify(appCoverage, null, 2))
}
} catch (err) {
// Silently ignore coverage collection errors
}
}
/**
* Merge all coverage files into a single summary.
*/
export async function mergeCoverage(): Promise<void> {
if (!COLLECT_COVERAGE || !existsSync(coverageDir)) return
const files = require('fs').readdirSync(coverageDir).filter((f: string) => f.startsWith('coverage-') && f.endsWith('.json'))
if (files.length === 0) return
const merged: Map<string, CoverageEntry> = new Map()
for (const file of files) {
const data: CoverageEntry[] = JSON.parse(readFileSync(join(coverageDir, file), 'utf-8'))
for (const entry of data) {
const existing = merged.get(entry.url)
if (!existing) {
merged.set(entry.url, entry)
} else {
// Merge function coverage counts
for (const func of entry.functions) {
const existingFunc = existing.functions.find(f => f.functionName === func.functionName)
if (existingFunc) {
for (let i = 0; i < func.ranges.length; i++) {
if (existingFunc.ranges[i]) {
existingFunc.ranges[i].count += func.ranges[i].count
}
}
} else {
existing.functions.push(func)
}
}
}
}
}
// Write merged coverage
writeFileSync(
join(coverageDir, 'coverage-merged.json'),
JSON.stringify(Array.from(merged.values()), null, 2)
)
// Generate simple coverage summary
let totalFunctions = 0
let coveredFunctions = 0
for (const entry of merged.values()) {
for (const func of entry.functions) {
totalFunctions++
const hasCoverage = func.ranges.some(r => r.count > 0)
if (hasCoverage) coveredFunctions++
}
}
const percentage = totalFunctions > 0 ? Math.round((coveredFunctions / totalFunctions) * 100) : 0
console.log(`\n 📊 Frontend JS Coverage: ${coveredFunctions}/${totalFunctions} functions (${percentage}%)`)
console.log(` ✅ Frontend coverage data: ${coverageDir}/coverage-merged.json\n`)
}
/**
* Extended test with coverage collection.
* This wraps each test to collect V8 coverage data.
*/
export const testWithCoverage = base.extend<{
coverageSession: CDPSession | null
}>({
coverageSession: async ({ page }, use, testInfo) => {
const cdp = await startCoverage(page)
await use(cdp)
await stopCoverage(cdp, testInfo.title)
},
})
+184 -50
View File
@@ -1,9 +1,10 @@
import { type Page } from '@playwright/test' import { type Page } from '@playwright/test'
import { existsSync, readFileSync } from 'fs' import { existsSync, readFileSync, writeFileSync } from 'fs'
import { join, dirname } from 'path' import { join, dirname } from 'path'
import { fileURLToPath } from 'url' import { fileURLToPath } from 'url'
const __dirname = dirname(fileURLToPath(import.meta.url)) const __dirname = dirname(fileURLToPath(import.meta.url))
const stateFile = join(__dirname, '..', '..', 'test-data', 'test-state.json')
/** /**
* WebSocket helpers for passkey registration and authentication. * WebSocket helpers for passkey registration and authentication.
@@ -26,7 +27,6 @@ export interface AuthenticationResult {
* Get the bootstrap reset token from the test state file. * Get the bootstrap reset token from the test state file.
*/ */
export function getBootstrapResetToken(): string | undefined { export function getBootstrapResetToken(): string | undefined {
const stateFile = join(__dirname, '..', '..', 'test-data', 'test-state.json')
if (existsSync(stateFile)) { if (existsSync(stateFile)) {
try { try {
const state = JSON.parse(readFileSync(stateFile, 'utf-8')) const state = JSON.parse(readFileSync(stateFile, 'utf-8'))
@@ -38,6 +38,118 @@ export function getBootstrapResetToken(): string | undefined {
return undefined return undefined
} }
/**
* Get the session cookie name from the test state file.
*/
export function getSessionCookieName(): string {
if (existsSync(stateFile)) {
try {
const state = JSON.parse(readFileSync(stateFile, 'utf-8'))
return state.sessionCookie || '__Host-auth'
} catch {
return '__Host-auth'
}
}
return '__Host-auth'
}
/**
* Save a session token to the test state file for sharing across test groups.
*/
export function saveSessionToken(sessionToken: string): void {
if (existsSync(stateFile)) {
try {
const state = JSON.parse(readFileSync(stateFile, 'utf-8'))
state.savedSessionToken = sessionToken
writeFileSync(stateFile, JSON.stringify(state, null, 2))
} catch {
// Ignore errors
}
}
}
/**
* 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.
*/
export function getSavedSessionToken(): string | undefined {
if (existsSync(stateFile)) {
try {
const state = JSON.parse(readFileSync(stateFile, 'utf-8'))
return state.savedSessionToken
} catch {
return 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. * Perform passkey registration via WebSocket.
* This runs in the browser context using the virtual authenticator. * This runs in the browser context using the virtual authenticator.
@@ -57,67 +169,70 @@ export async function registerPasskey(
return new Promise<any>((resolve, reject) => { return new Promise<any>((resolve, reject) => {
const ws = new WebSocket(wsUrl) const ws = new WebSocket(wsUrl)
ws.onopen = () => { ws.onopen = () => {
console.log('WebSocket connected for registration') console.log('WebSocket connected for registration')
} }
ws.onmessage = async (event) => { ws.onmessage = async (event) => {
const data = JSON.parse(event.data) const data = JSON.parse(event.data)
// Check for error response // Check for error response
if (data.detail) { if (data.detail) {
ws.close() ws.close()
reject(new Error(data.detail)) reject(new Error(data.detail))
return return
} }
// Check if this is the final success response // Check if this is the final success response
if (data.session_token) { if (data.session_token) {
ws.close() ws.close()
resolve(data) resolve(data)
return return
} }
// This should be the registration options from server // This should be the registration options from server (wrapped in optionsJSON)
// Use the native WebAuthn API with the virtual authenticator // Use the native WebAuthn API with the virtual authenticator
try { try {
// Extract options from the optionsJSON wrapper
const opts = data.optionsJSON
// Convert base64url challenge to ArrayBuffer // Convert base64url challenge to ArrayBuffer
const challenge = Uint8Array.from(atob(data.challenge.replace(/-/g, '+').replace(/_/g, '/')), c => c.charCodeAt(0)) const challenge = Uint8Array.from(atob(opts.challenge.replace(/-/g, '+').replace(/_/g, '/')), c => c.charCodeAt(0))
// Build the credential creation options // Build the credential creation options
const publicKeyCredentialCreationOptions: CredentialCreationOptions = { const publicKeyCredentialCreationOptions: CredentialCreationOptions = {
publicKey: { publicKey: {
challenge: challenge, challenge: challenge,
rp: { rp: {
name: data.rp.name, name: opts.rp.name,
id: data.rp.id, id: opts.rp.id,
}, },
user: { user: {
id: Uint8Array.from(atob(data.user.id.replace(/-/g, '+').replace(/_/g, '/')), c => c.charCodeAt(0)), id: Uint8Array.from(atob(opts.user.id.replace(/-/g, '+').replace(/_/g, '/')), c => c.charCodeAt(0)),
name: data.user.name, name: opts.user.name,
displayName: data.user.displayName, displayName: opts.user.displayName,
}, },
pubKeyCredParams: data.pubKeyCredParams, pubKeyCredParams: opts.pubKeyCredParams,
authenticatorSelection: data.authenticatorSelection, authenticatorSelection: opts.authenticatorSelection,
timeout: data.timeout, timeout: opts.timeout,
attestation: data.attestation, attestation: opts.attestation,
excludeCredentials: data.excludeCredentials?.map((cred: any) => ({ excludeCredentials: opts.excludeCredentials?.map((cred: any) => ({
...cred, ...cred,
id: Uint8Array.from(atob(cred.id.replace(/-/g, '+').replace(/_/g, '/')), c => c.charCodeAt(0)), id: Uint8Array.from(atob(cred.id.replace(/-/g, '+').replace(/_/g, '/')), c => c.charCodeAt(0)),
})) || [], })) || [],
} }
} }
// Create the credential using native WebAuthn API (virtual authenticator handles it) // Create the credential using native WebAuthn API (virtual authenticator handles it)
const credential = await navigator.credentials.create(publicKeyCredentialCreationOptions) as PublicKeyCredential const credential = await navigator.credentials.create(publicKeyCredentialCreationOptions) as PublicKeyCredential
if (!credential) { if (!credential) {
throw new Error('Failed to create credential') throw new Error('Failed to create credential')
} }
const response = credential.response as AuthenticatorAttestationResponse const response = credential.response as AuthenticatorAttestationResponse
// Convert response to JSON format expected by server // Convert response to JSON format expected by server
const registrationResponse = { const registrationResponse = {
id: credential.id, id: credential.id,
@@ -131,18 +246,18 @@ export async function registerPasskey(
clientExtensionResults: credential.getClientExtensionResults(), clientExtensionResults: credential.getClientExtensionResults(),
authenticatorAttachment: (credential as any).authenticatorAttachment, authenticatorAttachment: (credential as any).authenticatorAttachment,
} }
ws.send(JSON.stringify(registrationResponse)) ws.send(JSON.stringify(registrationResponse))
} catch (error: any) { } catch (error: any) {
ws.close() ws.close()
reject(new Error(error.message || 'Registration failed')) reject(new Error(error.message || 'Registration failed'))
} }
} }
ws.onerror = () => { ws.onerror = () => {
reject(new Error('WebSocket error during registration')) reject(new Error('WebSocket error during registration'))
} }
ws.onclose = (event) => { ws.onclose = (event) => {
if (!event.wasClean && event.code !== 1000) { if (!event.wasClean && event.code !== 1000) {
reject(new Error(`WebSocket closed unexpectedly: ${event.code}`)) reject(new Error(`WebSocket closed unexpectedly: ${event.code}`))
@@ -165,57 +280,60 @@ export async function authenticatePasskey(
return new Promise<any>((resolve, reject) => { return new Promise<any>((resolve, reject) => {
const ws = new WebSocket(wsUrl) const ws = new WebSocket(wsUrl)
ws.onopen = () => { ws.onopen = () => {
console.log('WebSocket connected for authentication') console.log('WebSocket connected for authentication')
} }
ws.onmessage = async (event) => { ws.onmessage = async (event) => {
const data = JSON.parse(event.data) const data = JSON.parse(event.data)
// Check for error response // Check for error response
if (data.detail) { if (data.detail) {
ws.close() ws.close()
reject(new Error(data.detail)) reject(new Error(data.detail))
return return
} }
// Check if this is the final success response // Check if this is the final success response
if (data.session_token) { if (data.session_token) {
ws.close() ws.close()
resolve(data) resolve(data)
return return
} }
// This should be the authentication options from server // This should be the authentication options from server (wrapped in optionsJSON)
try { try {
// Extract options from the optionsJSON wrapper
const opts = data.optionsJSON
// Convert base64url challenge to ArrayBuffer // Convert base64url challenge to ArrayBuffer
const challenge = Uint8Array.from(atob(data.challenge.replace(/-/g, '+').replace(/_/g, '/')), c => c.charCodeAt(0)) const challenge = Uint8Array.from(atob(opts.challenge.replace(/-/g, '+').replace(/_/g, '/')), c => c.charCodeAt(0))
// Build the credential request options // Build the credential request options
const publicKeyCredentialRequestOptions: CredentialRequestOptions = { const publicKeyCredentialRequestOptions: CredentialRequestOptions = {
publicKey: { publicKey: {
challenge: challenge, challenge: challenge,
rpId: data.rpId, rpId: opts.rpId,
timeout: data.timeout, timeout: opts.timeout,
userVerification: data.userVerification, userVerification: opts.userVerification,
allowCredentials: data.allowCredentials?.map((cred: any) => ({ allowCredentials: opts.allowCredentials?.map((cred: any) => ({
type: cred.type, type: cred.type,
id: Uint8Array.from(atob(cred.id.replace(/-/g, '+').replace(/_/g, '/')), c => c.charCodeAt(0)), id: Uint8Array.from(atob(cred.id.replace(/-/g, '+').replace(/_/g, '/')), c => c.charCodeAt(0)),
transports: cred.transports, transports: cred.transports,
})) || [], })) || [],
} }
} }
// Get the credential using native WebAuthn API (virtual authenticator handles it) // Get the credential using native WebAuthn API (virtual authenticator handles it)
const credential = await navigator.credentials.get(publicKeyCredentialRequestOptions) as PublicKeyCredential const credential = await navigator.credentials.get(publicKeyCredentialRequestOptions) as PublicKeyCredential
if (!credential) { if (!credential) {
throw new Error('Failed to get credential') throw new Error('Failed to get credential')
} }
const response = credential.response as AuthenticatorAssertionResponse const response = credential.response as AuthenticatorAssertionResponse
// Convert response to JSON format expected by server // Convert response to JSON format expected by server
const authenticationResponse = { const authenticationResponse = {
id: credential.id, id: credential.id,
@@ -230,18 +348,18 @@ export async function authenticatePasskey(
clientExtensionResults: credential.getClientExtensionResults(), clientExtensionResults: credential.getClientExtensionResults(),
authenticatorAttachment: (credential as any).authenticatorAttachment, authenticatorAttachment: (credential as any).authenticatorAttachment,
} }
ws.send(JSON.stringify(authenticationResponse)) ws.send(JSON.stringify(authenticationResponse))
} catch (error: any) { } catch (error: any) {
ws.close() ws.close()
reject(new Error(error.message || 'Authentication failed')) reject(new Error(error.message || 'Authentication failed'))
} }
} }
ws.onerror = () => { ws.onerror = () => {
reject(new Error('WebSocket error during authentication')) reject(new Error('WebSocket error during authentication'))
} }
ws.onclose = (event) => { ws.onclose = (event) => {
if (!event.wasClean && event.code !== 1000) { if (!event.wasClean && event.code !== 1000) {
reject(new Error(`WebSocket closed unexpectedly: ${event.code}`)) reject(new Error(`WebSocket closed unexpectedly: ${event.code}`))
@@ -259,9 +377,10 @@ export async function validateSession(
baseUrl: string, baseUrl: string,
sessionToken: string sessionToken: string
): Promise<{ valid: boolean; user_uuid: string; renewed: boolean }> { ): Promise<{ valid: boolean; user_uuid: string; renewed: boolean }> {
const cookieName = getSessionCookieName()
const response = await page.request.post(`${baseUrl}/auth/api/validate`, { const response = await page.request.post(`${baseUrl}/auth/api/validate`, {
headers: { headers: {
'Cookie': `__Host-auth=${sessionToken}`, 'Cookie': `${cookieName}=${sessionToken}`,
}, },
}) })
return await response.json() return await response.json()
@@ -275,9 +394,10 @@ export async function getUserInfo(
baseUrl: string, baseUrl: string,
sessionToken: string sessionToken: string
): Promise<any> { ): Promise<any> {
const cookieName = getSessionCookieName()
const response = await page.request.post(`${baseUrl}/auth/api/user-info`, { const response = await page.request.post(`${baseUrl}/auth/api/user-info`, {
headers: { headers: {
'Cookie': `__Host-auth=${sessionToken}`, 'Cookie': `${cookieName}=${sessionToken}`,
}, },
}) })
return await response.json() return await response.json()
@@ -285,17 +405,24 @@ export async function getUserInfo(
/** /**
* Logout via the API. * Logout via the API.
* If the session being logged out matches the saved session token, clears it.
*/ */
export async function logout( export async function logout(
page: Page, page: Page,
baseUrl: string, baseUrl: string,
sessionToken: string sessionToken: string
): Promise<void> { ): Promise<void> {
const cookieName = getSessionCookieName()
await page.request.post(`${baseUrl}/auth/api/logout`, { await page.request.post(`${baseUrl}/auth/api/logout`, {
headers: { headers: {
'Cookie': `__Host-auth=${sessionToken}`, 'Cookie': `${cookieName}=${sessionToken}`,
}, },
}) })
// Clear saved session token if it matches the one being logged out
const savedToken = getSavedSessionToken()
if (savedToken === sessionToken) {
clearSavedSessionToken()
}
} }
/** /**
@@ -306,12 +433,19 @@ export async function createDeviceLink(
baseUrl: string, baseUrl: string,
sessionToken: string sessionToken: string
): Promise<{ url: string; token: string }> { ): Promise<{ url: string; token: string }> {
const cookieName = getSessionCookieName()
const response = await page.request.post(`${baseUrl}/auth/api/user/create-link`, { const response = await page.request.post(`${baseUrl}/auth/api/user/create-link`, {
headers: { headers: {
'Cookie': `__Host-auth=${sessionToken}`, 'Cookie': `${cookieName}=${sessionToken}`,
}, },
}) })
if (!response.ok()) {
throw new Error(`Failed to create device link: ${response.status()} - ${await response.text()}`)
}
const data = await response.json() 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) // Extract token from URL (last path segment)
const url = new URL(data.url) const url = new URL(data.url)
const token = url.pathname.split('/').pop() || '' const token = url.pathname.split('/').pop() || ''
+56 -5
View File
@@ -1,4 +1,13 @@
import { test as base, expect, type CDPSession, type Page } from '@playwright/test' import { test as base, expect, type CDPSession, type Page } from '@playwright/test'
import { existsSync, mkdirSync, writeFileSync } from 'fs'
import { join, dirname } from 'path'
import { fileURLToPath } from 'url'
const __dirname = dirname(fileURLToPath(import.meta.url))
const coverageDir = join(__dirname, '..', '..', 'coverage-frontend')
// Check if frontend coverage is enabled
const COLLECT_COVERAGE = process.env.COVERAGE === '1' || process.env.COVERAGE === 'true'
/** /**
* Virtual Authenticator configuration for WebAuthn testing. * Virtual Authenticator configuration for WebAuthn testing.
@@ -27,7 +36,7 @@ export async function createVirtualAuthenticator(
options: VirtualAuthenticatorOptions = {} options: VirtualAuthenticatorOptions = {}
): Promise<VirtualAuthenticator> { ): Promise<VirtualAuthenticator> {
const cdpSession = await page.context().newCDPSession(page) const cdpSession = await page.context().newCDPSession(page)
// Enable WebAuthn in CDP // Enable WebAuthn in CDP
await cdpSession.send('WebAuthn.enable', { await cdpSession.send('WebAuthn.enable', {
enableUI: false, // Suppress any UI prompts enableUI: false, // Suppress any UI prompts
@@ -73,20 +82,62 @@ export async function getCredentials(
} }
/** /**
* Extended test fixture with virtual authenticator support. * Extended test fixture with virtual authenticator support and optional coverage.
*/ */
export const test = base.extend<{ export const test = base.extend<{
virtualAuthenticator: VirtualAuthenticator virtualAuthenticator: VirtualAuthenticator
}>({ }>({
virtualAuthenticator: async ({ page }, use) => { virtualAuthenticator: async ({ page }, use, testInfo) => {
// Start coverage collection if enabled
let coverageCdp: CDPSession | null = null
if (COLLECT_COVERAGE) {
try {
coverageCdp = await page.context().newCDPSession(page)
await coverageCdp.send('Profiler.enable')
await coverageCdp.send('Profiler.startPreciseCoverage', {
callCount: true,
detailed: true,
})
} catch {
coverageCdp = null
}
}
// Create virtual authenticator before test // Create virtual authenticator before test
const authenticator = await createVirtualAuthenticator(page) const authenticator = await createVirtualAuthenticator(page)
// Run the test // Run the test
await use(authenticator) await use(authenticator)
// Cleanup after test // Cleanup after test
await removeVirtualAuthenticator(authenticator) await removeVirtualAuthenticator(authenticator)
// Stop and save coverage
if (coverageCdp) {
try {
const { result } = await coverageCdp.send('Profiler.takePreciseCoverage')
await coverageCdp.send('Profiler.stopPreciseCoverage')
await coverageCdp.send('Profiler.disable')
// Filter to only include our app's JavaScript files
const appCoverage = result.filter((entry: any) =>
entry.url.includes('/auth/') &&
entry.url.endsWith('.js') &&
!entry.url.includes('node_modules')
)
if (appCoverage.length > 0) {
if (!existsSync(coverageDir)) {
mkdirSync(coverageDir, { recursive: true })
}
const safeName = testInfo.title.replace(/[^a-z0-9]/gi, '_').substring(0, 50)
const coverageFile = join(coverageDir, `coverage-${safeName}-${Date.now()}.json`)
writeFileSync(coverageFile, JSON.stringify(appCoverage, null, 2))
}
} catch {
// Silently ignore coverage collection errors
}
}
}, },
}) })
+60 -41
View File
@@ -1,94 +1,101 @@
import { spawn } from 'child_process' import { spawn } from 'child_process'
import { join, dirname } from 'path' import { join, dirname } from 'path'
import { existsSync, mkdirSync, rmSync, writeFileSync } from 'fs' import { existsSync, mkdirSync, writeFileSync } from 'fs'
import { fileURLToPath } from 'url' import { fileURLToPath } from 'url'
const __dirname = dirname(fileURLToPath(import.meta.url)) const __dirname = dirname(fileURLToPath(import.meta.url))
const testDataDir = join(__dirname, '..', 'test-data') const testDataDir = join(__dirname, '..', 'test-data')
const stateFile = join(testDataDir, 'test-state.json') const stateFile = join(testDataDir, 'test-state.json')
const dbPath = join(testDataDir, 'test.sqlite') const projectRoot = join(__dirname, '..', '..')
// Check if coverage is enabled
const COLLECT_COVERAGE = process.env.COVERAGE === '1' || process.env.COVERAGE === 'true'
interface TestState { interface TestState {
resetToken?: string resetToken?: string
serverPid?: number serverPid?: number
sessionCookie?: string
} }
/** /**
* Global setup for E2E tests. * Global setup for E2E tests.
* *
* This creates a fresh test database and starts the server, * Uses in-memory SQLite database for fast, isolated tests.
* capturing the bootstrap reset token for initial user registration. * Captures the bootstrap reset token for initial user registration.
*/ */
export default async function globalSetup() { export default async function globalSetup() {
console.log('\n🔧 Setting up E2E test environment...\n') console.log('\n🔧 Setting up E2E test environment...\n')
// Create test data directory // Create test data directory for state file
if (!existsSync(testDataDir)) { if (!existsSync(testDataDir)) {
mkdirSync(testDataDir, { recursive: true }) mkdirSync(testDataDir, { recursive: true })
} }
// Remove old database for clean state console.log(' Starting server with in-memory database...')
if (existsSync(dbPath)) { if (COLLECT_COVERAGE) {
console.log(' Removing old test database...') console.log(' 📊 Coverage collection enabled for Python backend')
rmSync(dbPath)
} }
// Remove any wal/shm files too
for (const ext of ['-wal', '-shm']) {
const file = dbPath + ext
if (existsSync(file)) rmSync(file)
}
console.log(' Starting server with fresh database...')
const state: TestState = {} const state: TestState = {}
// Build server command - with or without coverage
const serverArgs = COLLECT_COVERAGE
? [
'run', 'coverage', 'run', '--parallel-mode',
'-m', 'paskia.fastapi', 'serve', 'localhost:4404',
'--rp-id', 'localhost'
]
: [
'run', 'paskia', 'serve', 'localhost:4404',
'--rp-id', 'localhost'
]
// Start the server using Node's spawn // Start the server using Node's spawn
const serverProcess = spawn('uv', [ // Use in-memory SQLite for faster tests
'run', 'passkey-auth', 'serve', ':4401', const serverProcess = spawn('uv', serverArgs, {
'--rp-id', 'localhost', cwd: projectRoot,
'--origin', 'http://localhost:4401'
], {
cwd: testDataDir, // Run from test-data so DB is created there
env: { env: {
...process.env, ...process.env,
PASKIA_DB: 'sqlite+aiosqlite:///:memory:',
COVERAGE_FILE: join(projectRoot, '.coverage'),
}, },
stdio: ['ignore', 'pipe', 'pipe'], stdio: ['ignore', 'pipe', 'pipe'],
}) })
state.serverPid = serverProcess.pid state.serverPid = serverProcess.pid
// Capture output to find reset token // Capture output to find reset token
const resetTokenPromise = new Promise<string>((resolve, reject) => { const resetTokenPromise = new Promise<string>((resolve, reject) => {
const timeout = setTimeout(() => { const timeout = setTimeout(() => {
reject(new Error('Timed out waiting for server bootstrap (30s)')) reject(new Error('Timed out waiting for server bootstrap (30s)'))
}, 30000) }, 30000)
let output = '' let output = ''
const handleData = (data: Buffer) => { const handleData = (data: Buffer) => {
const text = data.toString() const text = data.toString()
output += text output += text
process.stdout.write(text) // Echo to console process.stdout.write(text) // Echo to console
// Look for the reset token URL in the output // Look for the reset token URL in the output
// Format: http://localhost:4401/auth/{token} where token is word.word.word.word.word (dot separated) // Format: https://localhost/auth/{token} or http://localhost:4404/auth/{token}
const match = output.match(/http:\/\/localhost:\d+\/auth\/([a-z]+(?:\.[a-z]+)+)/) // where token is word.word.word.word.word (dot separated)
const match = output.match(/https?:\/\/localhost(?::\d+)?\/auth\/([a-z]+(?:\.[a-z]+)+)/)
if (match) { if (match) {
clearTimeout(timeout) clearTimeout(timeout)
// Wait a bit for server to fully start // Wait a bit for server to fully start
setTimeout(() => resolve(match[1]), 1000) setTimeout(() => resolve(match[1]), 1000)
} }
} }
serverProcess.stdout?.on('data', handleData) serverProcess.stdout?.on('data', handleData)
serverProcess.stderr?.on('data', handleData) serverProcess.stderr?.on('data', handleData)
serverProcess.on('error', (err) => { serverProcess.on('error', (err) => {
clearTimeout(timeout) clearTimeout(timeout)
reject(err) reject(err)
}) })
serverProcess.on('exit', (code) => { serverProcess.on('exit', (code) => {
if (code !== 0 && code !== null) { if (code !== 0 && code !== null) {
clearTimeout(timeout) clearTimeout(timeout)
@@ -96,7 +103,7 @@ export default async function globalSetup() {
} }
}) })
}) })
try { try {
state.resetToken = await resetTokenPromise state.resetToken = await resetTokenPromise
console.log(`\n ✅ Captured reset token: ${state.resetToken}\n`) console.log(`\n ✅ Captured reset token: ${state.resetToken}\n`)
@@ -105,9 +112,21 @@ export default async function globalSetup() {
serverProcess.kill() serverProcess.kill()
throw err throw err
} }
// Fetch session cookie name from server settings
try {
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`)
} catch (err) {
console.error('Failed to fetch settings:', err)
serverProcess.kill()
throw err
}
// Save state for tests // Save state for tests
writeFileSync(stateFile, JSON.stringify(state, null, 2)) writeFileSync(stateFile, JSON.stringify(state, null, 2))
console.log(' ✅ E2E test environment ready\n') console.log(' ✅ E2E test environment ready\n')
} }
+94 -9
View File
@@ -1,35 +1,49 @@
import { join, dirname } from 'path' import { join, dirname } from 'path'
import { existsSync, rmSync, readFileSync } from 'fs' import { existsSync, rmSync, readFileSync, readdirSync, writeFileSync } from 'fs'
import { fileURLToPath } from 'url' import { fileURLToPath } from 'url'
import { execSync } from 'child_process'
const __dirname = dirname(fileURLToPath(import.meta.url)) const __dirname = dirname(fileURLToPath(import.meta.url))
const testDataDir = join(__dirname, '..', 'test-data') const testDataDir = join(__dirname, '..', 'test-data')
const stateFile = join(testDataDir, 'test-state.json') const stateFile = join(testDataDir, 'test-state.json')
const projectRoot = join(__dirname, '..', '..')
const coverageDir = join(__dirname, '..', 'coverage-frontend')
// Check if coverage is enabled
const COLLECT_COVERAGE = process.env.COVERAGE === '1' || process.env.COVERAGE === 'true'
interface TestState { interface TestState {
resetToken?: string resetToken?: string
serverPid?: number serverPid?: number
} }
interface CoverageEntry {
url: string
functions: Array<{
functionName: string
ranges: Array<{ count: number }>
}>
}
/** /**
* Global teardown for E2E tests. * Global teardown for E2E tests.
* *
* This cleans up the test server and optionally removes the test database. * This cleans up the test server and optionally removes the test database.
*/ */
export default async function globalTeardown() { export default async function globalTeardown() {
console.log('\n🧹 Cleaning up E2E test environment...\n') console.log('\n🧹 Cleaning up E2E test environment...\n')
// Read state file to get server PID // Read state file to get server PID
if (existsSync(stateFile)) { if (existsSync(stateFile)) {
try { try {
const state: TestState = JSON.parse(readFileSync(stateFile, 'utf-8')) const state: TestState = JSON.parse(readFileSync(stateFile, 'utf-8'))
if (state.serverPid) { if (state.serverPid) {
console.log(` Stopping server (PID: ${state.serverPid})...`) console.log(` Stopping server (PID: ${state.serverPid})...`)
try { try {
process.kill(state.serverPid, 'SIGTERM') process.kill(state.serverPid, 'SIGTERM')
// Wait a moment for graceful shutdown // Wait longer for graceful shutdown and coverage data flush
await new Promise(r => setTimeout(r, 500)) await new Promise(r => setTimeout(r, COLLECT_COVERAGE ? 2000 : 500))
} catch (err: any) { } catch (err: any) {
// Process may already be dead // Process may already be dead
if (err.code !== 'ESRCH') { if (err.code !== 'ESRCH') {
@@ -40,11 +54,11 @@ export default async function globalTeardown() {
} catch (err) { } catch (err) {
console.warn(' Warning: Could not read state file') console.warn(' Warning: Could not read state file')
} }
// Clean up state file // Clean up state file
rmSync(stateFile, { force: true }) rmSync(stateFile, { force: true })
} }
// Optionally clean up test database (keep it for debugging by default) // Optionally clean up test database (keep it for debugging by default)
if (process.env.CLEANUP_TEST_DB === 'true') { if (process.env.CLEANUP_TEST_DB === 'true') {
const dbPath = join(testDataDir, 'test.sqlite') const dbPath = join(testDataDir, 'test.sqlite')
@@ -58,6 +72,77 @@ export default async function globalTeardown() {
if (existsSync(file)) rmSync(file) if (existsSync(file)) rmSync(file)
} }
} }
// Generate Python coverage report if coverage was collected
if (COLLECT_COVERAGE) {
console.log(' 📊 Generating Python coverage report...')
try {
// Combine parallel coverage data and generate reports
execSync('uv run coverage combine', { cwd: projectRoot, stdio: 'inherit' })
execSync('uv run coverage report', { cwd: projectRoot, stdio: 'inherit' })
execSync('uv run coverage html', { cwd: projectRoot, stdio: 'inherit' })
console.log(` ✅ Python coverage report: ${join(projectRoot, 'coverage-html', 'index.html')}\n`)
} catch (err: any) {
console.warn(` Warning: Failed to generate coverage report: ${err.message}`)
}
// Merge and report frontend coverage
if (existsSync(coverageDir)) {
try {
const files = readdirSync(coverageDir).filter(f => f.startsWith('coverage-') && f.endsWith('.json') && f !== 'coverage-merged.json')
if (files.length > 0) {
const merged: Map<string, CoverageEntry> = new Map()
for (const file of files) {
const data: CoverageEntry[] = JSON.parse(readFileSync(join(coverageDir, file), 'utf-8'))
for (const entry of data) {
const existing = merged.get(entry.url)
if (!existing) {
merged.set(entry.url, entry)
} else {
// Merge function coverage counts
for (const func of entry.functions) {
const existingFunc = existing.functions.find(f => f.functionName === func.functionName)
if (existingFunc) {
for (let i = 0; i < func.ranges.length && i < existingFunc.ranges.length; i++) {
existingFunc.ranges[i].count += func.ranges[i].count
}
} else {
existing.functions.push(func)
}
}
}
}
}
// Write merged coverage
writeFileSync(
join(coverageDir, 'coverage-merged.json'),
JSON.stringify(Array.from(merged.values()), null, 2)
)
// Generate simple coverage summary
let totalFunctions = 0
let coveredFunctions = 0
for (const entry of merged.values()) {
for (const func of entry.functions) {
totalFunctions++
const hasCoverage = func.ranges.some(r => r.count > 0)
if (hasCoverage) coveredFunctions++
}
}
const percentage = totalFunctions > 0 ? Math.round((coveredFunctions / totalFunctions) * 100) : 0
console.log(` 📊 Frontend JS Coverage: ${coveredFunctions}/${totalFunctions} functions (${percentage}%)`)
console.log(` ✅ Frontend coverage data: ${coverageDir}/coverage-merged.json\n`)
}
} catch (err: any) {
console.warn(` Warning: Failed to merge frontend coverage: ${err.message}`)
}
}
}
console.log(' ✅ Cleanup complete\n') console.log(' ✅ Cleanup complete\n')
} }
-205
View File
@@ -1,205 +0,0 @@
import { test, expect } from './fixtures/virtual-authenticator'
import {
registerPasskey,
authenticatePasskey,
validateSession,
getUserInfo,
logout,
getBootstrapResetToken,
createDeviceLink,
} from './fixtures/passkey-helpers'
/**
* E2E tests for PasskeyAuth using Chrome's Virtual Authenticator.
*
* These tests exercise the complete WebAuthn flow:
* 1. Registration via WebSocket using bootstrap reset token
* 2. Authentication via WebSocket
* 3. Session validation
* 4. User info retrieval
* 5. Logout
*
* The virtual authenticator simulates a hardware passkey device,
* allowing fully automated testing without physical hardware.
*/
test.describe('Passkey Authentication E2E', () => {
const baseUrl = process.env.BASE_URL || 'http://localhost:4401'
test.describe.configure({ mode: 'serial' })
// Shared state across tests in this describe block
let sessionToken: string
let userUuid: string
let credentialUuid: string
let resetToken: string | undefined
test.beforeAll(() => {
// Get the bootstrap reset token from global setup
resetToken = getBootstrapResetToken()
if (!resetToken) {
console.warn('⚠️ No reset token found - registration test may fail')
} else {
console.log(`📝 Using reset token: ${resetToken}`)
}
})
test('should load the auth page', async ({ page }) => {
// Navigate to auth page to establish origin for WebAuthn
await page.goto('/auth/')
await expect(page).toHaveTitle(/.*/)
// Page should load - 401 errors are expected since user is not logged in
await page.waitForTimeout(500)
// Just verify the page loaded without JS errors (network 401s are OK)
console.log('✓ Auth page loaded successfully')
})
test('should register admin passkey via WebSocket using reset token', async ({ page, virtualAuthenticator }) => {
test.skip(!resetToken, 'No reset token available from bootstrap')
// Must visit the page first to establish origin
await page.goto('/auth/')
// Perform registration via WebSocket with virtual authenticator
// Using the bootstrap reset token for the admin user
const result = await registerPasskey(page, baseUrl, {
resetToken: resetToken,
displayName: 'Admin User',
})
// Verify registration result
expect(result.session_token).toBeDefined()
expect(result.session_token).toHaveLength(16)
expect(result.user_uuid).toBeDefined()
expect(result.credential_uuid).toBeDefined()
expect(result.message).toContain('successfully')
// Store for subsequent tests
sessionToken = result.session_token
userUuid = result.user_uuid
credentialUuid = result.credential_uuid
console.log(`✓ Registered user: ${userUuid}`)
console.log(`✓ Credential: ${credentialUuid}`)
console.log(`✓ Session token: ${sessionToken.substring(0, 4)}...`)
})
test('should validate the session token', async ({ page }) => {
// Skip if registration didn't run
test.skip(!sessionToken, 'Requires successful registration')
const validation = await validateSession(page, baseUrl, sessionToken)
expect(validation.valid).toBe(true)
expect(validation.user_uuid).toBe(userUuid)
console.log(`✓ Session validated for user: ${validation.user_uuid}`)
})
test('should retrieve user info', async ({ page }) => {
test.skip(!sessionToken, 'Requires successful registration')
const userInfo = await getUserInfo(page, baseUrl, sessionToken)
expect(userInfo.user.user_uuid).toBe(userUuid)
expect(userInfo.user.user_name).toBe('Admin User')
expect(userInfo.credentials).toBeDefined()
expect(userInfo.credentials.length).toBeGreaterThanOrEqual(1)
console.log(`✓ User info retrieved: ${userInfo.user.user_name}`)
console.log(`✓ Credentials count: ${userInfo.credentials.length}`)
})
test('should authenticate with existing passkey', async ({ page, virtualAuthenticator }) => {
test.skip(!sessionToken, 'Requires successful registration')
// Navigate to page (required for WebAuthn origin)
await page.goto('/auth/')
// The virtual authenticator in this context is new and doesn't have credentials.
// Create a device link using the current session, then register a new credential.
const deviceLink = await createDeviceLink(page, baseUrl, sessionToken)
console.log(`✓ Created device link with token: ${deviceLink.token}`)
// Register a new credential using the device link
const regResult = await registerPasskey(page, baseUrl, {
resetToken: deviceLink.token,
displayName: 'Admin User (test device)'
})
console.log(`✓ Added test credential: ${regResult.credential_uuid}`)
// Now logout and authenticate with the fresh credential
await logout(page, baseUrl, regResult.session_token)
console.log('✓ Logged out')
// Authenticate with the virtual authenticator (now has a valid credential)
const result = await authenticatePasskey(page, baseUrl)
expect(result.session_token).toBeDefined()
expect(result.session_token).toHaveLength(16)
expect(result.user_uuid).toBe(userUuid)
// Update session token for subsequent tests
sessionToken = result.session_token
console.log(`✓ Authenticated as user: ${result.user_uuid}`)
console.log(`✓ New session token: ${sessionToken.substring(0, 4)}...`)
})
test('should validate new session after authentication', async ({ page }) => {
test.skip(!sessionToken, 'Requires successful authentication')
const validation = await validateSession(page, baseUrl, sessionToken)
expect(validation.valid).toBe(true)
expect(validation.user_uuid).toBe(userUuid)
console.log(`✓ New session validated`)
})
test('should logout successfully', async ({ page }) => {
test.skip(!sessionToken, 'Requires valid session')
await logout(page, baseUrl, sessionToken)
// Session should no longer be valid
const response = await page.request.post(`${baseUrl}/auth/api/validate`, {
headers: {
'Cookie': `__Host-auth=${sessionToken}`,
},
failOnStatusCode: false,
})
expect(response.status()).toBe(401)
console.log(`✓ Logout successful, session invalidated`)
})
})
test.describe('Session Management', () => {
const baseUrl = process.env.BASE_URL || 'http://localhost:4401'
test('should reject invalid session token', async ({ page }) => {
const response = await page.request.post(`${baseUrl}/auth/api/validate`, {
headers: {
'Cookie': '__Host-auth=invalid_token_123',
},
failOnStatusCode: false,
})
// Server may return 400 (bad format) or 401 (unauthorized)
expect([400, 401]).toContain(response.status())
console.log(`✓ Invalid token correctly rejected`)
})
test('should reject missing session token', async ({ page }) => {
const response = await page.request.post(`${baseUrl}/auth/api/validate`, {
failOnStatusCode: false,
})
expect(response.status()).toBe(401)
console.log(`✓ Missing token correctly rejected`)
})
})
+4 -4
View File
@@ -3,7 +3,7 @@
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>PassKey Auth - Dev Mode</title> <title>Paskia - Dev Mode</title>
<style> <style>
:root { :root {
color-scheme: light dark; /* Automatic themes by browser */ color-scheme: light dark; /* Automatic themes by browser */
@@ -33,8 +33,8 @@
<body> <body>
<div class="container"> <div class="container">
<header> <header>
<h1>🔐 PassKey Auth</h1> <h1>🔐 Paskia - Development Server</h1>
<p class="subtitle">Development server demonstration page.</p> <p class="subtitle">The following features are available after you have registered your Admin account and logged in. You should also use the Admin Site to create non-privileged users to see the Forbidden dialog caused by missing permissions.</p>
</header> </header>
<div class="content"> <div class="content">
@@ -55,7 +55,7 @@
<div class="section"> <div class="section">
<h2>Browser Mode (full page)</h2> <h2>Browser Mode (full page)</h2>
<p>Block access to otherwise open site - intended for forward-auth mechanism (Caddy, Nxinx):</p> <p>Block access to otherwise open site - intended for forward-auth mechanism (Caddy, Nginx):</p>
<button onclick="browserNav('/auth/api/forward')">🔐 Basic Auth</button> <button onclick="browserNav('/auth/api/forward')">🔐 Basic Auth</button>
<button onclick="browserNav('/auth/api/forward?max_age=10s')">🔄 Reauth (max_age=10s)</button> <button onclick="browserNav('/auth/api/forward?max_age=10s')">🔄 Reauth (max_age=10s)</button>
<button onclick="browserNav('/auth/api/forward?perm=auth:admin')">🛡️ Admin Only</button> <button onclick="browserNav('/auth/api/forward?perm=auth:admin')">🛡️ Admin Only</button>
-1
View File
@@ -3,7 +3,6 @@ import { ref, onMounted, onUnmounted, computed, watch } from 'vue'
import Breadcrumbs from '@/components/Breadcrumbs.vue' import Breadcrumbs from '@/components/Breadcrumbs.vue'
import CredentialList from '@/components/CredentialList.vue' import CredentialList from '@/components/CredentialList.vue'
import UserBasicInfo from '@/components/UserBasicInfo.vue' import UserBasicInfo from '@/components/UserBasicInfo.vue'
import RegistrationLinkModal from '@/components/RegistrationLinkModal.vue'
import StatusMessage from '@/components/StatusMessage.vue' import StatusMessage from '@/components/StatusMessage.vue'
import LoadingView from '@/components/LoadingView.vue' import LoadingView from '@/components/LoadingView.vue'
import AuthRequiredMessage from '@/components/AccessDenied.vue' import AuthRequiredMessage from '@/components/AccessDenied.vue'
+24 -1
View File
@@ -1,15 +1,35 @@
<template> <template>
<RestrictedAuth <RestrictedAuth
:mode="authMode" :mode="authMode"
:remote-auth-token="remoteAuthToken"
@authenticated="handleAuthenticated" @authenticated="handleAuthenticated"
@back="handleBack" @back="handleBack"
/> />
</template> </template>
<script setup> <script setup>
import { computed, onMounted } from 'vue' import { computed, onMounted, ref } from 'vue'
import RestrictedAuth from '@/components/RestrictedAuth.vue' import RestrictedAuth from '@/components/RestrictedAuth.vue'
// Check if this is a remote auth URL: /auth/{token}
// The token is a 5-word passphrase like "word1.word2.word3.word4.word5"
const remoteAuthToken = ref(null)
function extractRemoteToken() {
const path = window.location.pathname
// Match /auth/{token} where token is a passphrase with dots
const match = path.match(/\/auth\/([^/]+)$/)
if (match) {
const token = match[1]
// Validate it looks like a 5-word passphrase
const parts = token.split('.')
if (parts.length === 5 && parts.every(p => p.length > 0)) {
return token
}
}
return null
}
// Detect mode from URL hash fragment // Detect mode from URL hash fragment
const authMode = computed(() => { const authMode = computed(() => {
const params = new URLSearchParams(window.location.hash.slice(1)) const params = new URLSearchParams(window.location.hash.slice(1))
@@ -40,6 +60,9 @@ function handleBack() {
} }
onMounted(() => { onMounted(() => {
// Check for remote auth token in URL
remoteAuthToken.value = extractRemoteToken()
postToParent({ postToParent({
type: 'auth-ready' type: 'auth-ready'
}) })
+2 -4
View File
@@ -23,7 +23,6 @@
<section class="section-block" v-else-if="!canRegister"> <section class="section-block" v-else-if="!canRegister">
<div class="section-body center"> <div class="section-body center">
<p>{{ errorMessage }}</p>
<div class="button-row center" style="justify-content: center;"> <div class="button-row center" style="justify-content: center;">
<button class="btn-secondary" @click="goHome">Return to sign-in</button> <button class="btn-secondary" @click="goHome">Return to sign-in</button>
</div> </div>
@@ -80,7 +79,7 @@ let statusTimer = null
const sessionDescriptor = computed(() => userInfo.value?.session_type || 'your enrollment') const sessionDescriptor = computed(() => userInfo.value?.session_type || 'your enrollment')
const subtitleMessage = computed(() => { const subtitleMessage = computed(() => {
if (initializing.value) return 'Preparing your secure enrollment…' if (initializing.value) return 'Preparing your secure enrollment…'
if (!canRegister.value) return 'This reset link is no longer valid.' if (!canRegister.value) return 'This authentication link is no longer valid.'
return `Finish up ${sessionDescriptor.value}. You may edit the name below if needed, and it will be saved to your passkey.` return `Finish up ${sessionDescriptor.value}. You may edit the name below if needed, and it will be saved to your passkey.`
}) })
@@ -120,10 +119,9 @@ async function fetchUserInfo() {
} catch (error) { } catch (error) {
console.error('Failed to load user info', error) console.error('Failed to load user info', error)
const message = error instanceof ApiError const message = error instanceof ApiError
? (error.data?.detail || 'Reset link is invalid or expired.') ? (error.data?.detail || 'The authentication link is invalid or expired.')
: getUserFriendlyErrorMessage(error) : getUserFriendlyErrorMessage(error)
errorMessage.value = message errorMessage.value = message
showMessage(message, 'error', 0)
} }
} }
-1
View File
@@ -132,7 +132,6 @@ async function handleTerminateSession(session) {
<RegistrationLinkModal <RegistrationLinkModal
v-if="showRegModal" v-if="showRegModal"
:endpoint="`/auth/api/admin/orgs/${selectedUser.org_uuid}/users/${selectedUser.uuid}/create-link`" :endpoint="`/auth/api/admin/orgs/${selectedUser.org_uuid}/users/${selectedUser.uuid}/create-link`"
:auto-copy="false"
:user-name="userDetail?.display_name || selectedUser.display_name" :user-name="userDetail?.display_name || selectedUser.display_name"
@close="$emit('closeRegModal')" @close="$emit('closeRegModal')"
@copied="onLinkCopied" @copied="onLinkCopied"
+3 -3
View File
@@ -422,9 +422,9 @@ th {
} }
.qr-code { .qr-code {
border: 1px solid var(--color-border); padding: 1rem;
padding: 0.75rem; background: #fff;
background: var(--color-surface); box-shadow: var(--shadow-soft);
} }
.link-container, .link-container,
+10 -10
View File
@@ -4,18 +4,17 @@
<h1>📱 Add Another Device</h1> <h1>📱 Add Another Device</h1>
<p class="view-lede">Generate a one-time link to set up passkeys on a new device.</p> <p class="view-lede">Generate a one-time link to set up passkeys on a new device.</p>
</header> </header>
<RegistrationLinkModal
inline
:endpoint="'/auth/api/user/create-link'"
:user-name="userName"
:auto-copy="false"
:prefix-copy-with-user-name="!!userName"
show-close-in-inline
@copied="onCopied"
/>
<div class="button-row" style="margin-top:1rem;"> <div class="button-row" style="margin-top:1rem;">
<button @click="showModal = true" class="btn-primary">Generate Registration Link</button>
<button @click="authStore.currentView = 'profile'" class="btn-secondary">Back to Profile</button> <button @click="authStore.currentView = 'profile'" class="btn-secondary">Back to Profile</button>
</div> </div>
<RegistrationLinkModal
v-if="showModal"
endpoint="/auth/api/user/create-link"
:user-name="userName"
@close="showModal = false"
@copied="onCopied"
/>
</section> </section>
</template> </template>
@@ -26,9 +25,10 @@ import RegistrationLinkModal from '@/components/RegistrationLinkModal.vue'
const authStore = useAuthStore() const authStore = useAuthStore()
const userName = ref(null) const userName = ref(null)
const showModal = ref(false)
const onCopied = () => { const onCopied = () => {
authStore.showMessage('Link copied to clipboard!', 'success', 2500) authStore.showMessage('Link copied to clipboard!', 'success', 2500)
authStore.currentView = 'profile'
} }
onMounted(async () => { onMounted(async () => {
+35 -7
View File
@@ -1,9 +1,9 @@
<template> <template>
<section class="view-root" data-view="profile"> <section class="view-root" data-view="profile">
<header class="view-header"> <header class="view-header">
<h1>👋 Welcome!</h1> <h1>User Profile</h1>
<Breadcrumbs :entries="breadcrumbEntries" /> <Breadcrumbs :entries="breadcrumbEntries" />
<p class="view-lede">Manage your account details and passkeys.</p> <p class="view-lede">Account dashboard for managing credentials and authenticating with other devices.</p>
</header> </header>
<section class="section-block"> <section class="section-block">
@@ -17,7 +17,20 @@
update-endpoint="/auth/api/user/display-name" update-endpoint="/auth/api/user/display-name"
@saved="authStore.loadUserInfo()" @saved="authStore.loadUserInfo()"
@edit-name="openNameDialog" @edit-name="openNameDialog"
/> >
<div class="remote-auth-inline">
<label v-if="!showDeviceInfo" class="remote-auth-label">Code words from remote device:</label>
<RemoteAuth
ref="pairingEntry"
title=""
description=""
placeholder="word word word"
@completed="handlePairingCompleted"
@error="handlePairingError"
@device-info-visible="showDeviceInfo = $event"
/>
</div>
</UserBasicInfo>
</section> </section>
<section class="section-block"> <section class="section-block">
@@ -84,11 +97,8 @@
</section> </section>
<RegistrationLinkModal <RegistrationLinkModal
v-if="showRegLink" v-if="showRegLink"
:endpoint="'/auth/api/user/create-link'" endpoint="/auth/api/user/create-link"
:auto-copy="false"
:prefix-copy-with-user-name="false"
@close="showRegLink = false" @close="showRegLink = false"
@copied="showRegLink = false; authStore.showMessage('Link copied to clipboard!', 'success', 2500)"
/> />
</section> </section>
</template> </template>
@@ -102,6 +112,7 @@ import Modal from '@/components/Modal.vue'
import NameEditForm from '@/components/NameEditForm.vue' import NameEditForm from '@/components/NameEditForm.vue'
import SessionList from '@/components/SessionList.vue' import SessionList from '@/components/SessionList.vue'
import RegistrationLinkModal from '@/components/RegistrationLinkModal.vue' import RegistrationLinkModal from '@/components/RegistrationLinkModal.vue'
import RemoteAuth from '@/components/RemoteAuthPermit.vue'
import { useAuthStore } from '@/stores/auth' import { useAuthStore } from '@/stores/auth'
import { adminUiPath, makeUiHref } from '@/utils/settings' import { adminUiPath, makeUiHref } from '@/utils/settings'
import passkey from '@/utils/passkey' import passkey from '@/utils/passkey'
@@ -116,6 +127,8 @@ const newName = ref('')
const saving = ref(false) const saving = ref(false)
const hoveredCredentialUuid = ref(null) const hoveredCredentialUuid = ref(null)
const hoveredSession = ref(null) const hoveredSession = ref(null)
const showDeviceInfo = ref(false)
const pairingEntry = ref(null)
watch(showNameDialog, (newVal) => { if (newVal) newName.value = authStore.userInfo?.user?.user_name || '' }) watch(showNameDialog, (newVal) => { if (newVal) newName.value = authStore.userInfo?.user?.user_name || '' })
@@ -138,6 +151,19 @@ const addNewCredential = async () => {
} }
} }
const handlePairingCompleted = () => {
authStore.showMessage('The other device is now signed in!', 'success', 4000)
// Reset the form after a delay
setTimeout(() => pairingEntry.value?.reset(), 3000)
}
const handlePairingError = (message) => {
// Error is already shown in the component, optionally show global message for severe errors
if (!message.includes('cancelled')) {
authStore.showMessage(message, 'error', 4000)
}
}
const handleDelete = async (credential) => { const handleDelete = async (credential) => {
const credentialId = credential?.credential_uuid const credentialId = credential?.credential_uuid
if (!credentialId) return if (!credentialId) return
@@ -199,5 +225,7 @@ const saveName = async () => {
.logout-row { gap: 1rem; } .logout-row { gap: 1rem; }
.logout-row.single { justify-content: flex-start; } .logout-row.single { justify-content: flex-start; }
.logout-note { margin: 0.75rem 0 0; color: var(--color-text-muted); font-size: 0.875rem; } .logout-note { margin: 0.75rem 0 0; color: var(--color-text-muted); font-size: 0.875rem; }
.remote-auth-inline { display: flex; flex-direction: column; gap: 0.5rem; }
.remote-auth-label { display: block; margin: 0; font-size: 0.875rem; color: var(--color-text-muted); font-weight: 500; }
@media (max-width: 720px) { .logout-button { width: 100%; } } @media (max-width: 720px) { .logout-button { width: 100%; } }
</style> </style>
+161
View File
@@ -0,0 +1,161 @@
<template>
<div class="qr-display">
<div class="qr-section">
<a :href="url" @click.prevent="copyLink" class="qr-link" title="Click to copy link">
<canvas ref="qrCanvas" class="qr-code"></canvas>
<div v-if="showLink && url" class="link-text">{{ displayUrl }}</div>
</a>
</div>
<div v-if="showCopyToast" class="copy-toast">
Link copied to clipboard
</div>
</div>
</template>
<script setup>
import { ref, watch, nextTick, computed } from 'vue'
import QRCode from 'qrcode/lib/browser'
const props = defineProps({
url: { type: String, required: true },
showLink: { type: Boolean, default: false }
})
const emit = defineEmits(['copied'])
const qrCanvas = ref(null)
const showCopyToast = ref(false)
let copyToastTimer = null
const displayUrl = computed(() => {
if (!props.url) return ''
return props.url.replace(/^https?:\/\//, '')
})
function drawQR() {
if (!props.url || !qrCanvas.value) {
return
}
try {
// Clear the canvas first
const ctx = qrCanvas.value.getContext('2d')
ctx.clearRect(0, 0, qrCanvas.value.width, qrCanvas.value.height)
// Generate QR code synchronously
QRCode.toCanvas(qrCanvas.value, props.url, {
scale: 6,
margin: 0,
color: {
dark: '#000000',
light: '#FFFFFF'
}
})
// Remove any inline styles added by QRCode library immediately
qrCanvas.value.removeAttribute('style')
} catch (err) {
console.error('QR code generation failed:', err)
}
}
async function copyLink() {
if (!props.url) return
try {
await navigator.clipboard.writeText(props.url)
showCopyToast.value = true
emit('copied')
if (copyToastTimer) clearTimeout(copyToastTimer)
copyToastTimer = setTimeout(() => {
showCopyToast.value = false
}, 2000)
} catch (err) {
console.error('Failed to copy link:', err)
}
}
// Watch for URL changes
watch(() => props.url, () => {
drawQR()
}, { immediate: true })
// Watch for canvas ref becoming available
watch(qrCanvas, () => {
if (qrCanvas.value && props.url) {
drawQR()
}
}, { immediate: true })
</script>
<style scoped>
.qr-display {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.75rem;
}
.qr-section {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.5rem;
}
.qr-link {
display: flex;
flex-direction: column;
align-items: center;
text-decoration: none;
color: inherit;
border-radius: var(--radius-sm, 6px);
overflow: hidden;
}
.qr-code {
display: block;
width: 200px;
height: 200px;
max-width: 100%;
object-fit: contain;
border-radius: var(--radius-sm, 6px);
background: #ffffff;
cursor: pointer;
}
.link-text {
padding: 0.5rem;
font-size: 0.75rem;
color: var(--color-text-muted);
font-family: monospace;
word-break: break-all;
line-height: 1.2;
transition: color 0.2s ease;
}
.qr-link:hover .link-text {
color: var(--color-text);
}
.copy-toast {
position: absolute;
top: -2rem;
left: 50%;
transform: translateX(-50%);
background: var(--color-success);
color: white;
padding: 0.5rem 1rem;
border-radius: var(--radius-sm);
font-size: 0.875rem;
z-index: 10;
animation: fadeInOut 2s ease-in-out;
}
@keyframes fadeInOut {
0%, 100% { opacity: 0; }
10%, 90% { opacity: 1; }
}
</style>
+71 -109
View File
@@ -1,147 +1,109 @@
<template> <template>
<div v-if="!inline && url" class="dialog-overlay" @keydown.esc.prevent="$emit('close')"> <div class="dialog-overlay" @keydown.esc.prevent="$emit('close')">
<div class="device-dialog" role="dialog" aria-modal="true" aria-labelledby="regTitle"> <div class="device-dialog" role="dialog" aria-modal="true" aria-labelledby="regTitle">
<div class="reg-header-row"> <div class="reg-header-row">
<h2 id="regTitle" class="reg-title"> <h2 id="regTitle" class="reg-title">
📱 <span v-if="userName">Registration for {{ userName }}</span><span v-else>Device Registration Link</span> 📱 <span v-if="userName">Registration for {{ userName }}</span><span v-else>Add Another Device</span>
</h2> </h2>
<button class="icon-btn" @click="$emit('close')" aria-label="Close"></button> <button class="icon-btn" @click="$emit('close')" aria-label="Close"></button>
</div> </div>
<div class="device-link-section"> <div class="device-link-section">
<div class="qr-container"> <!-- Loading state -->
<a :href="url" @click.prevent="copy" class="qr-link"> <div v-if="loading" class="loading-state">
<canvas ref="qrCanvas" class="qr-code"></canvas> <div class="spinner-small"></div>
<p>{{ displayUrl }}</p> <span>Generating registration link...</span>
</a>
<p class="reg-help">
<span v-if="userName">The user should open this link on the device where they want to register.</span>
<span v-else>Open or scan this link on the device you wish to register to your account.</span>
<br><small>{{ expirationMessage }}</small>
</p>
</div> </div>
<!-- Error state -->
<div v-else-if="error" class="error-state">
<p class="error-message">{{ error }}</p>
<button class="btn-secondary" @click="generateLink">Retry</button>
</div>
<!-- Success state with QR code and link -->
<template v-else-if="linkUrl">
<p class="reg-help">
Scan this QR code on the new device, or copy the link and open it there.
</p>
<QRCodeDisplay
:url="linkUrl"
:show-link="true"
@copied="onCopied"
/>
<p class="expiry-note" v-if="expiresAt">
This link expires {{ formatDate(expiresAt).toLowerCase() }}.
</p>
</template>
</div> </div>
<div class="reg-actions"> <div class="reg-actions">
<button class="btn-secondary" @click="$emit('close')">Close</button> <button class="btn-secondary" @click="$emit('close')">Close</button>
<button class="btn-primary" @click="copy">Copy Link</button>
</div>
</div>
</div>
<div v-else-if="inline && url" class="registration-inline-wrapper">
<div class="registration-inline-block section-block">
<div class="section-header">
<h2 class="inline-heading">📱 <span v-if="userName">Registration for {{ userName }}</span><span v-else>Device Registration Link</span></h2>
</div>
<div class="section-body">
<div class="device-link-section">
<div class="qr-container">
<a :href="url" @click.prevent="copy" class="qr-link">
<canvas ref="qrCanvas" class="qr-code"></canvas>
<p>{{ displayUrl }}</p>
</a>
<p class="reg-help">
<span v-if="userName">The user should open this link on the device where they want to register.</span>
<span v-else>Open this link on the device you wish to connect with.</span>
<br><small>{{ expirationMessage }}</small>
</p>
</div>
</div>
<div class="button-row" style="margin-top:1rem;">
<button class="btn-primary" @click="copy">Copy Link</button>
<button v-if="showCloseInInline" class="btn-secondary" @click="$emit('close')">Close</button>
</div>
</div> </div>
</div> </div>
</div> </div>
</template> </template>
<script setup> <script setup>
import { ref, onMounted, watch, computed, nextTick } from 'vue' import { ref, onMounted } from 'vue'
import QRCode from 'qrcode/lib/browser' import QRCodeDisplay from '@/components/QRCodeDisplay.vue'
import { apiJson } from '@/utils/api'
import { formatDate } from '@/utils/helpers' import { formatDate } from '@/utils/helpers'
import { useAuthStore } from '@/stores/auth'
import { apiJson, getUserFriendlyErrorMessage, shouldShowErrorToast } from '@/utils/api'
const authStore = useAuthStore()
const props = defineProps({ const props = defineProps({
endpoint: { type: String, required: true }, endpoint: { type: String, required: true },
autoCopy: { type: Boolean, default: true }, userName: { type: String, default: '' }
userName: { type: String, default: null },
inline: { type: Boolean, default: false },
showCloseInInline: { type: Boolean, default: false },
prefixCopyWithUserName: { type: Boolean, default: false }
}) })
const emit = defineEmits(['close','generated','copied']) const emit = defineEmits(['close', 'copied'])
const url = ref(null) const loading = ref(true)
const expires = ref(null) const error = ref(null)
const qrCanvas = ref(null) const linkUrl = ref(null)
const expiresAt = ref(null)
const displayUrl = computed(() => url.value ? url.value.replace(/^[^:]+:\/\//,'') : '') async function generateLink() {
loading.value = true
error.value = null
linkUrl.value = null
expiresAt.value = null
const expirationMessage = computed(() => {
const timeStr = formatDate(expires.value)
return `⚠️ Expires ${timeStr.startsWith('In ') ? timeStr.substring(3) : timeStr} and can only be used once.`
})
async function fetchLink() {
try { try {
const data = await apiJson(props.endpoint, { method: 'POST' }) const data = await apiJson(props.endpoint, { method: 'POST' })
url.value = data.url if (data.url) {
expires.value = data.expires linkUrl.value = data.url
emit('generated', { url: data.url, expires: data.expires }) expiresAt.value = data.expires ? new Date(data.expires) : null
await nextTick() } else {
drawQR() error.value = data.detail || 'Failed to generate link'
if (props.autoCopy) copy()
} catch (e) {
console.error('Failed to create link', e)
if (shouldShowErrorToast(e)) {
authStore.showMessage(getUserFriendlyErrorMessage(e), 'error', 4000)
} }
// Close the dialog on any error (auth cancelled, network error, etc.) } catch (err) {
emit('close') error.value = err.message || 'Failed to generate link'
} finally {
loading.value = false
} }
} }
async function drawQR() { function onCopied() {
if (!url.value) return emit('copied')
await nextTick()
if (!qrCanvas.value) return
QRCode.toCanvas(qrCanvas.value, url.value, { scale: 8 }, err => { if (err) console.error(err) })
} }
async function copy() { onMounted(() => {
if (!url.value) return generateLink()
let text = url.value })
if (props.prefixCopyWithUserName && props.userName) {
text = `${props.userName} ${text}`
}
try {
await navigator.clipboard.writeText(text)
emit('copied', text)
if (!props.inline) emit('close')
} catch (_) {
/* ignore */
}
}
onMounted(fetchLink)
watch(url, () => drawQR(), { flush: 'post' })
</script> </script>
<style scoped> <style scoped>
.icon-btn { background:none; border:none; cursor:pointer; font-size:1rem; opacity:.6; } .icon-btn { background: none; border: none; cursor: pointer; font-size: 1rem; opacity: .6; }
.icon-btn:hover { opacity:1; } .icon-btn:hover { opacity: 1; }
/* Minimal extra styling; main look comes from global styles */ .reg-header-row { display: flex; justify-content: space-between; align-items: center; gap: .75rem; margin-bottom: .75rem; }
.qr-link { text-decoration:none; color:inherit; } .reg-title { margin: 0; font-size: 1.25rem; font-weight: 600; }
.reg-header-row { display:flex; justify-content:space-between; align-items:center; gap:.75rem; margin-bottom:.75rem; } .device-dialog { background: var(--color-surface); padding: 1.25rem 1.25rem 1rem; border-radius: var(--radius-md); max-width: 480px; width: 100%; box-shadow: 0 6px 28px rgba(0,0,0,.25); }
.reg-title { margin:0; font-size:1.25rem; font-weight:600; } .reg-help { margin: .5rem 0 .75rem; font-size: .85rem; line-height: 1.4; text-align: center; color: var(--color-text-muted); }
.device-dialog { background: var(--color-surface); padding: 1.25rem 1.25rem 1rem; border-radius: var(--radius-md); max-width:480px; width:100%; box-shadow:0 6px 28px rgba(0,0,0,.25); } .reg-actions { display: flex; justify-content: flex-end; gap: .5rem; margin-top: 1rem; }
.qr-container { display:flex; flex-direction:column; align-items:center; gap:.5rem; } .loading-state { display: flex; align-items: center; justify-content: center; gap: .5rem; padding: 2rem 0; color: var(--color-text-muted); }
.qr-code { display:block; } .error-state { text-align: center; padding: 1rem 0; }
.reg-help { margin-top:.5rem; margin-bottom:.75rem; font-size:.85rem; line-height:1.25rem; text-align:center; } .error-message { color: var(--color-danger-text); margin-bottom: 1rem; }
.reg-actions { display:flex; justify-content:flex-end; gap:.5rem; margin-top:.25rem; } .expiry-note { font-size: .75rem; color: var(--color-text-muted); text-align: center; margin-top: .75rem; }
.registration-inline-block .qr-container { align-items:flex-start; }
.registration-inline-block .reg-help { text-align:left; }
</style> </style>
@@ -0,0 +1,894 @@
<template>
<div class="pairing-entry">
<form @submit.prevent="submitCode" class="pairing-form">
<!-- Code input (shown when device info not yet received) -->
<div v-if="!deviceInfo" class="input-row">
<div class="input-wrapper" :class="{ 'has-error': serverError, 'is-complete': deviceInfo && !serverError, 'focused': isFocused }">
<!-- Visual slot-machine display overlay -->
<div class="slot-machine" :class="{ 'has-error': serverError, 'is-complete': deviceInfo && !serverError }" aria-hidden="true">
<div v-for="(word, index) in displayWords" :key="index" class="slot-reel" :class="{ 'invalid-word': word.invalid, 'empty': !word.text && !word.typedPrefix }">
<div class="slot-word">
<template v-if="word.typedPrefix">
<span class="typed-prefix">{{ word.typedPrefix }}</span><span class="hint-suffix">{{ word.hintSuffix }}</span>
<span v-if="word.hasCursor" class="cursor-overlay" :style="{ '--cursor-pos': word.cursorCharIndex, '--word-len': word.wordLen }"></span>
</template>
<template v-else-if="word.text">
{{ word.text }}
<span v-if="word.hasCursor" class="cursor-overlay" :style="{ '--cursor-pos': word.cursorCharIndex, '--word-len': word.wordLen }"></span>
</template>
<template v-else>
<span v-if="word.hasCursor" class="cursor-overlay" :style="{ '--cursor-pos': 0, '--word-len': 0 }"></span>
</template>
</div>
</div>
</div>
<!-- Hidden input for actual text entry -->
<input
ref="inputRef"
v-model="code"
type="text"
:placeholder="placeholder"
autocomplete="off"
autocapitalize="none"
autocorrect="off"
spellcheck="false"
class="pairing-input hidden-input"
@input="handleInput"
@keydown="deferUpdateCursor"
@mouseup="updateCursorPos"
@focus="isFocused = true"
@blur="isFocused = false"
/>
</div>
<!-- Processing status beside input -->
<div v-if="processingStatus" class="processing-status">
<span class="processing-icon">{{ processingStatus === 'pow' ? '🔐' : '📡' }}</span>
<span class="processing-spinner-small"></span>
</div>
</div>
<!-- Device info display (shown when 3 words match a request) -->
<div v-else-if="deviceInfo" class="device-info">
<p class="device-permit-text">Permit {{ deviceInfo.action === 'register' ? 'registration' : 'login' }} to <strong>{{ deviceInfo.host }}</strong></p>
<p class="device-meta">{{ deviceInfo.user_agent_pretty }}</p>
<p v-if="error" class="error-message" style="margin-top: 0.5rem;">{{ error }}</p>
<div class="button-row" style="margin-top: 0.75rem; display: flex; gap: 0.5rem;">
<button
type="button"
class="btn-secondary"
:disabled="loading"
@click="deny"
style="flex: 1;"
>
Deny
</button>
<button
ref="submitBtnRef"
type="submit"
:disabled="loading"
class="btn-primary"
style="flex: 1;"
>
{{ loading ? 'Authenticating…' : 'Authorize' }}
</button>
</div>
</div>
<p v-if="error && !deviceInfo" class="error-message">{{ error }}</p>
</form>
</div>
</template>
<script setup>
import { computed, nextTick, onMounted, onUnmounted, ref, watch } from 'vue'
import { startAuthentication } from '@simplewebauthn/browser'
import aWebSocket from '@/utils/awaitable-websocket'
import { dec as b64dec, enc as b64enc } from '@/utils/base64url'
import { getSettings } from '@/utils/settings'
import { getUniqueMatch, isValidWord, isValidPrefix } from '@/utils/wordlist'
import { solvePoW } from '@/utils/pow'
import { useAuthStore } from '@/stores/auth'
const props = defineProps({
title: { type: String, default: 'Help Another Device Sign In' },
description: { type: String, default: 'Enter the code shown on the device that needs to sign in.' },
placeholder: { type: String, default: 'Enter three words' },
action: { type: String, default: 'login' } // 'login' or 'register'
})
const emit = defineEmits(['completed', 'error', 'cancelled', 'back', 'register', 'deviceInfoVisible'])
// State
const loading = ref(false)
const error = ref(null)
const settings = ref(null)
let ws = null
let authStore = null
// Try to get authStore (might fail if Pinia not installed in this app instance)
try { authStore = useAuthStore() } catch (e) { /* ignore */ }
const inputRef = ref(null)
const submitBtnRef = ref(null)
const code = ref('')
const isProcessing = ref(false)
const processingStatus = ref('')
const deviceInfo = ref(null)
const autocompleteHint = ref('')
// Watch deviceInfo and emit visibility change
watch(deviceInfo, (newVal) => {
emit('deviceInfoVisible', !!newVal)
})
const hasInvalidWord = ref(false)
const serverError = ref(false)
const cursorPos = ref(0)
const isFocused = ref(false)
let wsConnecting = false
let currentChallenge = null
let currentWork = null
let powPromise = null
let powSolution = null
let lookupTimeout = null
let lastLookedUpCode = null
// --- Helpers ---
function showMessage(message, type = 'info', duration = 3000) {
if (authStore) {
authStore.showMessage(message, type, duration)
}
}
async function fetchSettings() {
try {
const data = await getSettings()
settings.value = data
} catch (err) {
console.warn('Unable to load settings', err)
}
}
// --- Input Mode Logic ---
function getWordAtCursor(input, cursor) {
if (!input || cursor < 0) return { word: '', start: 0, end: 0 }
let start = cursor, end = cursor
while (start > 0 && /[a-zA-Z]/.test(input[start - 1])) start--
while (end < input.length && /[a-zA-Z]/.test(input[end])) end++
return { word: input.slice(start, end), start, end }
}
function getWords(input) {
return input.trim().split(/[.\s]+/).filter(w => w.length > 0)
}
function countCompleteWords(input) {
const endsWithSeparator = /[.\s]$/.test(input)
const words = getWords(input)
return endsWithSeparator ? words.length : Math.max(0, words.length - 1)
}
function analyzeWords(input) {
if (!input) return { valid: true, segments: [] }
const segments = []
const endsWithSeparator = /[.\s]$/.test(input)
let match, regex = /([a-zA-Z]+)|([.\s]+)/g
while ((match = regex.exec(input)) !== null) {
if (match[1]) segments.push({ text: match[1], isWord: true, start: match.index })
else if (match[2]) segments.push({ text: match[2], isWord: false, start: match.index })
}
const words = segments.filter(s => s.isWord)
let allValid = true
words.forEach((wordSeg, idx) => {
const isLastWord = idx === words.length - 1
const word = wordSeg.text.toLowerCase()
if (isLastWord && !endsWithSeparator) wordSeg.invalid = !isValidPrefix(word)
else wordSeg.invalid = !isValidWord(word)
if (wordSeg.invalid) allValid = false
})
return { valid: allValid, segments }
}
const coloredSegments = computed(() => {
const { segments } = analyzeWords(code.value)
return segments.map(s => ({ text: s.text, invalid: s.invalid || false }))
})
function checkWordsValidity(input) { return analyzeWords(input).valid }
function allWordsValid(input) { return getWords(input).length > 0 && getWords(input).every(w => isValidWord(w)) }
// Get the current partial word being typed (not yet a complete word)
function getCurrentPartialWord(input) {
const endsWithSeparator = /[.\s]$/.test(input)
if (endsWithSeparator) return ''
const match = input.match(/[a-zA-Z]+$/)
return match ? match[0].toLowerCase() : ''
}
// Calculate cursor position in the normalized display (wordIndex, charIndex within word)
// Returns { wordIndex: number, charIndex: number } where charIndex is position within the word text
function calcDisplayCursor(input, rawCursorPos) {
if (!input || rawCursorPos === 0) {
return { wordIndex: 0, charIndex: 0 }
}
// Parse input to find word boundaries
const beforeCursor = input.slice(0, rawCursorPos)
const wordMatches = [...beforeCursor.matchAll(/[a-zA-Z]+/g)]
// Check if cursor is in whitespace after words
const endsWithSeparator = /[.\s]$/.test(beforeCursor)
if (wordMatches.length === 0) {
// No words before cursor, cursor is at start of first word
return { wordIndex: 0, charIndex: 0 }
}
const lastMatch = wordMatches[wordMatches.length - 1]
const lastMatchEnd = lastMatch.index + lastMatch[0].length
if (endsWithSeparator || rawCursorPos > lastMatchEnd) {
// Cursor is after the last word (in whitespace), so it's at start of next word
return { wordIndex: Math.min(wordMatches.length, 2), charIndex: 0 }
}
// Cursor is within the last word
const charIndex = rawCursorPos - lastMatch.index
return { wordIndex: wordMatches.length - 1, charIndex: charIndex }
}
// Compute display words for slot-machine overlay (always 3 slots)
const displayWords = computed(() => {
const words = getWords(code.value)
const result = []
// Get analysis for validation
const { segments } = analyzeWords(code.value)
const wordSegments = segments.filter(s => s.isWord)
// Get current partial word and autocomplete hint
const partialWord = getCurrentPartialWord(code.value)
const hint = autocompleteHint.value
const endsWithSeparator = /[.\s]$/.test(code.value)
// Calculate where cursor should be displayed
const cursor = calcDisplayCursor(code.value, cursorPos.value)
// Always show exactly 3 slots
for (let i = 0; i < 3; i++) {
const isCursorSlot = cursor.wordIndex === i
if (i < words.length) {
const word = words[i].toLowerCase()
const isInvalid = wordSegments[i]?.invalid || false
const isLastWord = i === words.length - 1
if (isLastWord && !endsWithSeparator && hint && partialWord) {
// Show typed prefix + hint suffix in the same slot
// Total visible length is the full hint word
const totalLen = hint.length
result.push({
text: '',
typedPrefix: partialWord,
hintSuffix: hint.slice(partialWord.length),
invalid: isInvalid,
hasCursor: isCursorSlot,
cursorCharIndex: isCursorSlot ? cursor.charIndex : -1,
wordLen: totalLen
})
} else {
// Complete word - show cursor at appropriate position
result.push({
text: word,
invalid: isInvalid,
hasCursor: isCursorSlot,
cursorCharIndex: isCursorSlot ? cursor.charIndex : -1,
wordLen: word.length
})
}
} else {
// Empty slot
result.push({
text: '',
invalid: false,
hasCursor: isCursorSlot,
cursorCharIndex: 0,
wordLen: 0
})
}
}
return result
})
const hasThreeValidWords = computed(() => {
const words = getWords(code.value)
return words.length === 3 && words.every(w => isValidWord(w))
})
function normalizeCode(input) {
return input.trim().toLowerCase().split(/[.\s]+/).filter(w => w).join('.')
}
function startPowSolving() {
if (!currentChallenge || powPromise) return
const challenge = b64dec(currentChallenge)
powPromise = solvePoW(challenge, currentWork).then(solution => {
powSolution = solution
powPromise = null
})
}
async function getPowSolution() {
if (powSolution) { const s = powSolution; powSolution = null; return s }
if (powPromise) { await powPromise; const s = powSolution; powSolution = null; return s }
if (!currentChallenge) throw new Error('No PoW challenge available')
const challenge = b64dec(currentChallenge)
return await solvePoW(challenge, currentWork)
}
function updateChallenge(pow) {
if (pow?.challenge) {
currentChallenge = pow.challenge
currentWork = pow.work
powSolution = null
powPromise = null
startPowSolving()
}
}
async function ensureConnection() {
if (ws || wsConnecting) return
wsConnecting = true
try {
const authHost = settings.value?.auth_host
const wsPath = '/auth/ws/remote-auth/pair'
const wsUrl = authHost && location.host !== authHost ? `//${authHost}${wsPath}` : wsPath
ws = await aWebSocket(wsUrl)
const msg = await ws.receive_json()
if (msg.status && msg.detail) throw new Error(msg.detail)
if (!msg.pow?.challenge) throw new Error('Server did not send PoW challenge')
updateChallenge(msg.pow)
} catch (err) {
console.error('WebSocket connection error:', err)
ws = null
throw err
} finally {
wsConnecting = false
}
}
// Defer cursor position update to after browser processes the key
function deferUpdateCursor(event) {
// Handle Tab/Space for autocomplete immediately
if (event.key === 'Tab' || event.key === ' ') {
handleKeydown(event)
return
}
// Defer cursor update to next tick
setTimeout(updateCursorPos, 0)
}
// Update cursor position from input
function updateCursorPos() {
cursorPos.value = inputRef.value?.selectionStart ?? code.value.length
}
function updateAutocomplete() {
cursorPos.value = inputRef.value?.selectionStart ?? code.value.length
const { word, end } = getWordAtCursor(code.value, cursorPos.value)
const completeWordCount = countCompleteWords(code.value)
if (completeWordCount >= 3 || !word || word.length < 1 || cursorPos.value !== end) {
autocompleteHint.value = ''
return
}
const match = getUniqueMatch(word.toLowerCase())
if (match && match !== word.toLowerCase()) autocompleteHint.value = match
else autocompleteHint.value = ''
}
function applyAutocomplete() {
if (!autocompleteHint.value) return false
const { word, start, end } = getWordAtCursor(code.value, cursorPos.value)
if (!word) return false
const before = code.value.slice(0, start)
const wordsBefore = getWords(before).length
const isThirdWord = wordsBefore === 2
const suffix = isThirdWord ? '' : ' '
const after = code.value.slice(end)
code.value = before + autocompleteHint.value + suffix + after.trimStart()
const newPos = start + autocompleteHint.value.length + suffix.length
nextTick(() => {
inputRef.value?.setSelectionRange(newPos, newPos)
cursorPos.value = newPos
})
autocompleteHint.value = ''
return true
}
// Try to split concatenated words (e.g., "alienalien" -> "alien alien")
function trySplitWords(input) {
// Only process if there's a continuous string of letters at the end
const match = input.match(/^(.*?)([a-zA-Z]+)$/)
if (!match) return input
const prefix = match[1] // Everything before the letter sequence
const letters = match[2].toLowerCase()
// Try to find valid word boundaries in the letter sequence
const foundWords = []
let remaining = letters
while (remaining.length > 0) {
let foundWord = null
// Try to find the longest valid word from the start
for (let len = Math.min(remaining.length, 6); len >= 3; len--) {
const candidate = remaining.slice(0, len)
if (isValidWord(candidate)) {
foundWord = candidate
break
}
}
if (foundWord) {
foundWords.push(foundWord)
remaining = remaining.slice(foundWord.length)
// Stop after 3 words
if (foundWords.length >= 3) {
remaining = ''
break
}
} else {
// No valid word found, keep the remaining as-is
foundWords.push(remaining)
break
}
}
// Only return split version if we found at least one complete word
// and there's a clear boundary (more than one segment, or the segment is a complete word)
if (foundWords.length > 1 || (foundWords.length === 1 && isValidWord(foundWords[0]) && remaining === '')) {
return prefix + foundWords.join(' ')
}
return input
}
function handleInput() {
// Immediately update cursor position
cursorPos.value = inputRef.value?.selectionStart ?? code.value.length
// First, try to auto-split concatenated words
const splitCode = trySplitWords(code.value)
if (splitCode !== code.value) {
code.value = splitCode
nextTick(() => {
const newLen = splitCode.length
inputRef.value?.setSelectionRange(newLen, newLen)
cursorPos.value = newLen
})
}
const words = getWords(code.value)
if (words.length >= 3) {
const normalized = words.slice(0, 3).join(' ')
if (code.value !== normalized) {
const cursorWasAtEnd = cursorPos.value >= code.value.length
code.value = normalized
if (cursorWasAtEnd) {
nextTick(() => {
inputRef.value?.setSelectionRange(normalized.length, normalized.length)
cursorPos.value = normalized.length
})
}
}
}
updateAutocomplete()
if (lookupTimeout) { clearTimeout(lookupTimeout); lookupTimeout = null }
deviceInfo.value = null
error.value = null
serverError.value = false
hasInvalidWord.value = !checkWordsValidity(code.value)
const currentWords = getWords(code.value)
if (currentWords.length >= 1 && !ws && !wsConnecting) ensureConnection()
if (currentWords.length === 3) {
if (!allWordsValid(code.value)) return
lookupTimeout = setTimeout(() => { lookupDeviceInfo() }, 150)
}
}
async function lookupDeviceInfo() {
if (isProcessing.value || loading.value) return
if (!hasThreeValidWords.value) return
const normalizedCode = normalizeCode(code.value)
if (normalizedCode === lastLookedUpCode && deviceInfo.value) return
isProcessing.value = true
processingStatus.value = 'pow'
error.value = null
serverError.value = false
try {
await ensureConnection()
if (!ws) throw new Error('Failed to connect')
const solution = await getPowSolution()
const powB64 = b64enc(solution)
const currentCode = normalizeCode(code.value)
if (!hasThreeValidWords.value) return
processingStatus.value = 'server'
ws.send_json({ code: currentCode, pow: powB64 })
const res = await ws.receive_json()
updateChallenge(res.pow)
if (typeof res.status === 'number' && res.status >= 400) {
error.value = res.detail || 'Request failed'
serverError.value = true
deviceInfo.value = null
lastLookedUpCode = null
return
}
if (res.status === 'found' && res.host) {
code.value = currentCode.replace(/\./g, ' ')
deviceInfo.value = {
host: res.host,
user_agent_pretty: res.user_agent_pretty,
client_ip: res.client_ip,
action: res.action || 'login'
}
lastLookedUpCode = currentCode
nextTick(() => { submitBtnRef.value?.focus() })
} else {
error.value = 'Unexpected response from server'
serverError.value = true
deviceInfo.value = null
lastLookedUpCode = null
}
} catch (err) {
console.error('Lookup error:', err)
error.value = err.message || 'Lookup failed'
serverError.value = true
deviceInfo.value = null
lastLookedUpCode = null
if (ws) { ws.close(); ws = null }
} finally {
isProcessing.value = false
processingStatus.value = ''
}
}
function handleKeydown(event) {
if (event.key === 'Tab') {
if (autocompleteHint.value) {
const applied = applyAutocomplete()
if (applied) { event.preventDefault(); handleInput(); return }
}
if (code.value.trim()) event.preventDefault()
return
}
if (event.key === ' ' && autocompleteHint.value) {
const applied = applyAutocomplete()
if (applied) { event.preventDefault(); handleInput() }
}
}
async function submitCode() {
if (!deviceInfo.value || loading.value) return
loading.value = true
error.value = null
try {
if (!ws) await ensureConnection()
if (!ws) throw new Error('Failed to connect')
const solution = await getPowSolution()
const powB64 = b64enc(solution)
ws.send_json({ authenticate: true, pow: powB64 })
const res = await ws.receive_json()
if (typeof res.status === 'number' && res.status >= 400) throw new Error(res.detail || 'Authentication failed')
if (!res.optionsJSON) throw new Error(res.detail || 'Failed to get authentication options')
const authResponse = await startAuthentication(res)
ws.send_json(authResponse)
const result = await ws.receive_json()
if (typeof result.status === 'number' && result.status >= 400) throw new Error(result.detail || 'Authentication failed')
if (result.status === 'success') {
showMessage('Device authenticated successfully!', 'success', 3000)
emit('completed')
reset()
} else {
throw new Error(result.detail || 'Authentication failed')
}
} catch (err) {
console.error('Pairing error:', err)
const message = err.name === 'NotAllowedError'
? 'Passkey authentication was cancelled'
: (err.message || 'Authentication failed')
error.value = message
// Don't show toast - error is shown in dialog
emit('error', message)
} finally {
loading.value = false
if (ws) { ws.close(); ws = null }
}
}
async function deny() {
// Send deny message to server before closing websocket
if (ws) {
try {
ws.send_json({ deny: true })
// Give the server a moment to process the denial
await new Promise(resolve => setTimeout(resolve, 100))
} catch (e) {
console.error('Error sending deny message:', e)
}
ws.close()
ws = null
}
// Reset to initial state
reset()
}
function reset() {
code.value = ''
error.value = null
serverError.value = false
deviceInfo.value = null
isProcessing.value = false
processingStatus.value = ''
autocompleteHint.value = ''
hasInvalidWord.value = false
lastLookedUpCode = null
if (ws) { ws.close(); ws = null }
currentChallenge = null
currentWork = null
powPromise = null
powSolution = null
}
// --- Lifecycle ---
onMounted(async () => {
await fetchSettings()
inputRef.value?.focus()
// Initialize cursor position
nextTick(() => {
cursorPos.value = inputRef.value?.selectionStart ?? 0
})
})
onUnmounted(() => {
if (lookupTimeout) { clearTimeout(lookupTimeout); lookupTimeout = null }
if (ws) { ws.close(); ws = null }
})
defineExpose({ reset, deny, code, handleInput, loading, error })
</script>
<style scoped>
/* Input Mode Styles */
.pairing-entry {
display: flex;
flex-direction: column;
gap: 1rem;
}
.pairing-form {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.input-row {
display: flex;
align-items: center;
gap: 0.5rem;
}
.input-wrapper {
position: relative;
display: flex;
width: 280px;
max-width: 100%;
}
/* Slot machine visual display (matches RemoteAuthInline) */
.slot-machine {
position: absolute;
left: 0;
top: 0;
width: 100%;
height: 100%;
padding: 0.875rem 1rem;
background: var(--color-surface-hover, rgba(0, 0, 0, 0.03));
border: 2px solid var(--color-border);
border-radius: var(--radius-sm, 6px);
font-family: 'SF Mono', Monaco, 'Cascadia Code', 'Roboto Mono', Consolas, 'Courier New', monospace;
display: flex;
gap: 0;
align-items: center;
user-select: none;
pointer-events: none;
white-space: nowrap;
overflow: hidden;
box-sizing: border-box;
z-index: 1;
}
.slot-machine.has-error {
border-color: var(--color-error, #ef4444);
background: var(--color-error-bg, rgba(239, 68, 68, 0.05));
}
.slot-machine.is-complete {
border-color: var(--color-success, #10b981);
}
.slot-reel {
display: inline-flex;
align-items: center;
justify-content: center;
flex: 1 1 33.333%;
min-width: 0;
height: 1.8em;
overflow: visible;
position: relative;
border-radius: 3px;
}
.slot-reel:not(:last-child) {
margin-right: 0.5rem;
}
.slot-word {
font-size: 1.25rem;
font-weight: 600;
letter-spacing: 0.05em;
text-align: center;
width: 100%;
color: var(--color-text);
display: flex;
align-items: center;
justify-content: center;
position: relative;
}
.slot-word .typed-prefix {
color: var(--color-text);
}
.slot-word .hint-suffix {
color: var(--color-text-muted);
opacity: 0.6;
}
.cursor-overlay {
position: absolute;
width: 2px;
height: 1.2em;
background: var(--color-text);
animation: none;
pointer-events: none;
/* Position based on character index - calculate from center of slot */
left: calc(50% + (var(--cursor-pos) - var(--word-len, 0) / 2) * 0.65em);
transform: translateX(-1px);
opacity: 0;
}
.input-wrapper.focused .cursor-overlay {
opacity: 1;
animation: cursorBlink 1s ease-in-out infinite;
}
@keyframes cursorBlink {
0%, 49% {
opacity: 1;
}
50%, 100% {
opacity: 0;
}
}
.slot-reel.invalid-word .slot-word {
color: var(--color-error, #ef4444);
}
.slot-reel.invalid-word .slot-word .typed-prefix {
color: var(--color-error, #ef4444);
}
.slot-reel.invalid-word .cursor-overlay {
background: var(--color-error, #ef4444);
}
.slot-reel.empty .slot-word {
color: var(--color-text-muted);
}
/* Hidden input - keeps focus and handles keyboard input */
.pairing-input {
flex: 1;
width: 100%;
height: 100%;
padding: 0.875rem 1rem;
font-size: 1rem;
font-family: inherit;
border: 1px solid transparent;
border-radius: var(--radius-sm, 6px);
background: transparent;
color: transparent;
caret-color: transparent;
outline: none;
box-sizing: border-box;
position: relative;
z-index: 0;
}
.pairing-input.hidden-input {
color: transparent;
caret-color: transparent;
}
.pairing-input:disabled {
cursor: not-allowed;
}
.pairing-input::placeholder {
color: transparent;
}
.processing-status {
display: flex;
align-items: center;
gap: 0.25rem;
font-size: 0.875rem;
color: var(--color-text-muted);
}
.processing-icon {
font-size: 0.875rem;
}
.processing-spinner-small {
width: 12px;
height: 12px;
border: 2px solid var(--color-border);
border-top-color: var(--color-primary);
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
.device-info {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.device-permit-text {
margin: 0;
font-size: 0.95rem;
color: var(--color-text);
}
.device-meta {
margin: 0;
font-size: 0.8rem;
color: var(--color-text-muted);
font-family: 'SF Mono', Monaco, 'Cascadia Code', 'Roboto Mono', Consolas, 'Courier New', monospace;
}
.error-message {
margin: 0;
font-size: 0.875rem;
color: var(--color-error, #ef4444);
margin-bottom: 1rem;
}
</style>
@@ -0,0 +1,535 @@
<template>
<div class="remote-auth-inline">
<!-- Success state -->
<div v-if="completed" class="success-section">
<p class="success-message"> {{ successMessage }}</p>
</div>
<!-- Error state -->
<div v-else-if="error" class="error-section">
<p class="error-message">{{ error }}</p>
<button class="btn-primary" @click="retry" style="margin-top: 0.75rem;">Try Again</button>
</div>
<!-- Connecting phase -->
<div v-else-if="phase === 'connecting'" class="auth-display">
<div class="auth-content">
<div class="pairing-code-section">
<p class="pairing-label">Enter the code words:</p>
<div class="slot-machine" aria-hidden="true">
<div class="slot-reel" v-for="(word, index) in animatedWords" :key="index">
<div class="slot-word">{{ word }}</div>
</div>
</div>
<p class="site-url">{{ siteUrlDisplay }}</p>
</div>
</div>
<div class="waiting-indicator">
<div class="spinner-small"></div>
<span>Generating code</span>
</div>
</div>
<!-- Waiting/Authenticating phase - show codes -->
<div v-else class="auth-display">
<div class="auth-content">
<div v-if="pairingCode" class="pairing-code-section">
<p class="pairing-label">Enter the code words:</p>
<div class="slot-machine stopped">
<div class="slot-reel" v-for="(word, index) in displayCode.split(' ')" :key="index">
<div class="slot-word">{{ word }}</div>
</div>
</div>
<p class="site-url">{{ siteUrlDisplay }}</p>
</div>
</div>
<div class="waiting-indicator">
<div class="spinner-small"></div>
<span>{{ waitingMessage }}</span>
</div>
</div>
</div>
</template>
<script setup>
import { ref, computed, watch, onMounted, onUnmounted } from 'vue'
import aWebSocket from '@/utils/awaitable-websocket'
import { dec as b64dec, enc as b64enc } from '@/utils/base64url'
import { getSettings } from '@/utils/settings'
import { solvePoW } from '@/utils/pow'
import { words } from '@/utils/wordlist'
const props = defineProps({
active: { type: Boolean, default: false }
})
const emit = defineEmits(['authenticated', 'cancelled', 'error', 'register'])
const pairingCode = ref(null)
const completed = ref(false)
const error = ref(null)
const phase = ref('connecting')
const settings = ref(null)
const animatedWords = ref(['', '', ''])
let ws = null
let wordAnimationTimer = null
const displayCode = computed(() => pairingCode.value ? pairingCode.value.replace(/\./g, ' ') : '')
const siteUrlDisplay = computed(() => {
if (!settings.value) return ''
const authSiteUrl = settings.value.auth_site_url || `${location.protocol}//${location.host}/auth/`
// Remove the protocol and any trailing slash
const withoutProtocol = authSiteUrl.replace(/^https?:\/\//, '')
return withoutProtocol.endsWith('/') ? withoutProtocol.slice(0, -1) : withoutProtocol
})
const waitingMessage = computed(() => {
return phase.value === 'authenticating'
? 'Complete on another device…'
: 'Waiting for authentication…'
})
const successMessage = computed(() => 'Authenticated successfully!')
function getRandomWord() {
return words[Math.floor(Math.random() * words.length)]
}
function startWordAnimation() {
// Initialize with random words
animatedWords.value = [getRandomWord(), getRandomWord(), getRandomWord()]
let updateCount = 0
const maxUpdates = 20 // Number of cycles before stopping
// Different intervals for each slot to spin independently
const intervals = [
setInterval(() => {
const newWords = [...animatedWords.value]
newWords[0] = getRandomWord()
animatedWords.value = newWords
}, 140),
setInterval(() => {
const newWords = [...animatedWords.value]
newWords[1] = getRandomWord()
animatedWords.value = newWords
}, 170),
setInterval(() => {
const newWords = [...animatedWords.value]
newWords[2] = getRandomWord()
animatedWords.value = newWords
}, 200)
]
wordAnimationTimer = intervals
// Stop all after max updates
setTimeout(() => {
intervals.forEach(interval => clearInterval(interval))
wordAnimationTimer = null
}, maxUpdates * 170) // Average interval time
}
function stopWordAnimation() {
if (wordAnimationTimer) {
if (Array.isArray(wordAnimationTimer)) {
wordAnimationTimer.forEach(interval => clearInterval(interval))
} else {
clearInterval(wordAnimationTimer)
}
wordAnimationTimer = null
}
}
async function startRemoteAuth() {
error.value = null
completed.value = false
pairingCode.value = null
phase.value = 'connecting'
// Start word animation
startWordAnimation()
try {
settings.value = await getSettings()
const authHost = settings.value?.auth_host
const wsPath = '/auth/ws/remote-auth/request'
const wsUrl = authHost && location.host !== authHost ? `//${authHost}${wsPath}` : wsPath
ws = await aWebSocket(wsUrl)
// PoW challenge
const powChallenge = await ws.receive_json()
if (powChallenge.pow) {
const challenge = b64dec(powChallenge.pow.challenge)
const nonces = await solvePoW(challenge, powChallenge.pow.work)
ws.send_json({ pow: b64enc(nonces), action: 'login' })
}
// Receive the pairing code
const res = await ws.receive_json()
if (res.status) {
throw new Error(res.detail || `Failed to create remote auth request: ${res.status}`)
}
pairingCode.value = res.pairing_code
// Stop word animation
stopWordAnimation()
phase.value = 'waiting'
// Wait for authentication
while (true) {
const msg = await ws.receive_json()
if (msg.status === 'locked') {
// Someone has entered the code and is authenticating
phase.value = 'authenticating'
} else if (msg.status === 'paired') {
// Legacy/compatibility: Device paired, now authenticating
phase.value = 'authenticating'
} else if (msg.status === 'authenticated') {
// Success
completed.value = true
emit('authenticated', { session_token: msg.session_token })
break
} else if (msg.status === 'denied') {
// Explicitly denied by the authenticating device
throw new Error('Access denied')
} else if (msg.status === 'completed') {
// Registration flow
if (msg.reset_token) {
completed.value = true
emit('register', msg.reset_token)
}
break
} else if (msg.status === 'error' || msg.detail) {
throw new Error(msg.detail || 'Remote authentication failed')
}
}
} catch (err) {
console.error('Remote authentication error:', err)
const message = err.message || 'Authentication failed'
error.value = message
emit('error', message)
} finally {
if (ws) {
ws.close()
ws = null
}
}
}
function retry() {
startRemoteAuth()
}
function cancel() {
if (ws) {
ws.close()
ws = null
}
emit('cancelled')
}
watch(() => props.active, (newVal) => {
if (newVal && !pairingCode.value && !error.value && !completed.value) {
startRemoteAuth()
}
})
onMounted(() => {
if (props.active) {
startRemoteAuth()
}
})
onUnmounted(() => {
if (ws) {
ws.close()
ws = null
}
stopWordAnimation()
})
defineExpose({ retry, cancel })
</script>
<style scoped>
.remote-auth-inline {
display: flex;
flex-direction: column;
gap: 1rem;
width: 100%;
}
.loading-section {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.75rem;
padding: 2rem 1rem;
min-height: 180px;
justify-content: center;
}
.loading-section p {
margin: 0;
color: var(--color-text-muted);
font-size: 0.95rem;
}
.spinner {
width: 40px;
height: 40px;
border: 3px solid var(--color-border);
border-top-color: var(--color-primary);
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
.auth-display {
display: flex;
flex-direction: column;
gap: 1.25rem;
width: 100%;
min-height: 180px;
}
.auth-content {
display: flex;
gap: 2rem;
align-items: center;
justify-content: center;
flex-wrap: nowrap;
}
.loading-placeholder {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.75rem;
width: 100%;
padding: 1rem;
}
.loading-placeholder p {
margin: 0;
color: var(--color-text-muted);
font-size: 0.95rem;
}
.pairing-code-section {
flex: 0 0 auto;
display: flex;
flex-direction: column;
gap: 0.5rem;
width: 280px;
max-width: 100%;
}
.pairing-label {
margin: 0;
font-size: 0.875rem;
color: var(--color-text-muted);
font-weight: 500;
text-align: center;
}
.slot-machine {
padding: 0.875rem 1rem;
background: var(--color-surface-hover, rgba(0, 0, 0, 0.03));
border: 2px solid var(--color-border);
border-radius: var(--radius-sm, 6px);
font-family: 'SF Mono', Monaco, 'Cascadia Code', 'Roboto Mono', Consolas, 'Courier New', monospace;
display: flex;
align-items: center;
user-select: none;
pointer-events: none;
white-space: nowrap;
overflow: hidden;
}
.slot-reel {
display: inline-flex;
align-items: center;
justify-content: center;
flex: 1;
min-width: 0;
height: 1.8em;
overflow: hidden;
position: relative;
background: var(--color-surface, rgba(255, 255, 255, 0.5));
border-radius: 3px;
}
.slot-machine:not(.stopped) .slot-reel:nth-child(1) {
animation: slotSpin 0.14s ease-in-out infinite;
}
.slot-machine:not(.stopped) .slot-reel:nth-child(2) {
animation: slotSpin 0.17s ease-in-out infinite;
}
.slot-machine:not(.stopped) .slot-reel:nth-child(3) {
animation: slotSpin 0.20s ease-in-out infinite;
}
.slot-word {
font-size: 1.25rem;
font-weight: 600;
letter-spacing: 0.05em;
text-align: center;
width: 100%;
}
.slot-machine:not(.stopped) .slot-reel:nth-child(1) .slot-word {
animation: wordRoll 0.14s ease-in-out infinite;
}
.slot-machine:not(.stopped) .slot-reel:nth-child(2) .slot-word {
animation: wordRoll 0.17s ease-in-out infinite;
}
.slot-machine:not(.stopped) .slot-reel:nth-child(3) .slot-word {
animation: wordRoll 0.20s ease-in-out infinite;
}
@keyframes slotSpin {
0% {
box-shadow: inset 0 2px 4px rgba(0, 0, 0, 0.1);
}
50% {
box-shadow: inset 0 4px 8px rgba(0, 0, 0, 0.2);
}
100% {
box-shadow: inset 0 2px 4px rgba(0, 0, 0, 0.1);
}
}
@keyframes wordRoll {
0% {
transform: translateY(-30%) scale(0.9);
opacity: 0.4;
filter: blur(1.5px);
}
25% {
transform: translateY(-10%) scale(0.95);
opacity: 0.6;
filter: blur(1px);
}
50% {
transform: translateY(0) scale(1);
opacity: 1;
filter: blur(0);
}
75% {
transform: translateY(10%) scale(0.95);
opacity: 0.6;
filter: blur(1px);
}
100% {
transform: translateY(30%) scale(0.9);
opacity: 0.4;
filter: blur(1.5px);
}
}
.site-url {
margin: 0.5rem 0 0;
font-size: 0.8rem;
color: var(--color-text-muted);
text-align: center;
font-family: 'SF Mono', Monaco, 'Cascadia Code', 'Roboto Mono', Consolas, 'Courier New', monospace;
opacity: 0.8;
}
.waiting-indicator {
display: flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
padding: 0.75rem;
background: var(--color-surface-hover, rgba(0, 0, 0, 0.02));
border-radius: var(--radius-sm, 6px);
font-size: 0.875rem;
color: var(--color-text-muted);
}
.spinner-small {
width: 16px;
height: 16px;
border: 2px solid var(--color-border);
border-top-color: var(--color-primary);
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
.success-section {
padding: 1rem;
text-align: center;
min-height: 180px;
display: flex;
align-items: center;
justify-content: center;
}
.success-message {
margin: 0;
font-size: 1rem;
color: var(--color-success, #10b981);
font-weight: 500;
}
.error-section {
padding: 1rem;
text-align: center;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 0.75rem;
min-height: 180px;
}
.error-message {
margin: 0;
font-size: 0.95rem;
color: var(--color-error, #ef4444);
}
/* Responsive adjustments */
@media (max-width: 640px) {
.auth-content {
gap: 1.5rem;
flex-direction: column;
align-items: center;
}
.pairing-code-section {
width: 100%;
max-width: 280px;
}
}
@media (max-width: 480px) {
.pairing-code {
font-size: 1.1rem;
padding: 0.75rem 0.875rem;
}
.pairing-code-section {
width: 100%;
max-width: 100%;
}
}
</style>
+113 -28
View File
@@ -11,27 +11,41 @@
<header class="view-header center"> <header class="view-header center">
<h1>{{ headingTitle }}</h1> <h1>{{ headingTitle }}</h1>
<p v-if="isAuthenticated" class="user-line">👤 {{ userDisplayName }}</p> <p v-if="isAuthenticated" class="user-line">👤 {{ userDisplayName }}</p>
<p class="view-lede">{{ headerMessage }}</p> <p class="view-lede" v-html="headerMessage"></p>
</header> </header>
<section class="section-block"> <section class="section-block">
<div class="section-body center"> <div class="section-body center">
<div class="button-row center"> <!-- Local passkey authentication view -->
<slot name="actions" <div v-if="authView === 'local'" class="auth-view">
:loading="loading" <div class="button-row center">
:can-authenticate="canAuthenticate" <slot name="actions"
:is-authenticated="isAuthenticated" :loading="loading"
:authenticate="authenticateUser" :can-authenticate="canAuthenticate"
:logout="logoutUser" :is-authenticated="isAuthenticated"
:mode="mode"> :authenticate="authenticateUser"
<!-- Default actions --> :logout="logoutUser"
<button class="btn-secondary" :disabled="loading" @click="$emit('back')">Back</button> :mode="mode">
<button v-if="canAuthenticate" class="btn-primary" :disabled="loading" @click="authenticateUser"> <!-- Default actions -->
{{ loading ? (mode === 'reauth' ? 'Verifying' : 'Signing in') : (mode === 'reauth' ? 'Verify' : 'Login') }} <button class="btn-secondary" :disabled="loading" @click="$emit('back')">Back</button>
</button> <button v-if="canAuthenticate" class="btn-primary" :disabled="loading" @click="authenticateUser">
<button v-if="isAuthenticated && mode !== 'reauth'" class="btn-danger" :disabled="loading" @click="logoutUser">Logout</button> {{ loading ? (mode === 'reauth' ? 'Verifying' : 'Signing in') : (mode === 'reauth' ? 'Verify' : 'Login') }}
<button v-if="isAuthenticated && mode !== 'reauth'" class="btn-primary" :disabled="loading" @click="openProfile">Profile</button> </button>
</slot> <button v-if="isAuthenticated && mode !== 'reauth'" class="btn-danger" :disabled="loading" @click="logoutUser">Logout</button>
<button v-if="isAuthenticated && mode !== 'reauth'" class="btn-primary" :disabled="loading" @click="openProfile">Profile</button>
</slot>
</div>
</div>
<!-- Remote authentication view (request new remote auth) -->
<div v-else-if="authView === 'remote'" class="auth-view">
<RemoteAuthInline
:active="authView === 'remote'"
@authenticated="handleRemoteAuthenticated"
@register="handleRemoteRegistration"
@cancelled="switchToLocal"
@error="handleRemoteAuthError"
/>
</div> </div>
</div> </div>
</section> </section>
@@ -41,10 +55,11 @@
</template> </template>
<script setup> <script setup>
import { computed, onMounted, reactive, ref } from 'vue' import { computed, onMounted, onUnmounted, reactive, ref } from 'vue'
import passkey from '@/utils/passkey' import passkey from '@/utils/passkey'
import { getSettings } from '@/utils/settings' import { getSettings, uiBasePath } from '@/utils/settings'
import { fetchJson, getUserFriendlyErrorMessage } from '@/utils/api' import { fetchJson, getUserFriendlyErrorMessage } from '@/utils/api'
import RemoteAuthInline from '@/components/RemoteAuthRequest.vue'
const props = defineProps({ const props = defineProps({
mode: { mode: {
@@ -62,17 +77,15 @@ const loading = ref(false)
const settings = ref(null) const settings = ref(null)
const userInfo = ref(null) const userInfo = ref(null)
const currentView = ref('initial') // 'initial', 'login', 'forbidden' const currentView = ref('initial') // 'initial', 'login', 'forbidden'
const authView = ref('local') // 'local' or 'remote'
let statusTimer = null let statusTimer = null
const isAuthenticated = computed(() => !!userInfo.value?.authenticated) const isAuthenticated = computed(() => !!userInfo.value?.authenticated)
const canAuthenticate = computed(() => { const canAuthenticate = computed(() => {
if (initializing.value) return false if (initializing.value) return false
// In reauth mode, allow authentication even if already authenticated
if (props.mode === 'reauth') return true if (props.mode === 'reauth') return true
// In forbidden view (authenticated but lacking permissions), don't allow authentication
if (currentView.value === 'forbidden') return false if (currentView.value === 'forbidden') return false
// In login view or initial state, allow authentication
return true return true
}) })
@@ -91,6 +104,12 @@ const headerMessage = computed(() => {
if (currentView.value === 'forbidden') { if (currentView.value === 'forbidden') {
return 'You lack the required permissions.' return 'You lack the required permissions.'
} }
if (authView.value === 'remote') {
return 'Confirm from your other device. Or <a href="#" class="inline-link" data-action="local">this device</a>.'
}
if (canAuthenticate.value && props.mode !== 'reauth') {
return 'Please sign in with your passkey. Or use <a href="#" class="inline-link" data-action="remote">another device</a>.'
}
return 'Please sign in with your passkey.' return 'Please sign in with your passkey.'
}) })
@@ -122,7 +141,6 @@ async function fetchSettings() {
async function fetchUserInfo() { async function fetchUserInfo() {
try { try {
userInfo.value = await fetchJson('/auth/api/user-info', { method: 'POST' }) userInfo.value = await fetchJson('/auth/api/user-info', { method: 'POST' })
// Determine view based on authentication status
if (isAuthenticated.value && props.mode !== 'reauth') { if (isAuthenticated.value && props.mode !== 'reauth') {
currentView.value = 'forbidden' currentView.value = 'forbidden'
emit('forbidden', userInfo.value) emit('forbidden', userInfo.value)
@@ -131,7 +149,6 @@ async function fetchUserInfo() {
} }
} catch (error) { } catch (error) {
console.error('Failed to load user info', error) console.error('Failed to load user info', error)
// For 401/403 just go to login, for other errors show message
if (error.status !== 401 && error.status !== 403) { if (error.status !== 401 && error.status !== 403) {
showMessage(getUserFriendlyErrorMessage(error), 'error', 4000) showMessage(getUserFriendlyErrorMessage(error), 'error', 4000)
} }
@@ -170,7 +187,6 @@ async function logoutUser() {
try { try {
await fetchJson('/auth/api/logout', { method: 'POST' }) await fetchJson('/auth/api/logout', { method: 'POST' })
userInfo.value = null userInfo.value = null
// Switch to login view after logout
currentView.value = 'login' currentView.value = 'login'
showMessage('Logged out. You can sign in with a different account.', 'info', 3000) showMessage('Logged out. You can sign in with a different account.', 'info', 3000)
} catch (error) { } catch (error) {
@@ -181,7 +197,6 @@ async function logoutUser() {
} }
function openProfile() { function openProfile() {
// Open profile in a new window with a specific name to reuse the same tab
const profileWindow = window.open('/auth/', 'passkey_auth_profile') const profileWindow = window.open('/auth/', 'passkey_auth_profile')
if (profileWindow) profileWindow.focus() if (profileWindow) profileWindow.focus()
} }
@@ -196,10 +211,61 @@ async function setSessionCookie(result) {
}) })
} }
function switchToRemote() {
authView.value = 'remote'
}
function switchToLocal() {
authView.value = 'local'
}
async function handleRemoteAuthenticated(result) {
showMessage('Authenticated from another device!', 'success', 2000)
try {
await setSessionCookie(result)
} catch (error) {
const message = error?.message || 'Failed to establish session'
showMessage(message, 'error', 4000)
emit('auth-error', { message, cancelled: false })
return
}
emit('authenticated', result)
}
function handleRemoteRegistration(token) {
showMessage('Registration approved! Redirecting...', 'success', 2000)
const basePath = uiBasePath() || '/auth/'
window.location.href = `${basePath}${token}`
}
function handleRemoteAuthError(errorMsg) {
// Error is already shown in the RemoteAuth component, don't show toast
}
function handleHeaderLinkClick(event) {
const target = event.target
if (target.tagName === 'A' && target.classList.contains('inline-link')) {
event.preventDefault()
const action = target.dataset.action
if (action === 'remote') {
switchToRemote()
} else if (action === 'local') {
switchToLocal()
}
}
}
onMounted(async () => { onMounted(async () => {
await fetchSettings() await fetchSettings()
await fetchUserInfo() await fetchUserInfo()
initializing.value = false initializing.value = false
// Add click handler for inline links
document.addEventListener('click', handleHeaderLinkClick)
})
onUnmounted(() => {
document.removeEventListener('click', handleHeaderLinkClick)
}) })
defineExpose({ defineExpose({
@@ -210,9 +276,8 @@ defineExpose({
</script> </script>
<style scoped> <style scoped>
.button-row.center { display: flex; justify-content: center; gap: 0.75rem; } .button-row.center { display: flex; justify-content: center; gap: 0.75rem; flex-wrap: wrap; }
.user-line { margin: 0.5rem 0 0; font-weight: 500; color: var(--color-text); } .user-line { margin: 0.5rem 0 0; font-weight: 500; color: var(--color-text); }
/* Vertically center the restricted "dialog" surface in the viewport */
main.view-root { min-height: 100vh; align-items: center; justify-content: center; padding: 2rem 1rem; } main.view-root { min-height: 100vh; align-items: center; justify-content: center; padding: 2rem 1rem; }
.surface.surface--tight { .surface.surface--tight {
max-width: 520px; max-width: 520px;
@@ -222,4 +287,24 @@ main.view-root { min-height: 100vh; align-items: center; justify-content: center
flex-direction: column; flex-direction: column;
gap: 1.75rem; gap: 1.75rem;
} }
.auth-view {
display: flex;
flex-direction: column;
align-items: center;
gap: 1rem;
width: 100%;
}
.view-lede :deep(.inline-link) {
color: var(--color-primary);
text-decoration: none;
transition: opacity 0.15s;
font-weight: 400;
}
.view-lede :deep(.inline-link:hover) {
opacity: 0.8;
text-decoration: underline;
}
</style> </style>
+53 -12
View File
@@ -1,5 +1,5 @@
<template> <template>
<div v-if="userLoaded" class="user-info"> <div v-if="userLoaded" class="user-info" :class="{ 'has-extra': $slots.default }">
<h3 class="user-name-heading"> <h3 class="user-name-heading">
<span class="icon">👤</span> <span class="icon">👤</span>
<span class="user-name-row"> <span class="user-name-row">
@@ -11,12 +11,15 @@
<div class="org-line" v-if="orgDisplayName">{{ orgDisplayName }}</div> <div class="org-line" v-if="orgDisplayName">{{ orgDisplayName }}</div>
<div class="role-line" v-if="roleName">{{ roleName }}</div> <div class="role-line" v-if="roleName">{{ roleName }}</div>
</div> </div>
<span><strong>Visits:</strong></span> <span class="info-label"><strong>Visits:</strong></span>
<span>{{ visits || 0 }}</span> <span class="info-value">{{ visits || 0 }}</span>
<span><strong>Registered:</strong></span> <span class="info-label"><strong>Registered:</strong></span>
<span>{{ formatDate(createdAt) }}</span> <span class="info-value">{{ formatDate(createdAt) }}</span>
<span><strong>Last seen:</strong></span> <span class="info-label"><strong>Last seen:</strong></span>
<span>{{ formatDate(lastSeen) }}</span> <span class="info-value">{{ formatDate(lastSeen) }}</span>
<div v-if="$slots.default" class="user-info-extra">
<slot></slot>
</div>
</div> </div>
</template> </template>
@@ -44,13 +47,50 @@ const userLoaded = computed(() => !!props.name)
</script> </script>
<style scoped> <style scoped>
.user-info { display: grid; grid-template-columns: auto 1fr; gap: 10px; } .user-info.has-extra {
.user-info h3 { grid-column: span 2; } grid-template-columns: auto 1fr;
.org-role-sub { grid-column: span 2; display:flex; flex-direction:column; margin: -0.15rem 0 0.25rem; } grid-template-areas:
"heading heading"
"org org"
"label1 value1"
"label2 value2"
"label3 value3"
"extra extra";
}
.user-info:not(.has-extra) {
grid-template-columns: auto 1fr;
grid-template-areas:
"heading heading"
"org org"
"label1 value1"
"label2 value2"
"label3 value3";
}
@media (min-width: 769px) {
.user-info.has-extra {
grid-template-columns: auto 1fr 2fr;
grid-template-areas:
"heading heading extra"
"org org extra"
"label1 value1 extra"
"label2 value2 extra"
"label3 value3 extra";
}
}
.user-name-heading { grid-area: heading; display: flex; align-items: center; gap: 0.4rem; flex-wrap: wrap; margin: 0 0 0.25rem 0; }
.org-role-sub { grid-area: org; display:flex; flex-direction:column; margin: -0.15rem 0 0.25rem; }
.org-line { font-size: .7rem; font-weight:600; line-height:1.1; color: var(--color-text-muted); text-transform: uppercase; letter-spacing: 0.05em; } .org-line { font-size: .7rem; font-weight:600; line-height:1.1; color: var(--color-text-muted); text-transform: uppercase; letter-spacing: 0.05em; }
.role-line { font-size:.65rem; color: var(--color-text-muted); line-height:1.1; } .role-line { font-size:.65rem; color: var(--color-text-muted); line-height:1.1; }
.user-info span { text-align: left; } .info-label:nth-of-type(1) { grid-area: label1; }
.user-name-heading { display: flex; align-items: center; gap: 0.4rem; flex-wrap: wrap; margin: 0 0 0.25rem 0; } .info-value:nth-of-type(2) { grid-area: value1; }
.info-label:nth-of-type(3) { grid-area: label2; }
.info-value:nth-of-type(4) { grid-area: value2; }
.info-label:nth-of-type(5) { grid-area: label3; }
.info-value:nth-of-type(6) { grid-area: value3; }
.user-info-extra { grid-area: extra; padding-left: 2rem; border-left: 1px solid var(--color-border); }
.user-name-row { display: inline-flex; align-items: center; gap: 0.35rem; max-width: 100%; } .user-name-row { display: inline-flex; align-items: center; gap: 0.35rem; max-width: 100%; }
.user-name-row.editing { flex: 1 1 auto; } .user-name-row.editing { flex: 1 1 auto; }
.icon { flex: 0 0 auto; } .icon { flex: 0 0 auto; }
@@ -62,5 +102,6 @@ const userLoaded = computed(() => !!props.name)
.mini-btn:hover:not(:disabled) { background: var(--color-accent-soft); color: var(--color-accent); } .mini-btn:hover:not(:disabled) { background: var(--color-accent-soft); color: var(--color-accent); }
.mini-btn:active:not(:disabled) { transform: translateY(1px); } .mini-btn:active:not(:disabled) { transform: translateY(1px); }
.mini-btn:disabled { opacity: 0.5; cursor: not-allowed; } .mini-btn:disabled { opacity: 0.5; cursor: not-allowed; }
@media (max-width: 768px) { .user-info-extra { padding-left: 0; padding-top: 1rem; border-left: none; border-top: 1px solid var(--color-border); } }
@media (max-width: 480px) { .user-name-heading { flex-direction: column; align-items: flex-start; } .user-name-row.editing { width: 100%; } .display-name { max-width: 100%; } } @media (max-width: 480px) { .user-name-heading { flex-direction: column; align-items: flex-start; } .user-name-row.editing { width: 100%; } .display-name { max-width: 100%; } }
</style> </style>
+28 -4
View File
@@ -18,12 +18,36 @@ class AwaitableWebSocket extends WebSocket {
} }
this.onclose = e => { this.onclose = e => {
if (!this.#opened) { if (!this.#opened) {
reject(new Error(`WebSocket ${this.url} failed to connect, code ${e.code}`)) reject(new Error(`Failed to connect to server (code ${e.code})`))
return return
} }
this.#err = e.wasClean // Create user-friendly close messages
? new Error(`Websocket ${this.url} closed ${e.code}`) let message
: new Error(`WebSocket ${this.url} closed with error ${e.code}`) if (e.wasClean) {
// Standard close codes
switch (e.code) {
case 1000: message = 'Connection closed normally'; break
case 1001: message = 'Server is going away'; break
case 1002: message = 'Protocol error'; break
case 1003: message = 'Unsupported data received'; break
case 1006: message = 'Connection lost unexpectedly'; break
case 1007: message = 'Invalid data received'; break
case 1008: message = 'Policy violation'; break
case 1009: message = 'Message too large'; break
case 1010: message = 'Extension negotiation failed'; break
case 1011: message = 'Server encountered an error'; break
case 1012: message = 'Server is restarting'; break
case 1013: message = 'Server is overloaded, try again later'; break
case 1014: message = 'Bad gateway'; break
case 1015: message = 'TLS handshake failed'; break
default: message = `Connection closed (code ${e.code})`
}
} else {
message = e.code === 1006
? 'Connection lost unexpectedly'
: `Connection closed with error (code ${e.code})`
}
this.#err = new Error(message)
this.#waiting.splice(0).forEach(p => p.reject(this.#err)) this.#waiting.splice(0).forEach(p => p.reject(this.#err))
} }
} }
+33
View File
@@ -0,0 +1,33 @@
/**
* URL-safe Base64 encoding/decoding utilities.
*
* These functions handle base64url format (RFC 4648) which uses:
* - '-' instead of '+'
* - '_' instead of '/'
* - No padding '=' characters
*/
/**
* Decode a base64url string to Uint8Array.
* Handles both standard base64 and URL-safe base64 (with or without padding).
* @param {string} str - Base64url encoded string
* @returns {Uint8Array} - Decoded bytes
*/
export function dec(str) {
// Convert URL-safe characters to standard base64
const base64 = str.replace(/-/g, '+').replace(/_/g, '/')
// Add padding if needed
const padded = base64 + '='.repeat((4 - base64.length % 4) % 4)
return Uint8Array.from(atob(padded), c => c.charCodeAt(0))
}
/**
* Encode a Uint8Array to base64url string.
* @param {Uint8Array} bytes - Bytes to encode
* @returns {string} - Base64url encoded string (no padding)
*/
export function enc(bytes) {
const base64 = btoa(String.fromCharCode(...bytes))
// Convert to URL-safe and remove padding
return base64.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '')
}
+37
View File
@@ -0,0 +1,37 @@
import { solvePoW, verifyPoW } from './pow.js'
const TRIALS = 5
const WORK = 10
async function test() {
console.log(`Running ${TRIALS} trials with ${WORK} work units...\n`)
const times = []
for (let trial = 1; trial <= TRIALS; trial++) {
const challenge = crypto.getRandomValues(new Uint8Array(8))
const start = performance.now()
const solution = await solvePoW(challenge, WORK)
const elapsed = performance.now() - start
const valid = await verifyPoW(challenge, solution, WORK)
times.push(elapsed)
console.log(`Trial ${trial.toString().padStart(2)}: ${(elapsed / 1000).toFixed(3)}s, valid=${valid}`)
}
const avgTime = times.reduce((a, b) => a + b, 0) / times.length
const minTime = Math.min(...times)
const maxTime = Math.max(...times)
console.log('\n--- Summary ---')
console.log(`Trials: ${TRIALS}`)
console.log(`Work units: ${WORK}`)
console.log(`Avg time: ${(avgTime / 1000).toFixed(3)}s`)
console.log(`Min time: ${(minTime / 1000).toFixed(3)}s`)
console.log(`Max time: ${(maxTime / 1000).toFixed(3)}s`)
}
test()
+68
View File
@@ -0,0 +1,68 @@
/**
* Proof of Work utility using PBKDF2-SHA512
*
* The PoW requires finding nonces where PBKDF2(challenge, nonce) produces
* output with a zero first byte. Each work unit requires finding one such nonce.
* All valid nonces are concatenated into a solution for server verification.
*/
/**
* Solve a Proof of Work challenge
*
* @param {Uint8Array|ArrayBuffer} challenge - 8-byte server-provided challenge
* @param {number} work - Number of PBKDF2 work units required
* @param {object} [options] - Optional parameters
* @param {AbortSignal} [options.signal] - AbortSignal to cancel the operation
* @returns {Promise<Uint8Array>} Solution: concatenated 8-byte nonces (8 * work bytes)
* @throws {Error} If challenge is invalid or operation is aborted
*/
export async function solvePoW(challenge, work, options = {}) {
const { signal } = options
const startTime = performance.now()
// Validate inputs
const challengeBytes = challenge instanceof ArrayBuffer
? new Uint8Array(challenge)
: challenge
if (!(challengeBytes instanceof Uint8Array) || challengeBytes.length !== 8) {
throw new Error('Challenge must be exactly 8 bytes')
}
// Import challenge as PBKDF2 key material
const baseKey = await crypto.subtle.importKey('raw', challengeBytes, 'PBKDF2', false, ['deriveBits'])
// Build solution from found nonces
const solution = new Uint8Array(8 * work)
let totalIterations = 0
const mask = 0x7FF // The client must work 2048x harder than the server
// Sequential nonce starting at zero (little-endian, using Uint32Array for efficient increment)
const nonce = new Uint32Array(2)
for (let i = 0; i < work; i++) {
if (signal?.aborted) {
throw new DOMException('PoW operation aborted', 'AbortError')
}
// Find a nonce where PBKDF2 output passes the mask check
let result
do {
totalIterations++
if (++nonce[0] === 0x100000000) ++nonce[1] // Increment 64-bit little-endian nonce
result = new Uint32Array(await crypto.subtle.deriveBits(
{ name: 'PBKDF2', salt: nonce, iterations: 128, hash: 'SHA-512'},
baseKey,
32
))
} while (result[0] & mask)
solution.set(new Uint8Array(nonce.buffer), i * 8)
}
const elapsed = (performance.now() - startTime) / 1000
const expectedIterations = work * (mask + 1)
const luckRatio = (totalIterations / expectedIterations).toFixed(1)
const bench = totalIterations / ((mask + 1) * elapsed)
console.log(`PoW work=${work} solved in ${elapsed.toFixed(2)}s (${luckRatio}x expected ${bench.toFixed(1)} work/s)`)
return solution
}
File diff suppressed because one or more lines are too long
+12 -4
View File
@@ -27,7 +27,7 @@ export default defineConfig(({ command }) => ({
closeBundle() { closeBundle() {
if (command !== 'build') return if (command !== 'build') return
const outDir = resolve(__dirname, '../passkey/frontend-build') const outDir = resolve(__dirname, '../paskia/frontend-build')
const moves = [ const moves = [
{ from: 'auth.html', to: 'auth/index.html' }, { from: 'auth.html', to: 'auth/index.html' },
{ from: 'admin.html', to: 'admin/index.html' }, { from: 'admin.html', to: 'admin/index.html' },
@@ -53,23 +53,31 @@ export default defineConfig(({ command }) => ({
base: '/', base: '/',
server: { server: {
port: 4403, port: 4403,
allowedHosts: true,
fs: { fs: {
allow: ['..'] allow: ['..']
}, },
proxy: { proxy: {
// Only proxy these two specific backend API paths // Only proxy these two specific backend API paths
'/auth/api': { '/auth/api': {
target: 'http://localhost:4402', target: 'http://localhost:4402'
headers: { connection: 'close' }
}, },
'/auth/ws': { '/auth/ws': {
target: 'http://localhost:4402', target: 'http://localhost:4402',
ws: true ws: true
},
// Passphrase links: /auth/word1.word2.word3.word4.word5
'^/auth/[a-z]+\\.[a-z]+\\.[a-z]+\\.[a-z]+\\.[a-z]+$': {
target: 'http://localhost:4402'
},
// Passphrase links: /word1.word2.word3.word4.word5
'^/[a-z]+\\.[a-z]+\\.[a-z]+\\.[a-z]+\\.[a-z]+$': {
target: 'http://localhost:4402'
} }
} }
}, },
build: { build: {
outDir: '../passkey/frontend-build', outDir: '../paskia/frontend-build',
emptyOutDir: true, emptyOutDir: true,
rollupOptions: { rollupOptions: {
input: { input: {
+3
View File
@@ -0,0 +1,3 @@
from paskia.sansio import Passkey
__all__ = ["Passkey"]
@@ -14,7 +14,7 @@ from importlib.resources import files
__ALL__ = ["AAGUID", "filter"] __ALL__ = ["AAGUID", "filter"]
# Path to the AAGUID JSON file # Path to the AAGUID JSON file
AAGUID_FILE = files("passkey") / "aaguid" / "combined_aaguid.json" AAGUID_FILE = files("paskia") / "aaguid" / "combined_aaguid.json"
AAGUID: dict[str, dict] = json.loads(AAGUID_FILE.read_text(encoding="utf-8")) AAGUID: dict[str, dict] = json.loads(AAGUID_FILE.read_text(encoding="utf-8"))
@@ -11,11 +11,11 @@ independent of any web framework:
from datetime import datetime, timezone from datetime import datetime, timezone
from uuid import UUID from uuid import UUID
from .config import SESSION_LIFETIME from paskia.config import SESSION_LIFETIME
from .db import ResetToken, Session from paskia.db import ResetToken, Session
from .globals import db, passkey from paskia.globals import db, passkey
from .util import hostutil from paskia.util import hostutil
from .util.tokens import create_token, reset_key, session_key from paskia.util.tokens import create_token, reset_key, session_key
EXPIRES = SESSION_LIFETIME EXPIRES = SESSION_LIFETIME
@@ -71,7 +71,7 @@ async def get_reset(token: str) -> ResetToken:
record = await db.instance.get_reset_token(reset_key(token)) record = await db.instance.get_reset_token(reset_key(token))
if record and record.expiry >= datetime.now(timezone.utc): if record and record.expiry >= datetime.now(timezone.utc):
return record return record
raise ValueError("This reset link is invalid or has expired") raise ValueError("This authentication link is no longer valid.")
async def get_session(token: str, host: str | None = None) -> Session: async def get_session(token: str, host: str | None = None) -> Session:
+9 -39
View File
@@ -12,9 +12,9 @@ from datetime import datetime, timezone
import uuid7 import uuid7
from . import authsession, globals from paskia import authsession, globals
from .db import Org, Permission, Role, User from paskia.db import Org, Permission, Role, User
from .util import hostutil, passphrase, tokens from paskia.util import hostutil, passphrase, tokens
def _init_logger() -> logging.Logger: def _init_logger() -> logging.Logger:
@@ -53,16 +53,10 @@ async def _create_and_log_admin_reset_link(user_uuid, message, session_type) ->
return reset_link return reset_link
async def bootstrap_system( async def bootstrap_system() -> dict:
user_name: str | None = None, org_name: str | None = None
) -> dict:
""" """
Bootstrap the entire system with default data. Bootstrap the entire system with default data.
Args:
user_name: Display name for the admin user (default: "Admin")
org_name: Display name for the organization (default: "Organization")
Returns: Returns:
dict: Contains information about created entities and reset link dict: Contains information about created entities and reset link
""" """
@@ -70,7 +64,7 @@ async def bootstrap_system(
perm0 = Permission(id="auth:admin", display_name="Master Admin") perm0 = Permission(id="auth:admin", display_name="Master Admin")
await globals.db.instance.create_permission(perm0) await globals.db.instance.create_permission(perm0)
org = Org(uuid7.create(), org_name or "Organization") org = Org(uuid7.create(), "Organization")
await globals.db.instance.create_organization(org) await globals.db.instance.create_organization(org)
# After creation, org.permissions now includes the auto-created org admin permission # After creation, org.permissions now includes the auto-created org admin permission
@@ -89,7 +83,7 @@ async def bootstrap_system(
user = User( user = User(
uuid=uuid7.create(), uuid=uuid7.create(),
display_name=user_name or "Admin", display_name="Admin",
role_uuid=role.uuid, role_uuid=role.uuid,
created_at=datetime.now(timezone.utc), created_at=datetime.now(timezone.utc),
visits=0, visits=0,
@@ -159,16 +153,10 @@ async def check_admin_credentials() -> bool:
return False return False
async def bootstrap_if_needed( async def bootstrap_if_needed() -> bool:
default_admin: str | None = None, default_org: str | None = None
) -> bool:
""" """
Check if system needs bootstrapping and perform it if necessary. Check if system needs bootstrapping and perform it if necessary.
Args:
default_admin: Display name for the admin user
default_org: Display name for the organization
Returns: Returns:
bool: True if bootstrapping was performed, False if system was already set up bool: True if bootstrapping was performed, False if system was already set up
""" """
@@ -185,35 +173,17 @@ async def bootstrap_if_needed(
# No admin permission found, need to bootstrap # No admin permission found, need to bootstrap
# Bootstrap creates the admin user AND the reset link, so no need to check credentials after # Bootstrap creates the admin user AND the reset link, so no need to check credentials after
await bootstrap_system(default_admin, default_org) await bootstrap_system()
return True return True
# CLI interface # CLI interface
async def main(): async def main():
"""Main CLI entry point for bootstrapping.""" """Main CLI entry point for bootstrapping."""
import argparse
# Configure logging for CLI usage # Configure logging for CLI usage
logging.basicConfig(level=logging.INFO, format="%(message)s", force=True) logging.basicConfig(level=logging.INFO, format="%(message)s", force=True)
parser = argparse.ArgumentParser( await globals.init()
description="Bootstrap passkey authentication system"
)
parser.add_argument(
"--user-name",
default=None,
help="Name for the admin user (default: Admin)",
)
parser.add_argument(
"--org-name",
default=None,
help="Name for the organization (default: Organization)",
)
args = parser.parse_args()
await globals.init(default_admin=args.user_name, default_org=args.org_name)
if __name__ == "__main__": if __name__ == "__main__":
+25
View File
@@ -0,0 +1,25 @@
from dataclasses import dataclass
from datetime import timedelta
# Shared configuration constants for session management.
SESSION_LIFETIME = timedelta(hours=24)
# Lifetime for reset links created by admins
RESET_LIFETIME = timedelta(days=14)
@dataclass
class PaskiaConfig:
"""Runtime configuration for the Paskia authentication server."""
rp_id: str
rp_name: str | None
origins: list[str] | None
auth_host: str | None
site_url: str # Base URL without trailing path (e.g. https://example.com)
site_path: str # Path to auth UI: "/" if auth_host, else "/auth/"
# Listen address (one of host:port or uds)
host: str | None = None
port: int | None = None
uds: str | None = None
devmode: bool = False
+8 -6
View File
@@ -5,6 +5,7 @@ This module provides an async database layer using SQLAlchemy async mode
for managing users and credentials in a WebAuthn authentication system. for managing users and credentials in a WebAuthn authentication system.
""" """
import os
from contextlib import asynccontextmanager from contextlib import asynccontextmanager
from datetime import datetime, timezone from datetime import datetime, timezone
from uuid import UUID from uuid import UUID
@@ -26,9 +27,8 @@ from sqlalchemy.dialects.sqlite import BLOB
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column
from ..config import SESSION_LIFETIME from paskia.config import SESSION_LIFETIME
from ..globals import db from paskia.db import (
from . import (
Credential, Credential,
DatabaseInterface, DatabaseInterface,
Org, Org,
@@ -39,8 +39,9 @@ from . import (
SessionContext, SessionContext,
User, User,
) )
from paskia.globals import db
DB_PATH = "sqlite+aiosqlite:///passkey-auth.sqlite" DB_PATH_DEFAULT = "sqlite+aiosqlite:///paskia.sqlite"
def _normalize_dt(value: datetime | None) -> datetime | None: def _normalize_dt(value: datetime | None) -> datetime | None:
@@ -52,7 +53,8 @@ def _normalize_dt(value: datetime | None) -> datetime | None:
async def init(*args, **kwargs): async def init(*args, **kwargs):
db.instance = DB() db_path = os.environ.get("PASKIA_DB", DB_PATH_DEFAULT)
db.instance = DB(db_path)
await db.instance.init_db() await db.instance.init_db()
@@ -289,7 +291,7 @@ class RolePermission(Base):
class DB(DatabaseInterface): class DB(DatabaseInterface):
"""Database class that handles its own connections.""" """Database class that handles its own connections."""
def __init__(self, db_path: str = DB_PATH): def __init__(self, db_path: str = DB_PATH_DEFAULT):
"""Initialize with database path.""" """Initialize with database path."""
self.engine = create_async_engine(db_path, echo=False) self.engine = create_async_engine(db_path, echo=False)
# Ensure SQLite foreign key enforcement is ON for every new connection # Ensure SQLite foreign key enforcement is ON for every new connection
+3
View File
@@ -0,0 +1,3 @@
from paskia.fastapi.mainapp import app
__all__ = ["app"]
@@ -7,11 +7,10 @@ from urllib.parse import urlparse
import uvicorn import uvicorn
from passkey.util import frontend from paskia.util.hostutil import normalize_origin
DEFAULT_HOST = "localhost" DEFAULT_HOST = "localhost"
DEFAULT_SERVE_PORT = 4401 DEFAULT_SERVE_PORT = 4401
DEFAULT_DEV_PORT = 4402
def is_subdomain(sub: str, domain: str) -> bool: def is_subdomain(sub: str, domain: str) -> bool:
@@ -114,7 +113,13 @@ def add_common_options(p: argparse.ArgumentParser) -> None:
"--rp-id", default="localhost", help="Relying Party ID (default: localhost)" "--rp-id", default="localhost", help="Relying Party ID (default: localhost)"
) )
p.add_argument("--rp-name", help="Relying Party name (default: same as rp-id)") p.add_argument("--rp-name", help="Relying Party name (default: same as rp-id)")
p.add_argument("--origin", help="Origin URL (default: https://<rp-id>)") p.add_argument(
"--origin",
action="append",
dest="origins",
metavar="URL",
help="Allowed origin URL(s). May be specified multiple times. If any are specified, only those origins are permitted for WebSocket authentication.",
)
p.add_argument( p.add_argument(
"--auth-host", "--auth-host",
help=( help=(
@@ -129,7 +134,7 @@ def main():
logging.basicConfig(level=logging.INFO, format="%(message)s", force=True) logging.basicConfig(level=logging.INFO, format="%(message)s", force=True)
parser = argparse.ArgumentParser( parser = argparse.ArgumentParser(
prog="passkey-auth", description="Passkey authentication server" prog="paskia", description="Paskia authentication server"
) )
sub = parser.add_subparsers(dest="command", required=True) sub = parser.add_subparsers(dest="command", required=True)
@@ -147,18 +152,6 @@ def main():
) )
add_common_options(serve) add_common_options(serve)
# dev subcommand
dev = sub.add_parser("dev", help="Run the server in development (auto-reload)")
dev.add_argument(
"hostport",
nargs="?",
help=(
"Endpoint (default: localhost:4402). Forms: host[:port] | :port | "
"[ipv6][:port] | ipv6 | unix:/path.sock"
),
)
add_common_options(dev)
# reset subcommand # reset subcommand
reset = sub.add_parser( reset = sub.add_parser(
"reset", "reset",
@@ -176,66 +169,121 @@ def main():
args = parser.parse_args() args = parser.parse_args()
if args.command in {"serve", "dev"}: if args.command == "serve":
default_port = DEFAULT_DEV_PORT if args.command == "dev" else DEFAULT_SERVE_PORT host, port, uds, all_ifaces = parse_endpoint(args.hostport, DEFAULT_SERVE_PORT)
host, port, uds, all_ifaces = parse_endpoint(args.hostport, default_port)
devmode = args.command == "dev"
else: else:
host = port = uds = all_ifaces = None # type: ignore host = port = uds = all_ifaces = None # type: ignore
devmode = False
# Determine origin (dev mode default override)
origin = args.origin
if devmode and not args.origin and not args.rp_id:
# Dev mode: Vite runs on another port, override:
origin = "http://localhost:4403"
# Export configuration via environment for lifespan initialization in each process
os.environ.setdefault("PASSKEY_RP_ID", args.rp_id)
if args.rp_name:
os.environ["PASSKEY_RP_NAME"] = args.rp_name
if origin:
os.environ["PASSKEY_ORIGIN"] = origin
if getattr(args, "auth_host", None):
os.environ["PASSKEY_AUTH_HOST"] = args.auth_host
else:
# Preserve pre-set env variable if CLI option omitted
args.auth_host = os.environ.get("PASSKEY_AUTH_HOST")
# Collect and normalize origins, handle auth_host
origins = [normalize_origin(o) for o in (getattr(args, "origins", None) or [])]
if args.auth_host: if args.auth_host:
# Normalize auth_host with scheme
if "://" not in args.auth_host:
args.auth_host = f"https://{args.auth_host}"
validate_auth_host(args.auth_host, args.rp_id) validate_auth_host(args.auth_host, args.rp_id)
from passkey.util import hostutil as _hostutil # local import
_hostutil.reload_config() # If origins are configured, ensure auth_host is included at top
if origins:
# Insert auth_host at the beginning (Passkey.__init__ will dedupe)
origins.insert(0, args.auth_host)
# One-time initialization + bootstrap before starting any server processes. # Compute site_url and site_path for reset links
# Lifespan in worker processes will call globals.init with bootstrap disabled. # Priority: auth_host > first origin with localhost > http://localhost:port
from passkey import globals as _globals # local import if args.auth_host:
site_url = args.auth_host.rstrip("/")
site_path = "/"
elif origins:
# Find localhost origin if rp_id is localhost, else use first origin
localhost_origin = (
next((o for o in origins if "://localhost" in o), None)
if args.rp_id == "localhost"
else None
)
site_url = (localhost_origin or origins[0]).rstrip("/")
site_path = "/auth/"
elif args.rp_id == "localhost" and port:
# Dev mode: use http with port
site_url = f"http://localhost:{port}"
site_path = "/auth/"
else:
site_url = f"https://{args.rp_id}"
site_path = "/auth/"
# Build runtime configuration
from paskia.config import PaskiaConfig
config = PaskiaConfig(
rp_id=args.rp_id,
rp_name=args.rp_name or None,
origins=origins or None,
auth_host=args.auth_host or None,
site_url=site_url,
site_path=site_path,
host=host,
port=port,
uds=uds,
)
# Export configuration via single JSON env variable for worker processes
import json
config_json = {
"rp_id": config.rp_id,
"rp_name": config.rp_name,
"origins": config.origins,
"auth_host": config.auth_host,
"site_url": config.site_url,
"site_path": config.site_path,
}
os.environ["PASKIA_CONFIG"] = json.dumps(config_json)
# Initialize globals (without bootstrap yet)
from paskia import globals as _globals # local import
asyncio.run( asyncio.run(
_globals.init( _globals.init(
rp_id=args.rp_id, rp_id=config.rp_id,
rp_name=args.rp_name, rp_name=config.rp_name,
origin=origin, origins=config.origins,
default_admin=os.getenv("PASSKEY_DEFAULT_ADMIN") or None, bootstrap=False,
default_org=os.getenv("PASSKEY_DEFAULT_ORG") or None,
bootstrap=True,
) )
) )
# Print startup configuration
from paskia.util import startupbox
startupbox.print_startup_config(config)
# Bootstrap after startup box is printed
from paskia.bootstrap import bootstrap_if_needed
asyncio.run(bootstrap_if_needed())
# Handle recover-admin command (no server start) # Handle recover-admin command (no server start)
if args.command == "reset": if args.command == "reset":
from passkey.fastapi import reset as reset_cmd # local import from paskia.fastapi import reset as reset_cmd # local import
exit_code = reset_cmd.run(getattr(args, "query", None)) exit_code = reset_cmd.run(getattr(args, "query", None))
raise SystemExit(exit_code) raise SystemExit(exit_code)
if args.command in {"serve", "dev"}: if args.command == "serve":
run_kwargs: dict = { run_kwargs: dict = {
"reload": devmode,
"reload_dirs": ["passkey"] if devmode else None,
"log_level": "info", "log_level": "info",
} }
# Dev mode: enable reload when PASKIA_DEVMODE is set
devmode = bool(os.environ.get("PASKIA_DEVMODE"))
if devmode:
# Security: dev mode must run on localhost:4402 to prevent
# accidental public exposure of the Vite dev server
if host != "localhost" or port != 4402:
raise SystemExit(f"Dev mode requires localhost:4402, got {host}:{port}")
run_kwargs["reload"] = True
run_kwargs["reload_dirs"] = ["paskia"]
# Suppress uvicorn startup messages in dev mode
run_kwargs["log_level"] = "warning"
if uds: if uds:
run_kwargs["uds"] = uds run_kwargs["uds"] = uds
else: else:
@@ -243,19 +291,17 @@ def main():
run_kwargs["host"] = host run_kwargs["host"] = host
run_kwargs["port"] = port run_kwargs["port"] = port
if devmode:
os.environ["PASSKEY_DEVMODE"] = "1"
frontend.run_dev()
if all_ifaces and not uds: if all_ifaces and not uds:
# Dev mode with all interfaces: use simple single-server approach
if devmode: if devmode:
run_kwargs["host"] = "::" run_kwargs["host"] = "::"
run_kwargs["port"] = port run_kwargs["port"] = port
uvicorn.run("passkey.fastapi:app", **run_kwargs) uvicorn.run("paskia.fastapi:app", **run_kwargs)
else: else:
# Production: run separate servers for IPv4 and IPv6
from uvicorn import Config, Server # noqa: E402 local import from uvicorn import Config, Server # noqa: E402 local import
from passkey.fastapi import ( from paskia.fastapi import (
app as fastapi_app, # noqa: E402 local import app as fastapi_app, # noqa: E402 local import
) )
@@ -278,7 +324,7 @@ def main():
asyncio.run(serve_both()) asyncio.run(serve_both())
else: else:
uvicorn.run("passkey.fastapi:app", **run_kwargs) uvicorn.run("paskia.fastapi:app", **run_kwargs)
if __name__ == "__main__": if __name__ == "__main__":
@@ -5,9 +5,11 @@ from uuid import UUID, uuid4
from fastapi import Body, FastAPI, HTTPException, Request, Response from fastapi import Body, FastAPI, HTTPException, Request, Response
from fastapi.responses import JSONResponse from fastapi.responses import JSONResponse
from ..authsession import reset_expires from paskia.authsession import reset_expires
from ..globals import db from paskia.fastapi import authz
from ..util import ( from paskia.fastapi.session import AUTH_COOKIE
from paskia.globals import db
from paskia.util import (
frontend, frontend,
hostutil, hostutil,
passphrase, passphrase,
@@ -16,9 +18,7 @@ from ..util import (
tokens, tokens,
useragent, useragent,
) )
from ..util.tokens import encode_session_key, session_key from paskia.util.tokens import encode_session_key, session_key
from . import authz
from .session import AUTH_COOKIE
app = FastAPI() app = FastAPI()
@@ -38,7 +38,7 @@ async def auth_exception_handler(_request, exc: authz.AuthException):
@app.exception_handler(Exception) @app.exception_handler(Exception)
async def general_exception_handler(_request, exc: Exception): async def general_exception_handler(_request, exc: Exception): # pragma: no cover
logging.exception("Unhandled exception in admin app") logging.exception("Unhandled exception in admin app")
return JSONResponse(status_code=500, content={"detail": "Internal server error"}) return JSONResponse(status_code=500, content={"detail": "Internal server error"})
@@ -139,7 +139,9 @@ async def admin_update_org(
current = await db.instance.get_organization(str(org_uuid)) current = await db.instance.get_organization(str(org_uuid))
display_name = payload.get("display_name") or current.display_name display_name = payload.get("display_name") or current.display_name
permissions = payload.get("permissions") or current.permissions or [] permissions = payload.get("permissions")
if permissions is None:
permissions = current.permissions or []
# Sanity check: prevent removing permissions that would break current user's admin access # Sanity check: prevent removing permissions that would break current user's admin access
org_admin_perm = f"auth:org:{org_uuid}" org_admin_perm = f"auth:org:{org_uuid}"
@@ -398,7 +400,7 @@ async def admin_update_user_role(
# Sanity check: prevent admin from removing their own access # Sanity check: prevent admin from removing their own access
if ctx.user.uuid == user_uuid: if ctx.user.uuid == user_uuid:
new_role_obj = next((r for r in roles if r.display_name == new_role), None) new_role_obj = next((r for r in roles if r.display_name == new_role), None)
if new_role_obj: if new_role_obj: # pragma: no branch - always true, role validated above
has_admin_access = ( has_admin_access = (
"auth:admin" in new_role_obj.permissions "auth:admin" in new_role_obj.permissions
or f"auth:org:{org_uuid}" in new_role_obj.permissions or f"auth:org:{org_uuid}" in new_role_obj.permissions
@@ -432,7 +434,7 @@ async def admin_create_user_registration_link(
host=request.headers.get("host"), host=request.headers.get("host"),
max_age="5m", max_age="5m",
) )
if ( if ( # pragma: no cover - defense in depth, authz.verify already checked
"auth:admin" not in ctx.role.permissions "auth:admin" not in ctx.role.permissions
and f"auth:org:{org_uuid}" not in ctx.role.permissions and f"auth:org:{org_uuid}" not in ctx.role.permissions
): ):
@@ -452,9 +454,7 @@ async def admin_create_user_registration_link(
expiry=expiry, expiry=expiry,
token_type=token_type, token_type=token_type,
) )
url = hostutil.reset_link_url( url = hostutil.reset_link_url(token)
token, request.url.scheme, request.headers.get("host")
)
return { return {
"url": url, "url": url,
"expires": ( "expires": (
@@ -484,7 +484,7 @@ async def admin_get_user_detail(
match=permutil.has_any, match=permutil.has_any,
host=request.headers.get("host"), host=request.headers.get("host"),
) )
if ( if ( # pragma: no cover - defense in depth, authz.verify already checked
"auth:admin" not in ctx.role.permissions "auth:admin" not in ctx.role.permissions
and f"auth:org:{org_uuid}" not in ctx.role.permissions and f"auth:org:{org_uuid}" not in ctx.role.permissions
): ):
@@ -498,7 +498,7 @@ async def admin_get_user_detail(
for cid in cred_ids: for cid in cred_ids:
try: try:
c = await db.instance.get_credential_by_id(cid) c = await db.instance.get_credential_by_id(cid)
except ValueError: except ValueError: # pragma: no cover - race condition handling
continue continue
aaguid_str = str(c.aaguid) aaguid_str = str(c.aaguid)
aaguids.add(aaguid_str) aaguids.add(aaguid_str)
@@ -633,7 +633,7 @@ async def admin_update_user_display_name(
match=permutil.has_any, match=permutil.has_any,
host=request.headers.get("host"), host=request.headers.get("host"),
) )
if ( if ( # pragma: no cover - defense in depth, authz.verify already checked
"auth:admin" not in ctx.role.permissions "auth:admin" not in ctx.role.permissions
and f"auth:org:{org_uuid}" not in ctx.role.permissions and f"auth:org:{org_uuid}" not in ctx.role.permissions
): ):
@@ -670,7 +670,7 @@ async def admin_delete_user_credential(
host=request.headers.get("host"), host=request.headers.get("host"),
max_age="5m", max_age="5m",
) )
if ( if ( # pragma: no cover - defense in depth, authz.verify already checked
"auth:admin" not in ctx.role.permissions "auth:admin" not in ctx.role.permissions
and f"auth:org:{org_uuid}" not in ctx.role.permissions and f"auth:org:{org_uuid}" not in ctx.role.permissions
): ):
@@ -701,7 +701,7 @@ async def admin_delete_user_session(
match=permutil.has_any, match=permutil.has_any,
host=request.headers.get("host"), host=request.headers.get("host"),
) )
if ( if ( # pragma: no cover - defense in depth, authz.verify already checked
"auth:admin" not in ctx.role.permissions "auth:admin" not in ctx.role.permissions
and f"auth:org:{org_uuid}" not in ctx.role.permissions and f"auth:org:{org_uuid}" not in ctx.role.permissions
): ):
@@ -820,7 +820,7 @@ async def admin_rename_permission(
perm = await db.instance.get_permission(old_id) perm = await db.instance.get_permission(old_id)
display_name = perm.display_name display_name = perm.display_name
rename_fn = getattr(db.instance, "rename_permission", None) rename_fn = getattr(db.instance, "rename_permission", None)
if not rename_fn: if not rename_fn: # pragma: no cover - all current backends support rename
raise ValueError("Permission renaming not supported by this backend") raise ValueError("Permission renaming not supported by this backend")
await rename_fn(old_id, new_id, display_name) await rename_fn(old_id, new_id, display_name)
return {"status": "ok"} return {"status": "ok"}
@@ -13,21 +13,19 @@ from fastapi import (
from fastapi.responses import JSONResponse from fastapi.responses import JSONResponse
from fastapi.security import HTTPBearer from fastapi.security import HTTPBearer
from passkey.util import frontend from paskia.authsession import (
from ..authsession import (
EXPIRES, EXPIRES,
get_reset, get_reset,
get_session, get_session,
refresh_session_token, refresh_session_token,
session_expiry, session_expiry,
) )
from ..globals import db from paskia.fastapi import authz, session, user
from ..globals import passkey as global_passkey from paskia.fastapi.session import AUTH_COOKIE, AUTH_COOKIE_NAME
from ..util import hostutil, htmlutil, passphrase, userinfo from paskia.globals import db
from ..util.tokens import session_key from paskia.globals import passkey as global_passkey
from . import authz, session, user from paskia.util import frontend, hostutil, htmlutil, passphrase, userinfo
from .session import AUTH_COOKIE from paskia.util.tokens import session_key
bearer_auth = HTTPBearer(auto_error=True) bearer_auth = HTTPBearer(auto_error=True)
@@ -67,7 +65,9 @@ async def auth_exception_handler(_request: Request, exc: authz.AuthException):
@app.exception_handler(Exception) @app.exception_handler(Exception)
async def general_exception_handler(_request: Request, exc: Exception): async def general_exception_handler(
_request: Request, exc: Exception
): # pragma: no cover
logging.exception("Unhandled exception in API app") logging.exception("Unhandled exception in API app")
return JSONResponse(status_code=500, content={"detail": "Internal server error"}) return JSONResponse(status_code=500, content={"detail": "Internal server error"})
@@ -200,10 +200,37 @@ async def get_settings():
"rp_id": pk.rp_id, "rp_id": pk.rp_id,
"rp_name": pk.rp_name, "rp_name": pk.rp_name,
"ui_base_path": base_path, "ui_base_path": base_path,
"auth_host": hostutil.configured_auth_host(), "auth_host": hostutil.dedicated_auth_host(),
"auth_site_url": hostutil.auth_site_url(),
"session_cookie": AUTH_COOKIE_NAME,
} }
@app.get("/token-info")
async def api_token_info(token: str):
"""Get information about a reset token.
Returns:
- type: "reset"
- user_name: display name of the user
- token_type: type of reset token
"""
if not passphrase.is_well_formed(token):
raise HTTPException(status_code=404, detail="Invalid token")
# Check if this is a reset token
try:
reset_token = await get_reset(token)
user = await db.instance.get_user_by_uuid(reset_token.user_uuid)
return {
"type": "reset",
"user_name": user.display_name,
"token_type": reset_token.token_type,
}
except (ValueError, Exception):
raise HTTPException(status_code=404, detail="Token not found or expired")
@app.post("/user-info") @app.post("/user-info")
async def api_user_info( async def api_user_info(
request: Request, request: Request,
@@ -3,7 +3,7 @@
from fastapi import Request, Response from fastapi import Request, Response
from fastapi.responses import RedirectResponse from fastapi.responses import RedirectResponse
from passkey.util import hostutil, passphrase from paskia.util import hostutil, passphrase
def is_ui_path(path: str) -> bool: def is_ui_path(path: str) -> bool:
@@ -73,7 +73,7 @@ def redirect_to_root_on_auth_host(request: Request, cur: str, path: str) -> Resp
async def redirect_middleware(request: Request, call_next): async def redirect_middleware(request: Request, call_next):
"""Middleware to handle auth host redirects.""" """Middleware to handle auth host redirects."""
cfg = hostutil.configured_auth_host() cfg = hostutil.dedicated_auth_host()
if not cfg: if not cfg:
return await call_next(request) return await call_next(request)
@@ -2,7 +2,7 @@ import logging
from fastapi import HTTPException from fastapi import HTTPException
from ..util import permutil, sessionutil from paskia.util import permutil, sessionutil
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -1,41 +1,40 @@
import logging import logging
import os import os
from contextlib import asynccontextmanager from contextlib import asynccontextmanager
from pathlib import Path
from fastapi import FastAPI, HTTPException, Request, Response from fastapi import FastAPI, HTTPException, Request, Response
from fastapi.responses import RedirectResponse from fastapi.responses import FileResponse, RedirectResponse
from fastapi.staticfiles import StaticFiles from fastapi.staticfiles import StaticFiles
from passkey.util import frontend, hostutil, passphrase from paskia.fastapi import admin, api, auth_host, ws
from paskia.fastapi.session import AUTH_COOKIE
from paskia.util import frontend, hostutil, passphrase
from . import admin, api, auth_host, ws # Path to examples/index.html when running from source tree
from .session import AUTH_COOKIE _EXAMPLES_DIR = Path(__file__).parent.parent.parent / "examples"
@asynccontextmanager @asynccontextmanager
async def lifespan(app: FastAPI): # pragma: no cover - startup path async def lifespan(app: FastAPI): # pragma: no cover - startup path
"""Application lifespan to ensure globals (DB, passkey) are initialized in each process. """Application lifespan to ensure globals (DB, passkey) are initialized in each process.
We populate configuration from environment variables (set by the CLI entrypoint) Configuration is passed via PASKIA_CONFIG JSON env variable (set by the CLI entrypoint)
so that uvicorn reload / multiprocess workers inherit the settings. so that uvicorn reload / multiprocess workers inherit the settings.
All keys are guaranteed to exist; values are already normalized by __main__.py.
""" """
from .. import globals import json
from paskia import globals
config = json.loads(os.environ["PASKIA_CONFIG"])
rp_id = os.getenv("PASSKEY_RP_ID", "localhost")
rp_name = os.getenv("PASSKEY_RP_NAME") or None
origin = os.getenv("PASSKEY_ORIGIN") or None
default_admin = (
os.getenv("PASSKEY_DEFAULT_ADMIN") or None
) # still passed for context
default_org = os.getenv("PASSKEY_DEFAULT_ORG") or None
try: try:
# CLI (__main__) performs bootstrap once; here we skip to avoid duplicate work # CLI (__main__) performs bootstrap once; here we skip to avoid duplicate work
await globals.init( await globals.init(
rp_id=rp_id, rp_id=config["rp_id"],
rp_name=rp_name, rp_name=config["rp_name"],
origin=origin, origins=config["origins"],
default_admin=default_admin,
default_org=default_org,
bootstrap=False, bootstrap=False,
) )
except ValueError as e: except ValueError as e:
@@ -43,17 +42,12 @@ async def lifespan(app: FastAPI): # pragma: no cover - startup path
# Re-raise to fail fast # Re-raise to fail fast
raise raise
# In dev mode, Vite serves assets directly; in production, mount static files # Restore info level logging after startup (suppressed during uvicorn init in dev mode)
# This is deferred to lifespan because PASSKEY_DEVMODE is set after module import if frontend.is_dev_mode():
if not frontend.is_dev_mode(): logging.getLogger("uvicorn").setLevel(logging.INFO)
app.mount( logging.getLogger("uvicorn.access").setLevel(logging.INFO)
"/auth/assets/",
StaticFiles(directory=frontend.file("auth", "assets")),
name="assets",
)
yield yield
# (Optional) add shutdown cleanup here later
app = FastAPI(lifespan=lifespan) app = FastAPI(lifespan=lifespan)
@@ -65,6 +59,14 @@ app.mount("/auth/api/admin/", admin.app)
app.mount("/auth/api/", api.app) app.mount("/auth/api/", api.app)
app.mount("/auth/ws/", ws.app) app.mount("/auth/ws/", ws.app)
# In dev mode (PASKIA_DEVMODE=1), Vite serves assets directly; skip static files mount
if not frontend.is_dev_mode():
app.mount(
"/auth/assets/",
StaticFiles(directory=frontend.file("auth", "assets")),
name="assets",
)
@app.get("/auth/restricted/") @app.get("/auth/restricted/")
async def restricted_view(): async def restricted_view():
@@ -98,11 +100,31 @@ async def admin_root(request: Request, auth=AUTH_COOKIE):
return await admin.adminapp(request, auth) # Delegated to admin app return await admin.adminapp(request, auth) # Delegated to admin app
@app.get("/auth/examples/", include_in_schema=False)
async def examples_page():
"""Serve examples/index.html when running from source tree.
This provides a simple test page for API mode authentication flows
without depending on the Vue frontend build.
"""
index_file = _EXAMPLES_DIR / "index.html"
if not index_file.is_file():
raise HTTPException(
status_code=404,
detail="Examples not available (not running from source tree)",
)
return FileResponse(index_file, media_type="text/html")
# Note: this catch-all handler must be the last route defined # Note: this catch-all handler must be the last route defined
@app.get("/{reset}") @app.get("/{token}")
@app.get("/auth/{reset}") @app.get("/auth/{token}")
async def reset_link(reset: str): async def token_link(token: str):
"""Serve the reset app directly with an injected reset token.""" """Serve the reset app for reset tokens (password reset / device addition).
if not passphrase.is_well_formed(reset):
The frontend will validate the token via /auth/api/token-info.
"""
if not passphrase.is_well_formed(token):
raise HTTPException(status_code=404) raise HTTPException(status_code=404)
return Response(*await frontend.read("/int/reset/index.html")) return Response(*await frontend.read("/int/reset/index.html"))
+504
View File
@@ -0,0 +1,504 @@
"""
Remote authentication WebSocket endpoints.
This module handles cross-device authentication where one device (requesting)
wants to log in and another device (authenticating) provides the passkey.
Endpoints:
- /request: Called by the device wanting to be authenticated
- /pair: Called by the authenticating device to complete the request
"""
import asyncio
from uuid import UUID
import base64url
from fastapi import FastAPI, WebSocket, WebSocketDisconnect
from paskia import remoteauth
from paskia.authsession import create_session
from paskia.fastapi.session import infodict
from paskia.fastapi.wsutil import validate_origin, websocket_error_handler
from paskia.globals import db, passkey
from paskia.util import passphrase, pow
# Create a FastAPI subapp for remote auth WebSocket endpoints
app = FastAPI()
@app.websocket("/request")
@websocket_error_handler
async def websocket_remote_auth_request(ws: WebSocket):
"""Request authentication from another device.
This endpoint is called by the device that wants to be authenticated.
It creates a remote auth request and waits for another device to authenticate.
Flow:
1. Client connects
2. Server sends HARD PoW challenge, client solves and responds
3. Server creates a 3-word pairing code and sends it with expiry
4. Server waits for another device to authenticate via /remote-auth/pair
5. When auth completes, server sends session_token to this client
6. Client can then use the session token to set a cookie
7. Connection times out after 5 minutes with explicit timeout message
"""
origin = validate_origin(ws)
host = origin.split("://", 1)[1]
if remoteauth.instance is None:
raise ValueError("Remote authentication is not available")
# Track this WebSocket connection for load-based PoW difficulty
remoteauth.instance.increment_connections()
try:
# Send PoW challenge immediately with dynamic difficulty based on load
challenge = pow.generate_challenge()
work = remoteauth.instance.get_pow_difficulty()
await ws.send_json(
{
"pow": {
"challenge": base64url.enc(challenge),
"work": work,
}
}
)
# Receive client response with PoW solution and action
response = await ws.receive_json()
# Verify PoW (required for this endpoint - SECURITY)
solution_b64 = response.get("pow")
if not solution_b64:
raise ValueError("PoW solution required")
try:
solution = base64url.dec(solution_b64)
except Exception:
raise ValueError("Invalid PoW solution encoding")
pow.verify_pow(challenge, solution, work)
# Extract action from the same message
action = response.get("action", "login")
if action not in ("login", "register"):
action = "login"
metadata = infodict(ws, "remote-auth-request")
# Create the remote auth request
pairing_code, expiry = await remoteauth.instance.create_request(
host=host,
ip=metadata.get("ip") or "",
user_agent=metadata.get("user_agent") or "",
action=action,
)
# Send the pairing code to the client
await ws.send_json(
{
"pairing_code": pairing_code,
"expires": expiry.isoformat().replace("+00:00", "Z"),
}
)
# Set up async notification for completion
result_event = asyncio.Event()
result_data: dict = {}
def on_complete(
session_token: str | None,
user_uuid: UUID | None,
credential_uuid: UUID | None,
reset_token: str | None,
):
# Check if this was an explicit denial (UUID(int=0) is the signal)
was_denied = user_uuid is not None and user_uuid == UUID(int=0)
result_data["session_token"] = session_token
result_data["user_uuid"] = user_uuid
result_data["credential_uuid"] = credential_uuid
result_data["reset_token"] = reset_token
result_data["was_denied"] = was_denied
result_event.set()
await remoteauth.instance.set_notify_callback(pairing_code, on_complete)
# Set up async notification for action lock
locked_event = asyncio.Event()
locked_data: dict = {}
def on_action_locked(action: str):
locked_data["action"] = action
locked_event.set()
await remoteauth.instance.set_action_locked_callback(
pairing_code, on_action_locked
)
# 5 minute timeout for the entire remote auth flow
timeout_seconds = 5 * 60
try:
# Wait for either:
# 1. Authentication to complete (result_event set)
# 2. Action locked (locked_event set)
# 3. Client to disconnect
# 4. Client to send a cancel or update_action message
# 5. Timeout after 5 minutes
async with asyncio.timeout(timeout_seconds):
while True:
# Use asyncio.wait to handle events and websocket
receive_task = asyncio.create_task(ws.receive_json())
result_wait_task = asyncio.create_task(result_event.wait())
locked_wait_task = asyncio.create_task(locked_event.wait())
tasks = [receive_task, result_wait_task]
# Only wait for locked event if not already locked
if not locked_event.is_set():
tasks.append(locked_wait_task)
done, pending = await asyncio.wait(
tasks,
return_when=asyncio.FIRST_COMPLETED,
)
# Cancel pending tasks
for task in pending:
task.cancel()
try:
await task
except asyncio.CancelledError:
pass
if result_wait_task in done:
# Authentication completed (or expired/cancelled/denied)
was_denied = result_data.get("was_denied", False)
if result_data.get("session_token") or result_data.get(
"reset_token"
):
response = {
"status": "authenticated",
"user_uuid": str(result_data["user_uuid"]),
}
if result_data.get("session_token"):
response["session_token"] = result_data["session_token"]
if result_data.get("reset_token"):
response["reset_token"] = result_data["reset_token"]
await ws.send_json(response)
else:
# Check if it was explicitly denied
if was_denied:
await ws.send_json(
{
"status": "denied",
"detail": "Access denied",
}
)
else:
await ws.send_json(
{
"status": "expired",
"detail": "Remote authentication request expired or was cancelled",
}
)
return
if locked_wait_task in done:
# Action was locked by the authenticating device
await ws.send_json(
{
"status": "locked",
"action": locked_data.get("action", "login"),
}
)
# Continue waiting for result
if receive_task in done:
# Client sent a message
msg = receive_task.result()
if msg.get("action") == "cancel":
await remoteauth.instance.cancel_request(pairing_code)
await ws.send_json({"status": "cancelled"})
return
elif msg.get("action") == "update_action":
# Update the action (login/register) if not locked
new_action = "register" if msg.get("register") else "login"
await remoteauth.instance.update_action(
pairing_code, new_action
)
# Ignore other messages
except TimeoutError:
# 5 minute timeout reached
await remoteauth.instance.cancel_request(pairing_code)
await ws.send_json(
{
"status": "timeout",
"detail": "Remote authentication request timed out after 5 minutes",
}
)
except WebSocketDisconnect:
# Client disconnected, cancel the request and mark as denied
await remoteauth.instance.cancel_request(pairing_code, denied=True)
except Exception:
await remoteauth.instance.cancel_request(pairing_code)
raise
finally:
# Decrement connection count
remoteauth.instance.decrement_connections()
@app.websocket("/pair")
@websocket_error_handler
async def websocket_remote_auth_pair(ws: WebSocket):
"""Complete a remote authentication request using a 3-word pairing code.
This endpoint is called from the user's profile on the authenticating device.
The user enters the pairing code displayed on the requesting device.
Protocol:
1. Server sends PoW challenge immediately on connect
2. Client sends {code: "word.word.word", pow: "<base64>"} for 3-word pairing code
3. Server validates PoW and code:
- If invalid code/PoW: {status: 4xx, detail: "...", pow: {challenge, work}}
- If valid: {status: "found", host: "...", user_agent_pretty: "...", pow: {challenge, work}}
4. Client can then send {authenticate: true} to start WebAuthn
5. Server sends {optionsJSON: ...}
6. Client sends WebAuthn response
7. Server sends {status: "success", message: "..."}
"""
from paskia.util import useragent
origin = validate_origin(ws)
if remoteauth.instance is None:
raise ValueError("Remote authentication is not available")
# Generate initial PoW challenge (always NORMAL for authenticated users)
challenge = pow.generate_challenge()
work = pow.NORMAL
await ws.send_json(
{
"pow": {
"challenge": base64url.enc(challenge),
"work": work,
}
}
)
request = None
webauthn_challenge = None
explicitly_denied = False
try:
while True:
msg = await ws.receive_json()
# Handle deny request first (no PoW needed - already validated during lookup)
if msg.get("deny") and request is not None:
# Cancel the request and mark it as denied
explicitly_denied = True
await remoteauth.instance.cancel_request(request.key, denied=True)
await ws.send_json(
{
"status": "denied",
"message": "Request denied",
}
)
break
# Handle authenticate request (no PoW needed - already validated during lookup)
if msg.get("authenticate") and request is not None:
# Generate authentication options
options, webauthn_challenge = passkey.instance.auth_generate_options(
credential_ids=None
)
await ws.send_json({"optionsJSON": options})
# Wait for WebAuthn response
credential = passkey.instance.auth_parse(await ws.receive_json())
# Fetch and verify credential
try:
stored_cred = await db.instance.get_credential_by_id(
credential.raw_id
)
except ValueError:
raise ValueError(
f"This passkey is no longer registered with {passkey.instance.rp_name}"
)
# Verify the credential
passkey.instance.auth_verify(
credential, webauthn_challenge, stored_cred, origin
)
# Update credential last_used
await db.instance.login(stored_cred.user_uuid, stored_cred)
# Create a session for the REQUESTING device
assert stored_cred.uuid is not None
session_token = None
reset_token = None
if request.action == "register":
# For registration, create a reset token for device addition
from paskia.authsession import expires
from paskia.util import tokens
token_str = passphrase.generate()
expiry = expires()
await db.instance.create_reset_token(
user_uuid=stored_cred.user_uuid,
key=tokens.reset_key(token_str),
expiry=expiry,
token_type="device addition",
)
reset_token = token_str
# Also create a session so the device is logged in?
# User requested: "We can make the flow always create a new session, but make additional tokens for other possibilities."
session_token = await create_session(
user_uuid=stored_cred.user_uuid,
credential_uuid=stored_cred.uuid,
host=request.host,
ip=request.ip,
user_agent=request.user_agent,
)
else:
# Default login action
session_token = await create_session(
user_uuid=stored_cred.user_uuid,
credential_uuid=stored_cred.uuid,
host=request.host,
ip=request.ip,
user_agent=request.user_agent,
)
# Complete the remote auth request (notifies the waiting device)
completed = await remoteauth.instance.complete_request(
token=request.key,
session_token=session_token,
user_uuid=stored_cred.user_uuid,
credential_uuid=stored_cred.uuid,
reset_token=reset_token,
)
if not completed:
raise ValueError("Failed to complete remote authentication")
msg = "Authentication successful."
if request.action == "register":
msg += " The other device can now register a passkey."
else:
msg += " The other device is now logged in."
await ws.send_json(
{
"status": "success",
"message": msg,
}
)
break
# Handle code lookup request - requires PoW validation
code = msg.get("code", "")
# Validate PoW for pairing codes
solution_b64 = msg.get("pow")
if not solution_b64:
raise ValueError("PoW solution required")
try:
solution = base64url.dec(solution_b64)
except Exception:
raise ValueError("Invalid PoW solution encoding")
try:
pow.verify_pow(challenge, solution, work)
except ValueError as e:
# Invalid PoW - send new challenge
challenge = pow.generate_challenge()
await ws.send_json(
{
"status": 400,
"detail": str(e),
"pow": {
"challenge": base64url.enc(challenge),
"work": work,
},
}
)
continue
if not code:
raise ValueError("Pairing code required")
# Look up the remote auth request by pairing code
request = await remoteauth.instance.get_request(code)
# Generate new challenge for next request (always NORMAL for authenticated users)
challenge = pow.generate_challenge()
if request is None:
await ws.send_json(
{
"status": 404,
"detail": "Code not found",
"pow": {
"challenge": base64url.enc(challenge),
"work": work,
},
}
)
request = None # Reset for next attempt
continue
# Valid code found - lock the action so it can't be changed anymore
# This also notifies the requesting device
locked_action = await remoteauth.instance.lock_action(request.key)
if locked_action is None:
# Already locked by another device
await ws.send_json(
{
"status": 409,
"detail": "This request is already being processed in another window",
"pow": {
"challenge": base64url.enc(challenge),
"work": work,
},
}
)
request = None # Reset for next attempt
continue
request.action = locked_action # Update local copy with locked value
# Send device info to the authenticating device
await ws.send_json(
{
"status": "found",
"host": request.host,
"user_agent_pretty": useragent.compact_user_agent(
request.user_agent
),
"client_ip": request.ip,
"action": request.action,
"pow": {
"challenge": base64url.enc(challenge),
"work": work,
},
}
)
except Exception:
# If websocket disconnects without explicit denial, unlock the request
if request and not explicitly_denied:
# Unlock the request so the code can be used again
async with remoteauth.instance._lock:
req = remoteauth.instance._requests.get(request.key)
if req and req.locked:
req.locked = False
raise
@@ -1,7 +1,7 @@
"""CLI support for creating user credential reset links. """CLI support for creating user credential reset links.
Usage (via main CLI): Usage (via main CLI):
passkey-auth reset [query] paskia reset [query]
If query is omitted, the master admin (first Administration role user in If query is omitted, the master admin (first Administration role user in
an organization granting auth:admin) is targeted. Otherwise query is an organization granting auth:admin) is targeted. Otherwise query is
@@ -15,10 +15,10 @@ from __future__ import annotations
import asyncio import asyncio
from uuid import UUID from uuid import UUID
from passkey import authsession as _authsession from paskia import authsession as _authsession
from passkey import globals as _g from paskia import globals as _g
from passkey.util import hostutil, passphrase from paskia.util import hostutil, passphrase
from passkey.util import tokens as _tokens from paskia.util import tokens as _tokens
async def _resolve_targets(query: str | None): async def _resolve_targets(query: str | None):
@@ -10,9 +10,9 @@ Generic session management functions have been moved to authsession.py
from fastapi import Cookie, Request, Response, WebSocket from fastapi import Cookie, Request, Response, WebSocket
from ..authsession import EXPIRES from paskia.authsession import EXPIRES
AUTH_COOKIE_NAME = "__Host-auth" AUTH_COOKIE_NAME = "__Host-paskia"
AUTH_COOKIE = Cookie(None, alias=AUTH_COOKIE_NAME) AUTH_COOKIE = Cookie(None, alias=AUTH_COOKIE_NAME)
@@ -10,16 +10,16 @@ from fastapi import (
) )
from fastapi.responses import JSONResponse from fastapi.responses import JSONResponse
from ..authsession import ( from paskia.authsession import (
delete_credential, delete_credential,
expires, expires,
get_session, get_session,
) )
from ..globals import db from paskia.fastapi import authz, session
from ..util import hostutil, passphrase, tokens from paskia.fastapi.session import AUTH_COOKIE
from ..util.tokens import decode_session_key, session_key from paskia.globals import db
from . import authz, session from paskia.util import hostutil, passphrase, tokens
from .session import AUTH_COOKIE from paskia.util.tokens import decode_session_key, session_key
app = FastAPI() app = FastAPI()
@@ -150,9 +150,7 @@ async def api_create_link(
expiry=expiry, expiry=expiry,
token_type="device addition", token_type="device addition",
) )
url = hostutil.reset_link_url( url = hostutil.reset_link_url(token)
token, request.url.scheme, request.headers.get("host")
)
return { return {
"message": "Registration link generated successfully", "message": "Registration link generated successfully",
"url": url, "url": url,
+13 -42
View File
@@ -1,42 +1,14 @@
import logging
from functools import wraps
from uuid import UUID from uuid import UUID
from fastapi import FastAPI, WebSocket, WebSocketDisconnect from fastapi import FastAPI, WebSocket
from webauthn.helpers.exceptions import InvalidAuthenticationResponse
from ..authsession import create_session, get_reset, get_session
from ..globals import db, passkey
from ..util import passphrase
from ..util.tokens import create_token, session_key
from . import authz
from .session import AUTH_COOKIE, infodict
# WebSocket error handling decorator
def websocket_error_handler(func):
@wraps(func)
async def wrapper(ws: WebSocket, *args, **kwargs):
try:
await ws.accept()
return await func(ws, *args, **kwargs)
except WebSocketDisconnect:
pass
except authz.AuthException as e:
await ws.send_json(
{
"status": e.status_code,
**(await authz.auth_error_content(e)),
}
)
except (ValueError, InvalidAuthenticationResponse) as e:
await ws.send_json({"status": 401, "detail": str(e)})
except Exception:
logging.exception("Internal Server Error")
await ws.send_json({"status": 500, "detail": "Internal Server Error"})
return wrapper
from paskia.authsession import create_session, get_reset, get_session
from paskia.fastapi import authz
from paskia.fastapi.session import AUTH_COOKIE, infodict
from paskia.fastapi.wsutil import validate_origin, websocket_error_handler
from paskia.globals import db, passkey
from paskia.util import passphrase
from paskia.util.tokens import create_token, session_key
# Create a FastAPI subapp for WebSocket endpoints # Create a FastAPI subapp for WebSocket endpoints
app = FastAPI() app = FastAPI()
@@ -46,15 +18,14 @@ async def register_chat(
ws: WebSocket, ws: WebSocket,
user_uuid: UUID, user_uuid: UUID,
user_name: str, user_name: str,
origin: str,
credential_ids: list[bytes] | None = None, credential_ids: list[bytes] | None = None,
origin: str | None = None,
): ):
"""Generate registration options and send them to the client.""" """Generate registration options and send them to the client."""
options, challenge = passkey.instance.reg_generate_options( options, challenge = passkey.instance.reg_generate_options(
user_id=user_uuid, user_id=user_uuid,
user_name=user_name, user_name=user_name,
credential_ids=credential_ids, credential_ids=credential_ids,
origin=origin,
) )
await ws.send_json({"optionsJSON": options}) await ws.send_json({"optionsJSON": options})
response = await ws.receive_json() response = await ws.receive_json()
@@ -75,7 +46,7 @@ async def websocket_register_add(
- Normal session via auth cookie (requires recent authentication) - Normal session via auth cookie (requires recent authentication)
- Reset token supplied as ?reset=... (auth cookie ignored) - Reset token supplied as ?reset=... (auth cookie ignored)
""" """
origin = ws.headers["origin"] origin = validate_origin(ws)
host = origin.split("://", 1)[1] host = origin.split("://", 1)[1]
if reset is not None: if reset is not None:
if not passphrase.is_well_formed(reset): if not passphrase.is_well_formed(reset):
@@ -100,7 +71,7 @@ async def websocket_register_add(
challenge_ids = await db.instance.get_credentials_by_user_uuid(user_uuid) challenge_ids = await db.instance.get_credentials_by_user_uuid(user_uuid)
# WebAuthn registration # WebAuthn registration
credential = await register_chat(ws, user_uuid, user_name, challenge_ids, origin) credential = await register_chat(ws, user_uuid, user_name, origin, challenge_ids)
# Create a new session and store everything in database # Create a new session and store everything in database
token = create_token() token = create_token()
@@ -131,7 +102,7 @@ async def websocket_register_add(
@app.websocket("/authenticate") @app.websocket("/authenticate")
@websocket_error_handler @websocket_error_handler
async def websocket_authenticate(ws: WebSocket, auth=AUTH_COOKIE): async def websocket_authenticate(ws: WebSocket, auth=AUTH_COOKIE):
origin = ws.headers["origin"] origin = validate_origin(ws)
host = origin.split("://", 1)[1] host = origin.split("://", 1)[1]
# If there's an existing session, restrict to that user's credentials (reauth) # If there's an existing session, restrict to that user's credentials (reauth)
@@ -166,7 +137,7 @@ async def websocket_authenticate(ws: WebSocket, auth=AUTH_COOKIE):
raise ValueError("This passkey belongs to a different account") raise ValueError("This passkey belongs to a different account")
# Verify the credential matches the stored data # Verify the credential matches the stored data
passkey.instance.auth_verify(credential, challenge, stored_cred, origin=origin) passkey.instance.auth_verify(credential, challenge, stored_cred, origin)
# Update both credential and user's last_seen timestamp # Update both credential and user's last_seen timestamp
await db.instance.login(stored_cred.user_uuid, stored_cred) await db.instance.login(stored_cred.user_uuid, stored_cred)
+91
View File
@@ -0,0 +1,91 @@
"""
Shared WebSocket utilities for FastAPI endpoints.
"""
import logging
from functools import wraps
import base64url
from fastapi import WebSocket, WebSocketDisconnect
from webauthn.helpers.exceptions import InvalidAuthenticationResponse
from paskia.fastapi import authz
from paskia.globals import passkey
from paskia.util import pow
def websocket_error_handler(func):
"""Decorator for WebSocket endpoints that handles common errors."""
@wraps(func)
async def wrapper(ws: WebSocket, *args, **kwargs):
try:
await ws.accept()
return await func(ws, *args, **kwargs)
except WebSocketDisconnect:
pass
except authz.AuthException as e:
await ws.send_json(
{
"status": e.status_code,
**(await authz.auth_error_content(e)),
}
)
except (ValueError, InvalidAuthenticationResponse) as e:
await ws.send_json({"status": 401, "detail": str(e)})
except Exception:
logging.exception("Internal Server Error")
await ws.send_json({"status": 500, "detail": "Internal Server Error"})
return wrapper
async def require_pow(ws: WebSocket, work: int | None = None) -> None:
"""Send a PoW challenge and verify the client's solution.
Sends: {"pow": {"challenge": "<base64>", "work": 10}}
Expects: {"pow": "<base64-solution>"}
Args:
ws: WebSocket connection
work: PoW difficulty level (default: pow.DEFAULT_WORK)
Raises:
ValueError: If the PoW solution is invalid
"""
challenge = pow.generate_challenge()
if work is None:
work = pow.DEFAULT_WORK
await ws.send_json(
{
"pow": {
"challenge": base64url.enc(challenge),
"work": work,
}
}
)
response = await ws.receive_json()
solution_b64 = response.get("pow")
if not solution_b64:
raise ValueError("PoW solution required")
try:
solution = base64url.dec(solution_b64)
except Exception:
raise ValueError("Invalid PoW solution encoding")
pow.verify_pow(challenge, solution, work)
def validate_origin(ws: WebSocket) -> str:
"""Extract and validate origin from WebSocket request headers.
Raises:
ValueError: If origin header is missing or not in allowed list
"""
origin = ws.headers.get("origin")
if not origin:
raise ValueError("Origin header is required for WebSocket connections")
return passkey.instance.validate_origin(origin)
+5 -7
View File
@@ -1,7 +1,7 @@
from typing import Generic, TypeVar from typing import Generic, TypeVar
from .db import DatabaseInterface from paskia.db import DatabaseInterface
from .sansio import Passkey from paskia.sansio import Passkey
T = TypeVar("T") T = TypeVar("T")
@@ -29,9 +29,7 @@ class Manager(Generic[T]):
async def init( async def init(
rp_id: str = "localhost", rp_id: str = "localhost",
rp_name: str | None = None, rp_name: str | None = None,
origin: str | None = None, origins: list[str] | None = None,
default_admin: str | None = None,
default_org: str | None = None,
*, *,
bootstrap: bool = True, bootstrap: bool = True,
) -> None: ) -> None:
@@ -45,7 +43,7 @@ async def init(
passkey.instance = Passkey( passkey.instance = Passkey(
rp_id=rp_id, rp_id=rp_id,
rp_name=rp_name or rp_id, rp_name=rp_name or rp_id,
origin=origin, origins=origins,
) )
# Test if we have a database already initialized, otherwise use SQL # Test if we have a database already initialized, otherwise use SQL
@@ -60,7 +58,7 @@ async def init(
# Bootstrap system if needed # Bootstrap system if needed
from .bootstrap import bootstrap_if_needed from .bootstrap import bootstrap_if_needed
await bootstrap_if_needed(default_admin, default_org) await bootstrap_if_needed()
# Global instances # Global instances
+359
View File
@@ -0,0 +1,359 @@
"""
Cross-device (remote) authentication support.
This module manages the flow for authenticating from another device:
1. Device A (requesting) creates a remote auth request and displays QR/link
2. Device B (authenticating) opens the link and authenticates with passkey
3. Device A receives the session via WebSocket notification
Alternative flow (initiated from profile/authenticating device):
1. Device A (requesting) creates request and displays short pairing code
2. Device B (authenticating) enters the pairing code in their profile
3. Device B authenticates, Device A receives the session
The requests are stored in-memory with short expiration (5 minutes).
The link uses the same /{token} endpoint as reset tokens, but the server
distinguishes between them by checking if the token exists in remoteauth first.
The first 3 words of the token serve as the pairing code for manual entry.
"""
import asyncio
import logging
from dataclasses import dataclass
from datetime import datetime, timedelta, timezone
from typing import Callable
from uuid import UUID
from paskia.util import passphrase
# Remote auth requests expire after this duration
REMOTE_AUTH_LIFETIME = timedelta(minutes=5)
@dataclass
class RemoteAuthRequest:
"""A pending remote authentication request."""
key: str # The 3-word passphrase code
created_at: datetime
host: str # The host where the session should be created
ip: str # IP of the requesting device
user_agent: str # User agent of the requesting device
action: str = "login" # "login" or "register"
locked: bool = False # True once the authenticating device has entered the code
# Callback to notify the requesting device when auth completes
# Takes (session_token, user_uuid, credential_uuid, reset_token) or (None, None, None, None) on cancel/expire
notify: (
Callable[[str | None, UUID | None, UUID | None, str | None], None] | None
) = None
# Callback to notify the requesting device when action is locked
# Takes (action) to confirm what action was locked
action_locked_notify: Callable[[str], None] | None = None
# Set when authentication completes
completed: bool = False
denied: bool = False # True if explicitly denied by the authenticating device
session_token: str | None = None
user_uuid: UUID | None = None
credential_uuid: UUID | None = None
reset_token: str | None = None
class RemoteAuthManager:
"""Manages pending remote authentication requests."""
def __init__(self):
self._requests: dict[str, RemoteAuthRequest] = {} # keyed by 3-word code
self._cleanup_task: asyncio.Task | None = None
self._lock = asyncio.Lock()
async def start(self):
"""Start the cleanup background task."""
if self._cleanup_task is None:
self._cleanup_task = asyncio.create_task(self._cleanup_loop())
async def stop(self):
"""Stop the cleanup background task."""
if self._cleanup_task:
self._cleanup_task.cancel()
try:
await self._cleanup_task
except asyncio.CancelledError:
pass
self._cleanup_task = None
async def _cleanup_loop(self):
"""Periodically clean up expired requests."""
while True:
try:
await asyncio.sleep(60) # Check every minute
await self._cleanup_expired()
except asyncio.CancelledError:
break
except Exception:
logging.exception("Error in remote auth cleanup loop")
async def _cleanup_expired(self):
"""Remove expired requests and notify waiting clients."""
now = datetime.now(timezone.utc)
expired_keys = []
async with self._lock:
for key, req in self._requests.items():
if now > req.created_at + REMOTE_AUTH_LIFETIME:
expired_keys.append(key)
for key in expired_keys:
req = self._requests.pop(key)
if req.notify and not req.completed:
try:
req.notify(None, None, None, None)
except Exception:
pass
async def create_request(
self,
host: str,
ip: str,
user_agent: str,
action: str = "login",
) -> tuple[str, datetime]:
"""Create a new remote auth request.
The code is a 3-word passphrase.
We ensure uniqueness across concurrent requests.
Returns:
(code, expiry) - The 3-word passphrase code and expiration time
"""
now = datetime.now(timezone.utc)
expiry = now + REMOTE_AUTH_LIFETIME
async with self._lock:
# Generate unique 3-word code
max_attempts = 100
for _ in range(max_attempts):
code = passphrase.generate(n=passphrase.N_WORDS_SHORT)
if code not in self._requests:
break
else:
# Extremely unlikely but handle gracefully
raise ValueError("Unable to generate unique code")
request = RemoteAuthRequest(
key=code,
created_at=now,
host=host,
ip=ip,
user_agent=user_agent,
action=action,
)
self._requests[code] = request
return code, expiry
async def get_request(self, code: str) -> RemoteAuthRequest | None:
"""Get a pending request by code, if valid and not expired."""
# Normalize: lowercase, dot-separated words
normalized = code.lower().strip().replace(" ", ".")
if not passphrase.is_well_formed(normalized, n=passphrase.N_WORDS_SHORT):
return None
async with self._lock:
req = self._requests.get(normalized)
if req is None:
return None
now = datetime.now(timezone.utc)
if now > req.created_at + REMOTE_AUTH_LIFETIME:
# Expired
del self._requests[normalized]
return None
return req
async def set_notify_callback(
self,
token: str,
callback: Callable[[str | None, UUID | None, UUID | None, str | None], None],
) -> bool:
"""Set the notification callback for a request.
Returns True if the request exists and callback was set.
"""
async with self._lock:
req = self._requests.get(token)
if req is None:
return False
req.notify = callback
return True
async def set_action_locked_callback(
self,
token: str,
callback: Callable[[str], None],
) -> bool:
"""Set the callback for when the action is locked.
Returns True if the request exists and callback was set.
"""
async with self._lock:
req = self._requests.get(token)
if req is None:
return False
req.action_locked_notify = callback
return True
async def update_action(
self,
token: str,
action: str,
) -> bool:
"""Update the action for a request (only if not locked).
Returns True if the request exists and was updated.
"""
if action not in ("login", "register"):
return False
async with self._lock:
req = self._requests.get(token)
if req is None or req.locked:
return False
req.action = action
return True
async def lock_action(
self,
token: str,
) -> str | None:
"""Lock the action for a request (called when authenticating device enters code).
Returns the locked action, or None if request doesn't exist or is already locked.
Notifies the requesting device via action_locked_notify callback.
"""
async with self._lock:
req = self._requests.get(token)
if req is None:
return None
if req.locked:
# Already locked by another authenticating device
return None
req.locked = True
action = req.action
if req.action_locked_notify:
try:
req.action_locked_notify(action)
except Exception:
pass
return action
async def complete_request(
self,
token: str,
session_token: str | None,
user_uuid: UUID,
credential_uuid: UUID,
reset_token: str | None = None,
) -> bool:
"""Mark a request as completed with the authentication result.
The request is removed after notifying the waiting client.
Returns True if the request existed and was completed.
"""
async with self._lock:
req = self._requests.pop(token, None)
if req is None:
return False
if req.notify:
try:
req.notify(session_token, user_uuid, credential_uuid, reset_token)
except Exception:
pass
return True
async def cancel_request(
self, token: str, *, denied: bool = False
) -> RemoteAuthRequest | None:
"""Cancel and remove a request.
Args:
token: The request token
denied: If True, marks this as an explicit denial (not just timeout/disconnect)
Returns the removed request if it existed, None otherwise.
"""
async with self._lock:
req = self._requests.pop(token, None)
if req is None:
return None
if denied:
req.denied = True
if req.notify and not req.completed:
try:
# Pass denied status through a special UUID value (all zeros means denied)
if denied:
req.notify(None, UUID(int=0), None, None)
else:
req.notify(None, None, None, None)
except Exception:
pass
return req
def get_connection_count(self) -> int:
"""Get the current count of open WebSocket connections.
This is used to determine PoW difficulty based on load.
"""
# Count is maintained externally by the WebSocket endpoints
return getattr(self, "_ws_count", 0)
def increment_connections(self) -> None:
"""Increment the WebSocket connection counter."""
self._ws_count = getattr(self, "_ws_count", 0) + 1
def decrement_connections(self) -> None:
"""Decrement the WebSocket connection counter."""
self._ws_count = max(0, getattr(self, "_ws_count", 0) - 1)
def get_pow_difficulty(self) -> int:
"""Get PoW difficulty based on current WebSocket connection count.
Uses NORMAL difficulty with low load (< 10 connections),
HARD difficulty with high load (>= 10 connections).
Returns:
PoW work units (pow.NORMAL or pow.HARD)
"""
from paskia.util import pow
count = self.get_connection_count()
return pow.HARD if count >= 10 else pow.NORMAL
async def consume_request(self, token: str) -> RemoteAuthRequest | None:
"""Get and remove a request (for use by the authenticating device)."""
if not passphrase.is_well_formed(token, n=passphrase.N_WORDS_SHORT):
return None
async with self._lock:
req = self._requests.get(token)
if req is None:
return None
now = datetime.now(timezone.utc)
if now > req.created_at + REMOTE_AUTH_LIFETIME:
del self._requests[token]
return None
# Don't remove yet - wait until completion
return req
# Global instance
instance: RemoteAuthManager | None = None
async def init():
"""Initialize the global remote auth manager."""
global instance
instance = RemoteAuthManager()
await instance.start()
async def shutdown():
"""Shutdown the global remote auth manager."""
global instance
if instance:
await instance.stop()
instance = None
+40 -20
View File
@@ -37,7 +37,7 @@ from webauthn.helpers.structs import (
UserVerificationRequirement, UserVerificationRequirement,
) )
from .db import Credential from paskia.db import Credential
class Passkey: class Passkey:
@@ -47,7 +47,7 @@ class Passkey:
self, self,
rp_id: str, rp_id: str,
rp_name: str | None = None, rp_name: str | None = None,
origin: str | None = None, origins: list[str] | None = None,
supported_pub_key_algs: list[COSEAlgorithmIdentifier] | None = None, supported_pub_key_algs: list[COSEAlgorithmIdentifier] | None = None,
): ):
""" """
@@ -56,40 +56,58 @@ class Passkey:
Args: Args:
rp_id: Your security domain (e.g. "example.com") rp_id: Your security domain (e.g. "example.com")
rp_name: The relying party display name (e.g. "Example App"). May be shown in authenticators. rp_name: The relying party display name (e.g. "Example App"). May be shown in authenticators.
origin: The origin URL of the application (e.g. "https://app.example.com"). origins: List of allowed origin URLs (e.g. ["https://app.example.com", "https://auth.example.com"]).
If no scheme is provided, "https://" will be prepended. Each must be a subdomain or same as rp_id. If not provided, any subdomain of rp_id is allowed.
Must be a subdomain or same as rp_id, with port and scheme but no path included.
supported_pub_key_algs: List of supported COSE algorithms (default is EDDSA, ECDSA_SHA_256, RSASSA_PKCS1_v1_5_SHA_256). supported_pub_key_algs: List of supported COSE algorithms (default is EDDSA, ECDSA_SHA_256, RSASSA_PKCS1_v1_5_SHA_256).
Raises: Raises:
ValueError: If the origin domain doesn't match or isn't a subdomain of rp_id. ValueError: If any origin domain doesn't match or isn't a subdomain of rp_id.
""" """
self.rp_id = rp_id self.rp_id = rp_id
self.rp_name = rp_name or rp_id self.rp_name = rp_name or rp_id
self.origin = self._normalize_and_validate_origin(origin, rp_id) self.allowed_origins: set[str] | None = None
if origins:
# Validate and deduplicate origins into a set for O(1) lookups
for o in origins:
self._validate_origin(o, rp_id)
self.allowed_origins = set(origins)
self.supported_pub_key_algs = supported_pub_key_algs or [ self.supported_pub_key_algs = supported_pub_key_algs or [
COSEAlgorithmIdentifier.EDDSA, COSEAlgorithmIdentifier.EDDSA,
COSEAlgorithmIdentifier.ECDSA_SHA_256, COSEAlgorithmIdentifier.ECDSA_SHA_256,
COSEAlgorithmIdentifier.RSASSA_PKCS1_v1_5_SHA_256, COSEAlgorithmIdentifier.RSASSA_PKCS1_v1_5_SHA_256,
] ]
def _normalize_and_validate_origin(self, origin: str | None, rp_id: str) -> str: def _validate_origin(self, origin: str, rp_id: str) -> None:
if origin is None: """Validate an origin URL against the rp_id."""
origin = f"https://{rp_id}"
elif "://" not in origin:
origin = f"https://{origin}"
hostname = urlparse(origin).hostname hostname = urlparse(origin).hostname
if not hostname: if not hostname:
raise ValueError(f"Invalid origin URL: no hostname found in '{origin}'") raise ValueError(f"Invalid origin URL: no hostname found in '{origin}'")
if hostname == rp_id or hostname.endswith(f".{rp_id}"): if hostname == rp_id or hostname.endswith(f".{rp_id}"):
return origin return
raise ValueError( raise ValueError(
f"Origin domain '{hostname}' must be the same as or a subdomain of rp_id '{rp_id}'" f"Origin domain '{hostname}' must be the same as or a subdomain of rp_id '{rp_id}'"
) )
def validate_origin(self, origin: str) -> str:
"""Validate that origin is allowed and return it.
Args:
origin: The origin URL to validate (from WebSocket request header)
Returns:
The validated origin URL
Raises:
ValueError: If origin is not in the allowed list (when origins are configured)
or if origin is not a valid subdomain of rp_id
"""
self._validate_origin(origin, self.rp_id)
if self.allowed_origins is not None and origin not in self.allowed_origins:
raise ValueError(f"Origin '{origin}' is not in the allowed origins list")
return origin
### Registration Methods ### ### Registration Methods ###
def reg_generate_options( def reg_generate_options(
@@ -137,14 +155,16 @@ class Passkey:
response_json: dict | str, response_json: dict | str,
expected_challenge: bytes, expected_challenge: bytes,
user_uuid: UUID, user_uuid: UUID,
origin: str | None = None, origin: str,
) -> Credential: ) -> Credential:
""" """
Verify registration response. Verify registration response.
Args: Args:
credential: The credential response from the client response_json: The credential response from the client
expected_challenge: The expected challenge bytes expected_challenge: The expected challenge bytes
user_uuid: The user's UUID
origin: The origin URL (required, must be pre-validated)
Returns: Returns:
Registration verification result Registration verification result
@@ -153,7 +173,7 @@ class Passkey:
registration = verify_registration_response( registration = verify_registration_response(
credential=credential, credential=credential,
expected_challenge=expected_challenge, expected_challenge=expected_challenge,
expected_origin=origin or self.origin, expected_origin=origin,
expected_rp_id=self.rp_id, expected_rp_id=self.rp_id,
) )
return Credential( return Credential(
@@ -206,7 +226,7 @@ class Passkey:
credential: AuthenticationCredential, credential: AuthenticationCredential,
expected_challenge: bytes, expected_challenge: bytes,
stored_cred: Credential, stored_cred: Credential,
origin: str | None = None, origin: str,
) -> VerifiedAuthentication: ) -> VerifiedAuthentication:
""" """
Verify authentication response against locally stored credential data. Verify authentication response against locally stored credential data.
@@ -215,13 +235,13 @@ class Passkey:
credential: The authentication credential response from the client credential: The authentication credential response from the client
expected_challenge: The earlier generated challenge bytes expected_challenge: The earlier generated challenge bytes
stored_cred: The server stored credential record (modified by this function) stored_cred: The server stored credential record (modified by this function)
origin: The origin URL (required, must be pre-validated)
""" """
expected_origin = origin or self.origin
# Verify the authentication response # Verify the authentication response
verification = verify_authentication_response( verification = verify_authentication_response(
credential=credential, credential=credential,
expected_challenge=expected_challenge, expected_challenge=expected_challenge,
expected_origin=expected_origin, expected_origin=origin,
expected_rp_id=self.rp_id, expected_rp_id=self.rp_id,
credential_public_key=stored_cred.public_key, credential_public_key=stored_cred.public_key,
credential_current_sign_count=stored_cred.sign_count, credential_current_sign_count=stored_cred.sign_count,
+75
View File
@@ -0,0 +1,75 @@
import asyncio
import mimetypes
import os
from importlib import resources
from pathlib import Path
import httpx
__all__ = ["path", "file", "read", "is_dev_mode"]
def _get_dev_server() -> str | None:
"""Get the dev server URL from environment, or None if not in dev mode."""
return os.environ.get("PASKIA_DEVMODE") or None
def _resolve_static_dir() -> Path:
# Try packaged path via importlib.resources (works for wheel/installed).
try: # pragma: no cover - trivial path resolution
pkg_dir = resources.files("paskia") / "frontend-build"
fs_path = Path(str(pkg_dir))
if fs_path.is_dir():
return fs_path
except Exception: # pragma: no cover - defensive
pass
# Fallback for editable/development before build.
return Path(__file__).parent.parent / "frontend-build"
path: Path = _resolve_static_dir()
def file(*parts: str) -> Path:
"""Return a child path under the static root."""
return path.joinpath(*parts)
def is_dev_mode() -> bool:
"""Check if we're running in dev mode (Vite frontend server)."""
return bool(_get_dev_server())
async def read(filepath: str) -> tuple[bytes, int, dict[str, str]]:
"""Read file content and return response tuple.
In dev mode, fetches from the Vite dev server.
In production, reads from the static build directory.
Args:
filepath: Path relative to frontend root, e.g. "/auth/index.html"
Returns:
Tuple of (content, status_code, headers) suitable for
FastAPI Response(*args) or Sanic raw response.
"""
if is_dev_mode():
dev_server = _get_dev_server()
async with httpx.AsyncClient() as client:
resp = await client.get(f"{dev_server}{filepath}")
resp.raise_for_status()
mime = resp.headers.get("content-type", "application/octet-stream")
# Strip charset suffix if present
mime = mime.split(";")[0].strip()
return resp.content, resp.status_code, {"content-type": mime}
else:
# Production: read from static build
file_path = path / filepath.lstrip("/")
content = await _read_file_async(file_path)
mime, _ = mimetypes.guess_type(str(file_path))
return content, 200, {"content-type": mime or "application/octet-stream"}
async def _read_file_async(file_path: Path) -> bytes:
"""Read file asynchronously using asyncio.to_thread."""
return await asyncio.to_thread(file_path.read_bytes)
+76
View File
@@ -0,0 +1,76 @@
"""Utilities for determining the auth UI host and base URLs."""
import json
import os
from functools import lru_cache
from urllib.parse import urlsplit
@lru_cache(maxsize=1)
def _load_config() -> dict:
"""Load PASKIA_CONFIG JSON."""
config_json = os.getenv("PASKIA_CONFIG")
if not config_json:
return {}
return json.loads(config_json)
def is_root_mode() -> bool:
return _load_config().get("auth_host") is not None
def dedicated_auth_host() -> str | None:
"""Return configured auth_host netloc, or None."""
auth_host = _load_config().get("auth_host")
if not auth_host:
return None
from urllib.parse import urlparse
parsed = urlparse(auth_host if "://" in auth_host else f"//{auth_host}")
return parsed.netloc or parsed.path or None
def ui_base_path() -> str:
return "/" if is_root_mode() else "/auth/"
def auth_site_url() -> str:
"""Return the base URL for the auth site UI (computed at startup)."""
cfg = _load_config()
return cfg.get("site_url", "https://localhost") + cfg.get("site_path", "/auth/")
def reset_link_url(token: str) -> str:
"""Generate a reset link URL for the given token."""
return f"{auth_site_url()}{token}"
def normalize_origin(origin: str) -> str:
"""Normalize an origin URL by adding https:// if no scheme is present."""
if "://" not in origin:
return f"https://{origin}"
return origin
def reload_config() -> None:
_load_config.cache_clear()
def normalize_host(raw_host: str | None) -> str | None:
"""Normalize a Host header preserving port (exact match required)."""
if not raw_host:
return None
candidate = raw_host.strip()
if not candidate:
return None
# urlsplit to parse (add // for scheme-less); prefer netloc to retain port.
parsed = urlsplit(candidate if "//" in candidate else f"//{candidate}")
netloc = parsed.netloc or parsed.path or ""
# Strip IPv6 brackets around host part but retain port suffix.
if netloc.startswith("["):
# format: [ipv6]:port or [ipv6]
if "]" in netloc:
host_part, _, rest = netloc.partition("]")
port_part = rest.lstrip(":")
netloc = host_part.strip("[]") + (f":{port_part}" if port_part else "")
return netloc.lower() or None
@@ -1,8 +1,9 @@
import secrets import secrets
from .wordlist import words from paskia.util.wordlist import words
N_WORDS = 5 N_WORDS = 5
N_WORDS_SHORT = 3
wset = set(words) wset = set(words)
@@ -3,9 +3,9 @@
from collections.abc import Sequence from collections.abc import Sequence
from fnmatch import fnmatchcase from fnmatch import fnmatchcase
from ..globals import db from paskia.globals import db
from .hostutil import normalize_host from paskia.util.hostutil import normalize_host
from .tokens import session_key from paskia.util.tokens import session_key
__all__ = ["has_any", "has_all", "session_context"] __all__ = ["has_any", "has_all", "session_context"]
+45
View File
@@ -0,0 +1,45 @@
"""
Proof of Work utility using PBKDF2-SHA512.
The PoW requires finding nonces where PBKDF2(challenge, nonce) produces
output with a zero first byte. Each work unit requires finding one such nonce.
All valid nonces are concatenated into a solution for server verification.
"""
import hashlib
import secrets
EASY = 2 # Around 0.25s
NORMAL = 8 # Around 1s
HARD = 32 # Around 4s
def generate_challenge() -> bytes:
"""Generate a random 8-byte challenge."""
return secrets.token_bytes(8)
def verify_pow(challenge: bytes, solution: bytes, work: int = NORMAL) -> None:
"""Verify a Proof of Work solution.
Args:
challenge: 8-byte server-provided challenge
solution: Concatenated 8-byte nonces (8 * work bytes)
work: Number of work units expected
Raises:
ValueError: If the solution is invalid
"""
if len(challenge) != 8:
raise ValueError("Invalid challenge length")
if len(solution) != 8 * work:
raise ValueError("Invalid solution length")
# Verify each work unit - check that PBKDF2 output starts with 0x00
for i in range(work):
nonce = solution[i * 8 : (i + 1) * 8]
# Require first byte of PBKDF2-SHA512 to be zero
result = hashlib.pbkdf2_hmac("sha512", challenge, nonce, 128, 2)
if result[0] or result[1] & 0x07:
raise ValueError("Invalid PoW solution")
@@ -2,8 +2,8 @@
from datetime import datetime, timezone from datetime import datetime, timezone
from ..db import SessionContext from paskia.db import SessionContext
from .timeutil import parse_duration from paskia.util.timeutil import parse_duration
def check_session_age(ctx: SessionContext, max_age: str | None) -> bool: def check_session_age(ctx: SessionContext, max_age: str | None) -> bool:
+75
View File
@@ -0,0 +1,75 @@
"""Startup configuration box formatting utilities."""
import os
from sys import stderr
from typing import TYPE_CHECKING
from paskia._version import __version__
if TYPE_CHECKING:
from paskia.config import PaskiaConfig
BOX_WIDTH = 60 # Inner width (excluding box chars)
def line(text: str = "") -> str:
"""Format a line inside the box with proper padding, truncating if needed."""
if len(text) > BOX_WIDTH:
text = text[: BOX_WIDTH - 1] + ""
return f"{text:<{BOX_WIDTH}}\n"
def top() -> str:
return "" + "" * (BOX_WIDTH + 2) + "\n"
def bottom() -> str:
return "" + "" * (BOX_WIDTH + 2) + "\n"
def print_startup_config(config: "PaskiaConfig") -> None:
"""Print server configuration on startup."""
lines = [top()]
lines.append(line(" ▄▄▄▄▄"))
lines.append(line("█ █ Paskia " + __version__))
lines.append(line("█ █▄▄▄▄▄▄▄▄▄▄▄▄"))
lines.append(line("█ █▀▀▀▀█▀▀█▀▀█ " + config.site_url + config.site_path))
lines.append(line(" ▀▀▀▀▀"))
# Format auth host section
if config.auth_host:
lines.append(line(f"Auth Host: {config.auth_host}"))
# Show frontend URL if in dev mode
devmode = os.environ.get("PASKIA_DEVMODE")
if devmode:
lines.append(line(f"Dev Frontend: {devmode}"))
# Format listen address with scheme
if config.uds:
listen = f"unix:{config.uds}"
elif config.host:
listen = f"http://{config.host}:{config.port}"
else:
listen = f"http://0.0.0.0:{config.port} + [::]:{config.port}"
lines.append(line(f"Backend: {listen}"))
# Relying Party line (omit name if same as id)
rp_id = config.rp_id
rp_name = config.rp_name
if rp_name and rp_name != rp_id:
lines.append(line(f"Relying Party: {rp_id} ({rp_name})"))
else:
lines.append(line(f"Relying Party: {rp_id}"))
# Format origins section
allowed = config.origins
if allowed:
lines.append(line("Permitted Origins:"))
for origin in sorted(allowed):
lines.append(line(f" - {origin}"))
else:
lines.append(line(f"Origin: {rp_id} and all subdomains allowed"))
lines.append(bottom())
stderr.write("".join(lines))
@@ -1,8 +1,9 @@
import base64
import hashlib import hashlib
import secrets import secrets
from .passphrase import is_well_formed import base64url
from paskia.util.passphrase import is_well_formed
def create_token() -> str: def create_token() -> str:
@@ -12,21 +13,20 @@ def create_token() -> str:
def session_key(token: str) -> bytes: def session_key(token: str) -> bytes:
if len(token) != 16: if len(token) != 16:
raise ValueError("Session token must be exactly 16 characters long") raise ValueError("Session token must be exactly 16 characters long")
return b"sess" + base64.urlsafe_b64decode(token) return b"sess" + base64url.dec(token)
def encode_session_key(key: bytes) -> str: def encode_session_key(key: bytes) -> str:
"""Encode an opaque session key for external representation.""" """Encode an opaque session key for external representation."""
return base64.urlsafe_b64encode(key).decode().rstrip("=") return base64url.enc(key)
def decode_session_key(encoded: str) -> bytes: def decode_session_key(encoded: str) -> bytes:
"""Decode an opaque session key from its public representation.""" """Decode an opaque session key from its public representation."""
if not encoded: if not encoded:
raise ValueError("Invalid session identifier") raise ValueError("Invalid session identifier")
padding = "=" * (-len(encoded) % 4)
try: try:
raw = base64.urlsafe_b64decode(encoded + padding) raw = base64url.dec(encoded)
except Exception as exc: # pragma: no cover - defensive except Exception as exc: # pragma: no cover - defensive
raise ValueError("Invalid session identifier") from exc raise ValueError("Invalid session identifier") from exc
if not raw.startswith(b"sess"): if not raw.startswith(b"sess"):
@@ -2,12 +2,10 @@
from datetime import timezone from datetime import timezone
from passkey.util import useragent from paskia import aaguid
from paskia.authsession import session_key
from .. import aaguid from paskia.globals import db
from ..authsession import session_key from paskia.util import hostutil, permutil, tokens, useragent
from ..globals import db
from . import hostutil, permutil, tokens
def _format_datetime(dt): def _format_datetime(dt):
-3
View File
@@ -1,3 +0,0 @@
from .sansio import Passkey
__all__ = ["Passkey"]
-7
View File
@@ -1,7 +0,0 @@
from datetime import timedelta
# Shared configuration constants for session management.
SESSION_LIFETIME = timedelta(hours=24)
# Lifetime for reset links created by admins
RESET_LIFETIME = timedelta(days=14)
-3
View File
@@ -1,3 +0,0 @@
from .mainapp import app
__all__ = ["app"]
-154
View File
@@ -1,154 +0,0 @@
import asyncio
import atexit
import mimetypes
import os
import shutil
import signal
import subprocess
from importlib import resources
from pathlib import Path
from sys import stderr
from threading import Thread
import httpx
__all__ = ["path", "file", "read", "run_dev", "is_dev_mode"]
DEV_SERVER = "http://localhost:4403"
NO_FRONTEND_TOOL = """\
┃ ⚠️ deno, npm or bunx needed to run the frontend server.
"""
BUN_BUG = """\
┃ ⚠️ Bun cannot correctly proxy API requests to the backend.
┃ Bug report: https://github.com/oven-sh/bun/issues/9882
┃ Options:
┃ - sudo caddy run --config caddy/Caddyfile.dev
┃ - Install deno or npm instead
┃ Caddy will skip the Vite for API calls and serve everything at port 443.
┃ Otherwise Vite serves at port 8077 and proxies to backend (broken with bun).
"""
NO_FRONTEND = """\
┃ Note: only static build of the frontend is served at localhost:4402.
┃ The page will not update with frontend code changes.
"""
def _resolve_static_dir() -> Path:
# Try packaged path via importlib.resources (works for wheel/installed).
try: # pragma: no cover - trivial path resolution
pkg_dir = resources.files("passkey") / "frontend-build"
fs_path = Path(str(pkg_dir))
if fs_path.is_dir():
return fs_path
except Exception: # pragma: no cover - defensive
pass
# Fallback for editable/development before build.
return Path(__file__).parent.parent / "frontend-build"
path: Path = _resolve_static_dir()
def file(*parts: str) -> Path:
"""Return a child path under the static root."""
return path.joinpath(*parts)
def is_dev_mode() -> bool:
"""Check if we're running in dev mode (Vite frontend server)."""
return os.environ.get("PASSKEY_DEVMODE") == "1"
async def read(filepath: str) -> tuple[bytes, int, dict[str, str]]:
"""Read file content and return response tuple.
In dev mode, fetches from the Vite dev server.
In production, reads from the static build directory.
Args:
filepath: Path relative to frontend root, e.g. "/auth/index.html"
Returns:
Tuple of (content, status_code, headers) suitable for
FastAPI Response(*args) or Sanic raw response.
"""
if is_dev_mode():
async with httpx.AsyncClient() as client:
resp = await client.get(f"{DEV_SERVER}{filepath}")
resp.raise_for_status()
mime = resp.headers.get("content-type", "application/octet-stream")
# Strip charset suffix if present
mime = mime.split(";")[0].strip()
return resp.content, resp.status_code, {"content-type": mime}
else:
# Production: read from static build
file_path = path / filepath.lstrip("/")
content = await _read_file_async(file_path)
mime, _ = mimetypes.guess_type(str(file_path))
return content, 200, {"content-type": mime or "application/octet-stream"}
async def _read_file_async(file_path: Path) -> bytes:
"""Read file asynchronously using asyncio.to_thread."""
return await asyncio.to_thread(file_path.read_bytes)
def run_dev():
"""Spawn the frontend dev server (deno, npm, or bunx) as a background process."""
devpath = Path(__file__).parent.parent.parent / "frontend"
if not (devpath / "package.json").exists():
raise RuntimeError(
"Dev frontend is only available when running from git."
if "site-packages" in devpath.parts
else f"Frontend source code not found at {devpath}"
)
options = [
("deno", "run", "dev"),
("npm", "run", "dev", "--"),
("bunx", "--bun", "vite"),
]
cmd = None
tool_name = None
for option in options:
if tool := shutil.which(option[0]):
cmd = [tool, *option[1:]]
tool_name = option[0]
break
vite_process = None
def start_vite():
nonlocal vite_process
if cmd is None:
stderr.write(NO_FRONTEND_TOOL)
stderr.write(NO_FRONTEND)
return
assert tool_name is not None
try:
if tool_name == "bunx":
stderr.write(BUN_BUG)
stderr.write(f">>> {' '.join([tool_name, *cmd[1:]])}\n")
vite_process = subprocess.Popen(cmd, cwd=str(devpath), shell=False)
except Exception as e:
stderr.write(f"┃ ⚠️ Vite couldn't start: {e}\n")
stderr.write(NO_FRONTEND)
def cleanup():
vite_process.terminate()
vite_process.wait()
# Start Vite in a separate thread
vite_thread = Thread(target=start_vite, daemon=True)
vite_thread.start()
atexit.register(cleanup)
signal.signal(signal.SIGTERM, lambda *_: cleanup())
signal.signal(signal.SIGINT, lambda *_: cleanup())
-92
View File
@@ -1,92 +0,0 @@
"""Utilities for determining the auth UI host and base URLs."""
import os
from functools import lru_cache
from urllib.parse import urlparse, urlsplit
from ..globals import passkey as global_passkey
_AUTH_HOST_ENV = "PASSKEY_AUTH_HOST"
def _default_origin_scheme() -> str:
origin_url = urlparse(global_passkey.instance.origin)
return origin_url.scheme or "https"
@lru_cache(maxsize=1)
def _load_config() -> tuple[str | None, str] | None:
raw = os.getenv(_AUTH_HOST_ENV)
if not raw:
return None
candidate = raw.strip()
if not candidate:
return None
parsed = urlparse(candidate if "://" in candidate else f"//{candidate}")
netloc = parsed.netloc or parsed.path
if not netloc:
return None
return (parsed.scheme or None, netloc.strip("/"))
def configured_auth_host() -> str | None:
cfg = _load_config()
return cfg[1] if cfg else None
def is_root_mode() -> bool:
return _load_config() is not None
def ui_base_path() -> str:
return "/" if is_root_mode() else "/auth/"
def auth_site_base_url(scheme: str | None = None, host: str | None = None) -> str:
cfg = _load_config()
if cfg:
cfg_scheme, cfg_host = cfg
scheme_to_use = cfg_scheme or scheme or _default_origin_scheme()
netloc = cfg_host
else:
if host:
scheme_to_use = scheme or _default_origin_scheme()
netloc = host.strip("/")
else:
origin = global_passkey.instance.origin.rstrip("/")
return f"{origin}{ui_base_path()}"
base = f"{scheme_to_use}://{netloc}".rstrip("/")
path = ui_base_path().lstrip("/")
return f"{base}/{path}" if path else f"{base}/"
def reset_link_url(
token: str, scheme: str | None = None, host: str | None = None
) -> str:
base = auth_site_base_url(scheme, host)
return f"{base}{token}"
def reload_config() -> None:
_load_config.cache_clear()
def normalize_host(raw_host: str | None) -> str | None:
"""Normalize a Host header preserving port (exact match required)."""
if not raw_host:
return None
candidate = raw_host.strip()
if not candidate:
return None
# urlsplit to parse (add // for scheme-less); prefer netloc to retain port.
parsed = urlsplit(candidate if "//" in candidate else f"//{candidate}")
netloc = parsed.netloc or parsed.path or ""
# Strip IPv6 brackets around host part but retain port suffix.
if netloc.startswith("["):
# format: [ipv6]:port or [ipv6]
if "]" in netloc:
host_part, _, rest = netloc.partition("]")
port_part = rest.lstrip(":")
netloc = host_part.strip("[]") + (f":{port_part}" if port_part else "")
return netloc.lower() or None
+46 -6
View File
@@ -3,9 +3,10 @@ requires = ["hatchling", "hatch-vcs"]
build-backend = "hatchling.build" build-backend = "hatchling.build"
[project] [project]
name = "passkey" name = "paskia"
dynamic = ["version"] dynamic = ["version"]
description = "Passkey Authentication for Web Services" description = "Passkey Auth made easy: all sites and APIs can be guarded even without any changes on the protected site."
keywords = [ "forward_auth", "auth_request", "FastAPI" ]
authors = [ authors = [
{name = "Leo Vasanko"}, {name = "Leo Vasanko"},
] ]
@@ -26,11 +27,40 @@ requires-python = ">=3.10"
source = "vcs" source = "vcs"
[tool.hatch.build.hooks.vcs] [tool.hatch.build.hooks.vcs]
version-file = "passkey/_version.py" version-file = "paskia/_version.py"
[project.optional-dependencies] [project.optional-dependencies]
dev = [ dev = [
"ruff>=0.1.0", "ruff>=0.1.0",
"coverage[toml]>=7.0.0",
"pytest>=8.0.0",
"pytest-asyncio>=0.24.0",
"httpx>=0.27.0",
]
[tool.coverage.run]
source = ["paskia"]
branch = true
parallel = true
sigterm = true
[tool.coverage.report]
exclude_lines = [
"pragma: no cover",
"if TYPE_CHECKING:",
"if __name__ == .__main__.:",
]
show_missing = true
[tool.coverage.html]
directory = "coverage-html"
[tool.pytest.ini_options]
asyncio_mode = "auto"
asyncio_default_fixture_loop_scope = "function"
testpaths = ["tests"]
filterwarnings = [
"ignore::DeprecationWarning",
] ]
[tool.ruff] [tool.ruff]
@@ -40,11 +70,21 @@ line-length = 88
[tool.ruff.lint] [tool.ruff.lint]
select = ["E", "F", "I", "N", "W", "UP"] select = ["E", "F", "I", "N", "W", "UP"]
ignore = ["E501"] # Line too long ignore = ["E501"] # Line too long
isort.known-first-party = ["passkey"] isort.known-first-party = ["paskia"]
[dependency-groups]
dev = [
"coverage>=7.12.0",
"httpx>=0.28.1",
"pytest>=9.0.1",
"pytest-asyncio>=1.3.0",
"pytest-cov>=7.0.0",
"ruff>=0.14.8",
]
[project.scripts] [project.scripts]
passkey-auth = "passkey.fastapi.__main__:main" paskia = "paskia.fastapi.__main__:main"
[tool.hatch.build] [tool.hatch.build]
artifacts = ["passkey/frontend-build"] artifacts = ["paskia/frontend-build"]
targets.sdist.hooks.custom.path = "scripts/build-frontend.py" targets.sdist.hooks.custom.path = "scripts/build-frontend.py"
+463
View File
@@ -0,0 +1,463 @@
#!/usr/bin/env -S uv run
"""Run Vite development server for frontend and FastAPI backend with auto-reload.
This script is only available when running from the git repository source,
not from the installed package. It starts both the Vite frontend dev server
and the FastAPI backend with auto-reload enabled.
Usage:
uv run scripts/dev.py [host:port] [options...]
The optional host:port argument sets where the Vite frontend listens.
All other options are forwarded to `paskia serve`.
Backend always listens on localhost:4402.
Options:
--caddy Run Caddy as HTTPS proxy on port 443 (requires sudo)
--rp-id HOST Relying Party ID (used as hostname for Caddy)
--origin URL Allowed origin(s), passed to backend
--auth-host H Dedicated auth host, passed to backend
"""
import argparse
import atexit
import ipaddress
import json
import os
import shutil
import signal
import subprocess
from pathlib import Path
from sys import stderr
from threading import Thread
from urllib.parse import urlparse
DEFAULT_VITE_PORT = 4403 # overrides by CLI option
BACKEND_PORT = 4402 # hardcoded, also in vite.config.ts
CADDY_PORT = 443 # HTTPS port for Caddy proxy
CADDY_HTTP_PORT = 80 # HTTP port for ACME challenges
DEFAULT_HOST = "localhost"
NO_FRONTEND_TOOL = """\
┃ ⚠️ deno, npm or bunx needed to run the frontend server.
"""
BUN_BUG = """\
┃ ⚠️ Bun cannot correctly proxy API requests to the backend.
┃ Bug report: https://github.com/oven-sh/bun/issues/9882
┃ Options:
┃ - sudo caddy run --config caddy/Caddyfile.dev
┃ - Install deno or npm instead
┃ Caddy will skip the Vite for API calls and serve everything at port 443.
┃ Otherwise Vite serves at port 8077 and proxies to backend (broken with bun).
"""
NO_FRONTEND = """\
┃ The backend will still try reaching Vite at {vite_url}
┃ for various frontend assets, so make sure to start it manually.
"""
CADDYFILE_SITE_BLOCK = """\
SITE_ADDR {
# WebSockets bypass directly to backend (workaround for bun proxy bug)
handle /auth/ws/* {
reverse_proxy localhost:BACKEND_PORT
}
# Everything else goes to or via Vite
handle {
reverse_proxy localhost:VITE_PORT
}
}
"""
def parse_endpoint(
value: str | None, default_port: int
) -> tuple[str | None, int | None, str | None, bool]:
"""Parse an endpoint for Vite (simplified version for dev.py).
Returns (host, port, uds_path, all_ifaces).
"""
if not value:
return DEFAULT_HOST, default_port, None, False
# Port only (numeric) -> localhost:port
if value.isdigit():
return DEFAULT_HOST, int(value), None, False
# Leading colon :port -> bind all interfaces
if value.startswith(":") and value != ":":
port_part = value[1:]
if not port_part.isdigit():
raise SystemExit(f"Invalid port in '{value}'")
return None, int(port_part), None, True
# UNIX domain socket
if value.startswith("unix:"):
uds_path = value[5:] or None
if uds_path is None:
raise SystemExit("unix: path must not be empty")
return None, None, uds_path, False
# Unbracketed IPv6 (cannot safely contain a port)
if value.count(":") > 1 and not value.startswith("["):
try:
ipaddress.IPv6Address(value)
except ValueError as e:
raise SystemExit(f"Invalid IPv6 address '{value}': {e}")
return value, default_port, None, False
# Use urllib.parse for everything else
parsed = urlparse(f"//{value}")
host = parsed.hostname or DEFAULT_HOST
port = parsed.port or default_port
return host, port, None, False
def run_vite(vite_url: str, vite_host: str | None, vite_port: int):
"""Spawn the frontend dev server (deno, npm, or bunx) as a background process."""
devpath = Path(__file__).parent.parent / "frontend"
if not (devpath / "package.json").exists():
stderr.write(
f"┃ ⚠️ Frontend source not found at {devpath}\n"
+ NO_FRONTEND.format(vite_url=vite_url)
)
return
options = [
("deno", "run", "dev"),
("npm", "--silent", "run", "dev", "--"),
("bunx", "--bun", "vite"),
]
cmd = None
tool_name = None
for option in options:
if tool := shutil.which(option[0]):
cmd = [tool, *option[1:]]
tool_name = option[0]
break
# Add Vite CLI args for host/port
vite_args = ["--port", str(vite_port), "--logLevel", "silent"]
if vite_host:
vite_args.extend(["--host", vite_host])
vite_process = None
def start_vite():
nonlocal vite_process
if cmd is None:
stderr.write(NO_FRONTEND_TOOL + NO_FRONTEND.format(vite_url=vite_url))
return
assert tool_name is not None
try:
if tool_name == "bunx":
stderr.write(BUN_BUG)
full_cmd = cmd + vite_args
stderr.write(f">>> {' '.join([tool_name, *full_cmd[1:]])}\n")
vite_process = subprocess.Popen(full_cmd, cwd=str(devpath), shell=False)
except Exception as e:
stderr.write(
f"┃ ⚠️ Vite couldn't start: {e}\n"
+ NO_FRONTEND.format(vite_url=vite_url)
)
def cleanup():
if vite_process:
vite_process.terminate()
vite_process.wait()
# Start Vite in a separate thread
vite_thread = Thread(target=start_vite, daemon=True)
vite_thread.start()
atexit.register(cleanup)
signal.signal(signal.SIGTERM, lambda *_: cleanup())
signal.signal(signal.SIGINT, lambda *_: cleanup())
def run_caddy(origins: list[str], vite_port: int) -> subprocess.Popen | None:
"""Spawn Caddy as HTTPS reverse proxy for the given origins."""
caddy_path = shutil.which("caddy")
if not caddy_path:
stderr.write("┃ ⚠️ Caddy not found. Install it to use --caddy option.\n")
return None
# Build Caddyfile with a site block for each origin
caddyfile_parts = []
for origin in origins:
parsed = urlparse(origin)
# Extract scheme://host:port from origin URL
scheme = parsed.scheme or "https"
host = parsed.hostname or parsed.path # handle case without scheme
port = parsed.port or (CADDY_HTTP_PORT if scheme == "http" else CADDY_PORT)
# Use standard ports without explicit port in address (cleaner URLs)
if port in (80, 443):
site_addr = f"{scheme}://{host}"
else:
site_addr = f"{scheme}://{host}:{port}"
block = (
CADDYFILE_SITE_BLOCK.replace("SITE_ADDR", site_addr)
.replace("BACKEND_PORT", str(BACKEND_PORT))
.replace("VITE_PORT", str(vite_port))
)
caddyfile_parts.append(block)
caddyfile = "\n".join(caddyfile_parts)
caddy_process = None
try:
# Use sudo to bind to privileged ports (80/443) for ACME certificate fetching
cmd = ["sudo", caddy_path, "run", "--config", "-", "--adapter", "caddyfile"]
caddy_process = subprocess.Popen(
cmd,
stdin=subprocess.PIPE,
stderr=subprocess.PIPE,
)
caddy_process.stdin.write(caddyfile.encode())
caddy_process.stdin.close()
except Exception as e:
stderr.write(f"┃ ⚠️ Caddy couldn't start: {e}\n")
return None
# Helper to parse Caddy log line (JSON or plain text) into (level, logger, msg)
def parse_caddy_log(line: str) -> tuple[str, str, str] | None:
"""Parse a Caddy log line, return (level, logger, msg) or None if unparseable."""
line = line.rstrip("\n")
if not line:
return None
# Try JSON format first
try:
log = json.loads(line)
return (
log.get("level", ""),
log.get("logger", ""),
log.get("msg", ""),
)
except json.JSONDecodeError:
pass
# Plain text format: "2025/12/06 22:59:41.390 INFO logger msg..."
# or "2025/12/06 22:59:41.390 INFO msg..." (no logger)
parts = line.split("\t")
if len(parts) >= 2:
# First part is "timestamp LEVEL", rest are logger and/or message
first = parts[0].rsplit(None, 1) # split off the level from timestamp
if len(first) == 2:
level = first[1].lower()
if len(parts) == 2:
return (level, "", parts[1])
else:
return (level, parts[1], "\t".join(parts[2:]))
# Unparseable - return as-is with no level/logger
return ("", "", line)
def strip_caddy_verbose(msg: str) -> str:
"""Remove verbose prefixes from Caddy error messages."""
return msg.replace("loading initial config: loading new config: ", "")
def format_caddy_log(level: str, logger: str, msg: str) -> str:
"""Format a parsed Caddy log for display."""
msg = strip_caddy_verbose(msg)
if logger:
return f"┃ [{level.upper()}] {logger}: {msg}\n"
else:
return f"┃ [{level.upper()}] {msg}\n"
# Read stderr line by line until Caddy signals it's ready or exits
# Caddy outputs logs; "serving initial configuration" means it's ready
while True:
exit_code = caddy_process.poll()
if exit_code is not None:
# Process exited - read remaining stderr and report failure
remaining = (
caddy_process.stderr.read().decode() if caddy_process.stderr else ""
)
if remaining:
for line in remaining.splitlines():
if line:
parsed = parse_caddy_log(line)
if parsed:
level, logger, msg = parsed
if level:
stderr.write(format_caddy_log(level, logger, msg))
else:
stderr.write(f"{strip_caddy_verbose(msg)}\n")
else:
stderr.write(f"{strip_caddy_verbose(line)}\n")
stderr.write(f"┃ ⚠️ Caddy startup failed (exit code {exit_code})\n")
return None
# Read one line from stderr (blocks until data available)
line = caddy_process.stderr.readline().decode()
if not line:
continue
# Check for ready signal
if "serving initial configuration" in line:
break
parsed = parse_caddy_log(line)
if not parsed:
continue
level, logger, msg = parsed
# Filter out info-level and admin messages
if level == "info" or logger == "admin":
continue
# Show errors/fatal to user
if level in ("error", "fatal"):
stderr.write(format_caddy_log(level, logger, msg))
elif not level:
# Unparseable non-empty line (e.g., sudo prompt) - pass through with prefix
stderr.write(f"{strip_caddy_verbose(msg)}\n")
stderr.flush()
# Start a background thread to drain stderr and show errors
def drain_stderr():
while True:
line = caddy_process.stderr.readline().decode()
if not line:
break
parsed = parse_caddy_log(line)
if not parsed:
continue
level, logger, msg = parsed
# Filter out info-level and admin messages
if level == "info" or logger == "admin":
continue
# Show errors/warnings to user
if level in ("error", "fatal", "warn"):
stderr.write(format_caddy_log(level, logger, msg))
elif not level:
# Unparseable line - pass through with prefix
stderr.write(f"{strip_caddy_verbose(msg)}\n")
drain_thread = Thread(target=drain_stderr, daemon=True)
drain_thread.start()
def cleanup():
if caddy_process:
caddy_process.terminate()
caddy_process.wait()
atexit.register(cleanup)
return caddy_process
def main():
# Parse optional hostport argument for Vite frontend
parser = argparse.ArgumentParser(add_help=False)
parser.add_argument("hostport", nargs="?", default=None)
parser.add_argument("--caddy", action="store_true", help="Run Caddy as HTTPS proxy")
parser.add_argument("--rp-id", default="localhost", help="Relying Party ID")
parser.add_argument(
"--origin", action="append", dest="origins", help="Allowed origin(s)"
)
parser.add_argument("--auth-host", help="Dedicated auth host")
args, remaining = parser.parse_known_args()
# Parse Vite endpoint
vite_host, vite_port, vite_uds, all_ifaces = parse_endpoint(
args.hostport, DEFAULT_VITE_PORT
)
if vite_uds:
raise SystemExit("┃ ⚠️ Unix sockets are not supported for Vite frontend")
# Handle all-interfaces case (:port syntax)
# Vite uses 0.0.0.0 to listen on all interfaces (IPv4 only, sufficient for dev)
if all_ifaces:
vite_host = "0.0.0.0"
# Build Vite URL for PASKIA_DEVMODE (always use localhost for URL)
vite_url = f"http://localhost:{vite_port}"
# Compute origins for Caddy (user-specified or auto-generated)
caddy_origins = []
if args.origins:
# User specified explicit origins - use those
caddy_origins = args.origins
elif args.caddy:
# Caddy mode without explicit origins: add https origin for the hostname
if args.auth_host:
# auth-host is the primary origin
auth_host = args.auth_host
if "://" not in auth_host:
auth_host = f"https://{auth_host}"
caddy_origins.append(auth_host)
else:
# Use rp-id as the hostname (standard port 443, no port in URL)
caddy_origins.append(f"https://{args.rp_id}")
# Start Caddy if requested (after computing origins)
if args.caddy:
if not caddy_origins:
caddy_origins = [f"https://{args.rp_id}"]
stderr.write(f">>> sudo caddy @ {' '.join(caddy_origins)}\n")
if not run_caddy(caddy_origins, vite_port):
raise SystemExit(1)
# Start Vite dev server
run_vite(vite_url, vite_host, vite_port)
# Set dev mode with Vite URL in environment for subprocess
env = os.environ.copy()
env["PASKIA_DEVMODE"] = vite_url
# Build command with origin args
cmd = ["paskia", "serve", f"localhost:{BACKEND_PORT}"]
# Pass through rp-id (always pass, has default)
cmd.extend(["--rp-id", args.rp_id])
# Pass through auth-host if specified
if args.auth_host:
cmd.extend(["--auth-host", args.auth_host])
# Collect all origins: Caddy origins first (auth-host first), then user origins
# Use a set to track and avoid duplicates
all_origins = []
seen_origins = set(args.origins) if args.origins else set()
# Add Caddy origins first (they include auth-host origin if configured)
if args.caddy:
for origin in caddy_origins:
if origin not in seen_origins:
all_origins.append(origin)
seen_origins.add(origin)
# Add user-specified origins
if args.origins:
for origin in args.origins:
if origin not in seen_origins:
all_origins.append(origin)
seen_origins.add(origin)
# Pass all origins to backend
for origin in all_origins:
cmd.extend(["--origin", origin])
# Add remaining args (ones we didn't parse)
cmd.extend(remaining)
stderr.write(f">>> (devmode) {' '.join(cmd)}\n")
subprocess.run(cmd, env=env)
if __name__ == "__main__":
main()
+1
View File
@@ -0,0 +1 @@
# Paskia API Tests
+261
View File
@@ -0,0 +1,261 @@
"""
Pytest configuration and fixtures for Paskia API tests.
FastAPI provides excellent testing support through httpx.ASGITransport,
which allows us to make async requests directly to the ASGI app without
running a server.
Since we can't emulate WebAuthn passkeys, we create sessions directly
in the database to test authenticated endpoints.
"""
import asyncio
import os
from collections.abc import AsyncGenerator
from datetime import datetime, timezone
from uuid import UUID
import httpx
import pytest
import pytest_asyncio
import uuid7
from paskia import globals
from paskia.db import Credential, Org, Permission, Role, User
from paskia.db.sql import DB
from paskia.fastapi.session import AUTH_COOKIE_NAME
from paskia.sansio import Passkey
from paskia.util.tokens import create_token, session_key
# Use in-memory SQLite for tests
os.environ["PASKIA_DB"] = "sqlite+aiosqlite:///:memory:"
@pytest.fixture(scope="session")
def event_loop():
"""Create an event loop for the test session."""
loop = asyncio.get_event_loop_policy().new_event_loop()
yield loop
loop.close()
@pytest_asyncio.fixture(scope="function")
async def test_db() -> AsyncGenerator[DB, None]:
"""Create an in-memory SQLite database for testing.
We use :memory: for speed - each test gets a fresh database.
"""
db = DB("sqlite+aiosqlite:///:memory:")
await db.init_db()
globals.db._instance = db
yield db
# Clean up
globals.db._instance = None
@pytest_asyncio.fixture(scope="function")
async def passkey_instance() -> Passkey:
"""Initialize a passkey instance for testing."""
pk = Passkey(
rp_id="localhost",
rp_name="Test RP",
origins=["http://localhost:4401"],
)
globals.passkey._instance = pk
yield pk
globals.passkey._instance = None
@pytest_asyncio.fixture(scope="function")
async def test_org(test_db: DB, admin_permission: Permission) -> Org:
"""Create a test organization with admin permission."""
org = Org(
uuid=uuid7.create(),
display_name="Test Organization",
permissions=["auth:admin"], # Org can grant this permission
)
await test_db.create_organization(org)
return org
@pytest_asyncio.fixture(scope="function")
async def admin_permission(test_db: DB) -> Permission:
"""Create the auth:admin permission."""
perm = Permission(id="auth:admin", display_name="Master Admin")
await test_db.create_permission(perm)
return perm
@pytest_asyncio.fixture(scope="function")
async def test_role(test_db: DB, test_org: Org, admin_permission: Permission) -> Role:
"""Create a test role with admin permission."""
role = Role(
uuid=uuid7.create(),
org_uuid=test_org.uuid,
display_name="Test Admin Role",
permissions=["auth:admin", f"auth:org:{test_org.uuid}"],
)
await test_db.create_role(role)
return role
@pytest_asyncio.fixture(scope="function")
async def user_role(test_db: DB, test_org: Org) -> Role:
"""Create a test role without admin permission (regular user)."""
role = Role(
uuid=uuid7.create(),
org_uuid=test_org.uuid,
display_name="User Role",
permissions=[],
)
await test_db.create_role(role)
return role
@pytest_asyncio.fixture(scope="function")
async def test_user(test_db: DB, test_role: Role) -> User:
"""Create a test user with admin role."""
user = User(
uuid=uuid7.create(),
display_name="Test Admin",
role_uuid=test_role.uuid,
created_at=datetime.now(timezone.utc),
visits=0,
)
await test_db.create_user(user)
return user
@pytest_asyncio.fixture(scope="function")
async def regular_user(test_db: DB, user_role: Role) -> User:
"""Create a regular test user without admin permissions."""
user = User(
uuid=uuid7.create(),
display_name="Regular User",
role_uuid=user_role.uuid,
created_at=datetime.now(timezone.utc),
visits=0,
)
await test_db.create_user(user)
return user
@pytest_asyncio.fixture(scope="function")
async def test_credential(test_db: DB, test_user: User) -> Credential:
"""Create a test credential for the admin user."""
credential = Credential(
uuid=uuid7.create(),
credential_id=os.urandom(32),
user_uuid=test_user.uuid,
aaguid=UUID("00000000-0000-0000-0000-000000000000"),
public_key=os.urandom(64),
sign_count=0,
created_at=datetime.now(timezone.utc),
last_used=None,
last_verified=None,
)
await test_db.create_credential(credential)
return credential
@pytest_asyncio.fixture(scope="function")
async def regular_credential(test_db: DB, regular_user: User) -> Credential:
"""Create a test credential for the regular user."""
credential = Credential(
uuid=uuid7.create(),
credential_id=os.urandom(32),
user_uuid=regular_user.uuid,
aaguid=UUID("00000000-0000-0000-0000-000000000000"),
public_key=os.urandom(64),
sign_count=0,
created_at=datetime.now(timezone.utc),
last_used=None,
last_verified=None,
)
await test_db.create_credential(credential)
return credential
@pytest_asyncio.fixture(scope="function")
async def session_token(
test_db: DB, test_user: User, test_credential: Credential
) -> str:
"""Create a session for the admin user and return the token."""
token = create_token()
await test_db.create_session(
user_uuid=test_user.uuid,
credential_uuid=test_credential.uuid,
key=session_key(token),
host="localhost:4401",
ip="127.0.0.1",
user_agent="pytest",
renewed=datetime.now(timezone.utc),
)
return token
@pytest_asyncio.fixture(scope="function")
async def regular_session_token(
test_db: DB, regular_user: User, regular_credential: Credential
) -> str:
"""Create a session for a regular user and return the token."""
token = create_token()
await test_db.create_session(
user_uuid=regular_user.uuid,
credential_uuid=regular_credential.uuid,
key=session_key(token),
host="localhost:4401",
ip="127.0.0.1",
user_agent="pytest",
renewed=datetime.now(timezone.utc),
)
return token
@pytest_asyncio.fixture(scope="function")
async def reset_token(test_db: DB, test_user: User, test_credential: Credential) -> str:
"""Create a reset token for the test user."""
from paskia.authsession import reset_expires
from paskia.util.passphrase import generate
from paskia.util.tokens import reset_key
token = generate()
await test_db.create_reset_token(
user_uuid=test_user.uuid,
key=reset_key(token),
expiry=reset_expires(),
token_type="reset",
)
return token
@pytest_asyncio.fixture(scope="function")
async def client(
test_db: DB, passkey_instance: Passkey
) -> AsyncGenerator[httpx.AsyncClient, None]:
"""Create an async test client for the FastAPI app.
Note: We import the app inside the fixture to ensure globals are
initialized first.
"""
# Import app after globals are set
from paskia.fastapi.mainapp import app
transport = httpx.ASGITransport(app=app)
async with httpx.AsyncClient(
transport=transport,
base_url="http://localhost:4401",
) as client:
yield client
def auth_headers(token: str) -> dict[str, str]:
"""Return headers with auth cookie set."""
return {"Cookie": f"{AUTH_COOKIE_NAME}={token}"}
def auth_cookie(token: str) -> httpx.Cookies:
"""Return cookies dict with auth cookie."""
cookies = httpx.Cookies()
cookies.set(AUTH_COOKIE_NAME, token, domain="localhost")
return cookies
+1565
View File
File diff suppressed because it is too large Load Diff
+591
View File
@@ -0,0 +1,591 @@
"""
Tests for the core API endpoints (/auth/api/).
These tests cover:
- /auth/api/settings - Public settings endpoint
- /auth/api/validate - Session validation
- /auth/api/forward - Forward auth for reverse proxies
- /auth/api/logout - Session logout
- /auth/api/user-info - User information
- /auth/api/set-session - Set session from bearer token
"""
from datetime import datetime, timezone
import httpx
import pytest
from tests.conftest import auth_headers
class TestSettingsEndpoint:
"""Tests for GET /auth/api/settings"""
@pytest.mark.asyncio
async def test_get_settings_returns_rp_info(self, client: httpx.AsyncClient):
"""Settings endpoint should return RP configuration."""
response = await client.get("/auth/api/settings")
assert response.status_code == 200
data = response.json()
assert "rp_id" in data
assert "rp_name" in data
assert "session_cookie" in data
assert data["rp_id"] == "localhost"
assert data["rp_name"] == "Test RP"
assert data["session_cookie"] == "__Host-paskia"
@pytest.mark.asyncio
async def test_settings_includes_ui_base_path(self, client: httpx.AsyncClient):
"""Settings should include UI base path."""
response = await client.get("/auth/api/settings")
data = response.json()
assert "ui_base_path" in data
class TestValidateEndpoint:
"""Tests for POST /auth/api/validate"""
@pytest.mark.asyncio
async def test_validate_without_auth_returns_401(self, client: httpx.AsyncClient):
"""Validate without session should return 401."""
response = await client.post("/auth/api/validate")
assert response.status_code == 401
@pytest.mark.asyncio
async def test_validate_with_invalid_token_returns_error(
self, client: httpx.AsyncClient
):
"""Validate with invalid token should return 4xx error."""
response = await client.post(
"/auth/api/validate",
headers=auth_headers("invalid_token!!"),
)
# Invalid token format returns 400, expired/missing returns 401
assert response.status_code in (400, 401)
@pytest.mark.asyncio
async def test_validate_with_valid_token_returns_200(
self, client: httpx.AsyncClient, session_token: str
):
"""Validate with valid session should return success."""
response = await client.post(
"/auth/api/validate",
headers={**auth_headers(session_token), "Host": "localhost:4401"},
)
assert response.status_code == 200
data = response.json()
assert data["valid"] is True
assert "user_uuid" in data
@pytest.mark.asyncio
async def test_validate_with_permission_check(
self, client: httpx.AsyncClient, session_token: str
):
"""Validate should check permissions when provided."""
# Admin user should pass admin permission check
response = await client.post(
"/auth/api/validate?perm=auth:admin",
headers={**auth_headers(session_token), "Host": "localhost:4401"},
)
assert response.status_code == 200
@pytest.mark.asyncio
async def test_validate_permission_denied_for_regular_user(
self, client: httpx.AsyncClient, regular_session_token: str
):
"""Regular user should fail admin permission check."""
response = await client.post(
"/auth/api/validate?perm=auth:admin",
headers={
**auth_headers(regular_session_token),
"Host": "localhost:4401",
},
)
assert response.status_code == 403
class TestForwardEndpoint:
"""Tests for GET /auth/api/forward (reverse proxy auth)"""
@pytest.mark.asyncio
async def test_forward_without_auth_returns_401(self, client: httpx.AsyncClient):
"""Forward auth without session should return 401."""
response = await client.get("/auth/api/forward")
assert response.status_code == 401
@pytest.mark.asyncio
async def test_forward_401_json_response(self, client: httpx.AsyncClient):
"""Forward auth 401 should include auth iframe info for JSON clients."""
response = await client.get(
"/auth/api/forward",
headers={"Accept": "application/json"},
)
assert response.status_code == 401
data = response.json()
assert "auth" in data
assert "iframe" in data["auth"]
assert "mode" in data["auth"]
assert data["auth"]["mode"] == "login"
@pytest.mark.asyncio
async def test_forward_with_valid_session_returns_204(
self, client: httpx.AsyncClient, session_token: str
):
"""Forward auth with valid session should return 204 with headers."""
response = await client.get(
"/auth/api/forward",
headers={**auth_headers(session_token), "Host": "localhost:4401"},
)
assert response.status_code == 204
# Check Remote-* headers
assert "Remote-User" in response.headers
assert "Remote-Name" in response.headers
assert "Remote-Groups" in response.headers
assert "Remote-Org" in response.headers
@pytest.mark.asyncio
async def test_forward_with_permission_returns_204(
self, client: httpx.AsyncClient, session_token: str
):
"""Forward auth with valid permission should return 204."""
response = await client.get(
"/auth/api/forward?perm=auth:admin",
headers={**auth_headers(session_token), "Host": "localhost:4401"},
)
assert response.status_code == 204
@pytest.mark.asyncio
async def test_forward_permission_denied_returns_403(
self, client: httpx.AsyncClient, regular_session_token: str
):
"""Forward auth with missing permission should return 403."""
response = await client.get(
"/auth/api/forward?perm=auth:admin",
headers={
**auth_headers(regular_session_token),
"Host": "localhost:4401",
},
)
assert response.status_code == 403
@pytest.mark.asyncio
async def test_forward_403_json_includes_forbidden_mode(
self, client: httpx.AsyncClient, regular_session_token: str
):
"""403 response should include forbidden mode for iframe."""
response = await client.get(
"/auth/api/forward?perm=auth:admin",
headers={
**auth_headers(regular_session_token),
"Host": "localhost:4401",
"Accept": "application/json",
},
)
assert response.status_code == 403
data = response.json()
assert "auth" in data
assert data["auth"]["mode"] == "forbidden"
class TestLogoutEndpoint:
"""Tests for POST /auth/api/logout"""
@pytest.mark.asyncio
async def test_logout_without_session_returns_message(
self, client: httpx.AsyncClient
):
"""Logout without session should return already logged out message."""
response = await client.post("/auth/api/logout")
assert response.status_code == 200
data = response.json()
assert "message" in data
assert "Already logged out" in data["message"]
@pytest.mark.asyncio
async def test_logout_with_valid_session(
self, client: httpx.AsyncClient, session_token: str
):
"""Logout with valid session should succeed and clear session."""
response = await client.post(
"/auth/api/logout",
headers={**auth_headers(session_token), "Host": "localhost:4401"},
)
assert response.status_code == 200
data = response.json()
assert "Logged out successfully" in data["message"]
# Verify session is no longer valid
response2 = await client.post(
"/auth/api/validate",
headers={**auth_headers(session_token), "Host": "localhost:4401"},
)
assert response2.status_code == 401
class TestUserInfoEndpoint:
"""Tests for POST /auth/api/user-info"""
@pytest.mark.asyncio
async def test_user_info_without_auth_returns_401(self, client: httpx.AsyncClient):
"""User info without session should return 401."""
response = await client.post("/auth/api/user-info")
assert response.status_code == 401
@pytest.mark.asyncio
async def test_user_info_with_valid_session(
self, client: httpx.AsyncClient, session_token: str, test_user
):
"""User info with valid session should return user data."""
response = await client.post(
"/auth/api/user-info",
headers={**auth_headers(session_token), "Host": "localhost:4401"},
)
assert response.status_code == 200
data = response.json()
assert "user" in data
assert data["user"]["user_uuid"] == str(test_user.uuid)
assert data["user"]["user_name"] == test_user.display_name
@pytest.mark.asyncio
async def test_user_info_includes_credentials(
self, client: httpx.AsyncClient, session_token: str
):
"""User info should include user's credentials."""
response = await client.post(
"/auth/api/user-info",
headers={**auth_headers(session_token), "Host": "localhost:4401"},
)
assert response.status_code == 200
data = response.json()
assert "credentials" in data
assert len(data["credentials"]) >= 1
@pytest.mark.asyncio
async def test_user_info_includes_sessions(
self, client: httpx.AsyncClient, session_token: str
):
"""User info should include user's active sessions."""
response = await client.post(
"/auth/api/user-info",
headers={**auth_headers(session_token), "Host": "localhost:4401"},
)
assert response.status_code == 200
data = response.json()
assert "sessions" in data
assert len(data["sessions"]) >= 1
@pytest.mark.asyncio
async def test_user_info_includes_permissions(
self, client: httpx.AsyncClient, session_token: str
):
"""User info should include user's permissions."""
response = await client.post(
"/auth/api/user-info",
headers={**auth_headers(session_token), "Host": "localhost:4401"},
)
assert response.status_code == 200
data = response.json()
assert "permissions" in data
class TestSetSessionEndpoint:
"""Tests for POST /auth/api/set-session"""
@pytest.mark.asyncio
async def test_set_session_without_bearer_returns_403(
self, client: httpx.AsyncClient
):
"""Set session without bearer token should return 403."""
response = await client.post("/auth/api/set-session")
assert response.status_code == 403
@pytest.mark.asyncio
async def test_set_session_with_valid_bearer_token(
self, client: httpx.AsyncClient, session_token: str
):
"""Set session with valid bearer token should set cookie."""
response = await client.post(
"/auth/api/set-session",
headers={
"Authorization": f"Bearer {session_token}",
"Host": "localhost:4401",
},
)
assert response.status_code == 200
data = response.json()
assert "user_uuid" in data
# Check that Set-Cookie header is present
assert "set-cookie" in response.headers
class TestErrorHandling:
"""Tests for API error handling"""
@pytest.mark.asyncio
async def test_invalid_endpoint_returns_404(self, client: httpx.AsyncClient):
"""Request to non-existent endpoint should return 404."""
response = await client.get("/auth/api/nonexistent")
assert response.status_code == 404
@pytest.mark.asyncio
async def test_error_response_on_bad_token(self, client: httpx.AsyncClient):
"""Bad token should return error response."""
response = await client.post(
"/auth/api/validate",
headers=auth_headers("expired_token!"),
)
# Malformed token returns 400, expired returns 401
assert response.status_code in (400, 401)
class TestForwardAuthHtmlResponse:
"""Tests for forward auth HTML responses"""
@pytest.mark.asyncio
async def test_forward_401_html_response(self, client: httpx.AsyncClient):
"""Forward auth 401 should return HTML page for browser requests."""
response = await client.get(
"/auth/api/forward",
headers={"Accept": "text/html"},
)
assert response.status_code == 401
assert "text/html" in response.headers.get("content-type", "")
# HTML response should contain the mode data attribute
assert b"data-mode" in response.content or b"mode" in response.content
@pytest.mark.asyncio
async def test_forward_403_html_response(
self, client: httpx.AsyncClient, regular_session_token: str
):
"""Forward auth 403 should return HTML page for browser requests."""
response = await client.get(
"/auth/api/forward?perm=auth:admin",
headers={
**auth_headers(regular_session_token),
"Host": "localhost:4401",
"Accept": "text/html",
},
)
assert response.status_code == 403
assert "text/html" in response.headers.get("content-type", "")
@pytest.mark.asyncio
async def test_forward_with_expired_session_clears_cookie(
self, client: httpx.AsyncClient
):
"""Forward auth with expired session should trigger clear_session path."""
# Use a well-formed but non-existent session token
fake_token = "aaaaaaaaaaaaaaaa" # Exactly 16 characters
response = await client.get(
"/auth/api/forward",
headers={
**auth_headers(fake_token),
"Host": "localhost:4401",
"Accept": "application/json",
},
)
assert response.status_code == 401
# Verify the response contains auth info for re-login
data = response.json()
assert "auth" in data
assert data["auth"]["mode"] == "login"
class TestUserInfoWithResetToken:
"""Tests for user-info endpoint with reset tokens"""
@pytest.mark.asyncio
async def test_user_info_with_invalid_reset_token(self, client: httpx.AsyncClient):
"""User info with invalid reset token format should return 401."""
# Invalid format - not a well-formed passphrase (wrong separator)
response = await client.post(
"/auth/api/user-info?reset=invalid-token-format",
)
# Invalid format raises ValueError which gets converted to 401 HTTPException
assert response.status_code == 401
data = response.json()
assert "Invalid reset token" in data["detail"]
@pytest.mark.asyncio
async def test_user_info_with_nonexistent_reset_token(
self, client: httpx.AsyncClient
):
"""User info with well-formed but non-existent reset token should return 401."""
# We need a well-formed passphrase that doesn't exist in DB
from paskia.util.passphrase import generate
fake_token = generate() # Generates a well-formed token
response = await client.post(
f"/auth/api/user-info?reset={fake_token}",
)
# Should return 401 for non-existent token
assert response.status_code == 401
@pytest.mark.asyncio
async def test_user_info_with_valid_reset_token(
self, client: httpx.AsyncClient, reset_token: str, test_user
):
"""User info with valid reset token should return minimal user info."""
response = await client.post(
f"/auth/api/user-info?reset={reset_token}",
)
assert response.status_code == 200
data = response.json()
assert "user" in data
class TestSetSessionErrors:
"""Tests for set-session error cases"""
@pytest.mark.asyncio
async def test_set_session_with_invalid_bearer_token(
self, client: httpx.AsyncClient
):
"""Set session with invalid (malformed) bearer token should return 400."""
response = await client.post(
"/auth/api/set-session",
headers={
"Authorization": "Bearer invalid_token_here", # Wrong length (18 chars)
"Host": "localhost:4401",
},
)
# Invalid token format returns 400
assert response.status_code == 400
@pytest.mark.asyncio
async def test_set_session_with_nonexistent_token(self, client: httpx.AsyncClient):
"""Set session with valid format but non-existent token should fail."""
# Use a well-formed 16-char token that doesn't exist in DB
fake_token = "aaaaaaaaaaaaaaaa" # Exactly 16 characters
response = await client.post(
"/auth/api/set-session",
headers={
"Authorization": f"Bearer {fake_token}",
"Host": "localhost:4401",
},
)
# Non-existent session returns 400 (ValueError -> 400)
assert response.status_code == 400
class TestValidateSessionRefresh:
"""Tests for session refresh behavior in validate endpoint"""
@pytest.mark.asyncio
async def test_validate_does_not_refresh_within_interval(
self, client: httpx.AsyncClient, session_token: str
):
"""Validate should not refresh session if within refresh interval."""
# First call - may or may not refresh depending on session age
response1 = await client.post(
"/auth/api/validate",
headers={**auth_headers(session_token), "Host": "localhost:4401"},
)
assert response1.status_code == 200
# Second call immediately after - should NOT refresh (within 5 min interval)
response2 = await client.post(
"/auth/api/validate",
headers={**auth_headers(session_token), "Host": "localhost:4401"},
)
assert response2.status_code == 200
data = response2.json()
# Session shouldn't be renewed since we're within the refresh interval
assert data["renewed"] is False
@pytest.mark.asyncio
async def test_validate_with_expired_session_during_refresh(
self, client: httpx.AsyncClient, test_db
):
"""Validate should handle session expiry during refresh attempt."""
from paskia.util.tokens import create_token
# Create a token but don't create a session for it
token = create_token()
response = await client.post(
"/auth/api/validate",
headers={**auth_headers(token), "Host": "localhost:4401"},
)
# Should return 401 for non-existent session
assert response.status_code == 401
@pytest.mark.asyncio
async def test_validate_session_refresh_fails_concurrent_logout(
self,
client: httpx.AsyncClient,
test_db,
test_user,
test_credential,
):
"""Validate should return 401 if session disappears during refresh."""
from datetime import timedelta
from paskia.util.tokens import create_token, session_key
# Create a session with an old renewed time to trigger refresh
token = create_token()
old_time = datetime.now(timezone.utc) - timedelta(minutes=10)
await test_db.create_session(
user_uuid=test_user.uuid,
credential_uuid=test_credential.uuid,
key=session_key(token),
host="localhost:4401",
ip="127.0.0.1",
user_agent="pytest",
renewed=old_time,
)
# Delete the session right before validate tries to refresh
await test_db.delete_session(session_key(token))
response = await client.post(
"/auth/api/validate",
headers={**auth_headers(token), "Host": "localhost:4401"},
)
# Session was found initially but disappeared during refresh
assert response.status_code == 401
class TestForwardAuthMaxAge:
"""Tests for forward auth max_age parameter"""
@pytest.mark.asyncio
async def test_forward_with_max_age_recent_auth(
self, client: httpx.AsyncClient, session_token: str
):
"""Forward auth with max_age should pass for recent authentication."""
response = await client.get(
"/auth/api/forward?max_age=1h",
headers={**auth_headers(session_token), "Host": "localhost:4401"},
)
# Recently authenticated session should pass
assert response.status_code == 204
@pytest.mark.asyncio
async def test_forward_with_invalid_max_age_format(
self, client: httpx.AsyncClient, session_token: str
):
"""Forward auth with invalid max_age format should log warning but succeed."""
response = await client.get(
"/auth/api/forward?max_age=invalid",
headers={**auth_headers(session_token), "Host": "localhost:4401"},
)
# Invalid format is logged but request proceeds
assert response.status_code == 204
class TestValidateWithMaxAge:
"""Tests for validate endpoint with max_age parameter"""
@pytest.mark.asyncio
async def test_validate_with_max_age(
self, client: httpx.AsyncClient, session_token: str
):
"""Validate with max_age should check authentication age."""
response = await client.post(
"/auth/api/validate?max_age=1h",
headers={**auth_headers(session_token), "Host": "localhost:4401"},
)
# This exercises the max_age path - but isn't defined in validate
# Actually validate doesn't have max_age - this tests that unknown params are ignored
assert response.status_code == 200
+184
View File
@@ -0,0 +1,184 @@
"""
Tests for the user API endpoints (/auth/api/user/).
These tests cover user self-service operations:
- Display name update
- Logout all sessions
- Session management (delete specific session)
- Credential management (delete credential)
- Device addition link creation
"""
import httpx
import pytest
from tests.conftest import auth_headers
class TestUserDisplayName:
"""Tests for PUT /auth/api/user/display-name"""
@pytest.mark.asyncio
async def test_update_display_name_requires_auth(self, client: httpx.AsyncClient):
"""Update display name without auth should return 401."""
response = await client.put(
"/auth/api/user/display-name",
json={"display_name": "New Name"},
)
assert response.status_code == 401
@pytest.mark.asyncio
async def test_update_display_name_success(
self, client: httpx.AsyncClient, session_token: str
):
"""User should be able to update their display name."""
response = await client.put(
"/auth/api/user/display-name",
json={"display_name": "Updated Name"},
headers={**auth_headers(session_token), "Host": "localhost:4401"},
)
assert response.status_code == 200
data = response.json()
assert data["status"] == "ok"
@pytest.mark.asyncio
async def test_update_display_name_empty_fails(
self, client: httpx.AsyncClient, session_token: str
):
"""Empty display name should fail."""
response = await client.put(
"/auth/api/user/display-name",
json={"display_name": ""},
headers={**auth_headers(session_token), "Host": "localhost:4401"},
)
assert response.status_code == 400
@pytest.mark.asyncio
async def test_update_display_name_too_long_fails(
self, client: httpx.AsyncClient, session_token: str
):
"""Display name over 64 chars should fail."""
long_name = "x" * 100
response = await client.put(
"/auth/api/user/display-name",
json={"display_name": long_name},
headers={**auth_headers(session_token), "Host": "localhost:4401"},
)
assert response.status_code == 400
class TestUserLogoutAll:
"""Tests for POST /auth/api/user/logout-all"""
@pytest.mark.asyncio
async def test_logout_all_requires_auth(self, client: httpx.AsyncClient):
"""Logout all without auth should return already logged out."""
response = await client.post("/auth/api/user/logout-all")
assert response.status_code == 200
data = response.json()
assert "Already logged out" in data["message"]
@pytest.mark.asyncio
async def test_logout_all_success(
self, client: httpx.AsyncClient, session_token: str
):
"""User should be able to logout from all sessions."""
response = await client.post(
"/auth/api/user/logout-all",
headers={**auth_headers(session_token), "Host": "localhost:4401"},
)
assert response.status_code == 200
data = response.json()
assert "Logged out" in data["message"]
# Verify session is invalidated
response2 = await client.post(
"/auth/api/validate",
headers={**auth_headers(session_token), "Host": "localhost:4401"},
)
assert response2.status_code == 401
class TestUserSessionManagement:
"""Tests for DELETE /auth/api/user/session/{session_id}"""
@pytest.mark.asyncio
async def test_delete_session_requires_auth(self, client: httpx.AsyncClient):
"""Delete session without auth should return 401."""
response = await client.delete("/auth/api/user/session/fake-session-id")
assert response.status_code == 401
@pytest.mark.asyncio
async def test_delete_invalid_session_fails(
self, client: httpx.AsyncClient, session_token: str
):
"""Deleting invalid session ID should fail."""
response = await client.delete(
"/auth/api/user/session/invalid-session-id",
headers={**auth_headers(session_token), "Host": "localhost:4401"},
)
assert response.status_code == 400
@pytest.mark.asyncio
async def test_delete_nonexistent_session_returns_404(
self, client: httpx.AsyncClient, session_token: str
):
"""Deleting a properly-formatted but nonexistent session returns 404."""
# Use a valid format but non-existent session key
fake_session = "c2Vzc0FBQUFBQUFBQUFBQUFBQUE" # base64 of "sessAAAAAAAAAAAAAAAA"
response = await client.delete(
f"/auth/api/user/session/{fake_session}",
headers={**auth_headers(session_token), "Host": "localhost:4401"},
)
assert response.status_code == 404
class TestUserCredentialManagement:
"""Tests for DELETE /auth/api/user/credential/{uuid}"""
@pytest.mark.asyncio
async def test_delete_credential_requires_auth(self, client: httpx.AsyncClient):
"""Delete credential without auth should return 401."""
response = await client.delete(
"/auth/api/user/credential/00000000-0000-0000-0000-000000000000"
)
assert response.status_code == 401
@pytest.mark.asyncio
async def test_delete_credential_success(
self, client: httpx.AsyncClient, session_token: str, test_credential
):
"""User can delete their credential."""
response = await client.delete(
f"/auth/api/user/credential/{test_credential.uuid}",
headers={**auth_headers(session_token), "Host": "localhost:4401"},
)
# Note: API allows deleting even the only credential
assert response.status_code == 200
data = response.json()
assert "deleted" in data["message"].lower()
class TestUserCreateLink:
"""Tests for POST /auth/api/user/create-link"""
@pytest.mark.asyncio
async def test_create_link_requires_auth(self, client: httpx.AsyncClient):
"""Create link without auth should return 401."""
response = await client.post("/auth/api/user/create-link")
assert response.status_code == 401
@pytest.mark.asyncio
async def test_create_link_success(
self, client: httpx.AsyncClient, session_token: str
):
"""User should be able to create a device addition link."""
response = await client.post(
"/auth/api/user/create-link",
headers={**auth_headers(session_token), "Host": "localhost:4401"},
)
assert response.status_code == 200
data = response.json()
assert "url" in data
assert "expires" in data
assert "message" in data