Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
83419d1845 | ||
|
|
a2fe0b6f1a | ||
|
|
a1b73711e6 | ||
|
|
df5c176bcd | ||
|
|
8937905c9c | ||
|
|
127e06179b | ||
|
|
c1204ca020 | ||
|
|
208115ebc3 | ||
|
|
8609f2fe69 | ||
|
|
0355c55fc0 |
+5
-4
@@ -3,7 +3,8 @@ dist/
|
|||||||
.*
|
.*
|
||||||
!.gitignore
|
!.gitignore
|
||||||
*.lock
|
*.lock
|
||||||
passkey-auth.sqlite
|
paskia.sqlite
|
||||||
/passkey/frontend-build
|
/paskia/frontend-build
|
||||||
/test_*.py
|
/paskia/_version.py
|
||||||
passkey/_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
|
service and how they behave depending on whether a dedicated authentication host
|
||||||
(`--auth-host` / environment `PASSKEY_AUTH_HOST`) is configured.
|
(`--auth-host` / environment `PASSKEY_AUTH_HOST`) is configured.
|
||||||
|
|
||||||
|
|||||||
@@ -1,129 +1,72 @@
|
|||||||
# PasskeyAuth
|
# Paskia
|
||||||
|
|
||||||
A minimal FastAPI WebAuthn server with WebSocket support for passkey registration. This project demonstrates WebAuthn registration flow with Resident Keys (discoverable credentials) using modern Python tooling.
|
An easy to install passkey-based authentication service that protects any web application with strong passwordless login.
|
||||||
|
|
||||||
## Features
|
## What is Paskia?
|
||||||
|
|
||||||
- 🔐 WebAuthn registration with Resident Keys support
|
- Easy to use fully featured auth&auth system (login and permissions)
|
||||||
- 🔌 WebSocket-based communication for real-time interaction
|
- Organization and role-based access control (optional)
|
||||||
- 🚀 Modern Python packaging with `pyproject.toml`
|
* Org admins control their users and roles
|
||||||
- 🎨 Clean, responsive HTML interface using @simplewebauthn/browser
|
* Master admin can create multiple independent orgs
|
||||||
- 📦 No database required - challenges stored locally per connection
|
* Master admin makes permissions available for orgs to assign
|
||||||
- 🛠️ Development tools: `ruff` for linting and formatting
|
- User Profile and Administration by API and web interface.
|
||||||
- 🧹 Clean architecture with local challenge management
|
under `/auth/` or `auth.example.com`
|
||||||
|
- Reset tokens and additional device linking via QR code or codewords.
|
||||||
|
- Pure Python, FastAPI, packaged with prebuilt Vue frontend
|
||||||
|
|
||||||
## Docs
|
Two interfaces:
|
||||||
|
- API fetch: auth checks and login without leaving your app
|
||||||
|
- Forward-auth proxy: protect any unprotected site or service (Caddy, Nginx)
|
||||||
|
|
||||||
- Caddy integration: see `CADDY.md` for short, copy-paste snippets to secure your site with Caddy.
|
The API mode is useful for applications that can be customized to run with Paskia. Forward auth can also protect your javascript and other assets. Each provides fine-grained permission control and reauthentication requests where needed, and both can be mixed where needed.
|
||||||
|
|
||||||
## Requirements
|
Single Sign-On (SSO): Users register once and authenticate across all applications under your domain name (configured rp-id).
|
||||||
|
|
||||||
- Python 3.9+
|
|
||||||
- A WebAuthn-compatible authenticator (security key, biometric device, etc.)
|
|
||||||
|
|
||||||
## Quick Start
|
## Quick Start
|
||||||
|
|
||||||
### Install (editable dev mode)
|
Install [UV](https://docs.astral.sh/uv/getting-started/installation/) and run:
|
||||||
|
|
||||||
```fish
|
```fish
|
||||||
uv pip install -e .[dev]
|
uvx paskia serve --rp-id example.com
|
||||||
```
|
```
|
||||||
|
|
||||||
### Run (new CLI)
|
On the first run it downloads the software and prints a registration link for the Admin. If you are going to be connecting `localhost` directly, for testing, leave out the rp-id.
|
||||||
|
|
||||||
`passkey-auth` now provides subcommands:
|
The server will start up on [localhost:4401](http://localhost:4401) "for authentication required", serving for `*.example.com`.
|
||||||
|
|
||||||
|
Otherwise you will need a web server such as [Caddy](https://caddyserver.com/) to serve HTTPS on your actual domain names and proxy requests to Paskia and your backend apps.
|
||||||
|
|
||||||
|
A quick example without any config file:
|
||||||
|
```fish
|
||||||
|
sudo caddy reverse-proxy --from example.com --to :4401
|
||||||
|
```
|
||||||
|
|
||||||
|
For a permanent install of `paskia` CLI command, not needing `uvx`:
|
||||||
|
|
||||||
|
```fish
|
||||||
|
uv tool install paskia
|
||||||
|
```
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
There is no config file. Pass only the options on CLI:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
passkey-auth serve [host:port] [--options]
|
paskia serve [options]
|
||||||
passkey-auth dev [--options]
|
|
||||||
```
|
```
|
||||||
|
|
||||||
Examples (fish shell shown):
|
Optional options:
|
||||||
|
|
||||||
```fish
|
- Listen address (one of):
|
||||||
# Production style (no reload)
|
* `[host]:port`: Address and port (default: `localhost:4401`)
|
||||||
passkey-auth serve
|
* `unix:/path.sock`: Unix socket
|
||||||
passkey-auth serve 0.0.0.0:8080 --rp-id example.com --origin https://example.com
|
- `--rp-id <domain>`: Main domain (required for production)
|
||||||
|
- `--rp-name "<text>"`: Name of your company or site (default: same as rp-id)
|
||||||
|
- `--origin <url>`: Explicit single site (default: `https://<rp-id>`)
|
||||||
|
- `--auth-host <domain>`: Dedicated authentication site (e.g., `auth.example.com`)
|
||||||
|
|
||||||
# Development (auto-reload)
|
## Documentation
|
||||||
passkey-auth dev # localhost:4401
|
|
||||||
passkey-auth dev :5500 # localhost on port 5500
|
|
||||||
passkey-auth dev 127.0.0.1 # host only, default port 4401
|
|
||||||
```
|
|
||||||
|
|
||||||
Available options (both subcommands):
|
- `API.md`: Complete HTTP and WebSocket API reference
|
||||||
|
- `Caddy.md`: Caddy configuration examples
|
||||||
```text
|
- `Headers.md`: HTTP headers passed to protected applications
|
||||||
--rp-id <id> Relying Party ID (default: localhost)
|
|
||||||
--rp-name <name> Relying Party name (default: same as rp-id)
|
|
||||||
--origin <url> Explicit origin (default: https://<rp-id>)
|
|
||||||
```
|
|
||||||
|
|
||||||
### Legacy Invocation
|
|
||||||
|
|
||||||
If you previously used `python -m passkey.fastapi --dev --host ...`, switch to the new form above. The old flags `--host`, `--port`, and `--dev` are replaced by the `[host:port]` positional and the `dev` subcommand.
|
|
||||||
|
|
||||||
## Usage (Web)
|
|
||||||
|
|
||||||
1. Start the server with one of the commands above
|
|
||||||
2. Open your browser to `http://localhost:4401/auth/` (or your chosen host/port)
|
|
||||||
3. Enter a username (or use the default)
|
|
||||||
4. Click "Register Passkey"
|
|
||||||
5. Follow your authenticator's prompts
|
|
||||||
|
|
||||||
Real-time status updates stream over WebSocket.
|
|
||||||
|
|
||||||
## Development
|
|
||||||
|
|
||||||
### Code Quality
|
|
||||||
|
|
||||||
```fish
|
|
||||||
# Run linting and formatting with ruff
|
|
||||||
uv run ruff check .
|
|
||||||
uv run ruff format .
|
|
||||||
|
|
||||||
# Or with hatch
|
|
||||||
hatch run ruff check .
|
|
||||||
hatch run ruff format .
|
|
||||||
```
|
|
||||||
|
|
||||||
### Project Structure
|
|
||||||
|
|
||||||
```
|
|
||||||
passkeyauth/
|
|
||||||
├── passkeyauth/
|
|
||||||
│ ├── __init__.py
|
|
||||||
│ └── main.py # FastAPI server with WebSocket support
|
|
||||||
├── static/
|
|
||||||
│ └── index.html # Frontend interface
|
|
||||||
├── pyproject.toml # Modern Python packaging configuration
|
|
||||||
└── README.md
|
|
||||||
```
|
|
||||||
|
|
||||||
## Technical Details
|
|
||||||
|
|
||||||
### WebAuthn Configuration
|
|
||||||
|
|
||||||
- **Relying Party ID**: `localhost` (for development)
|
|
||||||
- **Resident Keys**: Required (enables discoverable credentials)
|
|
||||||
- **User Verification**: Preferred
|
|
||||||
- **Supported Algorithms**: ECDSA-SHA256, RSASSA-PKCS1-v1_5-SHA256
|
|
||||||
|
|
||||||
### WebSocket Message Flow
|
|
||||||
|
|
||||||
1. Client connects to `/ws/{client_id}`
|
|
||||||
2. Client sends `registration_challenge` message
|
|
||||||
3. Server responds with `registration_challenge_response`
|
|
||||||
4. Client completes WebAuthn ceremony and sends `registration_response`
|
|
||||||
5. Server verifies and responds with `registration_success` or `error`
|
|
||||||
|
|
||||||
### Security Notes
|
|
||||||
|
|
||||||
- This is a minimal demo - challenges are stored locally per WebSocket connection
|
|
||||||
- For production use, implement proper user storage and session management
|
|
||||||
- Consider using Redis or similar for challenge storage in production with multiple server instances
|
|
||||||
- Ensure HTTPS in production environments
|
|
||||||
|
|
||||||
## License
|
|
||||||
|
|
||||||
MIT License - feel free to use this as a starting point for your own WebAuthn implementations!
|
|
||||||
|
|||||||
+14
-4
@@ -1,6 +1,6 @@
|
|||||||
# PasskeyAuth E2E Tests
|
# Paskia E2E Tests
|
||||||
|
|
||||||
End-to-end tests for PasskeyAuth using [Playwright](https://playwright.dev/) with Chrome's **Virtual Authenticator**.
|
End-to-end tests for Paskia using [Playwright](https://playwright.dev/) with Chrome's **Virtual Authenticator**.
|
||||||
|
|
||||||
## Overview
|
## Overview
|
||||||
|
|
||||||
@@ -33,10 +33,20 @@ npm test
|
|||||||
```
|
```
|
||||||
|
|
||||||
This will:
|
This will:
|
||||||
1. Start a fresh PasskeyAuth server with a test database
|
1. Start a fresh Paskia server with a test database
|
||||||
2. Run all E2E tests against it
|
2. Run all E2E tests against it
|
||||||
3. Clean up the server when done
|
3. Clean up the server when done
|
||||||
|
|
||||||
|
### With Coverage
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run test:coverage
|
||||||
|
```
|
||||||
|
|
||||||
|
Runs tests and collects coverage for both:
|
||||||
|
- **Python backend** (via `coverage.py`) - HTML report in `coverage-html/`
|
||||||
|
- **Frontend JavaScript** (via Chrome V8 coverage) - JSON data in `e2e/coverage-frontend/`
|
||||||
|
|
||||||
### Interactive Mode
|
### Interactive Mode
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -125,7 +135,7 @@ This creates an in-browser authenticator that:
|
|||||||
|
|
||||||
| Variable | Description | Default |
|
| Variable | Description | Default |
|
||||||
|----------|-------------|---------|
|
|----------|-------------|---------|
|
||||||
| `BASE_URL` | Server URL | `http://localhost:4401` |
|
| `BASE_URL` | Server URL | `http://localhost:4404` |
|
||||||
| `CI` | CI environment flag | - |
|
| `CI` | CI environment flag | - |
|
||||||
| `CLEANUP_TEST_DB` | Remove test DB after run | `false` |
|
| `CLEANUP_TEST_DB` | Remove test DB after run | `false` |
|
||||||
|
|
||||||
|
|||||||
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",
|
"version": "1.0.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"description": "E2E tests for PasskeyAuth using Playwright with Virtual Authenticator",
|
"description": "E2E tests for Paskia using Playwright with Virtual Authenticator",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"test": "bunx playwright test",
|
"test": "bunx playwright test",
|
||||||
"test:headed": "bunx playwright test --headed",
|
"test:headed": "bunx playwright test --headed",
|
||||||
"test:debug": "bunx playwright test --debug",
|
"test:debug": "bunx playwright test --debug",
|
||||||
"test:ui": "bunx playwright test --ui",
|
"test:ui": "bunx playwright test --ui",
|
||||||
|
"test:coverage": "COVERAGE=1 bunx playwright test",
|
||||||
"report": "bunx playwright show-report",
|
"report": "bunx playwright show-report",
|
||||||
"install:browsers": "bunx playwright install chromium"
|
"install:browsers": "bunx playwright install chromium"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@playwright/test": "^1.49.0",
|
"@playwright/test": "^1.49.0",
|
||||||
"@simplewebauthn/browser": "^13.1.2",
|
"@simplewebauthn/browser": "^13.1.2",
|
||||||
"@types/bun": "^1.3.3"
|
"@types/bun": "^1.3.3",
|
||||||
|
"c8": "^10.1.3"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,7 +1,7 @@
|
|||||||
import { defineConfig, devices } from '@playwright/test'
|
import { defineConfig, devices } from '@playwright/test'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Playwright configuration for PasskeyAuth E2E tests.
|
* Playwright configuration for Paskia E2E tests.
|
||||||
* Uses Chrome's Virtual Authenticator for automated passkey testing.
|
* Uses Chrome's Virtual Authenticator for automated passkey testing.
|
||||||
*
|
*
|
||||||
* Run with: bun run test
|
* Run with: bun run test
|
||||||
@@ -23,7 +23,7 @@ export default defineConfig({
|
|||||||
globalTeardown: './tests/global-teardown.ts',
|
globalTeardown: './tests/global-teardown.ts',
|
||||||
|
|
||||||
use: {
|
use: {
|
||||||
// Base URL for the passkey-auth server
|
// Base URL for the Paskia server
|
||||||
baseURL: process.env.BASE_URL || 'http://localhost:4401',
|
baseURL: process.env.BASE_URL || 'http://localhost:4401',
|
||||||
|
|
||||||
// Collect trace on failure for debugging
|
// Collect trace on failure for debugging
|
||||||
@@ -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)
|
||||||
|
},
|
||||||
|
})
|
||||||
+158
-24
@@ -1,9 +1,10 @@
|
|||||||
import { type Page } from '@playwright/test'
|
import { type Page } from '@playwright/test'
|
||||||
import { existsSync, readFileSync } from 'fs'
|
import { existsSync, readFileSync, writeFileSync } from 'fs'
|
||||||
import { join, dirname } from 'path'
|
import { join, dirname } from 'path'
|
||||||
import { fileURLToPath } from 'url'
|
import { fileURLToPath } from 'url'
|
||||||
|
|
||||||
const __dirname = dirname(fileURLToPath(import.meta.url))
|
const __dirname = dirname(fileURLToPath(import.meta.url))
|
||||||
|
const stateFile = join(__dirname, '..', '..', 'test-data', 'test-state.json')
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* WebSocket helpers for passkey registration and authentication.
|
* WebSocket helpers for passkey registration and authentication.
|
||||||
@@ -26,7 +27,6 @@ export interface AuthenticationResult {
|
|||||||
* Get the bootstrap reset token from the test state file.
|
* Get the bootstrap reset token from the test state file.
|
||||||
*/
|
*/
|
||||||
export function getBootstrapResetToken(): string | undefined {
|
export function getBootstrapResetToken(): string | undefined {
|
||||||
const stateFile = join(__dirname, '..', '..', 'test-data', 'test-state.json')
|
|
||||||
if (existsSync(stateFile)) {
|
if (existsSync(stateFile)) {
|
||||||
try {
|
try {
|
||||||
const state = JSON.parse(readFileSync(stateFile, 'utf-8'))
|
const state = JSON.parse(readFileSync(stateFile, 'utf-8'))
|
||||||
@@ -38,6 +38,118 @@ export function getBootstrapResetToken(): string | undefined {
|
|||||||
return undefined
|
return undefined
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the session cookie name from the test state file.
|
||||||
|
*/
|
||||||
|
export function getSessionCookieName(): string {
|
||||||
|
if (existsSync(stateFile)) {
|
||||||
|
try {
|
||||||
|
const state = JSON.parse(readFileSync(stateFile, 'utf-8'))
|
||||||
|
return state.sessionCookie || '__Host-auth'
|
||||||
|
} catch {
|
||||||
|
return '__Host-auth'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return '__Host-auth'
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Save a session token to the test state file for sharing across test groups.
|
||||||
|
*/
|
||||||
|
export function saveSessionToken(sessionToken: string): void {
|
||||||
|
if (existsSync(stateFile)) {
|
||||||
|
try {
|
||||||
|
const state = JSON.parse(readFileSync(stateFile, 'utf-8'))
|
||||||
|
state.savedSessionToken = sessionToken
|
||||||
|
writeFileSync(stateFile, JSON.stringify(state, null, 2))
|
||||||
|
} catch {
|
||||||
|
// Ignore errors
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clear the saved session token from the test state file.
|
||||||
|
* Call this after logout to prevent accidental reuse of invalidated sessions.
|
||||||
|
*/
|
||||||
|
export function clearSavedSessionToken(): void {
|
||||||
|
if (existsSync(stateFile)) {
|
||||||
|
try {
|
||||||
|
const state = JSON.parse(readFileSync(stateFile, 'utf-8'))
|
||||||
|
delete state.savedSessionToken
|
||||||
|
writeFileSync(stateFile, JSON.stringify(state, null, 2))
|
||||||
|
} catch {
|
||||||
|
// Ignore errors
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get a saved session token from the test state file.
|
||||||
|
*/
|
||||||
|
export function getSavedSessionToken(): string | undefined {
|
||||||
|
if (existsSync(stateFile)) {
|
||||||
|
try {
|
||||||
|
const state = JSON.parse(readFileSync(stateFile, 'utf-8'))
|
||||||
|
return state.savedSessionToken
|
||||||
|
} catch {
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Save device tokens to the test state file for use by other tests.
|
||||||
|
* These tokens allow tests to register their own passkeys.
|
||||||
|
*/
|
||||||
|
export function saveDeviceTokens(tokens: string[]): void {
|
||||||
|
if (existsSync(stateFile)) {
|
||||||
|
try {
|
||||||
|
const state = JSON.parse(readFileSync(stateFile, 'utf-8'))
|
||||||
|
state.deviceTokens = tokens
|
||||||
|
writeFileSync(stateFile, JSON.stringify(state, null, 2))
|
||||||
|
} catch {
|
||||||
|
// Ignore errors
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get and consume a device token from the pool.
|
||||||
|
* Returns undefined if no tokens are available.
|
||||||
|
*/
|
||||||
|
export function popDeviceToken(): string | undefined {
|
||||||
|
if (existsSync(stateFile)) {
|
||||||
|
try {
|
||||||
|
const state = JSON.parse(readFileSync(stateFile, 'utf-8'))
|
||||||
|
if (state.deviceTokens && state.deviceTokens.length > 0) {
|
||||||
|
const token = state.deviceTokens.pop()
|
||||||
|
writeFileSync(stateFile, JSON.stringify(state, null, 2))
|
||||||
|
return token
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the count of remaining device tokens.
|
||||||
|
*/
|
||||||
|
export function getDeviceTokenCount(): number {
|
||||||
|
if (existsSync(stateFile)) {
|
||||||
|
try {
|
||||||
|
const state = JSON.parse(readFileSync(stateFile, 'utf-8'))
|
||||||
|
return state.deviceTokens?.length || 0
|
||||||
|
} catch {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Perform passkey registration via WebSocket.
|
* Perform passkey registration via WebSocket.
|
||||||
* This runs in the browser context using the virtual authenticator.
|
* This runs in the browser context using the virtual authenticator.
|
||||||
@@ -79,30 +191,33 @@ export async function registerPasskey(
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// This should be the registration options from server
|
// This should be the registration options from server (wrapped in optionsJSON)
|
||||||
// Use the native WebAuthn API with the virtual authenticator
|
// Use the native WebAuthn API with the virtual authenticator
|
||||||
try {
|
try {
|
||||||
|
// Extract options from the optionsJSON wrapper
|
||||||
|
const opts = data.optionsJSON
|
||||||
|
|
||||||
// Convert base64url challenge to ArrayBuffer
|
// Convert base64url challenge to ArrayBuffer
|
||||||
const challenge = Uint8Array.from(atob(data.challenge.replace(/-/g, '+').replace(/_/g, '/')), c => c.charCodeAt(0))
|
const challenge = Uint8Array.from(atob(opts.challenge.replace(/-/g, '+').replace(/_/g, '/')), c => c.charCodeAt(0))
|
||||||
|
|
||||||
// Build the credential creation options
|
// Build the credential creation options
|
||||||
const publicKeyCredentialCreationOptions: CredentialCreationOptions = {
|
const publicKeyCredentialCreationOptions: CredentialCreationOptions = {
|
||||||
publicKey: {
|
publicKey: {
|
||||||
challenge: challenge,
|
challenge: challenge,
|
||||||
rp: {
|
rp: {
|
||||||
name: data.rp.name,
|
name: opts.rp.name,
|
||||||
id: data.rp.id,
|
id: opts.rp.id,
|
||||||
},
|
},
|
||||||
user: {
|
user: {
|
||||||
id: Uint8Array.from(atob(data.user.id.replace(/-/g, '+').replace(/_/g, '/')), c => c.charCodeAt(0)),
|
id: Uint8Array.from(atob(opts.user.id.replace(/-/g, '+').replace(/_/g, '/')), c => c.charCodeAt(0)),
|
||||||
name: data.user.name,
|
name: opts.user.name,
|
||||||
displayName: data.user.displayName,
|
displayName: opts.user.displayName,
|
||||||
},
|
},
|
||||||
pubKeyCredParams: data.pubKeyCredParams,
|
pubKeyCredParams: opts.pubKeyCredParams,
|
||||||
authenticatorSelection: data.authenticatorSelection,
|
authenticatorSelection: opts.authenticatorSelection,
|
||||||
timeout: data.timeout,
|
timeout: opts.timeout,
|
||||||
attestation: data.attestation,
|
attestation: opts.attestation,
|
||||||
excludeCredentials: data.excludeCredentials?.map((cred: any) => ({
|
excludeCredentials: opts.excludeCredentials?.map((cred: any) => ({
|
||||||
...cred,
|
...cred,
|
||||||
id: Uint8Array.from(atob(cred.id.replace(/-/g, '+').replace(/_/g, '/')), c => c.charCodeAt(0)),
|
id: Uint8Array.from(atob(cred.id.replace(/-/g, '+').replace(/_/g, '/')), c => c.charCodeAt(0)),
|
||||||
})) || [],
|
})) || [],
|
||||||
@@ -187,19 +302,22 @@ export async function authenticatePasskey(
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// This should be the authentication options from server
|
// This should be the authentication options from server (wrapped in optionsJSON)
|
||||||
try {
|
try {
|
||||||
|
// Extract options from the optionsJSON wrapper
|
||||||
|
const opts = data.optionsJSON
|
||||||
|
|
||||||
// Convert base64url challenge to ArrayBuffer
|
// Convert base64url challenge to ArrayBuffer
|
||||||
const challenge = Uint8Array.from(atob(data.challenge.replace(/-/g, '+').replace(/_/g, '/')), c => c.charCodeAt(0))
|
const challenge = Uint8Array.from(atob(opts.challenge.replace(/-/g, '+').replace(/_/g, '/')), c => c.charCodeAt(0))
|
||||||
|
|
||||||
// Build the credential request options
|
// Build the credential request options
|
||||||
const publicKeyCredentialRequestOptions: CredentialRequestOptions = {
|
const publicKeyCredentialRequestOptions: CredentialRequestOptions = {
|
||||||
publicKey: {
|
publicKey: {
|
||||||
challenge: challenge,
|
challenge: challenge,
|
||||||
rpId: data.rpId,
|
rpId: opts.rpId,
|
||||||
timeout: data.timeout,
|
timeout: opts.timeout,
|
||||||
userVerification: data.userVerification,
|
userVerification: opts.userVerification,
|
||||||
allowCredentials: data.allowCredentials?.map((cred: any) => ({
|
allowCredentials: opts.allowCredentials?.map((cred: any) => ({
|
||||||
type: cred.type,
|
type: cred.type,
|
||||||
id: Uint8Array.from(atob(cred.id.replace(/-/g, '+').replace(/_/g, '/')), c => c.charCodeAt(0)),
|
id: Uint8Array.from(atob(cred.id.replace(/-/g, '+').replace(/_/g, '/')), c => c.charCodeAt(0)),
|
||||||
transports: cred.transports,
|
transports: cred.transports,
|
||||||
@@ -259,9 +377,10 @@ export async function validateSession(
|
|||||||
baseUrl: string,
|
baseUrl: string,
|
||||||
sessionToken: string
|
sessionToken: string
|
||||||
): Promise<{ valid: boolean; user_uuid: string; renewed: boolean }> {
|
): Promise<{ valid: boolean; user_uuid: string; renewed: boolean }> {
|
||||||
|
const cookieName = getSessionCookieName()
|
||||||
const response = await page.request.post(`${baseUrl}/auth/api/validate`, {
|
const response = await page.request.post(`${baseUrl}/auth/api/validate`, {
|
||||||
headers: {
|
headers: {
|
||||||
'Cookie': `__Host-auth=${sessionToken}`,
|
'Cookie': `${cookieName}=${sessionToken}`,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
return await response.json()
|
return await response.json()
|
||||||
@@ -275,9 +394,10 @@ export async function getUserInfo(
|
|||||||
baseUrl: string,
|
baseUrl: string,
|
||||||
sessionToken: string
|
sessionToken: string
|
||||||
): Promise<any> {
|
): Promise<any> {
|
||||||
|
const cookieName = getSessionCookieName()
|
||||||
const response = await page.request.post(`${baseUrl}/auth/api/user-info`, {
|
const response = await page.request.post(`${baseUrl}/auth/api/user-info`, {
|
||||||
headers: {
|
headers: {
|
||||||
'Cookie': `__Host-auth=${sessionToken}`,
|
'Cookie': `${cookieName}=${sessionToken}`,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
return await response.json()
|
return await response.json()
|
||||||
@@ -285,17 +405,24 @@ export async function getUserInfo(
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Logout via the API.
|
* Logout via the API.
|
||||||
|
* If the session being logged out matches the saved session token, clears it.
|
||||||
*/
|
*/
|
||||||
export async function logout(
|
export async function logout(
|
||||||
page: Page,
|
page: Page,
|
||||||
baseUrl: string,
|
baseUrl: string,
|
||||||
sessionToken: string
|
sessionToken: string
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
|
const cookieName = getSessionCookieName()
|
||||||
await page.request.post(`${baseUrl}/auth/api/logout`, {
|
await page.request.post(`${baseUrl}/auth/api/logout`, {
|
||||||
headers: {
|
headers: {
|
||||||
'Cookie': `__Host-auth=${sessionToken}`,
|
'Cookie': `${cookieName}=${sessionToken}`,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
// Clear saved session token if it matches the one being logged out
|
||||||
|
const savedToken = getSavedSessionToken()
|
||||||
|
if (savedToken === sessionToken) {
|
||||||
|
clearSavedSessionToken()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -306,12 +433,19 @@ export async function createDeviceLink(
|
|||||||
baseUrl: string,
|
baseUrl: string,
|
||||||
sessionToken: string
|
sessionToken: string
|
||||||
): Promise<{ url: string; token: string }> {
|
): Promise<{ url: string; token: string }> {
|
||||||
|
const cookieName = getSessionCookieName()
|
||||||
const response = await page.request.post(`${baseUrl}/auth/api/user/create-link`, {
|
const response = await page.request.post(`${baseUrl}/auth/api/user/create-link`, {
|
||||||
headers: {
|
headers: {
|
||||||
'Cookie': `__Host-auth=${sessionToken}`,
|
'Cookie': `${cookieName}=${sessionToken}`,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
if (!response.ok()) {
|
||||||
|
throw new Error(`Failed to create device link: ${response.status()} - ${await response.text()}`)
|
||||||
|
}
|
||||||
const data = await response.json()
|
const data = await response.json()
|
||||||
|
if (!data.url) {
|
||||||
|
throw new Error(`No URL in response: ${JSON.stringify(data)}`)
|
||||||
|
}
|
||||||
// Extract token from URL (last path segment)
|
// Extract token from URL (last path segment)
|
||||||
const url = new URL(data.url)
|
const url = new URL(data.url)
|
||||||
const token = url.pathname.split('/').pop() || ''
|
const token = url.pathname.split('/').pop() || ''
|
||||||
|
|||||||
+53
-2
@@ -1,4 +1,13 @@
|
|||||||
import { test as base, expect, type CDPSession, type Page } from '@playwright/test'
|
import { test as base, expect, type CDPSession, type Page } from '@playwright/test'
|
||||||
|
import { existsSync, mkdirSync, writeFileSync } from 'fs'
|
||||||
|
import { join, dirname } from 'path'
|
||||||
|
import { fileURLToPath } from 'url'
|
||||||
|
|
||||||
|
const __dirname = dirname(fileURLToPath(import.meta.url))
|
||||||
|
const coverageDir = join(__dirname, '..', '..', 'coverage-frontend')
|
||||||
|
|
||||||
|
// Check if frontend coverage is enabled
|
||||||
|
const COLLECT_COVERAGE = process.env.COVERAGE === '1' || process.env.COVERAGE === 'true'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Virtual Authenticator configuration for WebAuthn testing.
|
* Virtual Authenticator configuration for WebAuthn testing.
|
||||||
@@ -73,12 +82,27 @@ export async function getCredentials(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Extended test fixture with virtual authenticator support.
|
* Extended test fixture with virtual authenticator support and optional coverage.
|
||||||
*/
|
*/
|
||||||
export const test = base.extend<{
|
export const test = base.extend<{
|
||||||
virtualAuthenticator: VirtualAuthenticator
|
virtualAuthenticator: VirtualAuthenticator
|
||||||
}>({
|
}>({
|
||||||
virtualAuthenticator: async ({ page }, use) => {
|
virtualAuthenticator: async ({ page }, use, testInfo) => {
|
||||||
|
// Start coverage collection if enabled
|
||||||
|
let coverageCdp: CDPSession | null = null
|
||||||
|
if (COLLECT_COVERAGE) {
|
||||||
|
try {
|
||||||
|
coverageCdp = await page.context().newCDPSession(page)
|
||||||
|
await coverageCdp.send('Profiler.enable')
|
||||||
|
await coverageCdp.send('Profiler.startPreciseCoverage', {
|
||||||
|
callCount: true,
|
||||||
|
detailed: true,
|
||||||
|
})
|
||||||
|
} catch {
|
||||||
|
coverageCdp = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Create virtual authenticator before test
|
// Create virtual authenticator before test
|
||||||
const authenticator = await createVirtualAuthenticator(page)
|
const authenticator = await createVirtualAuthenticator(page)
|
||||||
|
|
||||||
@@ -87,6 +111,33 @@ export const test = base.extend<{
|
|||||||
|
|
||||||
// Cleanup after test
|
// Cleanup after test
|
||||||
await removeVirtualAuthenticator(authenticator)
|
await removeVirtualAuthenticator(authenticator)
|
||||||
|
|
||||||
|
// Stop and save coverage
|
||||||
|
if (coverageCdp) {
|
||||||
|
try {
|
||||||
|
const { result } = await coverageCdp.send('Profiler.takePreciseCoverage')
|
||||||
|
await coverageCdp.send('Profiler.stopPreciseCoverage')
|
||||||
|
await coverageCdp.send('Profiler.disable')
|
||||||
|
|
||||||
|
// Filter to only include our app's JavaScript files
|
||||||
|
const appCoverage = result.filter((entry: any) =>
|
||||||
|
entry.url.includes('/auth/') &&
|
||||||
|
entry.url.endsWith('.js') &&
|
||||||
|
!entry.url.includes('node_modules')
|
||||||
|
)
|
||||||
|
|
||||||
|
if (appCoverage.length > 0) {
|
||||||
|
if (!existsSync(coverageDir)) {
|
||||||
|
mkdirSync(coverageDir, { recursive: true })
|
||||||
|
}
|
||||||
|
const safeName = testInfo.title.replace(/[^a-z0-9]/gi, '_').substring(0, 50)
|
||||||
|
const coverageFile = join(coverageDir, `coverage-${safeName}-${Date.now()}.json`)
|
||||||
|
writeFileSync(coverageFile, JSON.stringify(appCoverage, null, 2))
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Silently ignore coverage collection errors
|
||||||
|
}
|
||||||
|
}
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
+44
-25
@@ -1,57 +1,63 @@
|
|||||||
import { spawn } from 'child_process'
|
import { spawn } from 'child_process'
|
||||||
import { join, dirname } from 'path'
|
import { join, dirname } from 'path'
|
||||||
import { existsSync, mkdirSync, rmSync, writeFileSync } from 'fs'
|
import { existsSync, mkdirSync, writeFileSync } from 'fs'
|
||||||
import { fileURLToPath } from 'url'
|
import { fileURLToPath } from 'url'
|
||||||
|
|
||||||
const __dirname = dirname(fileURLToPath(import.meta.url))
|
const __dirname = dirname(fileURLToPath(import.meta.url))
|
||||||
const testDataDir = join(__dirname, '..', 'test-data')
|
const testDataDir = join(__dirname, '..', 'test-data')
|
||||||
const stateFile = join(testDataDir, 'test-state.json')
|
const stateFile = join(testDataDir, 'test-state.json')
|
||||||
const dbPath = join(testDataDir, 'test.sqlite')
|
const projectRoot = join(__dirname, '..', '..')
|
||||||
|
|
||||||
|
// Check if coverage is enabled
|
||||||
|
const COLLECT_COVERAGE = process.env.COVERAGE === '1' || process.env.COVERAGE === 'true'
|
||||||
|
|
||||||
interface TestState {
|
interface TestState {
|
||||||
resetToken?: string
|
resetToken?: string
|
||||||
serverPid?: number
|
serverPid?: number
|
||||||
|
sessionCookie?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Global setup for E2E tests.
|
* Global setup for E2E tests.
|
||||||
*
|
*
|
||||||
* This creates a fresh test database and starts the server,
|
* Uses in-memory SQLite database for fast, isolated tests.
|
||||||
* capturing the bootstrap reset token for initial user registration.
|
* Captures the bootstrap reset token for initial user registration.
|
||||||
*/
|
*/
|
||||||
export default async function globalSetup() {
|
export default async function globalSetup() {
|
||||||
console.log('\n🔧 Setting up E2E test environment...\n')
|
console.log('\n🔧 Setting up E2E test environment...\n')
|
||||||
|
|
||||||
// Create test data directory
|
// Create test data directory for state file
|
||||||
if (!existsSync(testDataDir)) {
|
if (!existsSync(testDataDir)) {
|
||||||
mkdirSync(testDataDir, { recursive: true })
|
mkdirSync(testDataDir, { recursive: true })
|
||||||
}
|
}
|
||||||
|
|
||||||
// Remove old database for clean state
|
console.log(' Starting server with in-memory database...')
|
||||||
if (existsSync(dbPath)) {
|
if (COLLECT_COVERAGE) {
|
||||||
console.log(' Removing old test database...')
|
console.log(' 📊 Coverage collection enabled for Python backend')
|
||||||
rmSync(dbPath)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Remove any wal/shm files too
|
|
||||||
for (const ext of ['-wal', '-shm']) {
|
|
||||||
const file = dbPath + ext
|
|
||||||
if (existsSync(file)) rmSync(file)
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log(' Starting server with fresh database...')
|
|
||||||
|
|
||||||
const state: TestState = {}
|
const state: TestState = {}
|
||||||
|
|
||||||
|
// Build server command - with or without coverage
|
||||||
|
const serverArgs = COLLECT_COVERAGE
|
||||||
|
? [
|
||||||
|
'run', 'coverage', 'run', '--parallel-mode',
|
||||||
|
'-m', 'paskia.fastapi', 'serve', 'localhost:4404',
|
||||||
|
'--rp-id', 'localhost'
|
||||||
|
]
|
||||||
|
: [
|
||||||
|
'run', 'paskia', 'serve', 'localhost:4404',
|
||||||
|
'--rp-id', 'localhost'
|
||||||
|
]
|
||||||
|
|
||||||
// Start the server using Node's spawn
|
// Start the server using Node's spawn
|
||||||
const serverProcess = spawn('uv', [
|
// Use in-memory SQLite for faster tests
|
||||||
'run', 'passkey-auth', 'serve', ':4401',
|
const serverProcess = spawn('uv', serverArgs, {
|
||||||
'--rp-id', 'localhost',
|
cwd: projectRoot,
|
||||||
'--origin', 'http://localhost:4401'
|
|
||||||
], {
|
|
||||||
cwd: testDataDir, // Run from test-data so DB is created there
|
|
||||||
env: {
|
env: {
|
||||||
...process.env,
|
...process.env,
|
||||||
|
PASKIA_DB: 'sqlite+aiosqlite:///:memory:',
|
||||||
|
COVERAGE_FILE: join(projectRoot, '.coverage'),
|
||||||
},
|
},
|
||||||
stdio: ['ignore', 'pipe', 'pipe'],
|
stdio: ['ignore', 'pipe', 'pipe'],
|
||||||
})
|
})
|
||||||
@@ -72,8 +78,9 @@ export default async function globalSetup() {
|
|||||||
process.stdout.write(text) // Echo to console
|
process.stdout.write(text) // Echo to console
|
||||||
|
|
||||||
// Look for the reset token URL in the output
|
// Look for the reset token URL in the output
|
||||||
// Format: http://localhost:4401/auth/{token} where token is word.word.word.word.word (dot separated)
|
// Format: https://localhost/auth/{token} or http://localhost:4404/auth/{token}
|
||||||
const match = output.match(/http:\/\/localhost:\d+\/auth\/([a-z]+(?:\.[a-z]+)+)/)
|
// where token is word.word.word.word.word (dot separated)
|
||||||
|
const match = output.match(/https?:\/\/localhost(?::\d+)?\/auth\/([a-z]+(?:\.[a-z]+)+)/)
|
||||||
if (match) {
|
if (match) {
|
||||||
clearTimeout(timeout)
|
clearTimeout(timeout)
|
||||||
// Wait a bit for server to fully start
|
// Wait a bit for server to fully start
|
||||||
@@ -106,6 +113,18 @@ export default async function globalSetup() {
|
|||||||
throw err
|
throw err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Fetch session cookie name from server settings
|
||||||
|
try {
|
||||||
|
const response = await fetch('http://localhost:4404/auth/api/settings')
|
||||||
|
const settings = await response.json()
|
||||||
|
state.sessionCookie = settings.session_cookie
|
||||||
|
console.log(` ✅ Session cookie name: ${state.sessionCookie}\n`)
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to fetch settings:', err)
|
||||||
|
serverProcess.kill()
|
||||||
|
throw err
|
||||||
|
}
|
||||||
|
|
||||||
// Save state for tests
|
// Save state for tests
|
||||||
writeFileSync(stateFile, JSON.stringify(state, null, 2))
|
writeFileSync(stateFile, JSON.stringify(state, null, 2))
|
||||||
|
|
||||||
|
|||||||
@@ -1,16 +1,30 @@
|
|||||||
import { join, dirname } from 'path'
|
import { join, dirname } from 'path'
|
||||||
import { existsSync, rmSync, readFileSync } from 'fs'
|
import { existsSync, rmSync, readFileSync, readdirSync, writeFileSync } from 'fs'
|
||||||
import { fileURLToPath } from 'url'
|
import { fileURLToPath } from 'url'
|
||||||
|
import { execSync } from 'child_process'
|
||||||
|
|
||||||
const __dirname = dirname(fileURLToPath(import.meta.url))
|
const __dirname = dirname(fileURLToPath(import.meta.url))
|
||||||
const testDataDir = join(__dirname, '..', 'test-data')
|
const testDataDir = join(__dirname, '..', 'test-data')
|
||||||
const stateFile = join(testDataDir, 'test-state.json')
|
const stateFile = join(testDataDir, 'test-state.json')
|
||||||
|
const projectRoot = join(__dirname, '..', '..')
|
||||||
|
const coverageDir = join(__dirname, '..', 'coverage-frontend')
|
||||||
|
|
||||||
|
// Check if coverage is enabled
|
||||||
|
const COLLECT_COVERAGE = process.env.COVERAGE === '1' || process.env.COVERAGE === 'true'
|
||||||
|
|
||||||
interface TestState {
|
interface TestState {
|
||||||
resetToken?: string
|
resetToken?: string
|
||||||
serverPid?: number
|
serverPid?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface CoverageEntry {
|
||||||
|
url: string
|
||||||
|
functions: Array<{
|
||||||
|
functionName: string
|
||||||
|
ranges: Array<{ count: number }>
|
||||||
|
}>
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Global teardown for E2E tests.
|
* Global teardown for E2E tests.
|
||||||
*
|
*
|
||||||
@@ -28,8 +42,8 @@ export default async function globalTeardown() {
|
|||||||
console.log(` Stopping server (PID: ${state.serverPid})...`)
|
console.log(` Stopping server (PID: ${state.serverPid})...`)
|
||||||
try {
|
try {
|
||||||
process.kill(state.serverPid, 'SIGTERM')
|
process.kill(state.serverPid, 'SIGTERM')
|
||||||
// Wait a moment for graceful shutdown
|
// Wait longer for graceful shutdown and coverage data flush
|
||||||
await new Promise(r => setTimeout(r, 500))
|
await new Promise(r => setTimeout(r, COLLECT_COVERAGE ? 2000 : 500))
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
// Process may already be dead
|
// Process may already be dead
|
||||||
if (err.code !== 'ESRCH') {
|
if (err.code !== 'ESRCH') {
|
||||||
@@ -59,5 +73,76 @@ export default async function globalTeardown() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Generate Python coverage report if coverage was collected
|
||||||
|
if (COLLECT_COVERAGE) {
|
||||||
|
console.log(' 📊 Generating Python coverage report...')
|
||||||
|
try {
|
||||||
|
// Combine parallel coverage data and generate reports
|
||||||
|
execSync('uv run coverage combine', { cwd: projectRoot, stdio: 'inherit' })
|
||||||
|
execSync('uv run coverage report', { cwd: projectRoot, stdio: 'inherit' })
|
||||||
|
execSync('uv run coverage html', { cwd: projectRoot, stdio: 'inherit' })
|
||||||
|
console.log(` ✅ Python coverage report: ${join(projectRoot, 'coverage-html', 'index.html')}\n`)
|
||||||
|
} catch (err: any) {
|
||||||
|
console.warn(` Warning: Failed to generate coverage report: ${err.message}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Merge and report frontend coverage
|
||||||
|
if (existsSync(coverageDir)) {
|
||||||
|
try {
|
||||||
|
const files = readdirSync(coverageDir).filter(f => f.startsWith('coverage-') && f.endsWith('.json') && f !== 'coverage-merged.json')
|
||||||
|
|
||||||
|
if (files.length > 0) {
|
||||||
|
const merged: Map<string, CoverageEntry> = new Map()
|
||||||
|
|
||||||
|
for (const file of files) {
|
||||||
|
const data: CoverageEntry[] = JSON.parse(readFileSync(join(coverageDir, file), 'utf-8'))
|
||||||
|
for (const entry of data) {
|
||||||
|
const existing = merged.get(entry.url)
|
||||||
|
if (!existing) {
|
||||||
|
merged.set(entry.url, entry)
|
||||||
|
} else {
|
||||||
|
// Merge function coverage counts
|
||||||
|
for (const func of entry.functions) {
|
||||||
|
const existingFunc = existing.functions.find(f => f.functionName === func.functionName)
|
||||||
|
if (existingFunc) {
|
||||||
|
for (let i = 0; i < func.ranges.length && i < existingFunc.ranges.length; i++) {
|
||||||
|
existingFunc.ranges[i].count += func.ranges[i].count
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
existing.functions.push(func)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Write merged coverage
|
||||||
|
writeFileSync(
|
||||||
|
join(coverageDir, 'coverage-merged.json'),
|
||||||
|
JSON.stringify(Array.from(merged.values()), null, 2)
|
||||||
|
)
|
||||||
|
|
||||||
|
// Generate simple coverage summary
|
||||||
|
let totalFunctions = 0
|
||||||
|
let coveredFunctions = 0
|
||||||
|
|
||||||
|
for (const entry of merged.values()) {
|
||||||
|
for (const func of entry.functions) {
|
||||||
|
totalFunctions++
|
||||||
|
const hasCoverage = func.ranges.some(r => r.count > 0)
|
||||||
|
if (hasCoverage) coveredFunctions++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const percentage = totalFunctions > 0 ? Math.round((coveredFunctions / totalFunctions) * 100) : 0
|
||||||
|
console.log(` 📊 Frontend JS Coverage: ${coveredFunctions}/${totalFunctions} functions (${percentage}%)`)
|
||||||
|
console.log(` ✅ Frontend coverage data: ${coverageDir}/coverage-merged.json\n`)
|
||||||
|
}
|
||||||
|
} catch (err: any) {
|
||||||
|
console.warn(` Warning: Failed to merge frontend coverage: ${err.message}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
console.log(' ✅ Cleanup complete\n')
|
console.log(' ✅ Cleanup complete\n')
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
<head>
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title>PassKey Auth - Dev Mode</title>
|
<title>Paskia - Dev Mode</title>
|
||||||
<style>
|
<style>
|
||||||
:root {
|
:root {
|
||||||
color-scheme: light dark; /* Automatic themes by browser */
|
color-scheme: light dark; /* Automatic themes by browser */
|
||||||
@@ -33,8 +33,8 @@
|
|||||||
<body>
|
<body>
|
||||||
<div class="container">
|
<div class="container">
|
||||||
<header>
|
<header>
|
||||||
<h1>🔐 PassKey Auth</h1>
|
<h1>🔐 Paskia - Development Server</h1>
|
||||||
<p class="subtitle">Development server demonstration page.</p>
|
<p class="subtitle">The following features are available after you have registered your Admin account and logged in. You should also use the Admin Site to create non-privileged users to see the Forbidden dialog caused by missing permissions.</p>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<div class="content">
|
<div class="content">
|
||||||
@@ -55,7 +55,7 @@
|
|||||||
|
|
||||||
<div class="section">
|
<div class="section">
|
||||||
<h2>Browser Mode (full page)</h2>
|
<h2>Browser Mode (full page)</h2>
|
||||||
<p>Block access to otherwise open site - intended for forward-auth mechanism (Caddy, Nxinx):</p>
|
<p>Block access to otherwise open site - intended for forward-auth mechanism (Caddy, Nginx):</p>
|
||||||
<button onclick="browserNav('/auth/api/forward')">🔐 Basic Auth</button>
|
<button onclick="browserNav('/auth/api/forward')">🔐 Basic Auth</button>
|
||||||
<button onclick="browserNav('/auth/api/forward?max_age=10s')">🔄 Reauth (max_age=10s)</button>
|
<button onclick="browserNav('/auth/api/forward?max_age=10s')">🔄 Reauth (max_age=10s)</button>
|
||||||
<button onclick="browserNav('/auth/api/forward?perm=auth:admin')">🛡️ Admin Only</button>
|
<button onclick="browserNav('/auth/api/forward?perm=auth:admin')">🛡️ Admin Only</button>
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ export default defineConfig(({ command }) => ({
|
|||||||
closeBundle() {
|
closeBundle() {
|
||||||
if (command !== 'build') return
|
if (command !== 'build') return
|
||||||
|
|
||||||
const outDir = resolve(__dirname, '../passkey/frontend-build')
|
const outDir = resolve(__dirname, '../paskia/frontend-build')
|
||||||
const moves = [
|
const moves = [
|
||||||
{ from: 'auth.html', to: 'auth/index.html' },
|
{ from: 'auth.html', to: 'auth/index.html' },
|
||||||
{ from: 'admin.html', to: 'admin/index.html' },
|
{ from: 'admin.html', to: 'admin/index.html' },
|
||||||
@@ -69,7 +69,7 @@ export default defineConfig(({ command }) => ({
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
build: {
|
build: {
|
||||||
outDir: '../passkey/frontend-build',
|
outDir: '../paskia/frontend-build',
|
||||||
emptyOutDir: true,
|
emptyOutDir: true,
|
||||||
rollupOptions: {
|
rollupOptions: {
|
||||||
input: {
|
input: {
|
||||||
|
|||||||
@@ -0,0 +1,3 @@
|
|||||||
|
from paskia.sansio import Passkey
|
||||||
|
|
||||||
|
__all__ = ["Passkey"]
|
||||||
@@ -14,7 +14,7 @@ from importlib.resources import files
|
|||||||
__ALL__ = ["AAGUID", "filter"]
|
__ALL__ = ["AAGUID", "filter"]
|
||||||
|
|
||||||
# Path to the AAGUID JSON file
|
# Path to the AAGUID JSON file
|
||||||
AAGUID_FILE = files("passkey") / "aaguid" / "combined_aaguid.json"
|
AAGUID_FILE = files("paskia") / "aaguid" / "combined_aaguid.json"
|
||||||
AAGUID: dict[str, dict] = json.loads(AAGUID_FILE.read_text(encoding="utf-8"))
|
AAGUID: dict[str, dict] = json.loads(AAGUID_FILE.read_text(encoding="utf-8"))
|
||||||
|
|
||||||
|
|
||||||
@@ -11,11 +11,11 @@ independent of any web framework:
|
|||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
from uuid import UUID
|
from uuid import UUID
|
||||||
|
|
||||||
from .config import SESSION_LIFETIME
|
from paskia.config import SESSION_LIFETIME
|
||||||
from .db import ResetToken, Session
|
from paskia.db import ResetToken, Session
|
||||||
from .globals import db, passkey
|
from paskia.globals import db, passkey
|
||||||
from .util import hostutil
|
from paskia.util import hostutil
|
||||||
from .util.tokens import create_token, reset_key, session_key
|
from paskia.util.tokens import create_token, reset_key, session_key
|
||||||
|
|
||||||
EXPIRES = SESSION_LIFETIME
|
EXPIRES = SESSION_LIFETIME
|
||||||
|
|
||||||
@@ -12,9 +12,9 @@ from datetime import datetime, timezone
|
|||||||
|
|
||||||
import uuid7
|
import uuid7
|
||||||
|
|
||||||
from . import authsession, globals
|
from paskia import authsession, globals
|
||||||
from .db import Org, Permission, Role, User
|
from paskia.db import Org, Permission, Role, User
|
||||||
from .util import hostutil, passphrase, tokens
|
from paskia.util import hostutil, passphrase, tokens
|
||||||
|
|
||||||
|
|
||||||
def _init_logger() -> logging.Logger:
|
def _init_logger() -> logging.Logger:
|
||||||
@@ -53,16 +53,10 @@ async def _create_and_log_admin_reset_link(user_uuid, message, session_type) ->
|
|||||||
return reset_link
|
return reset_link
|
||||||
|
|
||||||
|
|
||||||
async def bootstrap_system(
|
async def bootstrap_system() -> dict:
|
||||||
user_name: str | None = None, org_name: str | None = None
|
|
||||||
) -> dict:
|
|
||||||
"""
|
"""
|
||||||
Bootstrap the entire system with default data.
|
Bootstrap the entire system with default data.
|
||||||
|
|
||||||
Args:
|
|
||||||
user_name: Display name for the admin user (default: "Admin")
|
|
||||||
org_name: Display name for the organization (default: "Organization")
|
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
dict: Contains information about created entities and reset link
|
dict: Contains information about created entities and reset link
|
||||||
"""
|
"""
|
||||||
@@ -70,7 +64,7 @@ async def bootstrap_system(
|
|||||||
perm0 = Permission(id="auth:admin", display_name="Master Admin")
|
perm0 = Permission(id="auth:admin", display_name="Master Admin")
|
||||||
await globals.db.instance.create_permission(perm0)
|
await globals.db.instance.create_permission(perm0)
|
||||||
|
|
||||||
org = Org(uuid7.create(), org_name or "Organization")
|
org = Org(uuid7.create(), "Organization")
|
||||||
await globals.db.instance.create_organization(org)
|
await globals.db.instance.create_organization(org)
|
||||||
|
|
||||||
# After creation, org.permissions now includes the auto-created org admin permission
|
# After creation, org.permissions now includes the auto-created org admin permission
|
||||||
@@ -89,7 +83,7 @@ async def bootstrap_system(
|
|||||||
|
|
||||||
user = User(
|
user = User(
|
||||||
uuid=uuid7.create(),
|
uuid=uuid7.create(),
|
||||||
display_name=user_name or "Admin",
|
display_name="Admin",
|
||||||
role_uuid=role.uuid,
|
role_uuid=role.uuid,
|
||||||
created_at=datetime.now(timezone.utc),
|
created_at=datetime.now(timezone.utc),
|
||||||
visits=0,
|
visits=0,
|
||||||
@@ -159,16 +153,10 @@ async def check_admin_credentials() -> bool:
|
|||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
async def bootstrap_if_needed(
|
async def bootstrap_if_needed() -> bool:
|
||||||
default_admin: str | None = None, default_org: str | None = None
|
|
||||||
) -> bool:
|
|
||||||
"""
|
"""
|
||||||
Check if system needs bootstrapping and perform it if necessary.
|
Check if system needs bootstrapping and perform it if necessary.
|
||||||
|
|
||||||
Args:
|
|
||||||
default_admin: Display name for the admin user
|
|
||||||
default_org: Display name for the organization
|
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
bool: True if bootstrapping was performed, False if system was already set up
|
bool: True if bootstrapping was performed, False if system was already set up
|
||||||
"""
|
"""
|
||||||
@@ -185,35 +173,17 @@ async def bootstrap_if_needed(
|
|||||||
|
|
||||||
# No admin permission found, need to bootstrap
|
# No admin permission found, need to bootstrap
|
||||||
# Bootstrap creates the admin user AND the reset link, so no need to check credentials after
|
# Bootstrap creates the admin user AND the reset link, so no need to check credentials after
|
||||||
await bootstrap_system(default_admin, default_org)
|
await bootstrap_system()
|
||||||
return True
|
return True
|
||||||
|
|
||||||
|
|
||||||
# CLI interface
|
# CLI interface
|
||||||
async def main():
|
async def main():
|
||||||
"""Main CLI entry point for bootstrapping."""
|
"""Main CLI entry point for bootstrapping."""
|
||||||
import argparse
|
|
||||||
|
|
||||||
# Configure logging for CLI usage
|
# Configure logging for CLI usage
|
||||||
logging.basicConfig(level=logging.INFO, format="%(message)s", force=True)
|
logging.basicConfig(level=logging.INFO, format="%(message)s", force=True)
|
||||||
|
|
||||||
parser = argparse.ArgumentParser(
|
await globals.init()
|
||||||
description="Bootstrap passkey authentication system"
|
|
||||||
)
|
|
||||||
parser.add_argument(
|
|
||||||
"--user-name",
|
|
||||||
default=None,
|
|
||||||
help="Name for the admin user (default: Admin)",
|
|
||||||
)
|
|
||||||
parser.add_argument(
|
|
||||||
"--org-name",
|
|
||||||
default=None,
|
|
||||||
help="Name for the organization (default: Organization)",
|
|
||||||
)
|
|
||||||
|
|
||||||
args = parser.parse_args()
|
|
||||||
|
|
||||||
await globals.init(default_admin=args.user_name, default_org=args.org_name)
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
@@ -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.
|
for managing users and credentials in a WebAuthn authentication system.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
from contextlib import asynccontextmanager
|
from contextlib import asynccontextmanager
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
from uuid import UUID
|
from uuid import UUID
|
||||||
@@ -26,9 +27,8 @@ from sqlalchemy.dialects.sqlite import BLOB
|
|||||||
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
|
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
|
||||||
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column
|
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column
|
||||||
|
|
||||||
from ..config import SESSION_LIFETIME
|
from paskia.config import SESSION_LIFETIME
|
||||||
from ..globals import db
|
from paskia.db import (
|
||||||
from . import (
|
|
||||||
Credential,
|
Credential,
|
||||||
DatabaseInterface,
|
DatabaseInterface,
|
||||||
Org,
|
Org,
|
||||||
@@ -39,8 +39,9 @@ from . import (
|
|||||||
SessionContext,
|
SessionContext,
|
||||||
User,
|
User,
|
||||||
)
|
)
|
||||||
|
from paskia.globals import db
|
||||||
|
|
||||||
DB_PATH = "sqlite+aiosqlite:///passkey-auth.sqlite"
|
DB_PATH_DEFAULT = "sqlite+aiosqlite:///paskia.sqlite"
|
||||||
|
|
||||||
|
|
||||||
def _normalize_dt(value: datetime | None) -> datetime | None:
|
def _normalize_dt(value: datetime | None) -> datetime | None:
|
||||||
@@ -52,7 +53,8 @@ def _normalize_dt(value: datetime | None) -> datetime | None:
|
|||||||
|
|
||||||
|
|
||||||
async def init(*args, **kwargs):
|
async def init(*args, **kwargs):
|
||||||
db.instance = DB()
|
db_path = os.environ.get("PASKIA_DB", DB_PATH_DEFAULT)
|
||||||
|
db.instance = DB(db_path)
|
||||||
await db.instance.init_db()
|
await db.instance.init_db()
|
||||||
|
|
||||||
|
|
||||||
@@ -289,7 +291,7 @@ class RolePermission(Base):
|
|||||||
class DB(DatabaseInterface):
|
class DB(DatabaseInterface):
|
||||||
"""Database class that handles its own connections."""
|
"""Database class that handles its own connections."""
|
||||||
|
|
||||||
def __init__(self, db_path: str = DB_PATH):
|
def __init__(self, db_path: str = DB_PATH_DEFAULT):
|
||||||
"""Initialize with database path."""
|
"""Initialize with database path."""
|
||||||
self.engine = create_async_engine(db_path, echo=False)
|
self.engine = create_async_engine(db_path, echo=False)
|
||||||
# Ensure SQLite foreign key enforcement is ON for every new connection
|
# Ensure SQLite foreign key enforcement is ON for every new connection
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
from paskia.fastapi.mainapp import app
|
||||||
|
|
||||||
|
__all__ = ["app"]
|
||||||
@@ -7,11 +7,10 @@ from urllib.parse import urlparse
|
|||||||
|
|
||||||
import uvicorn
|
import uvicorn
|
||||||
|
|
||||||
from passkey.util import frontend
|
from paskia.util.hostutil import normalize_origin
|
||||||
|
|
||||||
DEFAULT_HOST = "localhost"
|
DEFAULT_HOST = "localhost"
|
||||||
DEFAULT_SERVE_PORT = 4401
|
DEFAULT_SERVE_PORT = 4401
|
||||||
DEFAULT_DEV_PORT = 4402
|
|
||||||
|
|
||||||
|
|
||||||
def is_subdomain(sub: str, domain: str) -> bool:
|
def is_subdomain(sub: str, domain: str) -> bool:
|
||||||
@@ -114,7 +113,13 @@ def add_common_options(p: argparse.ArgumentParser) -> None:
|
|||||||
"--rp-id", default="localhost", help="Relying Party ID (default: localhost)"
|
"--rp-id", default="localhost", help="Relying Party ID (default: localhost)"
|
||||||
)
|
)
|
||||||
p.add_argument("--rp-name", help="Relying Party name (default: same as rp-id)")
|
p.add_argument("--rp-name", help="Relying Party name (default: same as rp-id)")
|
||||||
p.add_argument("--origin", help="Origin URL (default: https://<rp-id>)")
|
p.add_argument(
|
||||||
|
"--origin",
|
||||||
|
action="append",
|
||||||
|
dest="origins",
|
||||||
|
metavar="URL",
|
||||||
|
help="Allowed origin URL(s). May be specified multiple times. If any are specified, only those origins are permitted for WebSocket authentication.",
|
||||||
|
)
|
||||||
p.add_argument(
|
p.add_argument(
|
||||||
"--auth-host",
|
"--auth-host",
|
||||||
help=(
|
help=(
|
||||||
@@ -129,7 +134,7 @@ def main():
|
|||||||
logging.basicConfig(level=logging.INFO, format="%(message)s", force=True)
|
logging.basicConfig(level=logging.INFO, format="%(message)s", force=True)
|
||||||
|
|
||||||
parser = argparse.ArgumentParser(
|
parser = argparse.ArgumentParser(
|
||||||
prog="passkey-auth", description="Passkey authentication server"
|
prog="paskia", description="Paskia authentication server"
|
||||||
)
|
)
|
||||||
sub = parser.add_subparsers(dest="command", required=True)
|
sub = parser.add_subparsers(dest="command", required=True)
|
||||||
|
|
||||||
@@ -147,18 +152,6 @@ def main():
|
|||||||
)
|
)
|
||||||
add_common_options(serve)
|
add_common_options(serve)
|
||||||
|
|
||||||
# dev subcommand
|
|
||||||
dev = sub.add_parser("dev", help="Run the server in development (auto-reload)")
|
|
||||||
dev.add_argument(
|
|
||||||
"hostport",
|
|
||||||
nargs="?",
|
|
||||||
help=(
|
|
||||||
"Endpoint (default: localhost:4402). Forms: host[:port] | :port | "
|
|
||||||
"[ipv6][:port] | ipv6 | unix:/path.sock"
|
|
||||||
),
|
|
||||||
)
|
|
||||||
add_common_options(dev)
|
|
||||||
|
|
||||||
# reset subcommand
|
# reset subcommand
|
||||||
reset = sub.add_parser(
|
reset = sub.add_parser(
|
||||||
"reset",
|
"reset",
|
||||||
@@ -176,66 +169,119 @@ def main():
|
|||||||
|
|
||||||
args = parser.parse_args()
|
args = parser.parse_args()
|
||||||
|
|
||||||
if args.command in {"serve", "dev"}:
|
if args.command == "serve":
|
||||||
default_port = DEFAULT_DEV_PORT if args.command == "dev" else DEFAULT_SERVE_PORT
|
host, port, uds, all_ifaces = parse_endpoint(args.hostport, DEFAULT_SERVE_PORT)
|
||||||
host, port, uds, all_ifaces = parse_endpoint(args.hostport, default_port)
|
|
||||||
devmode = args.command == "dev"
|
|
||||||
else:
|
else:
|
||||||
host = port = uds = all_ifaces = None # type: ignore
|
host = port = uds = all_ifaces = None # type: ignore
|
||||||
devmode = False
|
|
||||||
|
|
||||||
# Determine origin (dev mode default override)
|
|
||||||
origin = args.origin
|
|
||||||
if devmode and not args.origin and not args.rp_id:
|
|
||||||
# Dev mode: Vite runs on another port, override:
|
|
||||||
origin = "http://localhost:4403"
|
|
||||||
|
|
||||||
# Export configuration via environment for lifespan initialization in each process
|
|
||||||
os.environ.setdefault("PASSKEY_RP_ID", args.rp_id)
|
|
||||||
if args.rp_name:
|
|
||||||
os.environ["PASSKEY_RP_NAME"] = args.rp_name
|
|
||||||
if origin:
|
|
||||||
os.environ["PASSKEY_ORIGIN"] = origin
|
|
||||||
if getattr(args, "auth_host", None):
|
|
||||||
os.environ["PASSKEY_AUTH_HOST"] = args.auth_host
|
|
||||||
else:
|
|
||||||
# Preserve pre-set env variable if CLI option omitted
|
|
||||||
args.auth_host = os.environ.get("PASSKEY_AUTH_HOST")
|
|
||||||
|
|
||||||
|
# Collect and normalize origins, handle auth_host
|
||||||
|
origins = [normalize_origin(o) for o in (getattr(args, "origins", None) or [])]
|
||||||
if args.auth_host:
|
if args.auth_host:
|
||||||
|
# Normalize auth_host with scheme
|
||||||
|
if "://" not in args.auth_host:
|
||||||
|
args.auth_host = f"https://{args.auth_host}"
|
||||||
|
|
||||||
validate_auth_host(args.auth_host, args.rp_id)
|
validate_auth_host(args.auth_host, args.rp_id)
|
||||||
from passkey.util import hostutil as _hostutil # local import
|
|
||||||
|
|
||||||
_hostutil.reload_config()
|
# If origins are configured, ensure auth_host is included at top
|
||||||
|
if origins:
|
||||||
|
# Insert auth_host at the beginning (Passkey.__init__ will dedupe)
|
||||||
|
origins.insert(0, args.auth_host)
|
||||||
|
|
||||||
# One-time initialization + bootstrap before starting any server processes.
|
# Compute site_url and site_path for reset links
|
||||||
# Lifespan in worker processes will call globals.init with bootstrap disabled.
|
# Priority: auth_host > first origin with localhost > http://localhost:port
|
||||||
from passkey import globals as _globals # local import
|
if args.auth_host:
|
||||||
|
site_url = args.auth_host.rstrip("/")
|
||||||
|
site_path = "/"
|
||||||
|
elif origins:
|
||||||
|
# Find localhost origin if rp_id is localhost, else use first origin
|
||||||
|
localhost_origin = (
|
||||||
|
next((o for o in origins if "://localhost" in o), None)
|
||||||
|
if args.rp_id == "localhost"
|
||||||
|
else None
|
||||||
|
)
|
||||||
|
site_url = (localhost_origin or origins[0]).rstrip("/")
|
||||||
|
site_path = "/auth/"
|
||||||
|
elif args.rp_id == "localhost" and port:
|
||||||
|
# Dev mode: use http with port
|
||||||
|
site_url = f"http://localhost:{port}"
|
||||||
|
site_path = "/auth/"
|
||||||
|
else:
|
||||||
|
site_url = f"https://{args.rp_id}"
|
||||||
|
site_path = "/auth/"
|
||||||
|
|
||||||
|
# Build runtime configuration
|
||||||
|
from paskia.config import PaskiaConfig
|
||||||
|
|
||||||
|
config = PaskiaConfig(
|
||||||
|
rp_id=args.rp_id,
|
||||||
|
rp_name=args.rp_name or None,
|
||||||
|
origins=origins or None,
|
||||||
|
auth_host=args.auth_host or None,
|
||||||
|
site_url=site_url,
|
||||||
|
site_path=site_path,
|
||||||
|
host=host,
|
||||||
|
port=port,
|
||||||
|
uds=uds,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Export configuration via single JSON env variable for worker processes
|
||||||
|
import json
|
||||||
|
|
||||||
|
config_json = {
|
||||||
|
"rp_id": config.rp_id,
|
||||||
|
"rp_name": config.rp_name,
|
||||||
|
"origins": config.origins,
|
||||||
|
"auth_host": config.auth_host,
|
||||||
|
"site_url": config.site_url,
|
||||||
|
"site_path": config.site_path,
|
||||||
|
}
|
||||||
|
os.environ["PASKIA_CONFIG"] = json.dumps(config_json)
|
||||||
|
|
||||||
|
# Initialize globals (without bootstrap yet)
|
||||||
|
from paskia import globals as _globals # local import
|
||||||
|
|
||||||
asyncio.run(
|
asyncio.run(
|
||||||
_globals.init(
|
_globals.init(
|
||||||
rp_id=args.rp_id,
|
rp_id=config.rp_id,
|
||||||
rp_name=args.rp_name,
|
rp_name=config.rp_name,
|
||||||
origin=origin,
|
origins=config.origins,
|
||||||
default_admin=os.getenv("PASSKEY_DEFAULT_ADMIN") or None,
|
bootstrap=False,
|
||||||
default_org=os.getenv("PASSKEY_DEFAULT_ORG") or None,
|
|
||||||
bootstrap=True,
|
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Print startup configuration
|
||||||
|
from paskia.util import startupbox
|
||||||
|
|
||||||
|
startupbox.print_startup_config(config)
|
||||||
|
|
||||||
|
# Bootstrap after startup box is printed
|
||||||
|
from paskia.bootstrap import bootstrap_if_needed
|
||||||
|
|
||||||
|
asyncio.run(bootstrap_if_needed())
|
||||||
|
|
||||||
# Handle recover-admin command (no server start)
|
# Handle recover-admin command (no server start)
|
||||||
if args.command == "reset":
|
if args.command == "reset":
|
||||||
from passkey.fastapi import reset as reset_cmd # local import
|
from paskia.fastapi import reset as reset_cmd # local import
|
||||||
|
|
||||||
exit_code = reset_cmd.run(getattr(args, "query", None))
|
exit_code = reset_cmd.run(getattr(args, "query", None))
|
||||||
raise SystemExit(exit_code)
|
raise SystemExit(exit_code)
|
||||||
|
|
||||||
if args.command in {"serve", "dev"}:
|
if args.command == "serve":
|
||||||
run_kwargs: dict = {
|
run_kwargs: dict = {
|
||||||
"reload": devmode,
|
|
||||||
"reload_dirs": ["passkey"] if devmode else None,
|
|
||||||
"log_level": "info",
|
"log_level": "info",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Dev mode: enable reload when PASKIA_DEVMODE is set
|
||||||
|
devmode = os.environ.get("PASKIA_DEVMODE") == "1"
|
||||||
|
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"]
|
||||||
|
|
||||||
if uds:
|
if uds:
|
||||||
run_kwargs["uds"] = uds
|
run_kwargs["uds"] = uds
|
||||||
else:
|
else:
|
||||||
@@ -243,19 +289,17 @@ def main():
|
|||||||
run_kwargs["host"] = host
|
run_kwargs["host"] = host
|
||||||
run_kwargs["port"] = port
|
run_kwargs["port"] = port
|
||||||
|
|
||||||
if devmode:
|
|
||||||
os.environ["PASSKEY_DEVMODE"] = "1"
|
|
||||||
frontend.run_dev()
|
|
||||||
|
|
||||||
if all_ifaces and not uds:
|
if all_ifaces and not uds:
|
||||||
|
# Dev mode with all interfaces: use simple single-server approach
|
||||||
if devmode:
|
if devmode:
|
||||||
run_kwargs["host"] = "::"
|
run_kwargs["host"] = "::"
|
||||||
run_kwargs["port"] = port
|
run_kwargs["port"] = port
|
||||||
uvicorn.run("passkey.fastapi:app", **run_kwargs)
|
uvicorn.run("paskia.fastapi:app", **run_kwargs)
|
||||||
else:
|
else:
|
||||||
|
# Production: run separate servers for IPv4 and IPv6
|
||||||
from uvicorn import Config, Server # noqa: E402 local import
|
from uvicorn import Config, Server # noqa: E402 local import
|
||||||
|
|
||||||
from passkey.fastapi import (
|
from paskia.fastapi import (
|
||||||
app as fastapi_app, # noqa: E402 local import
|
app as fastapi_app, # noqa: E402 local import
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -278,7 +322,7 @@ def main():
|
|||||||
|
|
||||||
asyncio.run(serve_both())
|
asyncio.run(serve_both())
|
||||||
else:
|
else:
|
||||||
uvicorn.run("passkey.fastapi:app", **run_kwargs)
|
uvicorn.run("paskia.fastapi:app", **run_kwargs)
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
@@ -5,9 +5,11 @@ from uuid import UUID, uuid4
|
|||||||
from fastapi import Body, FastAPI, HTTPException, Request, Response
|
from fastapi import Body, FastAPI, HTTPException, Request, Response
|
||||||
from fastapi.responses import JSONResponse
|
from fastapi.responses import JSONResponse
|
||||||
|
|
||||||
from ..authsession import reset_expires
|
from paskia.authsession import reset_expires
|
||||||
from ..globals import db
|
from paskia.fastapi import authz
|
||||||
from ..util import (
|
from paskia.fastapi.session import AUTH_COOKIE
|
||||||
|
from paskia.globals import db
|
||||||
|
from paskia.util import (
|
||||||
frontend,
|
frontend,
|
||||||
hostutil,
|
hostutil,
|
||||||
passphrase,
|
passphrase,
|
||||||
@@ -16,9 +18,7 @@ from ..util import (
|
|||||||
tokens,
|
tokens,
|
||||||
useragent,
|
useragent,
|
||||||
)
|
)
|
||||||
from ..util.tokens import encode_session_key, session_key
|
from paskia.util.tokens import encode_session_key, session_key
|
||||||
from . import authz
|
|
||||||
from .session import AUTH_COOKIE
|
|
||||||
|
|
||||||
app = FastAPI()
|
app = FastAPI()
|
||||||
|
|
||||||
@@ -38,7 +38,7 @@ async def auth_exception_handler(_request, exc: authz.AuthException):
|
|||||||
|
|
||||||
|
|
||||||
@app.exception_handler(Exception)
|
@app.exception_handler(Exception)
|
||||||
async def general_exception_handler(_request, exc: Exception):
|
async def general_exception_handler(_request, exc: Exception): # pragma: no cover
|
||||||
logging.exception("Unhandled exception in admin app")
|
logging.exception("Unhandled exception in admin app")
|
||||||
return JSONResponse(status_code=500, content={"detail": "Internal server error"})
|
return JSONResponse(status_code=500, content={"detail": "Internal server error"})
|
||||||
|
|
||||||
@@ -139,7 +139,9 @@ async def admin_update_org(
|
|||||||
|
|
||||||
current = await db.instance.get_organization(str(org_uuid))
|
current = await db.instance.get_organization(str(org_uuid))
|
||||||
display_name = payload.get("display_name") or current.display_name
|
display_name = payload.get("display_name") or current.display_name
|
||||||
permissions = payload.get("permissions") or current.permissions or []
|
permissions = payload.get("permissions")
|
||||||
|
if permissions is None:
|
||||||
|
permissions = current.permissions or []
|
||||||
|
|
||||||
# Sanity check: prevent removing permissions that would break current user's admin access
|
# Sanity check: prevent removing permissions that would break current user's admin access
|
||||||
org_admin_perm = f"auth:org:{org_uuid}"
|
org_admin_perm = f"auth:org:{org_uuid}"
|
||||||
@@ -398,7 +400,7 @@ async def admin_update_user_role(
|
|||||||
# Sanity check: prevent admin from removing their own access
|
# Sanity check: prevent admin from removing their own access
|
||||||
if ctx.user.uuid == user_uuid:
|
if ctx.user.uuid == user_uuid:
|
||||||
new_role_obj = next((r for r in roles if r.display_name == new_role), None)
|
new_role_obj = next((r for r in roles if r.display_name == new_role), None)
|
||||||
if new_role_obj:
|
if new_role_obj: # pragma: no branch - always true, role validated above
|
||||||
has_admin_access = (
|
has_admin_access = (
|
||||||
"auth:admin" in new_role_obj.permissions
|
"auth:admin" in new_role_obj.permissions
|
||||||
or f"auth:org:{org_uuid}" in new_role_obj.permissions
|
or f"auth:org:{org_uuid}" in new_role_obj.permissions
|
||||||
@@ -432,7 +434,7 @@ async def admin_create_user_registration_link(
|
|||||||
host=request.headers.get("host"),
|
host=request.headers.get("host"),
|
||||||
max_age="5m",
|
max_age="5m",
|
||||||
)
|
)
|
||||||
if (
|
if ( # pragma: no cover - defense in depth, authz.verify already checked
|
||||||
"auth:admin" not in ctx.role.permissions
|
"auth:admin" not in ctx.role.permissions
|
||||||
and f"auth:org:{org_uuid}" not in ctx.role.permissions
|
and f"auth:org:{org_uuid}" not in ctx.role.permissions
|
||||||
):
|
):
|
||||||
@@ -452,9 +454,7 @@ async def admin_create_user_registration_link(
|
|||||||
expiry=expiry,
|
expiry=expiry,
|
||||||
token_type=token_type,
|
token_type=token_type,
|
||||||
)
|
)
|
||||||
url = hostutil.reset_link_url(
|
url = hostutil.reset_link_url(token)
|
||||||
token, request.url.scheme, request.headers.get("host")
|
|
||||||
)
|
|
||||||
return {
|
return {
|
||||||
"url": url,
|
"url": url,
|
||||||
"expires": (
|
"expires": (
|
||||||
@@ -484,7 +484,7 @@ async def admin_get_user_detail(
|
|||||||
match=permutil.has_any,
|
match=permutil.has_any,
|
||||||
host=request.headers.get("host"),
|
host=request.headers.get("host"),
|
||||||
)
|
)
|
||||||
if (
|
if ( # pragma: no cover - defense in depth, authz.verify already checked
|
||||||
"auth:admin" not in ctx.role.permissions
|
"auth:admin" not in ctx.role.permissions
|
||||||
and f"auth:org:{org_uuid}" not in ctx.role.permissions
|
and f"auth:org:{org_uuid}" not in ctx.role.permissions
|
||||||
):
|
):
|
||||||
@@ -498,7 +498,7 @@ async def admin_get_user_detail(
|
|||||||
for cid in cred_ids:
|
for cid in cred_ids:
|
||||||
try:
|
try:
|
||||||
c = await db.instance.get_credential_by_id(cid)
|
c = await db.instance.get_credential_by_id(cid)
|
||||||
except ValueError:
|
except ValueError: # pragma: no cover - race condition handling
|
||||||
continue
|
continue
|
||||||
aaguid_str = str(c.aaguid)
|
aaguid_str = str(c.aaguid)
|
||||||
aaguids.add(aaguid_str)
|
aaguids.add(aaguid_str)
|
||||||
@@ -633,7 +633,7 @@ async def admin_update_user_display_name(
|
|||||||
match=permutil.has_any,
|
match=permutil.has_any,
|
||||||
host=request.headers.get("host"),
|
host=request.headers.get("host"),
|
||||||
)
|
)
|
||||||
if (
|
if ( # pragma: no cover - defense in depth, authz.verify already checked
|
||||||
"auth:admin" not in ctx.role.permissions
|
"auth:admin" not in ctx.role.permissions
|
||||||
and f"auth:org:{org_uuid}" not in ctx.role.permissions
|
and f"auth:org:{org_uuid}" not in ctx.role.permissions
|
||||||
):
|
):
|
||||||
@@ -670,7 +670,7 @@ async def admin_delete_user_credential(
|
|||||||
host=request.headers.get("host"),
|
host=request.headers.get("host"),
|
||||||
max_age="5m",
|
max_age="5m",
|
||||||
)
|
)
|
||||||
if (
|
if ( # pragma: no cover - defense in depth, authz.verify already checked
|
||||||
"auth:admin" not in ctx.role.permissions
|
"auth:admin" not in ctx.role.permissions
|
||||||
and f"auth:org:{org_uuid}" not in ctx.role.permissions
|
and f"auth:org:{org_uuid}" not in ctx.role.permissions
|
||||||
):
|
):
|
||||||
@@ -701,7 +701,7 @@ async def admin_delete_user_session(
|
|||||||
match=permutil.has_any,
|
match=permutil.has_any,
|
||||||
host=request.headers.get("host"),
|
host=request.headers.get("host"),
|
||||||
)
|
)
|
||||||
if (
|
if ( # pragma: no cover - defense in depth, authz.verify already checked
|
||||||
"auth:admin" not in ctx.role.permissions
|
"auth:admin" not in ctx.role.permissions
|
||||||
and f"auth:org:{org_uuid}" not in ctx.role.permissions
|
and f"auth:org:{org_uuid}" not in ctx.role.permissions
|
||||||
):
|
):
|
||||||
@@ -820,7 +820,7 @@ async def admin_rename_permission(
|
|||||||
perm = await db.instance.get_permission(old_id)
|
perm = await db.instance.get_permission(old_id)
|
||||||
display_name = perm.display_name
|
display_name = perm.display_name
|
||||||
rename_fn = getattr(db.instance, "rename_permission", None)
|
rename_fn = getattr(db.instance, "rename_permission", None)
|
||||||
if not rename_fn:
|
if not rename_fn: # pragma: no cover - all current backends support rename
|
||||||
raise ValueError("Permission renaming not supported by this backend")
|
raise ValueError("Permission renaming not supported by this backend")
|
||||||
await rename_fn(old_id, new_id, display_name)
|
await rename_fn(old_id, new_id, display_name)
|
||||||
return {"status": "ok"}
|
return {"status": "ok"}
|
||||||
@@ -13,21 +13,19 @@ from fastapi import (
|
|||||||
from fastapi.responses import JSONResponse
|
from fastapi.responses import JSONResponse
|
||||||
from fastapi.security import HTTPBearer
|
from fastapi.security import HTTPBearer
|
||||||
|
|
||||||
from passkey.util import frontend
|
from paskia.authsession import (
|
||||||
|
|
||||||
from ..authsession import (
|
|
||||||
EXPIRES,
|
EXPIRES,
|
||||||
get_reset,
|
get_reset,
|
||||||
get_session,
|
get_session,
|
||||||
refresh_session_token,
|
refresh_session_token,
|
||||||
session_expiry,
|
session_expiry,
|
||||||
)
|
)
|
||||||
from ..globals import db
|
from paskia.fastapi import authz, session, user
|
||||||
from ..globals import passkey as global_passkey
|
from paskia.fastapi.session import AUTH_COOKIE, AUTH_COOKIE_NAME
|
||||||
from ..util import hostutil, htmlutil, passphrase, userinfo
|
from paskia.globals import db
|
||||||
from ..util.tokens import session_key
|
from paskia.globals import passkey as global_passkey
|
||||||
from . import authz, session, user
|
from paskia.util import frontend, hostutil, htmlutil, passphrase, userinfo
|
||||||
from .session import AUTH_COOKIE
|
from paskia.util.tokens import session_key
|
||||||
|
|
||||||
bearer_auth = HTTPBearer(auto_error=True)
|
bearer_auth = HTTPBearer(auto_error=True)
|
||||||
|
|
||||||
@@ -67,7 +65,9 @@ async def auth_exception_handler(_request: Request, exc: authz.AuthException):
|
|||||||
|
|
||||||
|
|
||||||
@app.exception_handler(Exception)
|
@app.exception_handler(Exception)
|
||||||
async def general_exception_handler(_request: Request, exc: Exception):
|
async def general_exception_handler(
|
||||||
|
_request: Request, exc: Exception
|
||||||
|
): # pragma: no cover
|
||||||
logging.exception("Unhandled exception in API app")
|
logging.exception("Unhandled exception in API app")
|
||||||
return JSONResponse(status_code=500, content={"detail": "Internal server error"})
|
return JSONResponse(status_code=500, content={"detail": "Internal server error"})
|
||||||
|
|
||||||
@@ -201,6 +201,7 @@ async def get_settings():
|
|||||||
"rp_name": pk.rp_name,
|
"rp_name": pk.rp_name,
|
||||||
"ui_base_path": base_path,
|
"ui_base_path": base_path,
|
||||||
"auth_host": hostutil.configured_auth_host(),
|
"auth_host": hostutil.configured_auth_host(),
|
||||||
|
"session_cookie": AUTH_COOKIE_NAME,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -3,7 +3,7 @@
|
|||||||
from fastapi import Request, Response
|
from fastapi import Request, Response
|
||||||
from fastapi.responses import RedirectResponse
|
from fastapi.responses import RedirectResponse
|
||||||
|
|
||||||
from passkey.util import hostutil, passphrase
|
from paskia.util import hostutil, passphrase
|
||||||
|
|
||||||
|
|
||||||
def is_ui_path(path: str) -> bool:
|
def is_ui_path(path: str) -> bool:
|
||||||
@@ -2,7 +2,7 @@ import logging
|
|||||||
|
|
||||||
from fastapi import HTTPException
|
from fastapi import HTTPException
|
||||||
|
|
||||||
from ..util import permutil, sessionutil
|
from paskia.util import permutil, sessionutil
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -1,41 +1,40 @@
|
|||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
from contextlib import asynccontextmanager
|
from contextlib import asynccontextmanager
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
from fastapi import FastAPI, HTTPException, Request, Response
|
from fastapi import FastAPI, HTTPException, Request, Response
|
||||||
from fastapi.responses import RedirectResponse
|
from fastapi.responses import FileResponse, RedirectResponse
|
||||||
from fastapi.staticfiles import StaticFiles
|
from fastapi.staticfiles import StaticFiles
|
||||||
|
|
||||||
from passkey.util import frontend, hostutil, passphrase
|
from paskia.fastapi import admin, api, auth_host, ws
|
||||||
|
from paskia.fastapi.session import AUTH_COOKIE
|
||||||
|
from paskia.util import frontend, hostutil, passphrase
|
||||||
|
|
||||||
from . import admin, api, auth_host, ws
|
# Path to examples/index.html when running from source tree
|
||||||
from .session import AUTH_COOKIE
|
_EXAMPLES_DIR = Path(__file__).parent.parent.parent / "examples"
|
||||||
|
|
||||||
|
|
||||||
@asynccontextmanager
|
@asynccontextmanager
|
||||||
async def lifespan(app: FastAPI): # pragma: no cover - startup path
|
async def lifespan(app: FastAPI): # pragma: no cover - startup path
|
||||||
"""Application lifespan to ensure globals (DB, passkey) are initialized in each process.
|
"""Application lifespan to ensure globals (DB, passkey) are initialized in each process.
|
||||||
|
|
||||||
We populate configuration from environment variables (set by the CLI entrypoint)
|
Configuration is passed via PASKIA_CONFIG JSON env variable (set by the CLI entrypoint)
|
||||||
so that uvicorn reload / multiprocess workers inherit the settings.
|
so that uvicorn reload / multiprocess workers inherit the settings.
|
||||||
|
All keys are guaranteed to exist; values are already normalized by __main__.py.
|
||||||
"""
|
"""
|
||||||
from .. import globals
|
import json
|
||||||
|
|
||||||
|
from paskia import globals
|
||||||
|
|
||||||
|
config = json.loads(os.environ["PASKIA_CONFIG"])
|
||||||
|
|
||||||
rp_id = os.getenv("PASSKEY_RP_ID", "localhost")
|
|
||||||
rp_name = os.getenv("PASSKEY_RP_NAME") or None
|
|
||||||
origin = os.getenv("PASSKEY_ORIGIN") or None
|
|
||||||
default_admin = (
|
|
||||||
os.getenv("PASSKEY_DEFAULT_ADMIN") or None
|
|
||||||
) # still passed for context
|
|
||||||
default_org = os.getenv("PASSKEY_DEFAULT_ORG") or None
|
|
||||||
try:
|
try:
|
||||||
# CLI (__main__) performs bootstrap once; here we skip to avoid duplicate work
|
# CLI (__main__) performs bootstrap once; here we skip to avoid duplicate work
|
||||||
await globals.init(
|
await globals.init(
|
||||||
rp_id=rp_id,
|
rp_id=config["rp_id"],
|
||||||
rp_name=rp_name,
|
rp_name=config["rp_name"],
|
||||||
origin=origin,
|
origins=config["origins"],
|
||||||
default_admin=default_admin,
|
|
||||||
default_org=default_org,
|
|
||||||
bootstrap=False,
|
bootstrap=False,
|
||||||
)
|
)
|
||||||
except ValueError as e:
|
except ValueError as e:
|
||||||
@@ -43,15 +42,6 @@ async def lifespan(app: FastAPI): # pragma: no cover - startup path
|
|||||||
# Re-raise to fail fast
|
# Re-raise to fail fast
|
||||||
raise
|
raise
|
||||||
|
|
||||||
# In dev mode, Vite serves assets directly; in production, mount static files
|
|
||||||
# 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",
|
|
||||||
)
|
|
||||||
|
|
||||||
yield
|
yield
|
||||||
# (Optional) add shutdown cleanup here later
|
# (Optional) add shutdown cleanup here later
|
||||||
|
|
||||||
@@ -65,6 +55,14 @@ app.mount("/auth/api/admin/", admin.app)
|
|||||||
app.mount("/auth/api/", api.app)
|
app.mount("/auth/api/", api.app)
|
||||||
app.mount("/auth/ws/", ws.app)
|
app.mount("/auth/ws/", ws.app)
|
||||||
|
|
||||||
|
# In dev mode (PASKIA_DEVMODE=1), Vite serves assets directly; skip static files mount
|
||||||
|
if not frontend.is_dev_mode():
|
||||||
|
app.mount(
|
||||||
|
"/auth/assets/",
|
||||||
|
StaticFiles(directory=frontend.file("auth", "assets")),
|
||||||
|
name="assets",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@app.get("/auth/restricted/")
|
@app.get("/auth/restricted/")
|
||||||
async def restricted_view():
|
async def restricted_view():
|
||||||
@@ -98,6 +96,22 @@ async def admin_root(request: Request, auth=AUTH_COOKIE):
|
|||||||
return await admin.adminapp(request, auth) # Delegated to admin app
|
return await admin.adminapp(request, auth) # Delegated to admin app
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/auth/examples/", include_in_schema=False)
|
||||||
|
async def examples_page():
|
||||||
|
"""Serve examples/index.html when running from source tree.
|
||||||
|
|
||||||
|
This provides a simple test page for API mode authentication flows
|
||||||
|
without depending on the Vue frontend build.
|
||||||
|
"""
|
||||||
|
index_file = _EXAMPLES_DIR / "index.html"
|
||||||
|
if not index_file.is_file():
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=404,
|
||||||
|
detail="Examples not available (not running from source tree)",
|
||||||
|
)
|
||||||
|
return FileResponse(index_file, media_type="text/html")
|
||||||
|
|
||||||
|
|
||||||
# Note: this catch-all handler must be the last route defined
|
# Note: this catch-all handler must be the last route defined
|
||||||
@app.get("/{reset}")
|
@app.get("/{reset}")
|
||||||
@app.get("/auth/{reset}")
|
@app.get("/auth/{reset}")
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
"""CLI support for creating user credential reset links.
|
"""CLI support for creating user credential reset links.
|
||||||
|
|
||||||
Usage (via main CLI):
|
Usage (via main CLI):
|
||||||
passkey-auth reset [query]
|
paskia reset [query]
|
||||||
|
|
||||||
If query is omitted, the master admin (first Administration role user in
|
If query is omitted, the master admin (first Administration role user in
|
||||||
an organization granting auth:admin) is targeted. Otherwise query is
|
an organization granting auth:admin) is targeted. Otherwise query is
|
||||||
@@ -15,10 +15,10 @@ from __future__ import annotations
|
|||||||
import asyncio
|
import asyncio
|
||||||
from uuid import UUID
|
from uuid import UUID
|
||||||
|
|
||||||
from passkey import authsession as _authsession
|
from paskia import authsession as _authsession
|
||||||
from passkey import globals as _g
|
from paskia import globals as _g
|
||||||
from passkey.util import hostutil, passphrase
|
from paskia.util import hostutil, passphrase
|
||||||
from passkey.util import tokens as _tokens
|
from paskia.util import tokens as _tokens
|
||||||
|
|
||||||
|
|
||||||
async def _resolve_targets(query: str | None):
|
async def _resolve_targets(query: str | None):
|
||||||
@@ -10,9 +10,9 @@ Generic session management functions have been moved to authsession.py
|
|||||||
|
|
||||||
from fastapi import Cookie, Request, Response, WebSocket
|
from fastapi import Cookie, Request, Response, WebSocket
|
||||||
|
|
||||||
from ..authsession import EXPIRES
|
from paskia.authsession import EXPIRES
|
||||||
|
|
||||||
AUTH_COOKIE_NAME = "__Host-auth"
|
AUTH_COOKIE_NAME = "__Host-paskia"
|
||||||
AUTH_COOKIE = Cookie(None, alias=AUTH_COOKIE_NAME)
|
AUTH_COOKIE = Cookie(None, alias=AUTH_COOKIE_NAME)
|
||||||
|
|
||||||
|
|
||||||
@@ -10,16 +10,16 @@ from fastapi import (
|
|||||||
)
|
)
|
||||||
from fastapi.responses import JSONResponse
|
from fastapi.responses import JSONResponse
|
||||||
|
|
||||||
from ..authsession import (
|
from paskia.authsession import (
|
||||||
delete_credential,
|
delete_credential,
|
||||||
expires,
|
expires,
|
||||||
get_session,
|
get_session,
|
||||||
)
|
)
|
||||||
from ..globals import db
|
from paskia.fastapi import authz, session
|
||||||
from ..util import hostutil, passphrase, tokens
|
from paskia.fastapi.session import AUTH_COOKIE
|
||||||
from ..util.tokens import decode_session_key, session_key
|
from paskia.globals import db
|
||||||
from . import authz, session
|
from paskia.util import hostutil, passphrase, tokens
|
||||||
from .session import AUTH_COOKIE
|
from paskia.util.tokens import decode_session_key, session_key
|
||||||
|
|
||||||
app = FastAPI()
|
app = FastAPI()
|
||||||
|
|
||||||
@@ -150,9 +150,7 @@ async def api_create_link(
|
|||||||
expiry=expiry,
|
expiry=expiry,
|
||||||
token_type="device addition",
|
token_type="device addition",
|
||||||
)
|
)
|
||||||
url = hostutil.reset_link_url(
|
url = hostutil.reset_link_url(token)
|
||||||
token, request.url.scheme, request.headers.get("host")
|
|
||||||
)
|
|
||||||
return {
|
return {
|
||||||
"message": "Registration link generated successfully",
|
"message": "Registration link generated successfully",
|
||||||
"url": url,
|
"url": url,
|
||||||
@@ -5,12 +5,12 @@ from uuid import UUID
|
|||||||
from fastapi import FastAPI, WebSocket, WebSocketDisconnect
|
from fastapi import FastAPI, WebSocket, WebSocketDisconnect
|
||||||
from webauthn.helpers.exceptions import InvalidAuthenticationResponse
|
from webauthn.helpers.exceptions import InvalidAuthenticationResponse
|
||||||
|
|
||||||
from ..authsession import create_session, get_reset, get_session
|
from paskia.authsession import create_session, get_reset, get_session
|
||||||
from ..globals import db, passkey
|
from paskia.fastapi import authz
|
||||||
from ..util import passphrase
|
from paskia.fastapi.session import AUTH_COOKIE, infodict
|
||||||
from ..util.tokens import create_token, session_key
|
from paskia.globals import db, passkey
|
||||||
from . import authz
|
from paskia.util import passphrase
|
||||||
from .session import AUTH_COOKIE, infodict
|
from paskia.util.tokens import create_token, session_key
|
||||||
|
|
||||||
|
|
||||||
# WebSocket error handling decorator
|
# WebSocket error handling decorator
|
||||||
@@ -42,19 +42,30 @@ def websocket_error_handler(func):
|
|||||||
app = FastAPI()
|
app = FastAPI()
|
||||||
|
|
||||||
|
|
||||||
|
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)
|
||||||
|
|
||||||
|
|
||||||
async def register_chat(
|
async def register_chat(
|
||||||
ws: WebSocket,
|
ws: WebSocket,
|
||||||
user_uuid: UUID,
|
user_uuid: UUID,
|
||||||
user_name: str,
|
user_name: str,
|
||||||
|
origin: str,
|
||||||
credential_ids: list[bytes] | None = None,
|
credential_ids: list[bytes] | None = None,
|
||||||
origin: str | None = None,
|
|
||||||
):
|
):
|
||||||
"""Generate registration options and send them to the client."""
|
"""Generate registration options and send them to the client."""
|
||||||
options, challenge = passkey.instance.reg_generate_options(
|
options, challenge = passkey.instance.reg_generate_options(
|
||||||
user_id=user_uuid,
|
user_id=user_uuid,
|
||||||
user_name=user_name,
|
user_name=user_name,
|
||||||
credential_ids=credential_ids,
|
credential_ids=credential_ids,
|
||||||
origin=origin,
|
|
||||||
)
|
)
|
||||||
await ws.send_json({"optionsJSON": options})
|
await ws.send_json({"optionsJSON": options})
|
||||||
response = await ws.receive_json()
|
response = await ws.receive_json()
|
||||||
@@ -75,7 +86,7 @@ async def websocket_register_add(
|
|||||||
- Normal session via auth cookie (requires recent authentication)
|
- Normal session via auth cookie (requires recent authentication)
|
||||||
- Reset token supplied as ?reset=... (auth cookie ignored)
|
- Reset token supplied as ?reset=... (auth cookie ignored)
|
||||||
"""
|
"""
|
||||||
origin = ws.headers["origin"]
|
origin = _validate_origin(ws)
|
||||||
host = origin.split("://", 1)[1]
|
host = origin.split("://", 1)[1]
|
||||||
if reset is not None:
|
if reset is not None:
|
||||||
if not passphrase.is_well_formed(reset):
|
if not passphrase.is_well_formed(reset):
|
||||||
@@ -100,7 +111,7 @@ async def websocket_register_add(
|
|||||||
challenge_ids = await db.instance.get_credentials_by_user_uuid(user_uuid)
|
challenge_ids = await db.instance.get_credentials_by_user_uuid(user_uuid)
|
||||||
|
|
||||||
# WebAuthn registration
|
# WebAuthn registration
|
||||||
credential = await register_chat(ws, user_uuid, user_name, challenge_ids, origin)
|
credential = await register_chat(ws, user_uuid, user_name, origin, challenge_ids)
|
||||||
|
|
||||||
# Create a new session and store everything in database
|
# Create a new session and store everything in database
|
||||||
token = create_token()
|
token = create_token()
|
||||||
@@ -131,7 +142,7 @@ async def websocket_register_add(
|
|||||||
@app.websocket("/authenticate")
|
@app.websocket("/authenticate")
|
||||||
@websocket_error_handler
|
@websocket_error_handler
|
||||||
async def websocket_authenticate(ws: WebSocket, auth=AUTH_COOKIE):
|
async def websocket_authenticate(ws: WebSocket, auth=AUTH_COOKIE):
|
||||||
origin = ws.headers["origin"]
|
origin = _validate_origin(ws)
|
||||||
host = origin.split("://", 1)[1]
|
host = origin.split("://", 1)[1]
|
||||||
|
|
||||||
# If there's an existing session, restrict to that user's credentials (reauth)
|
# If there's an existing session, restrict to that user's credentials (reauth)
|
||||||
@@ -166,7 +177,7 @@ async def websocket_authenticate(ws: WebSocket, auth=AUTH_COOKIE):
|
|||||||
raise ValueError("This passkey belongs to a different account")
|
raise ValueError("This passkey belongs to a different account")
|
||||||
|
|
||||||
# Verify the credential matches the stored data
|
# Verify the credential matches the stored data
|
||||||
passkey.instance.auth_verify(credential, challenge, stored_cred, origin=origin)
|
passkey.instance.auth_verify(credential, challenge, stored_cred, origin)
|
||||||
# Update both credential and user's last_seen timestamp
|
# Update both credential and user's last_seen timestamp
|
||||||
await db.instance.login(stored_cred.user_uuid, stored_cred)
|
await db.instance.login(stored_cred.user_uuid, stored_cred)
|
||||||
|
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>Admin</title>
|
||||||
|
<script type="module" crossorigin src="/auth/assets/admin-D8zxJOk4.js"></script>
|
||||||
|
<link rel="modulepreload" crossorigin href="/auth/assets/_plugin-vue_export-helper-R4vr2A9I.js">
|
||||||
|
<link rel="modulepreload" crossorigin href="/auth/assets/helpers-CU0-cyzg.js">
|
||||||
|
<link rel="modulepreload" crossorigin href="/auth/assets/AccessDenied-guOGfNm-.js">
|
||||||
|
<link rel="stylesheet" crossorigin href="/auth/assets/_plugin-vue_export-helper-Bx2cFCEC.css">
|
||||||
|
<link rel="stylesheet" crossorigin href="/auth/assets/AccessDenied-TAST_piX.css">
|
||||||
|
<link rel="stylesheet" crossorigin href="/auth/assets/admin-DIOoLLHy.css">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="admin-app"></div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -0,0 +1 @@
|
|||||||
|
import{_ as z,G as E,r as g,c as y,o as L,d,e as h,i as f,f as n,t as _,n as N,R as O,C as Y,Y as w,V as T,p as D}from"./_plugin-vue_export-helper-R4vr2A9I.js";const q={class:"app-shell"},G={key:0,class:"global-status",style:{display:"block"}},J={class:"view-root"},W={key:0,class:"surface surface--tight"},j={class:"view-header center"},H={key:0,class:"user-line"},K={class:"view-lede"},Q={class:"section-block"},X={class:"section-body center"},Z={class:"button-row center"},ee=["disabled"],te=["disabled"],ae=["disabled"],se=["disabled"],ne={__name:"RestrictedAuth",props:{mode:{type:String,default:"login",validator:o=>["login","reauth","forbidden"].includes(o)}},emits:["authenticated","forbidden","logout","back","home","auth-error"],setup(o,{expose:V,emit:$}){const v=o,m=$,i=E({show:!1,message:"",type:"info"}),b=g(!0),t=g(!1),S=g(null),r=g(null),l=g("initial");let p=null;const u=y(()=>!!r.value?.authenticated),k=y(()=>b.value?!1:v.mode==="reauth"?!0:l.value!=="forbidden"),U=y(()=>v.mode==="reauth"?"🔐 Additional Authentication":l.value==="forbidden"?"🚫 Forbidden":`🔐 ${S.value?.rp_name||location.origin}`),B=y(()=>v.mode==="reauth"?"Please verify your identity to continue with this action.":l.value==="forbidden"?"You lack the required permissions.":"Please sign in with your passkey."),F=y(()=>r.value?.user?.user_name||"User");function c(e,a="info",s=3e3){i.show=!0,i.message=e,i.type=a,p&&clearTimeout(p),s>0&&(p=setTimeout(()=>{i.show=!1},s))}async function I(){try{const e=await Y();if(S.value=e,e?.rp_name){const a=v.mode==="reauth"?"Verify Identity":u.value?"Forbidden":"Sign In";document.title=`${e.rp_name} · ${a}`}}catch(e){console.warn("Unable to load settings",e)}}async function M(){try{r.value=await w("/auth/api/user-info",{method:"POST"}),u.value&&v.mode!=="reauth"?(l.value="forbidden",m("forbidden",r.value)):l.value="login"}catch(e){console.error("Failed to load user info",e),e.status!==401&&e.status!==403&&c(T(e),"error",4e3),r.value=null,l.value="login"}}async function A(){if(!k.value||t.value)return;t.value=!0,c("Starting authentication…","info");let e;try{e=await D.authenticate()}catch(a){t.value=!1;const s=a?.message||"Passkey authentication cancelled",P=s==="Passkey authentication cancelled";c(s,P?"info":"error",4e3),m("auth-error",{message:s,cancelled:P});return}try{await x(e)}catch(a){t.value=!1;const s=a?.message||"Failed to establish session";c(s,"error",4e3),m("auth-error",{message:s,cancelled:!1});return}t.value=!1,m("authenticated",e)}async function C(){if(!t.value){t.value=!0;try{await w("/auth/api/logout",{method:"POST"}),r.value=null,l.value="login",c("Logged out. You can sign in with a different account.","info",3e3)}catch(e){c(T(e),"error",4e3)}finally{t.value=!1}m("logout")}}function R(){const e=window.open("/auth/","passkey_auth_profile");e&&e.focus()}async function x(e){if(!e?.session_token)throw console.error("setSessionCookie called with missing session_token:",e),new Error("Authentication response missing session_token");return await w("/auth/api/set-session",{method:"POST",headers:{Authorization:`Bearer ${e.session_token}`}})}return L(async()=>{await I(),await M(),b.value=!1}),V({showMessage:c,isAuthenticated:u,userInfo:r}),(e,a)=>(h(),d("div",q,[i.show?(h(),d("div",G,[n("div",{class:N(["status",i.type])},_(i.message),3)])):f("",!0),n("main",J,[b.value?f("",!0):(h(),d("div",W,[n("header",j,[n("h1",null,_(U.value),1),u.value?(h(),d("p",H,"👤 "+_(F.value),1)):f("",!0),n("p",K,_(B.value),1)]),n("section",Q,[n("div",X,[n("div",Z,[O(e.$slots,"actions",{loading:t.value,canAuthenticate:k.value,isAuthenticated:u.value,authenticate:A,logout:C,mode:o.mode},()=>[n("button",{class:"btn-secondary",disabled:t.value,onClick:a[0]||(a[0]=s=>e.$emit("back"))},"Back",8,ee),k.value?(h(),d("button",{key:0,class:"btn-primary",disabled:t.value,onClick:A},_(t.value?o.mode==="reauth"?"Verifying…":"Signing in…":o.mode==="reauth"?"Verify":"Login"),9,te)):f("",!0),u.value&&o.mode!=="reauth"?(h(),d("button",{key:1,class:"btn-danger",disabled:t.value,onClick:C},"Logout",8,ae)):f("",!0),u.value&&o.mode!=="reauth"?(h(),d("button",{key:2,class:"btn-primary",disabled:t.value,onClick:R},"Profile",8,se)):f("",!0)])])])])]))])]))}},ie=z(ne,[["__scopeId","data-v-d00079a6"]]);export{ie as R};
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
.button-row.center[data-v-d00079a6]{display:flex;justify-content:center;gap:.75rem}.user-line[data-v-d00079a6]{margin:.5rem 0 0;font-weight:500;color:var(--color-text)}main.view-root[data-v-d00079a6]{min-height:100vh;align-items:center;justify-content:center;padding:2rem 1rem}.surface.surface--tight[data-v-d00079a6]{max-width:520px;margin:0 auto;width:100%;display:flex;flex-direction:column;gap:1.75rem}
|
||||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -0,0 +1 @@
|
|||||||
|
.view-lede[data-v-0cc830bd]{margin:0;color:var(--color-text-muted);font-size:1rem}.section-header[data-v-0cc830bd]{display:flex;flex-direction:column;gap:.4rem}.section-description[data-v-0cc830bd]{margin:0;color:var(--color-text-muted)}.empty-state[data-v-0cc830bd]{margin:0;color:var(--color-text-muted);text-align:center;padding:1rem 0}.logout-button[data-v-0cc830bd]{align-self:flex-start}.logout-row[data-v-0cc830bd]{gap:1rem}.logout-row.single[data-v-0cc830bd]{justify-content:flex-start}.logout-note[data-v-0cc830bd]{margin:.75rem 0 0;color:var(--color-text-muted);font-size:.875rem}@media(max-width:720px){.logout-button[data-v-0cc830bd]{width:100%}}.host-view[data-v-88828278]{padding:3rem 1.5rem 4rem}.host-actions[data-v-88828278]{display:flex;flex-direction:column;gap:.75rem}.host-actions .button-row[data-v-88828278]{gap:.75rem;flex-wrap:wrap}.host-actions .button-row button[data-v-88828278]{flex:0 0 auto}.note[data-v-88828278],.empty-state[data-v-88828278]{margin:0;color:var(--color-text-muted)}@media(max-width:600px){.host-actions .button-row[data-v-88828278]{flex-direction:column}.host-actions .button-row button[data-v-88828278]{width:100%}}
|
||||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1 @@
|
|||||||
|
import{c as o,W as d,o as i,h as s,e as m,u as l,v as h}from"./_plugin-vue_export-helper-R4vr2A9I.js";import{R as p}from"./RestrictedAuth-BIGLs28V.js";import{g as n}from"./helpers-CU0-cyzg.js";const f={__name:"RestrictedForward",setup(w){const a=o(()=>d()),r=o(()=>{const t=document.documentElement.getAttribute("data-mode");return t==="reauth"?"reauth":t==="forbidden"?"forbidden":"login"});function c(){location.reload()}function u(){const e=a.value||"/auth/";window.location.pathname!==e&&history.replaceState(null,"",e),window.location.href=e}return i(()=>{window.addEventListener("keydown",e=>{e.key==="Escape"&&n()})}),(e,t)=>(m(),s(p,{mode:r.value,onAuthenticated:c,onBack:l(n),onHome:u},null,8,["mode","onBack"]))}};h(f).mount("#app");
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
function f(r){if(!r)return"Never";const s=new Date(r),u=s-new Date,e=u>0,a=Math.abs(u),n=Math.round(a/(1e3*60)),o=Math.round(a/(1e3*60*60)),t=Math.round(a/(1e3*60*60*24));return a<1e3*60?"Now":n<=60?e?`In ${n} minute${n===1?"":"s"}`:n===1?"a minute ago":`${n} minutes ago`:o<=24?e?`In ${o} hour${o===1?"":"s"}`:o===1?"an hour ago":`${o} hours ago`:t<=14?e?`In ${t} day${t===1?"":"s"}`:t===1?"a day ago":`${t} days ago`:s.toLocaleDateString(void 0,{year:"numeric",month:"long",day:"numeric"})}const c=()=>history.back()||window.close();export{f,c as g};
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
.center[data-v-4f202f9a]{text-align:center}.button-row.center[data-v-4f202f9a]{display:flex;justify-content:center}.section-body[data-v-4f202f9a]{gap:1.25rem}.name-edit span[data-v-4f202f9a]{color:var(--color-text-muted);font-size:.9rem}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
import{_ as M,G as F,r as i,c as v,W as b,o as U,d as c,e as u,i as V,f as t,t as g,n as $,z,A as E,S as I,C as N,q as R,X as D,V as K,p as O,v as j}from"./_plugin-vue_export-helper-R4vr2A9I.js";const q={class:"app-shell"},G={key:0,class:"global-status",style:{display:"block"}},H={class:"view-root"},J={class:"surface surface--tight",style:{"max-width":"560px",margin:"0 auto",width:"100%"}},L={class:"view-header",style:{"text-align":"center"}},W={class:"view-lede"},X={key:0,class:"section-block"},Y={key:1,class:"section-block"},Q={class:"section-body center"},Z={key:2,class:"section-block"},ee={class:"section-body"},se={class:"name-edit"},te=["disabled"],ae=["disabled"],ne={__name:"ResetApp",setup(ie){const o=F({show:!1,message:"",type:"info"}),d=i(!0),n=i(!1),r=i(""),x=i(null),p=i(null),f=i(""),m=i("");let h=null;const P=v(()=>p.value?.session_type||"your enrollment"),S=v(()=>d.value?"Preparing your secure enrollment…":y.value?`Finish up ${P.value}. You may edit the name below if needed, and it will be saved to your passkey.`:"This reset link is no longer valid.");v(()=>b());const y=v(()=>!!(r.value&&p.value));function l(e,s="info",a=3e3){o.show=!0,o.message=e,o.type=s,h&&clearTimeout(h),a>0&&(h=setTimeout(()=>{o.show=!1},a))}async function T(){try{const e=await N();x.value=e,e?.rp_name&&(document.title=`${e.rp_name} · Passkey Setup`)}catch(e){console.warn("Unable to load settings",e)}}async function C(){if(r.value)try{p.value=await R(`/auth/api/user-info?reset=${encodeURIComponent(r.value)}`,{method:"POST"}),f.value=p.value?.user?.user_name||""}catch(e){console.error("Failed to load user info",e);const s=e instanceof D?e.data?.detail||"Reset link is invalid or expired.":K(e);m.value=s,l(s,"error",0)}}async function _(){if(!y.value||n.value)return;n.value=!0,l("Starting passkey registration…","info");let e;try{const s=f.value.trim()||null;e=await O.register(r.value,s)}catch(s){n.value=!1;const a=s?.message||"Passkey registration cancelled",k=a==="Passkey registration cancelled";l(k?a:`Registration failed: ${a}`,k?"info":"error",4e3);return}try{await A(e)}catch(s){n.value=!1;const a=s?.message||"Failed to establish session";l(a,"error",4e3);return}l("Passkey registered successfully!","success",800),setTimeout(()=>{n.value=!1,w()},800)}async function A(e){if(!e?.session_token)throw new Error("Registration response missing session_token");return await R("/auth/api/set-session",{method:"POST",headers:{Authorization:`Bearer ${e.session_token}`}})}function w(){const e=b.value||"/auth/";window.location.pathname!==e&&history.replaceState(null,"",e),window.location.reload()}function B(){const e=window.location.pathname.split("/").filter(Boolean);if(!e.length)return"";const s=e[e.length-1],a=e.slice(0,-1);return a.length>1||a.length===1&&a[0]!=="auth"||!s.includes(".")?"":s}return U(async()=>{if(r.value=B(),await T(),!r.value){const e="Reset link is missing or malformed.";m.value=e,l(e,"error",0),d.value=!1;return}await C(),d.value=!1}),(e,s)=>(u(),c("div",q,[o.show?(u(),c("div",G,[t("div",{class:$(["status",o.type])},g(o.message),3)])):V("",!0),t("main",H,[t("div",J,[t("header",L,[s[1]||(s[1]=t("h1",null,"🔑 Registration",-1)),t("p",W,g(S.value),1)]),d.value?(u(),c("section",X,[...s[2]||(s[2]=[t("div",{class:"section-body center"},[t("p",null,"Loading reset details…")],-1)])])):y.value?(u(),c("section",Z,[t("div",ee,[t("label",se,[s[3]||(s[3]=t("span",null,"👤 Name",-1)),z(t("input",{type:"text","onUpdate:modelValue":s[0]||(s[0]=a=>f.value=a),disabled:n.value,maxlength:"64",onKeyup:I(_,["enter"])},null,40,te),[[E,f.value]])]),t("button",{class:"btn-primary",disabled:n.value,onClick:_},g(n.value?"Registering…":"Register Passkey"),9,ae)])])):(u(),c("section",Y,[t("div",Q,[t("p",null,g(m.value),1),t("div",{class:"button-row center",style:{"justify-content":"center"}},[t("button",{class:"btn-secondary",onClick:w},"Return to sign-in")])])]))])])]))}},oe=M(ne,[["__scopeId","data-v-4f202f9a"]]);j(oe).mount("#app");
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
import{c as r,o as c,h as i,e as d,v as u}from"./_plugin-vue_export-helper-R4vr2A9I.js";import{R as p}from"./RestrictedAuth-BIGLs28V.js";const h={__name:"RestrictedApi",setup(m){const a=r(()=>{const n=new URLSearchParams(window.location.hash.slice(1)).get("mode");return n==="reauth"?"reauth":n==="forbidden"?"forbidden":"login"});function t(e){window.parent&&window.parent!==window&&window.parent.postMessage(e,"*")}function s(e){t({type:"auth-success",authenticated:!0,sessionToken:e.session_token})}function o(){t({type:"auth-back"})}return c(()=>{t({type:"auth-ready"}),window.addEventListener("keydown",e=>{e.key==="Escape"&&o()})}),(e,n)=>(d(),i(p,{mode:a.value,onAuthenticated:s,onBack:o},null,8,["mode"]))}};u(h).mount("#app");
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Auth Profile</title>
|
||||||
|
<script type="module" crossorigin src="/auth/assets/auth-a0yJ_sei.js"></script>
|
||||||
|
<link rel="modulepreload" crossorigin href="/auth/assets/_plugin-vue_export-helper-R4vr2A9I.js">
|
||||||
|
<link rel="modulepreload" crossorigin href="/auth/assets/helpers-CU0-cyzg.js">
|
||||||
|
<link rel="modulepreload" crossorigin href="/auth/assets/AccessDenied-guOGfNm-.js">
|
||||||
|
<link rel="stylesheet" crossorigin href="/auth/assets/_plugin-vue_export-helper-Bx2cFCEC.css">
|
||||||
|
<link rel="stylesheet" crossorigin href="/auth/assets/AccessDenied-TAST_piX.css">
|
||||||
|
<link rel="stylesheet" crossorigin href="/auth/assets/auth-CBojJKUK.css">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="app"></div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
<html style="background: transparent">
|
||||||
|
<script type="module" crossorigin src="/auth/assets/restricted-DVCvYFGN.js"></script>
|
||||||
|
<link rel="modulepreload" crossorigin href="/auth/assets/_plugin-vue_export-helper-R4vr2A9I.js">
|
||||||
|
<link rel="modulepreload" crossorigin href="/auth/assets/RestrictedAuth-BIGLs28V.js">
|
||||||
|
<link rel="stylesheet" crossorigin href="/auth/assets/_plugin-vue_export-helper-Bx2cFCEC.css">
|
||||||
|
<link rel="stylesheet" crossorigin href="/auth/assets/RestrictedAuth-CMHKrNJh.css">
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<div id="app"></div>
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Access Restricted</title>
|
||||||
|
<script type="module" crossorigin src="/auth/assets/forward-BHNzlQhM.js"></script>
|
||||||
|
<link rel="modulepreload" crossorigin href="/auth/assets/_plugin-vue_export-helper-R4vr2A9I.js">
|
||||||
|
<link rel="modulepreload" crossorigin href="/auth/assets/RestrictedAuth-BIGLs28V.js">
|
||||||
|
<link rel="modulepreload" crossorigin href="/auth/assets/helpers-CU0-cyzg.js">
|
||||||
|
<link rel="stylesheet" crossorigin href="/auth/assets/_plugin-vue_export-helper-Bx2cFCEC.css">
|
||||||
|
<link rel="stylesheet" crossorigin href="/auth/assets/RestrictedAuth-CMHKrNJh.css">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="app"></div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Complete Passkey Setup</title>
|
||||||
|
<script type="module" crossorigin src="/auth/assets/reset-YnZxhnI5.js"></script>
|
||||||
|
<link rel="modulepreload" crossorigin href="/auth/assets/_plugin-vue_export-helper-R4vr2A9I.js">
|
||||||
|
<link rel="stylesheet" crossorigin href="/auth/assets/_plugin-vue_export-helper-Bx2cFCEC.css">
|
||||||
|
<link rel="stylesheet" crossorigin href="/auth/assets/reset-DXzuKgh6.css">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="app"></div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
from typing import Generic, TypeVar
|
from typing import Generic, TypeVar
|
||||||
|
|
||||||
from .db import DatabaseInterface
|
from paskia.db import DatabaseInterface
|
||||||
from .sansio import Passkey
|
from paskia.sansio import Passkey
|
||||||
|
|
||||||
T = TypeVar("T")
|
T = TypeVar("T")
|
||||||
|
|
||||||
@@ -29,9 +29,7 @@ class Manager(Generic[T]):
|
|||||||
async def init(
|
async def init(
|
||||||
rp_id: str = "localhost",
|
rp_id: str = "localhost",
|
||||||
rp_name: str | None = None,
|
rp_name: str | None = None,
|
||||||
origin: str | None = None,
|
origins: list[str] | None = None,
|
||||||
default_admin: str | None = None,
|
|
||||||
default_org: str | None = None,
|
|
||||||
*,
|
*,
|
||||||
bootstrap: bool = True,
|
bootstrap: bool = True,
|
||||||
) -> None:
|
) -> None:
|
||||||
@@ -45,7 +43,7 @@ async def init(
|
|||||||
passkey.instance = Passkey(
|
passkey.instance = Passkey(
|
||||||
rp_id=rp_id,
|
rp_id=rp_id,
|
||||||
rp_name=rp_name or rp_id,
|
rp_name=rp_name or rp_id,
|
||||||
origin=origin,
|
origins=origins,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Test if we have a database already initialized, otherwise use SQL
|
# Test if we have a database already initialized, otherwise use SQL
|
||||||
@@ -60,7 +58,7 @@ async def init(
|
|||||||
# Bootstrap system if needed
|
# Bootstrap system if needed
|
||||||
from .bootstrap import bootstrap_if_needed
|
from .bootstrap import bootstrap_if_needed
|
||||||
|
|
||||||
await bootstrap_if_needed(default_admin, default_org)
|
await bootstrap_if_needed()
|
||||||
|
|
||||||
|
|
||||||
# Global instances
|
# Global instances
|
||||||
@@ -37,7 +37,7 @@ from webauthn.helpers.structs import (
|
|||||||
UserVerificationRequirement,
|
UserVerificationRequirement,
|
||||||
)
|
)
|
||||||
|
|
||||||
from .db import Credential
|
from paskia.db import Credential
|
||||||
|
|
||||||
|
|
||||||
class Passkey:
|
class Passkey:
|
||||||
@@ -47,7 +47,7 @@ class Passkey:
|
|||||||
self,
|
self,
|
||||||
rp_id: str,
|
rp_id: str,
|
||||||
rp_name: str | None = None,
|
rp_name: str | None = None,
|
||||||
origin: str | None = None,
|
origins: list[str] | None = None,
|
||||||
supported_pub_key_algs: list[COSEAlgorithmIdentifier] | None = None,
|
supported_pub_key_algs: list[COSEAlgorithmIdentifier] | None = None,
|
||||||
):
|
):
|
||||||
"""
|
"""
|
||||||
@@ -56,40 +56,58 @@ class Passkey:
|
|||||||
Args:
|
Args:
|
||||||
rp_id: Your security domain (e.g. "example.com")
|
rp_id: Your security domain (e.g. "example.com")
|
||||||
rp_name: The relying party display name (e.g. "Example App"). May be shown in authenticators.
|
rp_name: The relying party display name (e.g. "Example App"). May be shown in authenticators.
|
||||||
origin: The origin URL of the application (e.g. "https://app.example.com").
|
origins: List of allowed origin URLs (e.g. ["https://app.example.com", "https://auth.example.com"]).
|
||||||
If no scheme is provided, "https://" will be prepended.
|
Each must be a subdomain or same as rp_id. If not provided, any subdomain of rp_id is allowed.
|
||||||
Must be a subdomain or same as rp_id, with port and scheme but no path included.
|
|
||||||
supported_pub_key_algs: List of supported COSE algorithms (default is EDDSA, ECDSA_SHA_256, RSASSA_PKCS1_v1_5_SHA_256).
|
supported_pub_key_algs: List of supported COSE algorithms (default is EDDSA, ECDSA_SHA_256, RSASSA_PKCS1_v1_5_SHA_256).
|
||||||
|
|
||||||
Raises:
|
Raises:
|
||||||
ValueError: If the origin domain doesn't match or isn't a subdomain of rp_id.
|
ValueError: If any origin domain doesn't match or isn't a subdomain of rp_id.
|
||||||
"""
|
"""
|
||||||
self.rp_id = rp_id
|
self.rp_id = rp_id
|
||||||
self.rp_name = rp_name or rp_id
|
self.rp_name = rp_name or rp_id
|
||||||
self.origin = self._normalize_and_validate_origin(origin, rp_id)
|
self.allowed_origins: set[str] | None = None
|
||||||
|
if origins:
|
||||||
|
# Validate and deduplicate origins into a set for O(1) lookups
|
||||||
|
for o in origins:
|
||||||
|
self._validate_origin(o, rp_id)
|
||||||
|
self.allowed_origins = set(origins)
|
||||||
self.supported_pub_key_algs = supported_pub_key_algs or [
|
self.supported_pub_key_algs = supported_pub_key_algs or [
|
||||||
COSEAlgorithmIdentifier.EDDSA,
|
COSEAlgorithmIdentifier.EDDSA,
|
||||||
COSEAlgorithmIdentifier.ECDSA_SHA_256,
|
COSEAlgorithmIdentifier.ECDSA_SHA_256,
|
||||||
COSEAlgorithmIdentifier.RSASSA_PKCS1_v1_5_SHA_256,
|
COSEAlgorithmIdentifier.RSASSA_PKCS1_v1_5_SHA_256,
|
||||||
]
|
]
|
||||||
|
|
||||||
def _normalize_and_validate_origin(self, origin: str | None, rp_id: str) -> str:
|
def _validate_origin(self, origin: str, rp_id: str) -> None:
|
||||||
if origin is None:
|
"""Validate an origin URL against the rp_id."""
|
||||||
origin = f"https://{rp_id}"
|
|
||||||
elif "://" not in origin:
|
|
||||||
origin = f"https://{origin}"
|
|
||||||
|
|
||||||
hostname = urlparse(origin).hostname
|
hostname = urlparse(origin).hostname
|
||||||
if not hostname:
|
if not hostname:
|
||||||
raise ValueError(f"Invalid origin URL: no hostname found in '{origin}'")
|
raise ValueError(f"Invalid origin URL: no hostname found in '{origin}'")
|
||||||
|
|
||||||
if hostname == rp_id or hostname.endswith(f".{rp_id}"):
|
if hostname == rp_id or hostname.endswith(f".{rp_id}"):
|
||||||
return origin
|
return
|
||||||
|
|
||||||
raise ValueError(
|
raise ValueError(
|
||||||
f"Origin domain '{hostname}' must be the same as or a subdomain of rp_id '{rp_id}'"
|
f"Origin domain '{hostname}' must be the same as or a subdomain of rp_id '{rp_id}'"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def validate_origin(self, origin: str) -> str:
|
||||||
|
"""Validate that origin is allowed and return it.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
origin: The origin URL to validate (from WebSocket request header)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
The validated origin URL
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ValueError: If origin is not in the allowed list (when origins are configured)
|
||||||
|
or if origin is not a valid subdomain of rp_id
|
||||||
|
"""
|
||||||
|
self._validate_origin(origin, self.rp_id)
|
||||||
|
if self.allowed_origins is not None and origin not in self.allowed_origins:
|
||||||
|
raise ValueError(f"Origin '{origin}' is not in the allowed origins list")
|
||||||
|
return origin
|
||||||
|
|
||||||
### Registration Methods ###
|
### Registration Methods ###
|
||||||
|
|
||||||
def reg_generate_options(
|
def reg_generate_options(
|
||||||
@@ -137,14 +155,16 @@ class Passkey:
|
|||||||
response_json: dict | str,
|
response_json: dict | str,
|
||||||
expected_challenge: bytes,
|
expected_challenge: bytes,
|
||||||
user_uuid: UUID,
|
user_uuid: UUID,
|
||||||
origin: str | None = None,
|
origin: str,
|
||||||
) -> Credential:
|
) -> Credential:
|
||||||
"""
|
"""
|
||||||
Verify registration response.
|
Verify registration response.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
credential: The credential response from the client
|
response_json: The credential response from the client
|
||||||
expected_challenge: The expected challenge bytes
|
expected_challenge: The expected challenge bytes
|
||||||
|
user_uuid: The user's UUID
|
||||||
|
origin: The origin URL (required, must be pre-validated)
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Registration verification result
|
Registration verification result
|
||||||
@@ -153,7 +173,7 @@ class Passkey:
|
|||||||
registration = verify_registration_response(
|
registration = verify_registration_response(
|
||||||
credential=credential,
|
credential=credential,
|
||||||
expected_challenge=expected_challenge,
|
expected_challenge=expected_challenge,
|
||||||
expected_origin=origin or self.origin,
|
expected_origin=origin,
|
||||||
expected_rp_id=self.rp_id,
|
expected_rp_id=self.rp_id,
|
||||||
)
|
)
|
||||||
return Credential(
|
return Credential(
|
||||||
@@ -206,7 +226,7 @@ class Passkey:
|
|||||||
credential: AuthenticationCredential,
|
credential: AuthenticationCredential,
|
||||||
expected_challenge: bytes,
|
expected_challenge: bytes,
|
||||||
stored_cred: Credential,
|
stored_cred: Credential,
|
||||||
origin: str | None = None,
|
origin: str,
|
||||||
) -> VerifiedAuthentication:
|
) -> VerifiedAuthentication:
|
||||||
"""
|
"""
|
||||||
Verify authentication response against locally stored credential data.
|
Verify authentication response against locally stored credential data.
|
||||||
@@ -215,13 +235,13 @@ class Passkey:
|
|||||||
credential: The authentication credential response from the client
|
credential: The authentication credential response from the client
|
||||||
expected_challenge: The earlier generated challenge bytes
|
expected_challenge: The earlier generated challenge bytes
|
||||||
stored_cred: The server stored credential record (modified by this function)
|
stored_cred: The server stored credential record (modified by this function)
|
||||||
|
origin: The origin URL (required, must be pre-validated)
|
||||||
"""
|
"""
|
||||||
expected_origin = origin or self.origin
|
|
||||||
# Verify the authentication response
|
# Verify the authentication response
|
||||||
verification = verify_authentication_response(
|
verification = verify_authentication_response(
|
||||||
credential=credential,
|
credential=credential,
|
||||||
expected_challenge=expected_challenge,
|
expected_challenge=expected_challenge,
|
||||||
expected_origin=expected_origin,
|
expected_origin=origin,
|
||||||
expected_rp_id=self.rp_id,
|
expected_rp_id=self.rp_id,
|
||||||
credential_public_key=stored_cred.public_key,
|
credential_public_key=stored_cred.public_key,
|
||||||
credential_current_sign_count=stored_cred.sign_count,
|
credential_current_sign_count=stored_cred.sign_count,
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
import asyncio
|
||||||
|
import mimetypes
|
||||||
|
import os
|
||||||
|
from importlib import resources
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
__all__ = ["path", "file", "read", "is_dev_mode"]
|
||||||
|
|
||||||
|
DEV_SERVER = "http://localhost:4403"
|
||||||
|
|
||||||
|
|
||||||
|
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 os.environ.get("PASKIA_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)
|
||||||
@@ -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 configured_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_base_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_base_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,6 +1,6 @@
|
|||||||
import secrets
|
import secrets
|
||||||
|
|
||||||
from .wordlist import words
|
from paskia.util.wordlist import words
|
||||||
|
|
||||||
N_WORDS = 5
|
N_WORDS = 5
|
||||||
|
|
||||||
@@ -3,9 +3,9 @@
|
|||||||
from collections.abc import Sequence
|
from collections.abc import Sequence
|
||||||
from fnmatch import fnmatchcase
|
from fnmatch import fnmatchcase
|
||||||
|
|
||||||
from ..globals import db
|
from paskia.globals import db
|
||||||
from .hostutil import normalize_host
|
from paskia.util.hostutil import normalize_host
|
||||||
from .tokens import session_key
|
from paskia.util.tokens import session_key
|
||||||
|
|
||||||
__all__ = ["has_any", "has_all", "session_context"]
|
__all__ = ["has_any", "has_all", "session_context"]
|
||||||
|
|
||||||
@@ -2,8 +2,8 @@
|
|||||||
|
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
from ..db import SessionContext
|
from paskia.db import SessionContext
|
||||||
from .timeutil import parse_duration
|
from paskia.util.timeutil import parse_duration
|
||||||
|
|
||||||
|
|
||||||
def check_session_age(ctx: SessionContext, max_age: str | None) -> bool:
|
def check_session_age(ctx: SessionContext, max_age: str | None) -> bool:
|
||||||
@@ -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))
|
||||||
@@ -2,7 +2,7 @@ import base64
|
|||||||
import hashlib
|
import hashlib
|
||||||
import secrets
|
import secrets
|
||||||
|
|
||||||
from .passphrase import is_well_formed
|
from paskia.util.passphrase import is_well_formed
|
||||||
|
|
||||||
|
|
||||||
def create_token() -> str:
|
def create_token() -> str:
|
||||||
@@ -2,12 +2,10 @@
|
|||||||
|
|
||||||
from datetime import timezone
|
from datetime import timezone
|
||||||
|
|
||||||
from passkey.util import useragent
|
from paskia import aaguid
|
||||||
|
from paskia.authsession import session_key
|
||||||
from .. import aaguid
|
from paskia.globals import db
|
||||||
from ..authsession import session_key
|
from paskia.util import hostutil, permutil, tokens, useragent
|
||||||
from ..globals import db
|
|
||||||
from . import hostutil, permutil, tokens
|
|
||||||
|
|
||||||
|
|
||||||
def _format_datetime(dt):
|
def _format_datetime(dt):
|
||||||
@@ -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
|
|
||||||
+45
-6
@@ -3,9 +3,10 @@ requires = ["hatchling", "hatch-vcs"]
|
|||||||
build-backend = "hatchling.build"
|
build-backend = "hatchling.build"
|
||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "passkey"
|
name = "paskia"
|
||||||
dynamic = ["version"]
|
dynamic = ["version"]
|
||||||
description = "Passkey Authentication for Web Services"
|
description = "Passkey Auth made easy: all sites and APIs can be guarded even without any changes on the protected site."
|
||||||
|
keywords = [ "forward_auth", "auth_request", "FastAPI" ]
|
||||||
authors = [
|
authors = [
|
||||||
{name = "Leo Vasanko"},
|
{name = "Leo Vasanko"},
|
||||||
]
|
]
|
||||||
@@ -26,11 +27,40 @@ requires-python = ">=3.10"
|
|||||||
source = "vcs"
|
source = "vcs"
|
||||||
|
|
||||||
[tool.hatch.build.hooks.vcs]
|
[tool.hatch.build.hooks.vcs]
|
||||||
version-file = "passkey/_version.py"
|
version-file = "paskia/_version.py"
|
||||||
|
|
||||||
[project.optional-dependencies]
|
[project.optional-dependencies]
|
||||||
dev = [
|
dev = [
|
||||||
"ruff>=0.1.0",
|
"ruff>=0.1.0",
|
||||||
|
"coverage[toml]>=7.0.0",
|
||||||
|
"pytest>=8.0.0",
|
||||||
|
"pytest-asyncio>=0.24.0",
|
||||||
|
"httpx>=0.27.0",
|
||||||
|
]
|
||||||
|
|
||||||
|
[tool.coverage.run]
|
||||||
|
source = ["paskia"]
|
||||||
|
branch = true
|
||||||
|
parallel = true
|
||||||
|
sigterm = true
|
||||||
|
|
||||||
|
[tool.coverage.report]
|
||||||
|
exclude_lines = [
|
||||||
|
"pragma: no cover",
|
||||||
|
"if TYPE_CHECKING:",
|
||||||
|
"if __name__ == .__main__.:",
|
||||||
|
]
|
||||||
|
show_missing = true
|
||||||
|
|
||||||
|
[tool.coverage.html]
|
||||||
|
directory = "coverage-html"
|
||||||
|
|
||||||
|
[tool.pytest.ini_options]
|
||||||
|
asyncio_mode = "auto"
|
||||||
|
asyncio_default_fixture_loop_scope = "function"
|
||||||
|
testpaths = ["tests"]
|
||||||
|
filterwarnings = [
|
||||||
|
"ignore::DeprecationWarning",
|
||||||
]
|
]
|
||||||
|
|
||||||
[tool.ruff]
|
[tool.ruff]
|
||||||
@@ -40,11 +70,20 @@ line-length = 88
|
|||||||
[tool.ruff.lint]
|
[tool.ruff.lint]
|
||||||
select = ["E", "F", "I", "N", "W", "UP"]
|
select = ["E", "F", "I", "N", "W", "UP"]
|
||||||
ignore = ["E501"] # Line too long
|
ignore = ["E501"] # Line too long
|
||||||
isort.known-first-party = ["passkey"]
|
isort.known-first-party = ["paskia"]
|
||||||
|
|
||||||
|
[dependency-groups]
|
||||||
|
dev = [
|
||||||
|
"coverage>=7.12.0",
|
||||||
|
"httpx>=0.28.1",
|
||||||
|
"pytest>=9.0.1",
|
||||||
|
"pytest-asyncio>=1.3.0",
|
||||||
|
"pytest-cov>=7.0.0",
|
||||||
|
]
|
||||||
|
|
||||||
[project.scripts]
|
[project.scripts]
|
||||||
passkey-auth = "passkey.fastapi.__main__:main"
|
paskia = "paskia.fastapi.__main__:main"
|
||||||
|
|
||||||
[tool.hatch.build]
|
[tool.hatch.build]
|
||||||
artifacts = ["passkey/frontend-build"]
|
artifacts = ["paskia/frontend-build"]
|
||||||
targets.sdist.hooks.custom.path = "scripts/build-frontend.py"
|
targets.sdist.hooks.custom.path = "scripts/build-frontend.py"
|
||||||
|
|||||||
Executable
+156
@@ -0,0 +1,156 @@
|
|||||||
|
#!/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.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import atexit
|
||||||
|
import os
|
||||||
|
import shutil
|
||||||
|
import signal
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
from sys import stderr
|
||||||
|
from threading import Thread
|
||||||
|
|
||||||
|
from paskia.fastapi.__main__ import parse_endpoint
|
||||||
|
|
||||||
|
DEFAULT_VITE_PORT = 4403 # overrides by CLI option
|
||||||
|
BACKEND_PORT = 4402 # hardcoded, also in vite.config.ts
|
||||||
|
|
||||||
|
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.
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
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", "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)]
|
||||||
|
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 main():
|
||||||
|
# Parse optional hostport argument for Vite frontend
|
||||||
|
parser = argparse.ArgumentParser(add_help=False)
|
||||||
|
parser.add_argument("hostport", nargs="?", default=None)
|
||||||
|
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}"
|
||||||
|
|
||||||
|
# Start Vite dev server
|
||||||
|
run_vite(vite_url, vite_host, vite_port)
|
||||||
|
|
||||||
|
# Set dev mode with Vite URL
|
||||||
|
os.environ["PASKIA_DEVMODE"] = vite_url
|
||||||
|
|
||||||
|
# Import CLI after environment is set up
|
||||||
|
from paskia.fastapi.__main__ import main as cli_main
|
||||||
|
|
||||||
|
# Build argv for the main CLI in Dev mode
|
||||||
|
# Backend always listens on localhost only (Vite proxies API requests)
|
||||||
|
sys.argv = ["paskia", "serve", f"localhost:{BACKEND_PORT}"] + remaining
|
||||||
|
cli_main()
|
||||||
|
|
||||||
|
|
||||||
|
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