Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1bed2c39d8 | ||
|
|
83419d1845 | ||
|
|
a2fe0b6f1a | ||
|
|
a1b73711e6 | ||
|
|
df5c176bcd | ||
|
|
8937905c9c | ||
|
|
127e06179b | ||
|
|
c1204ca020 | ||
|
|
208115ebc3 | ||
|
|
8609f2fe69 | ||
|
|
0355c55fc0 |
+6
-4
@@ -3,7 +3,9 @@ dist/
|
||||
.*
|
||||
!.gitignore
|
||||
*.lock
|
||||
passkey-auth.sqlite
|
||||
/passkey/frontend-build
|
||||
/test_*.py
|
||||
passkey/_version.py
|
||||
package-lock.json
|
||||
paskia.sqlite
|
||||
/paskia/frontend-build
|
||||
/paskia/_version.py
|
||||
coverage-html/
|
||||
e2e/coverage-frontend/
|
||||
|
||||
@@ -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
|
||||
(`--auth-host` / environment `PASSKEY_AUTH_HOST`) is configured.
|
||||
|
||||
|
||||
@@ -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
|
||||
- 🔌 WebSocket-based communication for real-time interaction
|
||||
- 🚀 Modern Python packaging with `pyproject.toml`
|
||||
- 🎨 Clean, responsive HTML interface using @simplewebauthn/browser
|
||||
- 📦 No database required - challenges stored locally per connection
|
||||
- 🛠️ Development tools: `ruff` for linting and formatting
|
||||
- 🧹 Clean architecture with local challenge management
|
||||
- Easy to use fully featured auth&auth system (login and permissions)
|
||||
- Organization and role-based access control (optional)
|
||||
* Org admins control their users and roles
|
||||
* Master admin can create multiple independent orgs
|
||||
* Master admin makes permissions available for orgs to assign
|
||||
- User Profile and Administration by API and web interface.
|
||||
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
|
||||
|
||||
- Python 3.9+
|
||||
- A WebAuthn-compatible authenticator (security key, biometric device, etc.)
|
||||
Single Sign-On (SSO): Users register once and authenticate across all applications under your domain name (configured rp-id).
|
||||
|
||||
## Quick Start
|
||||
|
||||
### Install (editable dev mode)
|
||||
Install [UV](https://docs.astral.sh/uv/getting-started/installation/) and run:
|
||||
|
||||
```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
|
||||
passkey-auth serve [host:port] [--options]
|
||||
passkey-auth dev [--options]
|
||||
paskia serve [options]
|
||||
```
|
||||
|
||||
Examples (fish shell shown):
|
||||
Optional options:
|
||||
|
||||
```fish
|
||||
# Production style (no reload)
|
||||
passkey-auth serve
|
||||
passkey-auth serve 0.0.0.0:8080 --rp-id example.com --origin https://example.com
|
||||
- Listen address (one of):
|
||||
* `[host]:port`: Address and port (default: `localhost:4401`)
|
||||
* `unix:/path.sock`: Unix socket
|
||||
- `--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)
|
||||
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
|
||||
```
|
||||
## Documentation
|
||||
|
||||
Available options (both subcommands):
|
||||
|
||||
```text
|
||||
--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!
|
||||
- `API.md`: Complete HTTP and WebSocket API reference
|
||||
- `Caddy.md`: Caddy configuration examples
|
||||
- `Headers.md`: HTTP headers passed to protected applications
|
||||
|
||||
+15
-5
@@ -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
|
||||
|
||||
@@ -33,10 +33,20 @@ npm test
|
||||
```
|
||||
|
||||
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
|
||||
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
|
||||
|
||||
```bash
|
||||
@@ -86,7 +96,7 @@ e2e/
|
||||
- WebSocket challenge-response with virtual authenticator
|
||||
- Session token creation and validation
|
||||
|
||||
### Authentication Flow
|
||||
### Authentication Flow
|
||||
- Passkey authentication via WebSocket
|
||||
- Credential verification
|
||||
- Session management
|
||||
@@ -125,7 +135,7 @@ This creates an in-browser authenticator that:
|
||||
|
||||
| Variable | Description | Default |
|
||||
|----------|-------------|---------|
|
||||
| `BASE_URL` | Server URL | `http://localhost:4401` |
|
||||
| `BASE_URL` | Server URL | `http://localhost:4404` |
|
||||
| `CI` | CI environment flag | - |
|
||||
| `CLEANUP_TEST_DB` | Remove test DB after run | `false` |
|
||||
|
||||
|
||||
Generated
+1127
File diff suppressed because it is too large
Load Diff
+5
-3
@@ -1,20 +1,22 @@
|
||||
{
|
||||
"name": "passkey-auth-e2e",
|
||||
"name": "paskia-e2e",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"description": "E2E tests for PasskeyAuth using Playwright with Virtual Authenticator",
|
||||
"description": "E2E tests for Paskia using Playwright with Virtual Authenticator",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"test": "bunx playwright test",
|
||||
"test:headed": "bunx playwright test --headed",
|
||||
"test:debug": "bunx playwright test --debug",
|
||||
"test:ui": "bunx playwright test --ui",
|
||||
"test:coverage": "COVERAGE=1 bunx playwright test",
|
||||
"report": "bunx playwright show-report",
|
||||
"install:browsers": "bunx playwright install chromium"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@playwright/test": "^1.49.0",
|
||||
"@simplewebauthn/browser": "^13.1.2",
|
||||
"@types/bun": "^1.3.3"
|
||||
"@types/bun": "^1.3.3",
|
||||
"c8": "^10.1.3"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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'
|
||||
|
||||
/**
|
||||
* Playwright configuration for PasskeyAuth E2E tests.
|
||||
* Playwright configuration for Paskia E2E tests.
|
||||
* Uses Chrome's Virtual Authenticator for automated passkey testing.
|
||||
*
|
||||
*
|
||||
* Run with: bun run test
|
||||
*/
|
||||
|
||||
@@ -17,18 +17,18 @@ export default defineConfig({
|
||||
['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 passkey-auth server
|
||||
// Base URL for the Paskia server
|
||||
baseURL: process.env.BASE_URL || 'http://localhost:4401',
|
||||
|
||||
|
||||
// Collect trace on failure for debugging
|
||||
trace: 'on-first-retry',
|
||||
|
||||
|
||||
// Screenshot on failure
|
||||
screenshot: 'only-on-failure',
|
||||
},
|
||||
@@ -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)`)
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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`)
|
||||
})
|
||||
})
|
||||
Vendored
+147
@@ -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
@@ -1,9 +1,10 @@
|
||||
import { type Page } from '@playwright/test'
|
||||
import { existsSync, readFileSync } from 'fs'
|
||||
import { existsSync, readFileSync, writeFileSync } from 'fs'
|
||||
import { join, dirname } from 'path'
|
||||
import { fileURLToPath } from 'url'
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url))
|
||||
const stateFile = join(__dirname, '..', '..', 'test-data', 'test-state.json')
|
||||
|
||||
/**
|
||||
* WebSocket helpers for passkey registration and authentication.
|
||||
@@ -26,7 +27,6 @@ export interface AuthenticationResult {
|
||||
* Get the bootstrap reset token from the test state file.
|
||||
*/
|
||||
export function getBootstrapResetToken(): string | undefined {
|
||||
const stateFile = join(__dirname, '..', '..', 'test-data', 'test-state.json')
|
||||
if (existsSync(stateFile)) {
|
||||
try {
|
||||
const state = JSON.parse(readFileSync(stateFile, 'utf-8'))
|
||||
@@ -38,6 +38,118 @@ export function getBootstrapResetToken(): string | 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.
|
||||
* This runs in the browser context using the virtual authenticator.
|
||||
@@ -57,67 +169,70 @@ export async function registerPasskey(
|
||||
|
||||
return new Promise<any>((resolve, reject) => {
|
||||
const ws = new WebSocket(wsUrl)
|
||||
|
||||
|
||||
ws.onopen = () => {
|
||||
console.log('WebSocket connected for registration')
|
||||
}
|
||||
|
||||
|
||||
ws.onmessage = async (event) => {
|
||||
const data = JSON.parse(event.data)
|
||||
|
||||
|
||||
// Check for error response
|
||||
if (data.detail) {
|
||||
ws.close()
|
||||
reject(new Error(data.detail))
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
// Check if this is the final success response
|
||||
if (data.session_token) {
|
||||
ws.close()
|
||||
resolve(data)
|
||||
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
|
||||
try {
|
||||
// Extract options from the optionsJSON wrapper
|
||||
const opts = data.optionsJSON
|
||||
|
||||
// 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
|
||||
const publicKeyCredentialCreationOptions: CredentialCreationOptions = {
|
||||
publicKey: {
|
||||
challenge: challenge,
|
||||
rp: {
|
||||
name: data.rp.name,
|
||||
id: data.rp.id,
|
||||
name: opts.rp.name,
|
||||
id: opts.rp.id,
|
||||
},
|
||||
user: {
|
||||
id: Uint8Array.from(atob(data.user.id.replace(/-/g, '+').replace(/_/g, '/')), c => c.charCodeAt(0)),
|
||||
name: data.user.name,
|
||||
displayName: data.user.displayName,
|
||||
id: Uint8Array.from(atob(opts.user.id.replace(/-/g, '+').replace(/_/g, '/')), c => c.charCodeAt(0)),
|
||||
name: opts.user.name,
|
||||
displayName: opts.user.displayName,
|
||||
},
|
||||
pubKeyCredParams: data.pubKeyCredParams,
|
||||
authenticatorSelection: data.authenticatorSelection,
|
||||
timeout: data.timeout,
|
||||
attestation: data.attestation,
|
||||
excludeCredentials: data.excludeCredentials?.map((cred: any) => ({
|
||||
pubKeyCredParams: opts.pubKeyCredParams,
|
||||
authenticatorSelection: opts.authenticatorSelection,
|
||||
timeout: opts.timeout,
|
||||
attestation: opts.attestation,
|
||||
excludeCredentials: opts.excludeCredentials?.map((cred: any) => ({
|
||||
...cred,
|
||||
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)
|
||||
const credential = await navigator.credentials.create(publicKeyCredentialCreationOptions) as PublicKeyCredential
|
||||
|
||||
|
||||
if (!credential) {
|
||||
throw new Error('Failed to create credential')
|
||||
}
|
||||
|
||||
|
||||
const response = credential.response as AuthenticatorAttestationResponse
|
||||
|
||||
|
||||
// Convert response to JSON format expected by server
|
||||
const registrationResponse = {
|
||||
id: credential.id,
|
||||
@@ -131,18 +246,18 @@ export async function registerPasskey(
|
||||
clientExtensionResults: credential.getClientExtensionResults(),
|
||||
authenticatorAttachment: (credential as any).authenticatorAttachment,
|
||||
}
|
||||
|
||||
|
||||
ws.send(JSON.stringify(registrationResponse))
|
||||
} catch (error: any) {
|
||||
ws.close()
|
||||
reject(new Error(error.message || 'Registration failed'))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
ws.onerror = () => {
|
||||
reject(new Error('WebSocket error during registration'))
|
||||
}
|
||||
|
||||
|
||||
ws.onclose = (event) => {
|
||||
if (!event.wasClean && event.code !== 1000) {
|
||||
reject(new Error(`WebSocket closed unexpectedly: ${event.code}`))
|
||||
@@ -165,57 +280,60 @@ export async function authenticatePasskey(
|
||||
|
||||
return new Promise<any>((resolve, reject) => {
|
||||
const ws = new WebSocket(wsUrl)
|
||||
|
||||
|
||||
ws.onopen = () => {
|
||||
console.log('WebSocket connected for authentication')
|
||||
}
|
||||
|
||||
|
||||
ws.onmessage = async (event) => {
|
||||
const data = JSON.parse(event.data)
|
||||
|
||||
|
||||
// Check for error response
|
||||
if (data.detail) {
|
||||
ws.close()
|
||||
reject(new Error(data.detail))
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
// Check if this is the final success response
|
||||
if (data.session_token) {
|
||||
ws.close()
|
||||
resolve(data)
|
||||
return
|
||||
}
|
||||
|
||||
// This should be the authentication options from server
|
||||
|
||||
// This should be the authentication options from server (wrapped in optionsJSON)
|
||||
try {
|
||||
// Extract options from the optionsJSON wrapper
|
||||
const opts = data.optionsJSON
|
||||
|
||||
// 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
|
||||
const publicKeyCredentialRequestOptions: CredentialRequestOptions = {
|
||||
publicKey: {
|
||||
challenge: challenge,
|
||||
rpId: data.rpId,
|
||||
timeout: data.timeout,
|
||||
userVerification: data.userVerification,
|
||||
allowCredentials: data.allowCredentials?.map((cred: any) => ({
|
||||
rpId: opts.rpId,
|
||||
timeout: opts.timeout,
|
||||
userVerification: opts.userVerification,
|
||||
allowCredentials: opts.allowCredentials?.map((cred: any) => ({
|
||||
type: cred.type,
|
||||
id: Uint8Array.from(atob(cred.id.replace(/-/g, '+').replace(/_/g, '/')), c => c.charCodeAt(0)),
|
||||
transports: cred.transports,
|
||||
})) || [],
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// Get the credential using native WebAuthn API (virtual authenticator handles it)
|
||||
const credential = await navigator.credentials.get(publicKeyCredentialRequestOptions) as PublicKeyCredential
|
||||
|
||||
|
||||
if (!credential) {
|
||||
throw new Error('Failed to get credential')
|
||||
}
|
||||
|
||||
|
||||
const response = credential.response as AuthenticatorAssertionResponse
|
||||
|
||||
|
||||
// Convert response to JSON format expected by server
|
||||
const authenticationResponse = {
|
||||
id: credential.id,
|
||||
@@ -230,18 +348,18 @@ export async function authenticatePasskey(
|
||||
clientExtensionResults: credential.getClientExtensionResults(),
|
||||
authenticatorAttachment: (credential as any).authenticatorAttachment,
|
||||
}
|
||||
|
||||
|
||||
ws.send(JSON.stringify(authenticationResponse))
|
||||
} catch (error: any) {
|
||||
ws.close()
|
||||
reject(new Error(error.message || 'Authentication failed'))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
ws.onerror = () => {
|
||||
reject(new Error('WebSocket error during authentication'))
|
||||
}
|
||||
|
||||
|
||||
ws.onclose = (event) => {
|
||||
if (!event.wasClean && event.code !== 1000) {
|
||||
reject(new Error(`WebSocket closed unexpectedly: ${event.code}`))
|
||||
@@ -259,9 +377,10 @@ export async function validateSession(
|
||||
baseUrl: string,
|
||||
sessionToken: string
|
||||
): Promise<{ valid: boolean; user_uuid: string; renewed: boolean }> {
|
||||
const cookieName = getSessionCookieName()
|
||||
const response = await page.request.post(`${baseUrl}/auth/api/validate`, {
|
||||
headers: {
|
||||
'Cookie': `__Host-auth=${sessionToken}`,
|
||||
'Cookie': `${cookieName}=${sessionToken}`,
|
||||
},
|
||||
})
|
||||
return await response.json()
|
||||
@@ -275,9 +394,10 @@ export async function getUserInfo(
|
||||
baseUrl: string,
|
||||
sessionToken: string
|
||||
): Promise<any> {
|
||||
const cookieName = getSessionCookieName()
|
||||
const response = await page.request.post(`${baseUrl}/auth/api/user-info`, {
|
||||
headers: {
|
||||
'Cookie': `__Host-auth=${sessionToken}`,
|
||||
'Cookie': `${cookieName}=${sessionToken}`,
|
||||
},
|
||||
})
|
||||
return await response.json()
|
||||
@@ -285,17 +405,24 @@ export async function getUserInfo(
|
||||
|
||||
/**
|
||||
* Logout via the API.
|
||||
* If the session being logged out matches the saved session token, clears it.
|
||||
*/
|
||||
export async function logout(
|
||||
page: Page,
|
||||
baseUrl: string,
|
||||
sessionToken: string
|
||||
): Promise<void> {
|
||||
const cookieName = getSessionCookieName()
|
||||
await page.request.post(`${baseUrl}/auth/api/logout`, {
|
||||
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,
|
||||
sessionToken: string
|
||||
): Promise<{ url: string; token: string }> {
|
||||
const cookieName = getSessionCookieName()
|
||||
const response = await page.request.post(`${baseUrl}/auth/api/user/create-link`, {
|
||||
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()
|
||||
if (!data.url) {
|
||||
throw new Error(`No URL in response: ${JSON.stringify(data)}`)
|
||||
}
|
||||
// Extract token from URL (last path segment)
|
||||
const url = new URL(data.url)
|
||||
const token = url.pathname.split('/').pop() || ''
|
||||
|
||||
+56
-5
@@ -1,4 +1,13 @@
|
||||
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.
|
||||
@@ -27,7 +36,7 @@ export async function createVirtualAuthenticator(
|
||||
options: VirtualAuthenticatorOptions = {}
|
||||
): Promise<VirtualAuthenticator> {
|
||||
const cdpSession = await page.context().newCDPSession(page)
|
||||
|
||||
|
||||
// Enable WebAuthn in CDP
|
||||
await cdpSession.send('WebAuthn.enable', {
|
||||
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<{
|
||||
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
|
||||
const authenticator = await createVirtualAuthenticator(page)
|
||||
|
||||
|
||||
// Run the test
|
||||
await use(authenticator)
|
||||
|
||||
|
||||
// Cleanup after test
|
||||
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
@@ -1,94 +1,101 @@
|
||||
import { spawn } from 'child_process'
|
||||
import { join, dirname } from 'path'
|
||||
import { existsSync, mkdirSync, rmSync, writeFileSync } from 'fs'
|
||||
import { existsSync, mkdirSync, writeFileSync } from 'fs'
|
||||
import { fileURLToPath } from 'url'
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url))
|
||||
const testDataDir = join(__dirname, '..', 'test-data')
|
||||
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 {
|
||||
resetToken?: string
|
||||
serverPid?: number
|
||||
sessionCookie?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Global setup for E2E tests.
|
||||
*
|
||||
* This creates a fresh test database and starts the server,
|
||||
* capturing the bootstrap reset token for initial user registration.
|
||||
*
|
||||
* Uses in-memory SQLite database for fast, isolated tests.
|
||||
* Captures the bootstrap reset token for initial user registration.
|
||||
*/
|
||||
export default async function globalSetup() {
|
||||
console.log('\n🔧 Setting up E2E test environment...\n')
|
||||
|
||||
// Create test data directory
|
||||
|
||||
// Create test data directory for state file
|
||||
if (!existsSync(testDataDir)) {
|
||||
mkdirSync(testDataDir, { recursive: true })
|
||||
}
|
||||
|
||||
// Remove old database for clean state
|
||||
if (existsSync(dbPath)) {
|
||||
console.log(' Removing old test database...')
|
||||
rmSync(dbPath)
|
||||
|
||||
console.log(' Starting server with in-memory database...')
|
||||
if (COLLECT_COVERAGE) {
|
||||
console.log(' 📊 Coverage collection enabled for Python backend')
|
||||
}
|
||||
|
||||
// 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 = {}
|
||||
|
||||
|
||||
// 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
|
||||
const serverProcess = spawn('uv', [
|
||||
'run', 'passkey-auth', 'serve', ':4401',
|
||||
'--rp-id', 'localhost',
|
||||
'--origin', 'http://localhost:4401'
|
||||
], {
|
||||
cwd: testDataDir, // Run from test-data so DB is created there
|
||||
// Use in-memory SQLite for faster tests
|
||||
const serverProcess = spawn('uv', serverArgs, {
|
||||
cwd: projectRoot,
|
||||
env: {
|
||||
...process.env,
|
||||
PASKIA_DB: 'sqlite+aiosqlite:///:memory:',
|
||||
COVERAGE_FILE: join(projectRoot, '.coverage'),
|
||||
},
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
})
|
||||
|
||||
|
||||
state.serverPid = serverProcess.pid
|
||||
|
||||
|
||||
// Capture output to find reset token
|
||||
const resetTokenPromise = new Promise<string>((resolve, reject) => {
|
||||
const timeout = setTimeout(() => {
|
||||
reject(new Error('Timed out waiting for server bootstrap (30s)'))
|
||||
}, 30000)
|
||||
|
||||
|
||||
let output = ''
|
||||
|
||||
|
||||
const handleData = (data: Buffer) => {
|
||||
const text = data.toString()
|
||||
output += text
|
||||
process.stdout.write(text) // Echo to console
|
||||
|
||||
|
||||
// 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)
|
||||
const match = output.match(/http:\/\/localhost:\d+\/auth\/([a-z]+(?:\.[a-z]+)+)/)
|
||||
// Format: https://localhost/auth/{token} or http://localhost:4404/auth/{token}
|
||||
// where token is word.word.word.word.word (dot separated)
|
||||
const match = output.match(/https?:\/\/localhost(?::\d+)?\/auth\/([a-z]+(?:\.[a-z]+)+)/)
|
||||
if (match) {
|
||||
clearTimeout(timeout)
|
||||
// Wait a bit for server to fully start
|
||||
setTimeout(() => resolve(match[1]), 1000)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
serverProcess.stdout?.on('data', handleData)
|
||||
serverProcess.stderr?.on('data', handleData)
|
||||
|
||||
|
||||
serverProcess.on('error', (err) => {
|
||||
clearTimeout(timeout)
|
||||
reject(err)
|
||||
})
|
||||
|
||||
|
||||
serverProcess.on('exit', (code) => {
|
||||
if (code !== 0 && code !== null) {
|
||||
clearTimeout(timeout)
|
||||
@@ -96,7 +103,7 @@ export default async function globalSetup() {
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
try {
|
||||
state.resetToken = await resetTokenPromise
|
||||
console.log(`\n ✅ Captured reset token: ${state.resetToken}\n`)
|
||||
@@ -105,9 +112,21 @@ export default async function globalSetup() {
|
||||
serverProcess.kill()
|
||||
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
|
||||
writeFileSync(stateFile, JSON.stringify(state, null, 2))
|
||||
|
||||
|
||||
console.log(' ✅ E2E test environment ready\n')
|
||||
}
|
||||
|
||||
@@ -1,35 +1,49 @@
|
||||
import { join, dirname } from 'path'
|
||||
import { existsSync, rmSync, readFileSync } from 'fs'
|
||||
import { existsSync, rmSync, readFileSync, readdirSync, writeFileSync } from 'fs'
|
||||
import { fileURLToPath } from 'url'
|
||||
import { execSync } from 'child_process'
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url))
|
||||
const testDataDir = join(__dirname, '..', 'test-data')
|
||||
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 {
|
||||
resetToken?: string
|
||||
serverPid?: number
|
||||
}
|
||||
|
||||
interface CoverageEntry {
|
||||
url: string
|
||||
functions: Array<{
|
||||
functionName: string
|
||||
ranges: Array<{ count: number }>
|
||||
}>
|
||||
}
|
||||
|
||||
/**
|
||||
* Global teardown for E2E tests.
|
||||
*
|
||||
*
|
||||
* This cleans up the test server and optionally removes the test database.
|
||||
*/
|
||||
export default async function globalTeardown() {
|
||||
console.log('\n🧹 Cleaning up E2E test environment...\n')
|
||||
|
||||
|
||||
// Read state file to get server PID
|
||||
if (existsSync(stateFile)) {
|
||||
try {
|
||||
const state: TestState = JSON.parse(readFileSync(stateFile, 'utf-8'))
|
||||
|
||||
|
||||
if (state.serverPid) {
|
||||
console.log(` Stopping server (PID: ${state.serverPid})...`)
|
||||
try {
|
||||
process.kill(state.serverPid, 'SIGTERM')
|
||||
// Wait a moment for graceful shutdown
|
||||
await new Promise(r => setTimeout(r, 500))
|
||||
// Wait longer for graceful shutdown and coverage data flush
|
||||
await new Promise(r => setTimeout(r, COLLECT_COVERAGE ? 2000 : 500))
|
||||
} catch (err: any) {
|
||||
// Process may already be dead
|
||||
if (err.code !== 'ESRCH') {
|
||||
@@ -40,11 +54,11 @@ export default async function globalTeardown() {
|
||||
} catch (err) {
|
||||
console.warn(' Warning: Could not read state file')
|
||||
}
|
||||
|
||||
|
||||
// Clean up state file
|
||||
rmSync(stateFile, { force: true })
|
||||
}
|
||||
|
||||
|
||||
// Optionally clean up test database (keep it for debugging by default)
|
||||
if (process.env.CLEANUP_TEST_DB === 'true') {
|
||||
const dbPath = join(testDataDir, 'test.sqlite')
|
||||
@@ -58,6 +72,77 @@ export default async function globalTeardown() {
|
||||
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')
|
||||
}
|
||||
|
||||
@@ -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
@@ -3,7 +3,7 @@
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>PassKey Auth - Dev Mode</title>
|
||||
<title>Paskia - Dev Mode</title>
|
||||
<style>
|
||||
:root {
|
||||
color-scheme: light dark; /* Automatic themes by browser */
|
||||
@@ -33,8 +33,8 @@
|
||||
<body>
|
||||
<div class="container">
|
||||
<header>
|
||||
<h1>🔐 PassKey Auth</h1>
|
||||
<p class="subtitle">Development server demonstration page.</p>
|
||||
<h1>🔐 Paskia - Development Server</h1>
|
||||
<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>
|
||||
|
||||
<div class="content">
|
||||
@@ -55,7 +55,7 @@
|
||||
|
||||
<div class="section">
|
||||
<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?max_age=10s')">🔄 Reauth (max_age=10s)</button>
|
||||
<button onclick="browserNav('/auth/api/forward?perm=auth:admin')">🛡️ Admin Only</button>
|
||||
|
||||
@@ -3,7 +3,6 @@ import { ref, onMounted, onUnmounted, computed, watch } from 'vue'
|
||||
import Breadcrumbs from '@/components/Breadcrumbs.vue'
|
||||
import CredentialList from '@/components/CredentialList.vue'
|
||||
import UserBasicInfo from '@/components/UserBasicInfo.vue'
|
||||
import RegistrationLinkModal from '@/components/RegistrationLinkModal.vue'
|
||||
import StatusMessage from '@/components/StatusMessage.vue'
|
||||
import LoadingView from '@/components/LoadingView.vue'
|
||||
import AuthRequiredMessage from '@/components/AccessDenied.vue'
|
||||
|
||||
@@ -1,15 +1,35 @@
|
||||
<template>
|
||||
<RestrictedAuth
|
||||
:mode="authMode"
|
||||
:remote-auth-token="remoteAuthToken"
|
||||
@authenticated="handleAuthenticated"
|
||||
@back="handleBack"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, onMounted } from 'vue'
|
||||
import { computed, onMounted, ref } from '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
|
||||
const authMode = computed(() => {
|
||||
const params = new URLSearchParams(window.location.hash.slice(1))
|
||||
@@ -40,6 +60,9 @@ function handleBack() {
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
// Check for remote auth token in URL
|
||||
remoteAuthToken.value = extractRemoteToken()
|
||||
|
||||
postToParent({
|
||||
type: 'auth-ready'
|
||||
})
|
||||
|
||||
@@ -23,7 +23,6 @@
|
||||
|
||||
<section class="section-block" v-else-if="!canRegister">
|
||||
<div class="section-body center">
|
||||
<p>{{ errorMessage }}</p>
|
||||
<div class="button-row center" style="justify-content: center;">
|
||||
<button class="btn-secondary" @click="goHome">Return to sign-in</button>
|
||||
</div>
|
||||
@@ -80,7 +79,7 @@ let statusTimer = null
|
||||
const sessionDescriptor = computed(() => userInfo.value?.session_type || 'your enrollment')
|
||||
const subtitleMessage = computed(() => {
|
||||
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.`
|
||||
})
|
||||
|
||||
@@ -120,10 +119,9 @@ async function fetchUserInfo() {
|
||||
} catch (error) {
|
||||
console.error('Failed to load user info', error)
|
||||
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)
|
||||
errorMessage.value = message
|
||||
showMessage(message, 'error', 0)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -132,7 +132,6 @@ async function handleTerminateSession(session) {
|
||||
<RegistrationLinkModal
|
||||
v-if="showRegModal"
|
||||
:endpoint="`/auth/api/admin/orgs/${selectedUser.org_uuid}/users/${selectedUser.uuid}/create-link`"
|
||||
:auto-copy="false"
|
||||
:user-name="userDetail?.display_name || selectedUser.display_name"
|
||||
@close="$emit('closeRegModal')"
|
||||
@copied="onLinkCopied"
|
||||
|
||||
@@ -422,9 +422,9 @@ th {
|
||||
}
|
||||
|
||||
.qr-code {
|
||||
border: 1px solid var(--color-border);
|
||||
padding: 0.75rem;
|
||||
background: var(--color-surface);
|
||||
padding: 1rem;
|
||||
background: #fff;
|
||||
box-shadow: var(--shadow-soft);
|
||||
}
|
||||
|
||||
.link-container,
|
||||
|
||||
@@ -4,18 +4,17 @@
|
||||
<h1>📱 Add Another Device</h1>
|
||||
<p class="view-lede">Generate a one-time link to set up passkeys on a new device.</p>
|
||||
</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;">
|
||||
<button @click="showModal = true" class="btn-primary">Generate Registration Link</button>
|
||||
<button @click="authStore.currentView = 'profile'" class="btn-secondary">Back to Profile</button>
|
||||
</div>
|
||||
<RegistrationLinkModal
|
||||
v-if="showModal"
|
||||
endpoint="/auth/api/user/create-link"
|
||||
:user-name="userName"
|
||||
@close="showModal = false"
|
||||
@copied="onCopied"
|
||||
/>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
@@ -26,9 +25,10 @@ import RegistrationLinkModal from '@/components/RegistrationLinkModal.vue'
|
||||
|
||||
const authStore = useAuthStore()
|
||||
const userName = ref(null)
|
||||
const showModal = ref(false)
|
||||
|
||||
const onCopied = () => {
|
||||
authStore.showMessage('Link copied to clipboard!', 'success', 2500)
|
||||
authStore.currentView = 'profile'
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
<template>
|
||||
<section class="view-root" data-view="profile">
|
||||
<header class="view-header">
|
||||
<h1>👋 Welcome!</h1>
|
||||
<h1>User Profile</h1>
|
||||
<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>
|
||||
|
||||
<section class="section-block">
|
||||
@@ -17,7 +17,20 @@
|
||||
update-endpoint="/auth/api/user/display-name"
|
||||
@saved="authStore.loadUserInfo()"
|
||||
@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 class="section-block">
|
||||
@@ -84,11 +97,8 @@
|
||||
</section>
|
||||
<RegistrationLinkModal
|
||||
v-if="showRegLink"
|
||||
:endpoint="'/auth/api/user/create-link'"
|
||||
:auto-copy="false"
|
||||
:prefix-copy-with-user-name="false"
|
||||
endpoint="/auth/api/user/create-link"
|
||||
@close="showRegLink = false"
|
||||
@copied="showRegLink = false; authStore.showMessage('Link copied to clipboard!', 'success', 2500)"
|
||||
/>
|
||||
</section>
|
||||
</template>
|
||||
@@ -102,6 +112,7 @@ import Modal from '@/components/Modal.vue'
|
||||
import NameEditForm from '@/components/NameEditForm.vue'
|
||||
import SessionList from '@/components/SessionList.vue'
|
||||
import RegistrationLinkModal from '@/components/RegistrationLinkModal.vue'
|
||||
import RemoteAuth from '@/components/RemoteAuthPermit.vue'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { adminUiPath, makeUiHref } from '@/utils/settings'
|
||||
import passkey from '@/utils/passkey'
|
||||
@@ -116,6 +127,8 @@ const newName = ref('')
|
||||
const saving = ref(false)
|
||||
const hoveredCredentialUuid = 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 || '' })
|
||||
|
||||
@@ -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 credentialId = credential?.credential_uuid
|
||||
if (!credentialId) return
|
||||
@@ -199,5 +225,7 @@ const saveName = async () => {
|
||||
.logout-row { gap: 1rem; }
|
||||
.logout-row.single { justify-content: flex-start; }
|
||||
.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%; } }
|
||||
</style>
|
||||
|
||||
@@ -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>
|
||||
@@ -1,147 +1,109 @@
|
||||
<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="reg-header-row">
|
||||
<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>
|
||||
<button class="icon-btn" @click="$emit('close')" aria-label="Close">❌</button>
|
||||
</div>
|
||||
|
||||
<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 or scan this link on the device you wish to register to your account.</span>
|
||||
<br><small>{{ expirationMessage }}</small>
|
||||
</p>
|
||||
<!-- Loading state -->
|
||||
<div v-if="loading" class="loading-state">
|
||||
<div class="spinner-small"></div>
|
||||
<span>Generating registration link...</span>
|
||||
</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 class="reg-actions">
|
||||
<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>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted, watch, computed, nextTick } from 'vue'
|
||||
import QRCode from 'qrcode/lib/browser'
|
||||
import { ref, onMounted } from 'vue'
|
||||
import QRCodeDisplay from '@/components/QRCodeDisplay.vue'
|
||||
import { apiJson } from '@/utils/api'
|
||||
import { formatDate } from '@/utils/helpers'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { apiJson, getUserFriendlyErrorMessage, shouldShowErrorToast } from '@/utils/api'
|
||||
|
||||
const authStore = useAuthStore()
|
||||
|
||||
const props = defineProps({
|
||||
endpoint: { type: String, required: true },
|
||||
autoCopy: { type: Boolean, default: true },
|
||||
userName: { type: String, default: null },
|
||||
inline: { type: Boolean, default: false },
|
||||
showCloseInInline: { type: Boolean, default: false },
|
||||
prefixCopyWithUserName: { type: Boolean, default: false }
|
||||
userName: { type: String, default: '' }
|
||||
})
|
||||
|
||||
const emit = defineEmits(['close','generated','copied'])
|
||||
const emit = defineEmits(['close', 'copied'])
|
||||
|
||||
const url = ref(null)
|
||||
const expires = ref(null)
|
||||
const qrCanvas = ref(null)
|
||||
const loading = ref(true)
|
||||
const error = 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 {
|
||||
const data = await apiJson(props.endpoint, { method: 'POST' })
|
||||
url.value = data.url
|
||||
expires.value = data.expires
|
||||
emit('generated', { url: data.url, expires: data.expires })
|
||||
await nextTick()
|
||||
drawQR()
|
||||
if (props.autoCopy) copy()
|
||||
} catch (e) {
|
||||
console.error('Failed to create link', e)
|
||||
if (shouldShowErrorToast(e)) {
|
||||
authStore.showMessage(getUserFriendlyErrorMessage(e), 'error', 4000)
|
||||
if (data.url) {
|
||||
linkUrl.value = data.url
|
||||
expiresAt.value = data.expires ? new Date(data.expires) : null
|
||||
} else {
|
||||
error.value = data.detail || 'Failed to generate link'
|
||||
}
|
||||
// Close the dialog on any error (auth cancelled, network error, etc.)
|
||||
emit('close')
|
||||
} catch (err) {
|
||||
error.value = err.message || 'Failed to generate link'
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function drawQR() {
|
||||
if (!url.value) return
|
||||
await nextTick()
|
||||
if (!qrCanvas.value) return
|
||||
QRCode.toCanvas(qrCanvas.value, url.value, { scale: 8 }, err => { if (err) console.error(err) })
|
||||
function onCopied() {
|
||||
emit('copied')
|
||||
}
|
||||
|
||||
async function copy() {
|
||||
if (!url.value) return
|
||||
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' })
|
||||
|
||||
onMounted(() => {
|
||||
generateLink()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.icon-btn { background:none; border:none; cursor:pointer; font-size:1rem; opacity:.6; }
|
||||
.icon-btn:hover { opacity:1; }
|
||||
/* Minimal extra styling; main look comes from global styles */
|
||||
.qr-link { text-decoration:none; color:inherit; }
|
||||
.reg-header-row { display:flex; justify-content:space-between; align-items:center; gap:.75rem; margin-bottom:.75rem; }
|
||||
.reg-title { margin:0; font-size:1.25rem; font-weight:600; }
|
||||
.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); }
|
||||
.qr-container { display:flex; flex-direction:column; align-items:center; gap:.5rem; }
|
||||
.qr-code { display:block; }
|
||||
.reg-help { margin-top:.5rem; margin-bottom:.75rem; font-size:.85rem; line-height:1.25rem; text-align:center; }
|
||||
.reg-actions { display:flex; justify-content:flex-end; gap:.5rem; margin-top:.25rem; }
|
||||
.registration-inline-block .qr-container { align-items:flex-start; }
|
||||
.registration-inline-block .reg-help { text-align:left; }
|
||||
.icon-btn { background: none; border: none; cursor: pointer; font-size: 1rem; opacity: .6; }
|
||||
.icon-btn:hover { opacity: 1; }
|
||||
.reg-header-row { display: flex; justify-content: space-between; align-items: center; gap: .75rem; margin-bottom: .75rem; }
|
||||
.reg-title { margin: 0; font-size: 1.25rem; font-weight: 600; }
|
||||
.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-help { margin: .5rem 0 .75rem; font-size: .85rem; line-height: 1.4; text-align: center; color: var(--color-text-muted); }
|
||||
.reg-actions { display: flex; justify-content: flex-end; gap: .5rem; margin-top: 1rem; }
|
||||
.loading-state { display: flex; align-items: center; justify-content: center; gap: .5rem; padding: 2rem 0; color: var(--color-text-muted); }
|
||||
.error-state { text-align: center; padding: 1rem 0; }
|
||||
.error-message { color: var(--color-danger-text); margin-bottom: 1rem; }
|
||||
.expiry-note { font-size: .75rem; color: var(--color-text-muted); text-align: center; margin-top: .75rem; }
|
||||
</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>
|
||||
@@ -11,27 +11,41 @@
|
||||
<header class="view-header center">
|
||||
<h1>{{ headingTitle }}</h1>
|
||||
<p v-if="isAuthenticated" class="user-line">👤 {{ userDisplayName }}</p>
|
||||
<p class="view-lede">{{ headerMessage }}</p>
|
||||
<p class="view-lede" v-html="headerMessage"></p>
|
||||
</header>
|
||||
|
||||
<section class="section-block">
|
||||
<div class="section-body center">
|
||||
<div class="button-row center">
|
||||
<slot name="actions"
|
||||
:loading="loading"
|
||||
:can-authenticate="canAuthenticate"
|
||||
:is-authenticated="isAuthenticated"
|
||||
:authenticate="authenticateUser"
|
||||
:logout="logoutUser"
|
||||
:mode="mode">
|
||||
<!-- Default actions -->
|
||||
<button class="btn-secondary" :disabled="loading" @click="$emit('back')">Back</button>
|
||||
<button v-if="canAuthenticate" class="btn-primary" :disabled="loading" @click="authenticateUser">
|
||||
{{ loading ? (mode === 'reauth' ? 'Verifying…' : 'Signing in…') : (mode === 'reauth' ? 'Verify' : 'Login') }}
|
||||
</button>
|
||||
<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>
|
||||
<!-- Local passkey authentication view -->
|
||||
<div v-if="authView === 'local'" class="auth-view">
|
||||
<div class="button-row center">
|
||||
<slot name="actions"
|
||||
:loading="loading"
|
||||
:can-authenticate="canAuthenticate"
|
||||
:is-authenticated="isAuthenticated"
|
||||
:authenticate="authenticateUser"
|
||||
:logout="logoutUser"
|
||||
:mode="mode">
|
||||
<!-- Default actions -->
|
||||
<button class="btn-secondary" :disabled="loading" @click="$emit('back')">Back</button>
|
||||
<button v-if="canAuthenticate" class="btn-primary" :disabled="loading" @click="authenticateUser">
|
||||
{{ loading ? (mode === 'reauth' ? 'Verifying…' : 'Signing in…') : (mode === 'reauth' ? 'Verify' : 'Login') }}
|
||||
</button>
|
||||
<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>
|
||||
</section>
|
||||
@@ -41,10 +55,11 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, onMounted, reactive, ref } from 'vue'
|
||||
import { computed, onMounted, onUnmounted, reactive, ref } from 'vue'
|
||||
import passkey from '@/utils/passkey'
|
||||
import { getSettings } from '@/utils/settings'
|
||||
import { getSettings, uiBasePath } from '@/utils/settings'
|
||||
import { fetchJson, getUserFriendlyErrorMessage } from '@/utils/api'
|
||||
import RemoteAuthInline from '@/components/RemoteAuthRequest.vue'
|
||||
|
||||
const props = defineProps({
|
||||
mode: {
|
||||
@@ -62,17 +77,15 @@ const loading = ref(false)
|
||||
const settings = ref(null)
|
||||
const userInfo = ref(null)
|
||||
const currentView = ref('initial') // 'initial', 'login', 'forbidden'
|
||||
const authView = ref('local') // 'local' or 'remote'
|
||||
let statusTimer = null
|
||||
|
||||
const isAuthenticated = computed(() => !!userInfo.value?.authenticated)
|
||||
|
||||
const canAuthenticate = computed(() => {
|
||||
if (initializing.value) return false
|
||||
// In reauth mode, allow authentication even if already authenticated
|
||||
if (props.mode === 'reauth') return true
|
||||
// In forbidden view (authenticated but lacking permissions), don't allow authentication
|
||||
if (currentView.value === 'forbidden') return false
|
||||
// In login view or initial state, allow authentication
|
||||
return true
|
||||
})
|
||||
|
||||
@@ -91,6 +104,12 @@ const headerMessage = computed(() => {
|
||||
if (currentView.value === 'forbidden') {
|
||||
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.'
|
||||
})
|
||||
|
||||
@@ -122,7 +141,6 @@ async function fetchSettings() {
|
||||
async function fetchUserInfo() {
|
||||
try {
|
||||
userInfo.value = await fetchJson('/auth/api/user-info', { method: 'POST' })
|
||||
// Determine view based on authentication status
|
||||
if (isAuthenticated.value && props.mode !== 'reauth') {
|
||||
currentView.value = 'forbidden'
|
||||
emit('forbidden', userInfo.value)
|
||||
@@ -131,7 +149,6 @@ async function fetchUserInfo() {
|
||||
}
|
||||
} catch (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) {
|
||||
showMessage(getUserFriendlyErrorMessage(error), 'error', 4000)
|
||||
}
|
||||
@@ -170,7 +187,6 @@ async function logoutUser() {
|
||||
try {
|
||||
await fetchJson('/auth/api/logout', { method: 'POST' })
|
||||
userInfo.value = null
|
||||
// Switch to login view after logout
|
||||
currentView.value = 'login'
|
||||
showMessage('Logged out. You can sign in with a different account.', 'info', 3000)
|
||||
} catch (error) {
|
||||
@@ -181,7 +197,6 @@ async function logoutUser() {
|
||||
}
|
||||
|
||||
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')
|
||||
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 () => {
|
||||
await fetchSettings()
|
||||
await fetchUserInfo()
|
||||
initializing.value = false
|
||||
|
||||
// Add click handler for inline links
|
||||
document.addEventListener('click', handleHeaderLinkClick)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
document.removeEventListener('click', handleHeaderLinkClick)
|
||||
})
|
||||
|
||||
defineExpose({
|
||||
@@ -210,9 +276,8 @@ defineExpose({
|
||||
</script>
|
||||
|
||||
<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); }
|
||||
/* Vertically center the restricted "dialog" surface in the viewport */
|
||||
main.view-root { min-height: 100vh; align-items: center; justify-content: center; padding: 2rem 1rem; }
|
||||
.surface.surface--tight {
|
||||
max-width: 520px;
|
||||
@@ -222,4 +287,24 @@ main.view-root { min-height: 100vh; align-items: center; justify-content: center
|
||||
flex-direction: column;
|
||||
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>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<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">
|
||||
<span class="icon">👤</span>
|
||||
<span class="user-name-row">
|
||||
@@ -11,12 +11,15 @@
|
||||
<div class="org-line" v-if="orgDisplayName">{{ orgDisplayName }}</div>
|
||||
<div class="role-line" v-if="roleName">{{ roleName }}</div>
|
||||
</div>
|
||||
<span><strong>Visits:</strong></span>
|
||||
<span>{{ visits || 0 }}</span>
|
||||
<span><strong>Registered:</strong></span>
|
||||
<span>{{ formatDate(createdAt) }}</span>
|
||||
<span><strong>Last seen:</strong></span>
|
||||
<span>{{ formatDate(lastSeen) }}</span>
|
||||
<span class="info-label"><strong>Visits:</strong></span>
|
||||
<span class="info-value">{{ visits || 0 }}</span>
|
||||
<span class="info-label"><strong>Registered:</strong></span>
|
||||
<span class="info-value">{{ formatDate(createdAt) }}</span>
|
||||
<span class="info-label"><strong>Last seen:</strong></span>
|
||||
<span class="info-value">{{ formatDate(lastSeen) }}</span>
|
||||
<div v-if="$slots.default" class="user-info-extra">
|
||||
<slot></slot>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -44,13 +47,50 @@ const userLoaded = computed(() => !!props.name)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.user-info { display: grid; grid-template-columns: auto 1fr; gap: 10px; }
|
||||
.user-info h3 { grid-column: span 2; }
|
||||
.org-role-sub { grid-column: span 2; display:flex; flex-direction:column; margin: -0.15rem 0 0.25rem; }
|
||||
.user-info.has-extra {
|
||||
grid-template-columns: auto 1fr;
|
||||
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; }
|
||||
.role-line { font-size:.65rem; color: var(--color-text-muted); line-height:1.1; }
|
||||
.user-info span { text-align: left; }
|
||||
.user-name-heading { display: flex; align-items: center; gap: 0.4rem; flex-wrap: wrap; margin: 0 0 0.25rem 0; }
|
||||
.info-label:nth-of-type(1) { grid-area: label1; }
|
||||
.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.editing { flex: 1 1 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:active:not(:disabled) { transform: translateY(1px); }
|
||||
.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%; } }
|
||||
</style>
|
||||
|
||||
@@ -18,12 +18,36 @@ class AwaitableWebSocket extends WebSocket {
|
||||
}
|
||||
this.onclose = e => {
|
||||
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
|
||||
}
|
||||
this.#err = e.wasClean
|
||||
? new Error(`Websocket ${this.url} closed ${e.code}`)
|
||||
: new Error(`WebSocket ${this.url} closed with error ${e.code}`)
|
||||
// Create user-friendly close messages
|
||||
let message
|
||||
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))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(/=+$/, '')
|
||||
}
|
||||
@@ -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()
|
||||
@@ -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
@@ -27,7 +27,7 @@ export default defineConfig(({ command }) => ({
|
||||
closeBundle() {
|
||||
if (command !== 'build') return
|
||||
|
||||
const outDir = resolve(__dirname, '../passkey/frontend-build')
|
||||
const outDir = resolve(__dirname, '../paskia/frontend-build')
|
||||
const moves = [
|
||||
{ from: 'auth.html', to: 'auth/index.html' },
|
||||
{ from: 'admin.html', to: 'admin/index.html' },
|
||||
@@ -53,23 +53,31 @@ export default defineConfig(({ command }) => ({
|
||||
base: '/',
|
||||
server: {
|
||||
port: 4403,
|
||||
allowedHosts: true,
|
||||
fs: {
|
||||
allow: ['..']
|
||||
},
|
||||
proxy: {
|
||||
// Only proxy these two specific backend API paths
|
||||
'/auth/api': {
|
||||
target: 'http://localhost:4402',
|
||||
headers: { connection: 'close' }
|
||||
target: 'http://localhost:4402'
|
||||
},
|
||||
'/auth/ws': {
|
||||
target: 'http://localhost:4402',
|
||||
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: {
|
||||
outDir: '../passkey/frontend-build',
|
||||
outDir: '../paskia/frontend-build',
|
||||
emptyOutDir: true,
|
||||
rollupOptions: {
|
||||
input: {
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
from paskia.sansio import Passkey
|
||||
|
||||
__all__ = ["Passkey"]
|
||||
@@ -14,7 +14,7 @@ from importlib.resources import files
|
||||
__ALL__ = ["AAGUID", "filter"]
|
||||
|
||||
# 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"))
|
||||
|
||||
|
||||
@@ -11,11 +11,11 @@ independent of any web framework:
|
||||
from datetime import datetime, timezone
|
||||
from uuid import UUID
|
||||
|
||||
from .config import SESSION_LIFETIME
|
||||
from .db import ResetToken, Session
|
||||
from .globals import db, passkey
|
||||
from .util import hostutil
|
||||
from .util.tokens import create_token, reset_key, session_key
|
||||
from paskia.config import SESSION_LIFETIME
|
||||
from paskia.db import ResetToken, Session
|
||||
from paskia.globals import db, passkey
|
||||
from paskia.util import hostutil
|
||||
from paskia.util.tokens import create_token, reset_key, session_key
|
||||
|
||||
EXPIRES = SESSION_LIFETIME
|
||||
|
||||
@@ -71,7 +71,7 @@ async def get_reset(token: str) -> ResetToken:
|
||||
record = await db.instance.get_reset_token(reset_key(token))
|
||||
if record and record.expiry >= datetime.now(timezone.utc):
|
||||
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:
|
||||
@@ -12,9 +12,9 @@ from datetime import datetime, timezone
|
||||
|
||||
import uuid7
|
||||
|
||||
from . import authsession, globals
|
||||
from .db import Org, Permission, Role, User
|
||||
from .util import hostutil, passphrase, tokens
|
||||
from paskia import authsession, globals
|
||||
from paskia.db import Org, Permission, Role, User
|
||||
from paskia.util import hostutil, passphrase, tokens
|
||||
|
||||
|
||||
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
|
||||
|
||||
|
||||
async def bootstrap_system(
|
||||
user_name: str | None = None, org_name: str | None = None
|
||||
) -> dict:
|
||||
async def bootstrap_system() -> dict:
|
||||
"""
|
||||
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:
|
||||
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")
|
||||
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)
|
||||
|
||||
# After creation, org.permissions now includes the auto-created org admin permission
|
||||
@@ -89,7 +83,7 @@ async def bootstrap_system(
|
||||
|
||||
user = User(
|
||||
uuid=uuid7.create(),
|
||||
display_name=user_name or "Admin",
|
||||
display_name="Admin",
|
||||
role_uuid=role.uuid,
|
||||
created_at=datetime.now(timezone.utc),
|
||||
visits=0,
|
||||
@@ -159,16 +153,10 @@ async def check_admin_credentials() -> bool:
|
||||
return False
|
||||
|
||||
|
||||
async def bootstrap_if_needed(
|
||||
default_admin: str | None = None, default_org: str | None = None
|
||||
) -> bool:
|
||||
async def bootstrap_if_needed() -> bool:
|
||||
"""
|
||||
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:
|
||||
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
|
||||
# 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
|
||||
|
||||
|
||||
# CLI interface
|
||||
async def main():
|
||||
"""Main CLI entry point for bootstrapping."""
|
||||
import argparse
|
||||
|
||||
# Configure logging for CLI usage
|
||||
logging.basicConfig(level=logging.INFO, format="%(message)s", force=True)
|
||||
|
||||
parser = argparse.ArgumentParser(
|
||||
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)
|
||||
await globals.init()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
@@ -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
|
||||
@@ -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.
|
||||
"""
|
||||
|
||||
import os
|
||||
from contextlib import asynccontextmanager
|
||||
from datetime import datetime, timezone
|
||||
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.orm import DeclarativeBase, Mapped, mapped_column
|
||||
|
||||
from ..config import SESSION_LIFETIME
|
||||
from ..globals import db
|
||||
from . import (
|
||||
from paskia.config import SESSION_LIFETIME
|
||||
from paskia.db import (
|
||||
Credential,
|
||||
DatabaseInterface,
|
||||
Org,
|
||||
@@ -39,8 +39,9 @@ from . import (
|
||||
SessionContext,
|
||||
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:
|
||||
@@ -52,7 +53,8 @@ def _normalize_dt(value: datetime | None) -> datetime | None:
|
||||
|
||||
|
||||
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()
|
||||
|
||||
|
||||
@@ -289,7 +291,7 @@ class RolePermission(Base):
|
||||
class DB(DatabaseInterface):
|
||||
"""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."""
|
||||
self.engine = create_async_engine(db_path, echo=False)
|
||||
# Ensure SQLite foreign key enforcement is ON for every new connection
|
||||
@@ -0,0 +1,3 @@
|
||||
from paskia.fastapi.mainapp import app
|
||||
|
||||
__all__ = ["app"]
|
||||
@@ -7,11 +7,10 @@ from urllib.parse import urlparse
|
||||
|
||||
import uvicorn
|
||||
|
||||
from passkey.util import frontend
|
||||
from paskia.util.hostutil import normalize_origin
|
||||
|
||||
DEFAULT_HOST = "localhost"
|
||||
DEFAULT_SERVE_PORT = 4401
|
||||
DEFAULT_DEV_PORT = 4402
|
||||
|
||||
|
||||
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)"
|
||||
)
|
||||
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(
|
||||
"--auth-host",
|
||||
help=(
|
||||
@@ -129,7 +134,7 @@ def main():
|
||||
logging.basicConfig(level=logging.INFO, format="%(message)s", force=True)
|
||||
|
||||
parser = argparse.ArgumentParser(
|
||||
prog="passkey-auth", description="Passkey authentication server"
|
||||
prog="paskia", description="Paskia authentication server"
|
||||
)
|
||||
sub = parser.add_subparsers(dest="command", required=True)
|
||||
|
||||
@@ -147,18 +152,6 @@ def main():
|
||||
)
|
||||
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 = sub.add_parser(
|
||||
"reset",
|
||||
@@ -176,66 +169,121 @@ def main():
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
if args.command in {"serve", "dev"}:
|
||||
default_port = DEFAULT_DEV_PORT if args.command == "dev" else DEFAULT_SERVE_PORT
|
||||
host, port, uds, all_ifaces = parse_endpoint(args.hostport, default_port)
|
||||
devmode = args.command == "dev"
|
||||
if args.command == "serve":
|
||||
host, port, uds, all_ifaces = parse_endpoint(args.hostport, DEFAULT_SERVE_PORT)
|
||||
else:
|
||||
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:
|
||||
# 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)
|
||||
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.
|
||||
# Lifespan in worker processes will call globals.init with bootstrap disabled.
|
||||
from passkey import globals as _globals # local import
|
||||
# Compute site_url and site_path for reset links
|
||||
# Priority: auth_host > first origin with localhost > http://localhost:port
|
||||
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(
|
||||
_globals.init(
|
||||
rp_id=args.rp_id,
|
||||
rp_name=args.rp_name,
|
||||
origin=origin,
|
||||
default_admin=os.getenv("PASSKEY_DEFAULT_ADMIN") or None,
|
||||
default_org=os.getenv("PASSKEY_DEFAULT_ORG") or None,
|
||||
bootstrap=True,
|
||||
rp_id=config.rp_id,
|
||||
rp_name=config.rp_name,
|
||||
origins=config.origins,
|
||||
bootstrap=False,
|
||||
)
|
||||
)
|
||||
|
||||
# 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)
|
||||
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))
|
||||
raise SystemExit(exit_code)
|
||||
|
||||
if args.command in {"serve", "dev"}:
|
||||
if args.command == "serve":
|
||||
run_kwargs: dict = {
|
||||
"reload": devmode,
|
||||
"reload_dirs": ["passkey"] if devmode else None,
|
||||
"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:
|
||||
run_kwargs["uds"] = uds
|
||||
else:
|
||||
@@ -243,19 +291,17 @@ def main():
|
||||
run_kwargs["host"] = host
|
||||
run_kwargs["port"] = port
|
||||
|
||||
if devmode:
|
||||
os.environ["PASSKEY_DEVMODE"] = "1"
|
||||
frontend.run_dev()
|
||||
|
||||
if all_ifaces and not uds:
|
||||
# Dev mode with all interfaces: use simple single-server approach
|
||||
if devmode:
|
||||
run_kwargs["host"] = "::"
|
||||
run_kwargs["port"] = port
|
||||
uvicorn.run("passkey.fastapi:app", **run_kwargs)
|
||||
uvicorn.run("paskia.fastapi:app", **run_kwargs)
|
||||
else:
|
||||
# Production: run separate servers for IPv4 and IPv6
|
||||
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
|
||||
)
|
||||
|
||||
@@ -278,7 +324,7 @@ def main():
|
||||
|
||||
asyncio.run(serve_both())
|
||||
else:
|
||||
uvicorn.run("passkey.fastapi:app", **run_kwargs)
|
||||
uvicorn.run("paskia.fastapi:app", **run_kwargs)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
@@ -5,9 +5,11 @@ from uuid import UUID, uuid4
|
||||
from fastapi import Body, FastAPI, HTTPException, Request, Response
|
||||
from fastapi.responses import JSONResponse
|
||||
|
||||
from ..authsession import reset_expires
|
||||
from ..globals import db
|
||||
from ..util import (
|
||||
from paskia.authsession import reset_expires
|
||||
from paskia.fastapi import authz
|
||||
from paskia.fastapi.session import AUTH_COOKIE
|
||||
from paskia.globals import db
|
||||
from paskia.util import (
|
||||
frontend,
|
||||
hostutil,
|
||||
passphrase,
|
||||
@@ -16,9 +18,7 @@ from ..util import (
|
||||
tokens,
|
||||
useragent,
|
||||
)
|
||||
from ..util.tokens import encode_session_key, session_key
|
||||
from . import authz
|
||||
from .session import AUTH_COOKIE
|
||||
from paskia.util.tokens import encode_session_key, session_key
|
||||
|
||||
app = FastAPI()
|
||||
|
||||
@@ -38,7 +38,7 @@ async def auth_exception_handler(_request, exc: authz.AuthException):
|
||||
|
||||
|
||||
@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")
|
||||
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))
|
||||
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
|
||||
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
|
||||
if ctx.user.uuid == user_uuid:
|
||||
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 = (
|
||||
"auth:admin" 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"),
|
||||
max_age="5m",
|
||||
)
|
||||
if (
|
||||
if ( # pragma: no cover - defense in depth, authz.verify already checked
|
||||
"auth:admin" 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,
|
||||
token_type=token_type,
|
||||
)
|
||||
url = hostutil.reset_link_url(
|
||||
token, request.url.scheme, request.headers.get("host")
|
||||
)
|
||||
url = hostutil.reset_link_url(token)
|
||||
return {
|
||||
"url": url,
|
||||
"expires": (
|
||||
@@ -484,7 +484,7 @@ async def admin_get_user_detail(
|
||||
match=permutil.has_any,
|
||||
host=request.headers.get("host"),
|
||||
)
|
||||
if (
|
||||
if ( # pragma: no cover - defense in depth, authz.verify already checked
|
||||
"auth:admin" 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:
|
||||
try:
|
||||
c = await db.instance.get_credential_by_id(cid)
|
||||
except ValueError:
|
||||
except ValueError: # pragma: no cover - race condition handling
|
||||
continue
|
||||
aaguid_str = str(c.aaguid)
|
||||
aaguids.add(aaguid_str)
|
||||
@@ -633,7 +633,7 @@ async def admin_update_user_display_name(
|
||||
match=permutil.has_any,
|
||||
host=request.headers.get("host"),
|
||||
)
|
||||
if (
|
||||
if ( # pragma: no cover - defense in depth, authz.verify already checked
|
||||
"auth:admin" 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"),
|
||||
max_age="5m",
|
||||
)
|
||||
if (
|
||||
if ( # pragma: no cover - defense in depth, authz.verify already checked
|
||||
"auth:admin" 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,
|
||||
host=request.headers.get("host"),
|
||||
)
|
||||
if (
|
||||
if ( # pragma: no cover - defense in depth, authz.verify already checked
|
||||
"auth:admin" 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)
|
||||
display_name = perm.display_name
|
||||
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")
|
||||
await rename_fn(old_id, new_id, display_name)
|
||||
return {"status": "ok"}
|
||||
@@ -13,21 +13,19 @@ from fastapi import (
|
||||
from fastapi.responses import JSONResponse
|
||||
from fastapi.security import HTTPBearer
|
||||
|
||||
from passkey.util import frontend
|
||||
|
||||
from ..authsession import (
|
||||
from paskia.authsession import (
|
||||
EXPIRES,
|
||||
get_reset,
|
||||
get_session,
|
||||
refresh_session_token,
|
||||
session_expiry,
|
||||
)
|
||||
from ..globals import db
|
||||
from ..globals import passkey as global_passkey
|
||||
from ..util import hostutil, htmlutil, passphrase, userinfo
|
||||
from ..util.tokens import session_key
|
||||
from . import authz, session, user
|
||||
from .session import AUTH_COOKIE
|
||||
from paskia.fastapi import authz, session, user
|
||||
from paskia.fastapi.session import AUTH_COOKIE, AUTH_COOKIE_NAME
|
||||
from paskia.globals import db
|
||||
from paskia.globals import passkey as global_passkey
|
||||
from paskia.util import frontend, hostutil, htmlutil, passphrase, userinfo
|
||||
from paskia.util.tokens import session_key
|
||||
|
||||
bearer_auth = HTTPBearer(auto_error=True)
|
||||
|
||||
@@ -67,7 +65,9 @@ async def auth_exception_handler(_request: Request, exc: authz.AuthException):
|
||||
|
||||
|
||||
@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")
|
||||
return JSONResponse(status_code=500, content={"detail": "Internal server error"})
|
||||
|
||||
@@ -200,10 +200,37 @@ async def get_settings():
|
||||
"rp_id": pk.rp_id,
|
||||
"rp_name": pk.rp_name,
|
||||
"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")
|
||||
async def api_user_info(
|
||||
request: Request,
|
||||
@@ -3,7 +3,7 @@
|
||||
from fastapi import Request, Response
|
||||
from fastapi.responses import RedirectResponse
|
||||
|
||||
from passkey.util import hostutil, passphrase
|
||||
from paskia.util import hostutil, passphrase
|
||||
|
||||
|
||||
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):
|
||||
"""Middleware to handle auth host redirects."""
|
||||
cfg = hostutil.configured_auth_host()
|
||||
cfg = hostutil.dedicated_auth_host()
|
||||
if not cfg:
|
||||
return await call_next(request)
|
||||
|
||||
@@ -2,7 +2,7 @@ import logging
|
||||
|
||||
from fastapi import HTTPException
|
||||
|
||||
from ..util import permutil, sessionutil
|
||||
from paskia.util import permutil, sessionutil
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -1,41 +1,40 @@
|
||||
import logging
|
||||
import os
|
||||
from contextlib import asynccontextmanager
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import FastAPI, HTTPException, Request, Response
|
||||
from fastapi.responses import RedirectResponse
|
||||
from fastapi.responses import FileResponse, RedirectResponse
|
||||
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
|
||||
from .session import AUTH_COOKIE
|
||||
# Path to examples/index.html when running from source tree
|
||||
_EXAMPLES_DIR = Path(__file__).parent.parent.parent / "examples"
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI): # pragma: no cover - startup path
|
||||
"""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.
|
||||
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:
|
||||
# CLI (__main__) performs bootstrap once; here we skip to avoid duplicate work
|
||||
await globals.init(
|
||||
rp_id=rp_id,
|
||||
rp_name=rp_name,
|
||||
origin=origin,
|
||||
default_admin=default_admin,
|
||||
default_org=default_org,
|
||||
rp_id=config["rp_id"],
|
||||
rp_name=config["rp_name"],
|
||||
origins=config["origins"],
|
||||
bootstrap=False,
|
||||
)
|
||||
except ValueError as e:
|
||||
@@ -43,17 +42,12 @@ async def lifespan(app: FastAPI): # pragma: no cover - startup path
|
||||
# Re-raise to fail fast
|
||||
raise
|
||||
|
||||
# In dev mode, Vite serves assets directly; in production, mount static files
|
||||
# This is deferred to lifespan because PASSKEY_DEVMODE is set after module import
|
||||
if not frontend.is_dev_mode():
|
||||
app.mount(
|
||||
"/auth/assets/",
|
||||
StaticFiles(directory=frontend.file("auth", "assets")),
|
||||
name="assets",
|
||||
)
|
||||
# Restore info level logging after startup (suppressed during uvicorn init in dev mode)
|
||||
if frontend.is_dev_mode():
|
||||
logging.getLogger("uvicorn").setLevel(logging.INFO)
|
||||
logging.getLogger("uvicorn.access").setLevel(logging.INFO)
|
||||
|
||||
yield
|
||||
# (Optional) add shutdown cleanup here later
|
||||
|
||||
|
||||
app = FastAPI(lifespan=lifespan)
|
||||
@@ -65,6 +59,14 @@ app.mount("/auth/api/admin/", admin.app)
|
||||
app.mount("/auth/api/", api.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/")
|
||||
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
|
||||
|
||||
|
||||
@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
|
||||
@app.get("/{reset}")
|
||||
@app.get("/auth/{reset}")
|
||||
async def reset_link(reset: str):
|
||||
"""Serve the reset app directly with an injected reset token."""
|
||||
if not passphrase.is_well_formed(reset):
|
||||
@app.get("/{token}")
|
||||
@app.get("/auth/{token}")
|
||||
async def token_link(token: str):
|
||||
"""Serve the reset app for reset tokens (password reset / device addition).
|
||||
|
||||
The frontend will validate the token via /auth/api/token-info.
|
||||
"""
|
||||
if not passphrase.is_well_formed(token):
|
||||
raise HTTPException(status_code=404)
|
||||
|
||||
return Response(*await frontend.read("/int/reset/index.html"))
|
||||
@@ -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.
|
||||
|
||||
Usage (via main CLI):
|
||||
passkey-auth reset [query]
|
||||
paskia reset [query]
|
||||
|
||||
If query is omitted, the master admin (first Administration role user in
|
||||
an organization granting auth:admin) is targeted. Otherwise query is
|
||||
@@ -15,10 +15,10 @@ from __future__ import annotations
|
||||
import asyncio
|
||||
from uuid import UUID
|
||||
|
||||
from passkey import authsession as _authsession
|
||||
from passkey import globals as _g
|
||||
from passkey.util import hostutil, passphrase
|
||||
from passkey.util import tokens as _tokens
|
||||
from paskia import authsession as _authsession
|
||||
from paskia import globals as _g
|
||||
from paskia.util import hostutil, passphrase
|
||||
from paskia.util import tokens as _tokens
|
||||
|
||||
|
||||
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 ..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)
|
||||
|
||||
|
||||
@@ -10,16 +10,16 @@ from fastapi import (
|
||||
)
|
||||
from fastapi.responses import JSONResponse
|
||||
|
||||
from ..authsession import (
|
||||
from paskia.authsession import (
|
||||
delete_credential,
|
||||
expires,
|
||||
get_session,
|
||||
)
|
||||
from ..globals import db
|
||||
from ..util import hostutil, passphrase, tokens
|
||||
from ..util.tokens import decode_session_key, session_key
|
||||
from . import authz, session
|
||||
from .session import AUTH_COOKIE
|
||||
from paskia.fastapi import authz, session
|
||||
from paskia.fastapi.session import AUTH_COOKIE
|
||||
from paskia.globals import db
|
||||
from paskia.util import hostutil, passphrase, tokens
|
||||
from paskia.util.tokens import decode_session_key, session_key
|
||||
|
||||
app = FastAPI()
|
||||
|
||||
@@ -150,9 +150,7 @@ async def api_create_link(
|
||||
expiry=expiry,
|
||||
token_type="device addition",
|
||||
)
|
||||
url = hostutil.reset_link_url(
|
||||
token, request.url.scheme, request.headers.get("host")
|
||||
)
|
||||
url = hostutil.reset_link_url(token)
|
||||
return {
|
||||
"message": "Registration link generated successfully",
|
||||
"url": url,
|
||||
@@ -1,42 +1,14 @@
|
||||
import logging
|
||||
from functools import wraps
|
||||
from uuid import UUID
|
||||
|
||||
from fastapi import FastAPI, WebSocket, WebSocketDisconnect
|
||||
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 fastapi import FastAPI, WebSocket
|
||||
|
||||
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
|
||||
app = FastAPI()
|
||||
@@ -46,15 +18,14 @@ async def register_chat(
|
||||
ws: WebSocket,
|
||||
user_uuid: UUID,
|
||||
user_name: str,
|
||||
origin: str,
|
||||
credential_ids: list[bytes] | None = None,
|
||||
origin: str | None = None,
|
||||
):
|
||||
"""Generate registration options and send them to the client."""
|
||||
options, challenge = passkey.instance.reg_generate_options(
|
||||
user_id=user_uuid,
|
||||
user_name=user_name,
|
||||
credential_ids=credential_ids,
|
||||
origin=origin,
|
||||
)
|
||||
await ws.send_json({"optionsJSON": options})
|
||||
response = await ws.receive_json()
|
||||
@@ -75,7 +46,7 @@ async def websocket_register_add(
|
||||
- Normal session via auth cookie (requires recent authentication)
|
||||
- Reset token supplied as ?reset=... (auth cookie ignored)
|
||||
"""
|
||||
origin = ws.headers["origin"]
|
||||
origin = validate_origin(ws)
|
||||
host = origin.split("://", 1)[1]
|
||||
if reset is not None:
|
||||
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)
|
||||
|
||||
# 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
|
||||
token = create_token()
|
||||
@@ -131,7 +102,7 @@ async def websocket_register_add(
|
||||
@app.websocket("/authenticate")
|
||||
@websocket_error_handler
|
||||
async def websocket_authenticate(ws: WebSocket, auth=AUTH_COOKIE):
|
||||
origin = ws.headers["origin"]
|
||||
origin = validate_origin(ws)
|
||||
host = origin.split("://", 1)[1]
|
||||
|
||||
# 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")
|
||||
|
||||
# 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
|
||||
await db.instance.login(stored_cred.user_uuid, stored_cred)
|
||||
|
||||
@@ -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)
|
||||
@@ -1,7 +1,7 @@
|
||||
from typing import Generic, TypeVar
|
||||
|
||||
from .db import DatabaseInterface
|
||||
from .sansio import Passkey
|
||||
from paskia.db import DatabaseInterface
|
||||
from paskia.sansio import Passkey
|
||||
|
||||
T = TypeVar("T")
|
||||
|
||||
@@ -29,9 +29,7 @@ class Manager(Generic[T]):
|
||||
async def init(
|
||||
rp_id: str = "localhost",
|
||||
rp_name: str | None = None,
|
||||
origin: str | None = None,
|
||||
default_admin: str | None = None,
|
||||
default_org: str | None = None,
|
||||
origins: list[str] | None = None,
|
||||
*,
|
||||
bootstrap: bool = True,
|
||||
) -> None:
|
||||
@@ -45,7 +43,7 @@ async def init(
|
||||
passkey.instance = Passkey(
|
||||
rp_id=rp_id,
|
||||
rp_name=rp_name or rp_id,
|
||||
origin=origin,
|
||||
origins=origins,
|
||||
)
|
||||
|
||||
# Test if we have a database already initialized, otherwise use SQL
|
||||
@@ -60,7 +58,7 @@ async def init(
|
||||
# Bootstrap system if needed
|
||||
from .bootstrap import bootstrap_if_needed
|
||||
|
||||
await bootstrap_if_needed(default_admin, default_org)
|
||||
await bootstrap_if_needed()
|
||||
|
||||
|
||||
# Global instances
|
||||
@@ -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
|
||||
@@ -37,7 +37,7 @@ from webauthn.helpers.structs import (
|
||||
UserVerificationRequirement,
|
||||
)
|
||||
|
||||
from .db import Credential
|
||||
from paskia.db import Credential
|
||||
|
||||
|
||||
class Passkey:
|
||||
@@ -47,7 +47,7 @@ class Passkey:
|
||||
self,
|
||||
rp_id: str,
|
||||
rp_name: str | None = None,
|
||||
origin: str | None = None,
|
||||
origins: list[str] | None = None,
|
||||
supported_pub_key_algs: list[COSEAlgorithmIdentifier] | None = None,
|
||||
):
|
||||
"""
|
||||
@@ -56,40 +56,58 @@ class Passkey:
|
||||
Args:
|
||||
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.
|
||||
origin: The origin URL of the application (e.g. "https://app.example.com").
|
||||
If no scheme is provided, "https://" will be prepended.
|
||||
Must be a subdomain or same as rp_id, with port and scheme but no path included.
|
||||
origins: List of allowed origin URLs (e.g. ["https://app.example.com", "https://auth.example.com"]).
|
||||
Each must be a subdomain or same as rp_id. If not provided, any subdomain of rp_id is allowed.
|
||||
supported_pub_key_algs: List of supported COSE algorithms (default is EDDSA, ECDSA_SHA_256, RSASSA_PKCS1_v1_5_SHA_256).
|
||||
|
||||
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_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 [
|
||||
COSEAlgorithmIdentifier.EDDSA,
|
||||
COSEAlgorithmIdentifier.ECDSA_SHA_256,
|
||||
COSEAlgorithmIdentifier.RSASSA_PKCS1_v1_5_SHA_256,
|
||||
]
|
||||
|
||||
def _normalize_and_validate_origin(self, origin: str | None, rp_id: str) -> str:
|
||||
if origin is None:
|
||||
origin = f"https://{rp_id}"
|
||||
elif "://" not in origin:
|
||||
origin = f"https://{origin}"
|
||||
|
||||
def _validate_origin(self, origin: str, rp_id: str) -> None:
|
||||
"""Validate an origin URL against the rp_id."""
|
||||
hostname = urlparse(origin).hostname
|
||||
if not hostname:
|
||||
raise ValueError(f"Invalid origin URL: no hostname found in '{origin}'")
|
||||
|
||||
if hostname == rp_id or hostname.endswith(f".{rp_id}"):
|
||||
return origin
|
||||
return
|
||||
|
||||
raise ValueError(
|
||||
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 ###
|
||||
|
||||
def reg_generate_options(
|
||||
@@ -137,14 +155,16 @@ class Passkey:
|
||||
response_json: dict | str,
|
||||
expected_challenge: bytes,
|
||||
user_uuid: UUID,
|
||||
origin: str | None = None,
|
||||
origin: str,
|
||||
) -> Credential:
|
||||
"""
|
||||
Verify registration response.
|
||||
|
||||
Args:
|
||||
credential: The credential response from the client
|
||||
response_json: The credential response from the client
|
||||
expected_challenge: The expected challenge bytes
|
||||
user_uuid: The user's UUID
|
||||
origin: The origin URL (required, must be pre-validated)
|
||||
|
||||
Returns:
|
||||
Registration verification result
|
||||
@@ -153,7 +173,7 @@ class Passkey:
|
||||
registration = verify_registration_response(
|
||||
credential=credential,
|
||||
expected_challenge=expected_challenge,
|
||||
expected_origin=origin or self.origin,
|
||||
expected_origin=origin,
|
||||
expected_rp_id=self.rp_id,
|
||||
)
|
||||
return Credential(
|
||||
@@ -206,7 +226,7 @@ class Passkey:
|
||||
credential: AuthenticationCredential,
|
||||
expected_challenge: bytes,
|
||||
stored_cred: Credential,
|
||||
origin: str | None = None,
|
||||
origin: str,
|
||||
) -> VerifiedAuthentication:
|
||||
"""
|
||||
Verify authentication response against locally stored credential data.
|
||||
@@ -215,13 +235,13 @@ class Passkey:
|
||||
credential: The authentication credential response from the client
|
||||
expected_challenge: The earlier generated challenge bytes
|
||||
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
|
||||
verification = verify_authentication_response(
|
||||
credential=credential,
|
||||
expected_challenge=expected_challenge,
|
||||
expected_origin=expected_origin,
|
||||
expected_origin=origin,
|
||||
expected_rp_id=self.rp_id,
|
||||
credential_public_key=stored_cred.public_key,
|
||||
credential_current_sign_count=stored_cred.sign_count,
|
||||
@@ -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)
|
||||
@@ -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
|
||||
|
||||
from .wordlist import words
|
||||
from paskia.util.wordlist import words
|
||||
|
||||
N_WORDS = 5
|
||||
N_WORDS_SHORT = 3
|
||||
|
||||
wset = set(words)
|
||||
|
||||
@@ -3,9 +3,9 @@
|
||||
from collections.abc import Sequence
|
||||
from fnmatch import fnmatchcase
|
||||
|
||||
from ..globals import db
|
||||
from .hostutil import normalize_host
|
||||
from .tokens import session_key
|
||||
from paskia.globals import db
|
||||
from paskia.util.hostutil import normalize_host
|
||||
from paskia.util.tokens import session_key
|
||||
|
||||
__all__ = ["has_any", "has_all", "session_context"]
|
||||
|
||||
@@ -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 ..db import SessionContext
|
||||
from .timeutil import parse_duration
|
||||
from paskia.db import SessionContext
|
||||
from paskia.util.timeutil import parse_duration
|
||||
|
||||
|
||||
def check_session_age(ctx: SessionContext, max_age: str | None) -> bool:
|
||||
@@ -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 secrets
|
||||
|
||||
from .passphrase import is_well_formed
|
||||
import base64url
|
||||
|
||||
from paskia.util.passphrase import is_well_formed
|
||||
|
||||
|
||||
def create_token() -> str:
|
||||
@@ -12,21 +13,20 @@ def create_token() -> str:
|
||||
def session_key(token: str) -> bytes:
|
||||
if len(token) != 16:
|
||||
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:
|
||||
"""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:
|
||||
"""Decode an opaque session key from its public representation."""
|
||||
if not encoded:
|
||||
raise ValueError("Invalid session identifier")
|
||||
padding = "=" * (-len(encoded) % 4)
|
||||
try:
|
||||
raw = base64.urlsafe_b64decode(encoded + padding)
|
||||
raw = base64url.dec(encoded)
|
||||
except Exception as exc: # pragma: no cover - defensive
|
||||
raise ValueError("Invalid session identifier") from exc
|
||||
if not raw.startswith(b"sess"):
|
||||
@@ -2,12 +2,10 @@
|
||||
|
||||
from datetime import timezone
|
||||
|
||||
from passkey.util import useragent
|
||||
|
||||
from .. import aaguid
|
||||
from ..authsession import session_key
|
||||
from ..globals import db
|
||||
from . import hostutil, permutil, tokens
|
||||
from paskia import aaguid
|
||||
from paskia.authsession import session_key
|
||||
from paskia.globals import db
|
||||
from paskia.util import hostutil, permutil, tokens, useragent
|
||||
|
||||
|
||||
def _format_datetime(dt):
|
||||
@@ -1,3 +0,0 @@
|
||||
from .sansio import Passkey
|
||||
|
||||
__all__ = ["Passkey"]
|
||||
@@ -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)
|
||||
@@ -1,3 +0,0 @@
|
||||
from .mainapp import app
|
||||
|
||||
__all__ = ["app"]
|
||||
@@ -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())
|
||||
@@ -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
@@ -3,9 +3,10 @@ requires = ["hatchling", "hatch-vcs"]
|
||||
build-backend = "hatchling.build"
|
||||
|
||||
[project]
|
||||
name = "passkey"
|
||||
name = "paskia"
|
||||
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 = [
|
||||
{name = "Leo Vasanko"},
|
||||
]
|
||||
@@ -26,11 +27,40 @@ requires-python = ">=3.10"
|
||||
source = "vcs"
|
||||
|
||||
[tool.hatch.build.hooks.vcs]
|
||||
version-file = "passkey/_version.py"
|
||||
version-file = "paskia/_version.py"
|
||||
|
||||
[project.optional-dependencies]
|
||||
dev = [
|
||||
"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]
|
||||
@@ -40,11 +70,21 @@ line-length = 88
|
||||
[tool.ruff.lint]
|
||||
select = ["E", "F", "I", "N", "W", "UP"]
|
||||
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]
|
||||
passkey-auth = "passkey.fastapi.__main__:main"
|
||||
paskia = "paskia.fastapi.__main__:main"
|
||||
|
||||
[tool.hatch.build]
|
||||
artifacts = ["passkey/frontend-build"]
|
||||
artifacts = ["paskia/frontend-build"]
|
||||
targets.sdist.hooks.custom.path = "scripts/build-frontend.py"
|
||||
|
||||
Executable
+463
@@ -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()
|
||||
@@ -0,0 +1 @@
|
||||
# Paskia API Tests
|
||||
@@ -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
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user