Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
83419d1845 | ||
|
|
a2fe0b6f1a | ||
|
|
a1b73711e6 | ||
|
|
df5c176bcd | ||
|
|
8937905c9c | ||
|
|
127e06179b | ||
|
|
c1204ca020 | ||
|
|
208115ebc3 | ||
|
|
8609f2fe69 | ||
|
|
0355c55fc0 |
No files matched your search
+5
-4
@@ -3,7 +3,8 @@ dist/
|
||||
.*
|
||||
!.gitignore
|
||||
*.lock
|
||||
passkey-auth.sqlite
|
||||
/passkey/frontend-build
|
||||
/test_*.py
|
||||
passkey/_version.py
|
||||
paskia.sqlite
|
||||
/paskia/frontend-build
|
||||
/paskia/_version.py
|
||||
coverage-html/
|
||||
e2e/coverage-frontend/
|
||||
@@ -1,6 +1,6 @@
|
||||
# PassKey Auth API Documentation
|
||||
# Paskia API Documentation
|
||||
|
||||
This document lists the HTTP and WebSocket endpoints exposed by the PassKey Auth
|
||||
This document lists the HTTP and WebSocket endpoints exposed by the Paskia
|
||||
service and how they behave depending on whether a dedicated authentication host
|
||||
(`--auth-host` / environment `PASSKEY_AUTH_HOST`) is configured.
|
||||
|
||||
|
||||
@@ -1,129 +1,72 @@
|
||||
# PasskeyAuth
|
||||
# Paskia
|
||||
|
||||
A minimal FastAPI WebAuthn server with WebSocket support for passkey registration. This project demonstrates WebAuthn registration flow with Resident Keys (discoverable credentials) using modern Python tooling.
|
||||
An easy to install passkey-based authentication service that protects any web application with strong passwordless login.
|
||||
|
||||
## Features
|
||||
## What is Paskia?
|
||||
|
||||
- 🔐 WebAuthn registration with Resident Keys support
|
||||
- 🔌 WebSocket-based communication for real-time interaction
|
||||
- 🚀 Modern Python packaging with `pyproject.toml`
|
||||
- 🎨 Clean, responsive HTML interface using @simplewebauthn/browser
|
||||
- 📦 No database required - challenges stored locally per connection
|
||||
- 🛠️ Development tools: `ruff` for linting and formatting
|
||||
- 🧹 Clean architecture with local challenge management
|
||||
- Easy to use fully featured auth&auth system (login and permissions)
|
||||
- Organization and role-based access control (optional)
|
||||
* Org admins control their users and roles
|
||||
* Master admin can create multiple independent orgs
|
||||
* Master admin makes permissions available for orgs to assign
|
||||
- User Profile and Administration by API and web interface.
|
||||
under `/auth/` or `auth.example.com`
|
||||
- Reset tokens and additional device linking via QR code or codewords.
|
||||
- Pure Python, FastAPI, packaged with prebuilt Vue frontend
|
||||
|
||||
## Docs
|
||||
Two interfaces:
|
||||
- API fetch: auth checks and login without leaving your app
|
||||
- Forward-auth proxy: protect any unprotected site or service (Caddy, Nginx)
|
||||
|
||||
- Caddy integration: see `CADDY.md` for short, copy-paste snippets to secure your site with Caddy.
|
||||
The API mode is useful for applications that can be customized to run with Paskia. Forward auth can also protect your javascript and other assets. Each provides fine-grained permission control and reauthentication requests where needed, and both can be mixed where needed.
|
||||
|
||||
## Requirements
|
||||
|
||||
- Python 3.9+
|
||||
- A WebAuthn-compatible authenticator (security key, biometric device, etc.)
|
||||
Single Sign-On (SSO): Users register once and authenticate across all applications under your domain name (configured rp-id).
|
||||
|
||||
## Quick Start
|
||||
|
||||
### Install (editable dev mode)
|
||||
Install [UV](https://docs.astral.sh/uv/getting-started/installation/) and run:
|
||||
|
||||
```fish
|
||||
uv pip install -e .[dev]
|
||||
uvx paskia serve --rp-id example.com
|
||||
```
|
||||
|
||||
### Run (new CLI)
|
||||
On the first run it downloads the software and prints a registration link for the Admin. If you are going to be connecting `localhost` directly, for testing, leave out the rp-id.
|
||||
|
||||
`passkey-auth` now provides subcommands:
|
||||
The server will start up on [localhost:4401](http://localhost:4401) "for authentication required", serving for `*.example.com`.
|
||||
|
||||
Otherwise you will need a web server such as [Caddy](https://caddyserver.com/) to serve HTTPS on your actual domain names and proxy requests to Paskia and your backend apps.
|
||||
|
||||
A quick example without any config file:
|
||||
```fish
|
||||
sudo caddy reverse-proxy --from example.com --to :4401
|
||||
```
|
||||
|
||||
For a permanent install of `paskia` CLI command, not needing `uvx`:
|
||||
|
||||
```fish
|
||||
uv tool install paskia
|
||||
```
|
||||
|
||||
## Configuration
|
||||
|
||||
There is no config file. Pass only the options on CLI:
|
||||
|
||||
```text
|
||||
passkey-auth serve [host:port] [--options]
|
||||
passkey-auth dev [--options]
|
||||
paskia serve [options]
|
||||
```
|
||||
|
||||
Examples (fish shell shown):
|
||||
Optional options:
|
||||
|
||||
```fish
|
||||
# Production style (no reload)
|
||||
passkey-auth serve
|
||||
passkey-auth serve 0.0.0.0:8080 --rp-id example.com --origin https://example.com
|
||||
- Listen address (one of):
|
||||
* `[host]:port`: Address and port (default: `localhost:4401`)
|
||||
* `unix:/path.sock`: Unix socket
|
||||
- `--rp-id <domain>`: Main domain (required for production)
|
||||
- `--rp-name "<text>"`: Name of your company or site (default: same as rp-id)
|
||||
- `--origin <url>`: Explicit single site (default: `https://<rp-id>`)
|
||||
- `--auth-host <domain>`: Dedicated authentication site (e.g., `auth.example.com`)
|
||||
|
||||
# Development (auto-reload)
|
||||
passkey-auth dev # localhost:4401
|
||||
passkey-auth dev :5500 # localhost on port 5500
|
||||
passkey-auth dev 127.0.0.1 # host only, default port 4401
|
||||
```
|
||||
## Documentation
|
||||
|
||||
Available options (both subcommands):
|
||||
|
||||
```text
|
||||
--rp-id <id> Relying Party ID (default: localhost)
|
||||
--rp-name <name> Relying Party name (default: same as rp-id)
|
||||
--origin <url> Explicit origin (default: https://<rp-id>)
|
||||
```
|
||||
|
||||
### Legacy Invocation
|
||||
|
||||
If you previously used `python -m passkey.fastapi --dev --host ...`, switch to the new form above. The old flags `--host`, `--port`, and `--dev` are replaced by the `[host:port]` positional and the `dev` subcommand.
|
||||
|
||||
## Usage (Web)
|
||||
|
||||
1. Start the server with one of the commands above
|
||||
2. Open your browser to `http://localhost:4401/auth/` (or your chosen host/port)
|
||||
3. Enter a username (or use the default)
|
||||
4. Click "Register Passkey"
|
||||
5. Follow your authenticator's prompts
|
||||
|
||||
Real-time status updates stream over WebSocket.
|
||||
|
||||
## Development
|
||||
|
||||
### Code Quality
|
||||
|
||||
```fish
|
||||
# Run linting and formatting with ruff
|
||||
uv run ruff check .
|
||||
uv run ruff format .
|
||||
|
||||
# Or with hatch
|
||||
hatch run ruff check .
|
||||
hatch run ruff format .
|
||||
```
|
||||
|
||||
### Project Structure
|
||||
|
||||
```
|
||||
passkeyauth/
|
||||
├── passkeyauth/
|
||||
│ ├── __init__.py
|
||||
│ └── main.py # FastAPI server with WebSocket support
|
||||
├── static/
|
||||
│ └── index.html # Frontend interface
|
||||
├── pyproject.toml # Modern Python packaging configuration
|
||||
└── README.md
|
||||
```
|
||||
|
||||
## Technical Details
|
||||
|
||||
### WebAuthn Configuration
|
||||
|
||||
- **Relying Party ID**: `localhost` (for development)
|
||||
- **Resident Keys**: Required (enables discoverable credentials)
|
||||
- **User Verification**: Preferred
|
||||
- **Supported Algorithms**: ECDSA-SHA256, RSASSA-PKCS1-v1_5-SHA256
|
||||
|
||||
### WebSocket Message Flow
|
||||
|
||||
1. Client connects to `/ws/{client_id}`
|
||||
2. Client sends `registration_challenge` message
|
||||
3. Server responds with `registration_challenge_response`
|
||||
4. Client completes WebAuthn ceremony and sends `registration_response`
|
||||
5. Server verifies and responds with `registration_success` or `error`
|
||||
|
||||
### Security Notes
|
||||
|
||||
- This is a minimal demo - challenges are stored locally per WebSocket connection
|
||||
- For production use, implement proper user storage and session management
|
||||
- Consider using Redis or similar for challenge storage in production with multiple server instances
|
||||
- Ensure HTTPS in production environments
|
||||
|
||||
## License
|
||||
|
||||
MIT License - feel free to use this as a starting point for your own WebAuthn implementations!
|
||||
- `API.md`: Complete HTTP and WebSocket API reference
|
||||
- `Caddy.md`: Caddy configuration examples
|
||||
- `Headers.md`: HTTP headers passed to protected applications
|
||||
+15
-5
@@ -1,6 +1,6 @@
|
||||
# PasskeyAuth E2E Tests
|
||||
# Paskia E2E Tests
|
||||
|
||||
End-to-end tests for PasskeyAuth using [Playwright](https://playwright.dev/) with Chrome's **Virtual Authenticator**.
|
||||
End-to-end tests for Paskia using [Playwright](https://playwright.dev/) with Chrome's **Virtual Authenticator**.
|
||||
|
||||
## Overview
|
||||
|
||||
@@ -33,10 +33,20 @@ npm test
|
||||
```
|
||||
|
||||
This will:
|
||||
1. Start a fresh PasskeyAuth server with a test database
|
||||
1. Start a fresh Paskia server with a test database
|
||||
2. Run all E2E tests against it
|
||||
3. Clean up the server when done
|
||||
|
||||
### With Coverage
|
||||
|
||||
```bash
|
||||
npm run test:coverage
|
||||
```
|
||||
|
||||
Runs tests and collects coverage for both:
|
||||
- **Python backend** (via `coverage.py`) - HTML report in `coverage-html/`
|
||||
- **Frontend JavaScript** (via Chrome V8 coverage) - JSON data in `e2e/coverage-frontend/`
|
||||
|
||||
### Interactive Mode
|
||||
|
||||
```bash
|
||||
@@ -86,7 +96,7 @@ e2e/
|
||||
- WebSocket challenge-response with virtual authenticator
|
||||
- Session token creation and validation
|
||||
|
||||
### Authentication Flow
|
||||
### Authentication Flow
|
||||
- Passkey authentication via WebSocket
|
||||
- Credential verification
|
||||
- Session management
|
||||
@@ -125,7 +135,7 @@ This creates an in-browser authenticator that:
|
||||
|
||||
| Variable | Description | Default |
|
||||
|----------|-------------|---------|
|
||||
| `BASE_URL` | Server URL | `http://localhost:4401` |
|
||||
| `BASE_URL` | Server URL | `http://localhost:4404` |
|
||||
| `CI` | CI environment flag | - |
|
||||
| `CLEANUP_TEST_DB` | Remove test DB after run | `false` |
|
||||
|
||||
|
||||
Generated
+1127
File diff suppressed because it is too large.
Load diff
+5
-3
@@ -1,20 +1,22 @@
|
||||
{
|
||||
"name": "passkey-auth-e2e",
|
||||
"name": "paskia-e2e",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"description": "E2E tests for PasskeyAuth using Playwright with Virtual Authenticator",
|
||||
"description": "E2E tests for Paskia using Playwright with Virtual Authenticator",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"test": "bunx playwright test",
|
||||
"test:headed": "bunx playwright test --headed",
|
||||
"test:debug": "bunx playwright test --debug",
|
||||
"test:ui": "bunx playwright test --ui",
|
||||
"test:coverage": "COVERAGE=1 bunx playwright test",
|
||||
"report": "bunx playwright show-report",
|
||||
"install:browsers": "bunx playwright install chromium"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@playwright/test": "^1.49.0",
|
||||
"@simplewebauthn/browser": "^13.1.2",
|
||||
"@types/bun": "^1.3.3"
|
||||
"@types/bun": "^1.3.3",
|
||||
"c8": "^10.1.3"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import { defineConfig, devices } from '@playwright/test'
|
||||
|
||||
/**
|
||||
* Playwright configuration for Paskia E2E tests.
|
||||
* Uses Chrome's Virtual Authenticator for automated passkey testing.
|
||||
*
|
||||
* Run with: bun run test
|
||||
*/
|
||||
|
||||
export default defineConfig({
|
||||
testDir: './tests',
|
||||
fullyParallel: false, // Run tests sequentially for passkey state consistency
|
||||
forbidOnly: !!process.env.CI,
|
||||
retries: process.env.CI ? 2 : 0,
|
||||
workers: 1, // Single worker for database state consistency
|
||||
reporter: [
|
||||
['html', { open: 'never' }],
|
||||
['list']
|
||||
],
|
||||
|
||||
// Global setup/teardown for test database and server
|
||||
globalSetup: './tests/global-setup.ts',
|
||||
globalTeardown: './tests/global-teardown.ts',
|
||||
|
||||
use: {
|
||||
// Base URL for the Paskia server
|
||||
baseURL: process.env.BASE_URL || 'http://localhost:4404',
|
||||
|
||||
// Collect trace on failure for debugging
|
||||
trace: 'on-first-retry',
|
||||
|
||||
// Screenshot on failure
|
||||
screenshot: 'only-on-failure',
|
||||
},
|
||||
|
||||
projects: [
|
||||
{
|
||||
name: 'chromium',
|
||||
use: {
|
||||
...devices['Desktop Chrome'],
|
||||
// Chrome-specific settings for virtual authenticator
|
||||
launchOptions: {
|
||||
args: [
|
||||
'--enable-features=WebAuthenticationEnterpriseAttestation',
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
})
|
||||
@@ -1,9 +1,9 @@
|
||||
import { defineConfig, devices } from '@playwright/test'
|
||||
|
||||
/**
|
||||
* Playwright configuration for PasskeyAuth E2E tests.
|
||||
* Playwright configuration for Paskia E2E tests.
|
||||
* Uses Chrome's Virtual Authenticator for automated passkey testing.
|
||||
*
|
||||
*
|
||||
* Run with: bun run test
|
||||
*/
|
||||
|
||||
@@ -17,18 +17,18 @@ export default defineConfig({
|
||||
['html', { open: 'never' }],
|
||||
['list']
|
||||
],
|
||||
|
||||
|
||||
// Global setup/teardown for test database and server
|
||||
globalSetup: './tests/global-setup.ts',
|
||||
globalTeardown: './tests/global-teardown.ts',
|
||||
|
||||
|
||||
use: {
|
||||
// Base URL for the passkey-auth server
|
||||
// Base URL for the Paskia server
|
||||
baseURL: process.env.BASE_URL || 'http://localhost:4401',
|
||||
|
||||
|
||||
// Collect trace on failure for debugging
|
||||
trace: 'on-first-retry',
|
||||
|
||||
|
||||
// Screenshot on failure
|
||||
screenshot: 'only-on-failure',
|
||||
},
|
||||
@@ -0,0 +1,635 @@
|
||||
import { test, expect, createVirtualAuthenticator } from './fixtures/virtual-authenticator'
|
||||
import {
|
||||
registerPasskey,
|
||||
authenticatePasskey,
|
||||
validateSession,
|
||||
getUserInfo,
|
||||
logout,
|
||||
getBootstrapResetToken,
|
||||
createDeviceLink,
|
||||
getSessionCookieName,
|
||||
saveSessionToken,
|
||||
getSavedSessionToken,
|
||||
saveDeviceTokens,
|
||||
} from './fixtures/passkey-helpers'
|
||||
import type { Page, BrowserContext } from '@playwright/test'
|
||||
|
||||
/**
|
||||
* Helper to set up session cookie for a page.
|
||||
*/
|
||||
async function setupSessionCookie(page: Page, sessionToken: string): Promise<void> {
|
||||
const cookieName = getSessionCookieName()
|
||||
await page.context().addCookies([{
|
||||
name: cookieName,
|
||||
value: sessionToken,
|
||||
domain: 'localhost',
|
||||
path: '/',
|
||||
secure: true,
|
||||
httpOnly: true,
|
||||
sameSite: 'Strict' as const,
|
||||
}])
|
||||
}
|
||||
|
||||
/**
|
||||
* E2E tests for Paskia using Chrome's Virtual Authenticator.
|
||||
*
|
||||
* These tests exercise the complete WebAuthn flow:
|
||||
* 1. Registration via WebSocket using bootstrap reset token
|
||||
* 2. Authentication via WebSocket
|
||||
* 3. Session validation
|
||||
* 4. User info retrieval
|
||||
* 5. Logout
|
||||
*
|
||||
* The virtual authenticator simulates a hardware passkey device,
|
||||
* allowing fully automated testing without physical hardware.
|
||||
*/
|
||||
|
||||
test.describe('Passkey Authentication E2E', () => {
|
||||
const baseUrl = process.env.BASE_URL || 'http://localhost:4404'
|
||||
|
||||
test.describe.configure({ mode: 'serial' })
|
||||
|
||||
// Shared state across tests in this describe block
|
||||
let sessionToken: string
|
||||
let userUuid: string
|
||||
let credentialUuid: string
|
||||
let resetToken: string | undefined
|
||||
|
||||
test.beforeAll(() => {
|
||||
// Get the bootstrap reset token from global setup
|
||||
resetToken = getBootstrapResetToken()
|
||||
if (!resetToken) {
|
||||
console.warn('⚠️ No reset token found - registration test may fail')
|
||||
} else {
|
||||
console.log(`📝 Using reset token: ${resetToken}`)
|
||||
}
|
||||
})
|
||||
|
||||
test('should load the auth page', async ({ page }) => {
|
||||
// Navigate to auth page to establish origin for WebAuthn
|
||||
await page.goto('/auth/')
|
||||
await expect(page).toHaveTitle(/.*/)
|
||||
|
||||
// Page should load - 401 errors are expected since user is not logged in
|
||||
await page.waitForTimeout(500)
|
||||
|
||||
// Take screenshot of the login view
|
||||
await page.screenshot({ path: 'test-results/login-view.png' })
|
||||
console.log('✓ Screenshot saved: test-results/login-view.png')
|
||||
|
||||
// Just verify the page loaded without JS errors (network 401s are OK)
|
||||
console.log('✓ Auth page loaded successfully')
|
||||
})
|
||||
|
||||
test('should register admin passkey via WebSocket using reset token', async ({ page, virtualAuthenticator }) => {
|
||||
test.skip(!resetToken, 'No reset token available from bootstrap')
|
||||
|
||||
// Must visit the page first to establish origin
|
||||
await page.goto('/auth/')
|
||||
|
||||
// Perform registration via WebSocket with virtual authenticator
|
||||
// Using the bootstrap reset token for the admin user
|
||||
const result = await registerPasskey(page, baseUrl, {
|
||||
resetToken: resetToken,
|
||||
displayName: 'Admin User',
|
||||
})
|
||||
|
||||
// Verify registration result
|
||||
expect(result.session_token).toBeDefined()
|
||||
expect(result.session_token).toHaveLength(16)
|
||||
expect(result.user_uuid).toBeDefined()
|
||||
expect(result.credential_uuid).toBeDefined()
|
||||
expect(result.message).toContain('successfully')
|
||||
|
||||
// Store for subsequent tests
|
||||
sessionToken = result.session_token
|
||||
userUuid = result.user_uuid
|
||||
credentialUuid = result.credential_uuid
|
||||
|
||||
// Save session token for other test groups to use
|
||||
saveSessionToken(sessionToken)
|
||||
|
||||
console.log(`✓ Registered user: ${userUuid}`)
|
||||
console.log(`✓ Credential: ${credentialUuid}`)
|
||||
console.log(`✓ Session token: ${sessionToken.substring(0, 4)}...`)
|
||||
})
|
||||
|
||||
test('should create device tokens for other tests', async ({ page }) => {
|
||||
test.skip(!sessionToken, 'Requires successful registration')
|
||||
|
||||
// Create a batch of device tokens for API tests to use
|
||||
// Each API test needs its own token to register a passkey in its virtual authenticator
|
||||
const tokenCount = 15 // Enough for all API tests
|
||||
const tokens: string[] = []
|
||||
|
||||
for (let i = 0; i < tokenCount; i++) {
|
||||
const deviceLink = await createDeviceLink(page, baseUrl, sessionToken)
|
||||
tokens.push(deviceLink.token)
|
||||
}
|
||||
|
||||
saveDeviceTokens(tokens)
|
||||
console.log(`✓ Created ${tokens.length} device tokens for API tests`)
|
||||
})
|
||||
|
||||
test('should validate the session token', async ({ page }) => {
|
||||
// Skip if registration didn't run
|
||||
test.skip(!sessionToken, 'Requires successful registration')
|
||||
|
||||
const validation = await validateSession(page, baseUrl, sessionToken)
|
||||
|
||||
expect(validation.valid).toBe(true)
|
||||
expect(validation.user_uuid).toBe(userUuid)
|
||||
|
||||
console.log(`✓ Session validated for user: ${validation.user_uuid}`)
|
||||
})
|
||||
|
||||
test('should retrieve user info', async ({ page }) => {
|
||||
test.skip(!sessionToken, 'Requires successful registration')
|
||||
|
||||
const userInfo = await getUserInfo(page, baseUrl, sessionToken)
|
||||
|
||||
expect(userInfo.user.user_uuid).toBe(userUuid)
|
||||
expect(userInfo.user.user_name).toBe('Admin User')
|
||||
expect(userInfo.credentials).toBeDefined()
|
||||
expect(userInfo.credentials.length).toBeGreaterThanOrEqual(1)
|
||||
|
||||
// Navigate to profile and take screenshot
|
||||
const cookieName = getSessionCookieName()
|
||||
await page.context().addCookies([{
|
||||
name: cookieName,
|
||||
value: sessionToken,
|
||||
domain: 'localhost',
|
||||
path: '/',
|
||||
secure: true,
|
||||
httpOnly: true,
|
||||
sameSite: 'Strict' as const,
|
||||
}])
|
||||
await page.goto('/auth/')
|
||||
await page.waitForSelector('[data-view="profile"]', { timeout: 5000 })
|
||||
await page.screenshot({ path: 'test-results/profile-view.png' })
|
||||
console.log('✓ Screenshot saved: test-results/profile-view.png')
|
||||
|
||||
console.log(`✓ User info retrieved: ${userInfo.user.user_name}`)
|
||||
console.log(`✓ Credentials count: ${userInfo.credentials.length}`)
|
||||
})
|
||||
|
||||
test('should authenticate with existing passkey', async ({ page, virtualAuthenticator }) => {
|
||||
test.skip(!sessionToken, 'Requires successful registration')
|
||||
|
||||
// Navigate to page (required for WebAuthn origin)
|
||||
await page.goto('/auth/')
|
||||
|
||||
// The virtual authenticator in this context is new and doesn't have credentials.
|
||||
// Create a device link using the current session, then register a new credential.
|
||||
const deviceLink = await createDeviceLink(page, baseUrl, sessionToken)
|
||||
console.log(`✓ Created device link with token: ${deviceLink.token}`)
|
||||
|
||||
// Register a new credential using the device link
|
||||
const regResult = await registerPasskey(page, baseUrl, {
|
||||
resetToken: deviceLink.token,
|
||||
displayName: 'Admin User (test device)'
|
||||
})
|
||||
|
||||
console.log(`✓ Added test credential: ${regResult.credential_uuid}`)
|
||||
|
||||
// Now logout and authenticate with the fresh credential
|
||||
await logout(page, baseUrl, regResult.session_token)
|
||||
console.log('✓ Logged out')
|
||||
|
||||
// Authenticate with the virtual authenticator (now has a valid credential)
|
||||
const result = await authenticatePasskey(page, baseUrl)
|
||||
|
||||
expect(result.session_token).toBeDefined()
|
||||
expect(result.session_token).toHaveLength(16)
|
||||
expect(result.user_uuid).toBe(userUuid)
|
||||
|
||||
// Update session token for subsequent tests
|
||||
sessionToken = result.session_token
|
||||
|
||||
// Save session token for other test groups to use
|
||||
saveSessionToken(sessionToken)
|
||||
|
||||
console.log(`✓ Authenticated as user: ${result.user_uuid}`)
|
||||
console.log(`✓ New session token: ${sessionToken.substring(0, 4)}...`)
|
||||
})
|
||||
|
||||
test('should validate new session after authentication', async ({ page }) => {
|
||||
test.skip(!sessionToken, 'Requires successful authentication')
|
||||
|
||||
const validation = await validateSession(page, baseUrl, sessionToken)
|
||||
|
||||
expect(validation.valid).toBe(true)
|
||||
expect(validation.user_uuid).toBe(userUuid)
|
||||
|
||||
console.log(`✓ New session validated`)
|
||||
})
|
||||
|
||||
// Note: Logout test moved to the end so other test groups can use the session
|
||||
})
|
||||
|
||||
test.describe('Session Management', () => {
|
||||
const baseUrl = process.env.BASE_URL || 'http://localhost:4404'
|
||||
|
||||
test('should reject invalid session token', async ({ page }) => {
|
||||
const cookieName = getSessionCookieName()
|
||||
const response = await page.request.post(`${baseUrl}/auth/api/validate`, {
|
||||
headers: {
|
||||
'Cookie': `${cookieName}=invalid_token_123`,
|
||||
},
|
||||
failOnStatusCode: false,
|
||||
})
|
||||
|
||||
// Server may return 400 (bad format) or 401 (unauthorized)
|
||||
expect([400, 401]).toContain(response.status())
|
||||
console.log(`✓ Invalid token correctly rejected`)
|
||||
})
|
||||
|
||||
test('should reject missing session token', async ({ page }) => {
|
||||
const response = await page.request.post(`${baseUrl}/auth/api/validate`, {
|
||||
failOnStatusCode: false,
|
||||
})
|
||||
|
||||
expect(response.status()).toBe(401)
|
||||
console.log(`✓ Missing token correctly rejected`)
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Device Addition Dialog', () => {
|
||||
const baseUrl = process.env.BASE_URL || 'http://localhost:4404'
|
||||
|
||||
test.describe.configure({ mode: 'serial' })
|
||||
|
||||
let sessionToken: string
|
||||
|
||||
test.beforeAll(() => {
|
||||
// Get the session token saved by the previous test group
|
||||
// Note: This runs before the logout test, so the session should still be valid
|
||||
const saved = getSavedSessionToken()
|
||||
if (saved) {
|
||||
sessionToken = saved
|
||||
}
|
||||
})
|
||||
|
||||
test('should open device addition dialog and show QR code', async ({ page }) => {
|
||||
test.skip(!sessionToken, 'Requires saved session token from previous tests')
|
||||
|
||||
// Set the session cookie for this test context
|
||||
const cookieName = getSessionCookieName()
|
||||
await page.context().addCookies([{
|
||||
name: cookieName,
|
||||
value: sessionToken,
|
||||
domain: 'localhost',
|
||||
path: '/',
|
||||
secure: true,
|
||||
httpOnly: true,
|
||||
sameSite: 'Strict',
|
||||
}])
|
||||
|
||||
// Navigate to auth page (which should show profile when logged in)
|
||||
await page.goto('/auth/')
|
||||
|
||||
// Wait for the profile view to load
|
||||
await page.waitForSelector('[data-view="profile"]', { timeout: 5000 })
|
||||
|
||||
// Click the "Add Another Device" button
|
||||
const addDeviceButton = page.getByRole('button', { name: 'Add Another Device' })
|
||||
await expect(addDeviceButton).toBeVisible()
|
||||
await addDeviceButton.click()
|
||||
|
||||
// Wait for the registration link modal to appear
|
||||
const dialog = page.locator('.device-dialog')
|
||||
await expect(dialog).toBeVisible({ timeout: 5000 })
|
||||
|
||||
// Verify dialog contains expected elements
|
||||
await expect(dialog.locator('h2')).toContainText('Device Registration Link')
|
||||
|
||||
// Wait for QR code to be generated (canvas should have content)
|
||||
const qrCanvas = dialog.locator('.qr-code')
|
||||
await expect(qrCanvas).toBeVisible()
|
||||
|
||||
// Verify the link is displayed (text strips scheme, but href has it)
|
||||
const linkElement = dialog.locator('a.qr-link')
|
||||
await expect(linkElement).toBeVisible()
|
||||
const linkText = await linkElement.textContent()
|
||||
const linkHref = await linkElement.getAttribute('href')
|
||||
// Text shows hostname without scheme
|
||||
expect(linkText).toContain('localhost:4404/auth/')
|
||||
// Href includes full URL with scheme
|
||||
expect(linkHref).toContain('http://localhost:4404/auth/')
|
||||
console.log(`✓ Device link displayed: ${linkText} (href: ${linkHref})`)
|
||||
|
||||
// Verify expiration warning is shown
|
||||
await expect(dialog.locator('.reg-help')).toContainText('Expires')
|
||||
|
||||
// Take screenshot of the dialog
|
||||
await dialog.screenshot({ path: 'test-results/device-addition-dialog.png' })
|
||||
console.log(`✓ Screenshot saved: test-results/device-addition-dialog.png`)
|
||||
|
||||
// Verify Copy Link button exists
|
||||
const copyButton = dialog.getByRole('button', { name: 'Copy Link' })
|
||||
await expect(copyButton).toBeVisible()
|
||||
|
||||
// Close the dialog (use the text button, not the icon button)
|
||||
const closeButton = dialog.locator('button.btn-secondary', { hasText: 'Close' })
|
||||
await closeButton.click()
|
||||
await expect(dialog).not.toBeVisible()
|
||||
|
||||
console.log(`✓ Device addition dialog test complete`)
|
||||
})
|
||||
|
||||
test('should extract valid reset token from dialog', async ({ page }) => {
|
||||
test.skip(!sessionToken, 'Requires successful registration')
|
||||
|
||||
// Set the session cookie
|
||||
// __Host- cookies require: secure=true, path=/, no domain (but we set domain for localhost)
|
||||
const cookieName = getSessionCookieName()
|
||||
await page.context().addCookies([{
|
||||
name: cookieName,
|
||||
value: sessionToken,
|
||||
domain: 'localhost',
|
||||
path: '/',
|
||||
secure: true,
|
||||
httpOnly: true,
|
||||
sameSite: 'Strict',
|
||||
}])
|
||||
|
||||
await page.goto('/auth/')
|
||||
await page.waitForSelector('[data-view="profile"]', { timeout: 5000 })
|
||||
|
||||
// Open the dialog
|
||||
await page.getByRole('button', { name: 'Add Another Device' }).click()
|
||||
const dialog = page.locator('.device-dialog')
|
||||
await expect(dialog).toBeVisible({ timeout: 5000 })
|
||||
|
||||
// Extract the reset token from the displayed URL
|
||||
const linkText = dialog.locator('.qr-link p')
|
||||
const linkContent = await linkText.textContent()
|
||||
|
||||
// URL format: localhost/auth/word1.word2.word3.word4.word5
|
||||
const tokenMatch = linkContent?.match(/\/auth\/([a-z]+\.[a-z]+\.[a-z]+\.[a-z]+\.[a-z]+)/)
|
||||
expect(tokenMatch).toBeTruthy()
|
||||
const extractedToken = tokenMatch![1]
|
||||
console.log(`✓ Extracted reset token: ${extractedToken}`)
|
||||
|
||||
// Close the dialog (use the text button, not the icon button)
|
||||
await dialog.locator('button.btn-secondary', { hasText: 'Close' }).click()
|
||||
|
||||
// Verify the token can be used for registration via API
|
||||
// (We won't complete registration, just verify the WebSocket accepts it)
|
||||
const wsUrl = `${baseUrl.replace('http', 'ws')}/auth/ws/register?reset=${encodeURIComponent(extractedToken)}&name=Test`
|
||||
|
||||
// Use page.evaluate to test WebSocket connection
|
||||
const wsResult = await page.evaluate(async (wsUrl) => {
|
||||
return new Promise<{ success: boolean; hasOptions: boolean }>((resolve) => {
|
||||
const ws = new WebSocket(wsUrl)
|
||||
ws.onmessage = (event) => {
|
||||
const data = JSON.parse(event.data)
|
||||
ws.close()
|
||||
// Check if we got registration options (not an error)
|
||||
resolve({
|
||||
success: !data.status && !data.detail,
|
||||
hasOptions: !!data.optionsJSON?.challenge
|
||||
})
|
||||
}
|
||||
ws.onerror = () => resolve({ success: false, hasOptions: false })
|
||||
setTimeout(() => {
|
||||
ws.close()
|
||||
resolve({ success: false, hasOptions: false })
|
||||
}, 5000)
|
||||
})
|
||||
}, wsUrl)
|
||||
|
||||
expect(wsResult.success).toBe(true)
|
||||
expect(wsResult.hasOptions).toBe(true)
|
||||
console.log(`✓ Reset token is valid and accepted by server`)
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('ProfileView - Add New Passkey', () => {
|
||||
const baseUrl = process.env.BASE_URL || 'http://localhost:4404'
|
||||
|
||||
test('should show credentials list in profile', async ({ page }) => {
|
||||
const sessionToken = getSavedSessionToken()
|
||||
test.skip(!sessionToken, 'Requires saved session token')
|
||||
|
||||
await setupSessionCookie(page, sessionToken!)
|
||||
|
||||
// Navigate to profile page
|
||||
await page.goto(`${baseUrl}/auth/`)
|
||||
await page.waitForLoadState('networkidle')
|
||||
|
||||
// Wait for credentials to load
|
||||
await page.waitForSelector('.credential-list', { timeout: 10000 })
|
||||
|
||||
// Should have at least one credential from initial registration
|
||||
const credentialItems = await page.locator('.credential-item').count()
|
||||
expect(credentialItems).toBeGreaterThanOrEqual(1)
|
||||
console.log(`✓ Profile shows ${credentialItems} credential(s) in list`)
|
||||
})
|
||||
|
||||
test('should add a new passkey using Add New Passkey button', async ({ page }) => {
|
||||
const sessionToken = getSavedSessionToken()
|
||||
test.skip(!sessionToken, 'Requires saved session token')
|
||||
|
||||
// Create virtual authenticator for this page
|
||||
await createVirtualAuthenticator(page)
|
||||
await setupSessionCookie(page, sessionToken!)
|
||||
|
||||
// Navigate to profile page
|
||||
await page.goto(`${baseUrl}/auth/`)
|
||||
await page.waitForLoadState('networkidle')
|
||||
|
||||
// Wait for credentials list and get initial count
|
||||
await page.waitForSelector('.credential-list', { timeout: 10000 })
|
||||
const initialCredentialCount = await page.locator('.credential-item').count()
|
||||
console.log(`Initial credential count: ${initialCredentialCount}`)
|
||||
|
||||
// Click "Add New Passkey" button
|
||||
const addPasskeyBtn = page.locator('button:has-text("Add New Passkey")')
|
||||
await expect(addPasskeyBtn).toBeVisible()
|
||||
await addPasskeyBtn.click()
|
||||
|
||||
// Wait for WebAuthn registration to complete (virtual authenticator handles it automatically)
|
||||
// The button might show loading state or there might be a success message
|
||||
await page.waitForTimeout(2000) // Give time for WebSocket registration to complete
|
||||
|
||||
// Refresh the page to ensure we see updated credentials
|
||||
await page.reload()
|
||||
await page.waitForLoadState('networkidle')
|
||||
await page.waitForSelector('.credential-list', { timeout: 10000 })
|
||||
|
||||
// Should now have one more credential
|
||||
const newCredentialCount = await page.locator('.credential-item').count()
|
||||
expect(newCredentialCount).toBe(initialCredentialCount + 1)
|
||||
console.log(`✓ Successfully added new passkey. Credentials: ${initialCredentialCount} -> ${newCredentialCount}`)
|
||||
})
|
||||
|
||||
test('should reject duplicate passkey from same authenticator', async ({ page }) => {
|
||||
const sessionToken = getSavedSessionToken()
|
||||
test.skip(!sessionToken, 'Requires saved session token')
|
||||
|
||||
// Create virtual authenticator with resident key support
|
||||
// Using same authenticator configuration - credentials stored on authenticator
|
||||
await createVirtualAuthenticator(page, {
|
||||
protocol: 'ctap2',
|
||||
transport: 'internal',
|
||||
hasResidentKey: true,
|
||||
hasUserVerification: true,
|
||||
isUserVerified: true,
|
||||
})
|
||||
|
||||
await setupSessionCookie(page, sessionToken!)
|
||||
|
||||
// Navigate to profile page
|
||||
await page.goto(`${baseUrl}/auth/`)
|
||||
await page.waitForLoadState('networkidle')
|
||||
|
||||
// Wait for credentials list
|
||||
await page.waitForSelector('.credential-list', { timeout: 10000 })
|
||||
const initialCredentialCount = await page.locator('.credential-item').count()
|
||||
|
||||
// Try to add a passkey - with excludeCredentials the authenticator should
|
||||
// prevent re-registration of the same credential
|
||||
const addPasskeyBtn = page.locator('button:has-text("Add New Passkey")')
|
||||
await expect(addPasskeyBtn).toBeVisible()
|
||||
await addPasskeyBtn.click()
|
||||
|
||||
// Wait for response - could be success (new credential) or error (duplicate)
|
||||
await page.waitForTimeout(3000)
|
||||
|
||||
// Check for error message or status message
|
||||
const statusMessage = page.locator('.status-message')
|
||||
const hasError = await statusMessage.locator('.error, .status-error').isVisible().catch(() => false)
|
||||
|
||||
// Reload to check final credential count
|
||||
await page.reload()
|
||||
await page.waitForLoadState('networkidle')
|
||||
await page.waitForSelector('.credential-list', { timeout: 10000 })
|
||||
const finalCredentialCount = await page.locator('.credential-item').count()
|
||||
|
||||
// The test passes if either:
|
||||
// 1. An error was shown (duplicate rejected by excludeCredentials)
|
||||
// 2. A new credential was added (fresh authenticator has no stored credential)
|
||||
console.log(`Credentials: ${initialCredentialCount} -> ${finalCredentialCount}, error shown: ${hasError}`)
|
||||
console.log(`✓ Add passkey flow completed (new authenticator creates new credential)`)
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('ProfileView - Multi-Authenticator', () => {
|
||||
const baseUrl = process.env.BASE_URL || 'http://localhost:4404'
|
||||
|
||||
test('should add passkey from different authenticator', async ({ page }) => {
|
||||
const sessionToken = getSavedSessionToken()
|
||||
test.skip(!sessionToken, 'Requires saved session token')
|
||||
|
||||
// Create a different virtual authenticator (simulating a different device)
|
||||
await createVirtualAuthenticator(page, {
|
||||
protocol: 'ctap2',
|
||||
transport: 'usb', // Different transport - like a USB security key
|
||||
hasResidentKey: true,
|
||||
hasUserVerification: true,
|
||||
isUserVerified: true,
|
||||
})
|
||||
|
||||
await setupSessionCookie(page, sessionToken!)
|
||||
|
||||
// Navigate to profile page
|
||||
await page.goto(`${baseUrl}/auth/`)
|
||||
await page.waitForLoadState('networkidle')
|
||||
|
||||
// Wait for credentials list and get initial count
|
||||
await page.waitForSelector('.credential-list', { timeout: 10000 })
|
||||
const initialCredentialCount = await page.locator('.credential-item').count()
|
||||
|
||||
// Click "Add New Passkey" button
|
||||
const addPasskeyBtn = page.locator('button:has-text("Add New Passkey")')
|
||||
await expect(addPasskeyBtn).toBeVisible()
|
||||
await addPasskeyBtn.click()
|
||||
|
||||
// Wait for registration to complete
|
||||
await page.waitForTimeout(2000)
|
||||
|
||||
// Refresh to see updated list
|
||||
await page.reload()
|
||||
await page.waitForLoadState('networkidle')
|
||||
await page.waitForSelector('.credential-list', { timeout: 10000 })
|
||||
|
||||
const newCredentialCount = await page.locator('.credential-item').count()
|
||||
expect(newCredentialCount).toBe(initialCredentialCount + 1)
|
||||
console.log(`✓ Added passkey from USB authenticator. Credentials: ${initialCredentialCount} -> ${newCredentialCount}`)
|
||||
})
|
||||
|
||||
test('should display multiple credentials with details', async ({ page }) => {
|
||||
const sessionToken = getSavedSessionToken()
|
||||
test.skip(!sessionToken, 'Requires saved session token')
|
||||
|
||||
await setupSessionCookie(page, sessionToken!)
|
||||
|
||||
// Navigate to profile page
|
||||
await page.goto(`${baseUrl}/auth/`)
|
||||
await page.waitForLoadState('networkidle')
|
||||
await page.waitForSelector('.credential-list', { timeout: 10000 })
|
||||
|
||||
// Should have multiple credentials now from previous tests
|
||||
const credentialItems = page.locator('.credential-item')
|
||||
const count = await credentialItems.count()
|
||||
|
||||
// Verify each credential has required elements
|
||||
for (let i = 0; i < count; i++) {
|
||||
const item = credentialItems.nth(i)
|
||||
|
||||
// Should have title/name
|
||||
const title = item.locator('.item-title')
|
||||
await expect(title).toBeVisible()
|
||||
|
||||
// Should have date information
|
||||
const dates = item.locator('.credential-dates')
|
||||
await expect(dates).toBeVisible()
|
||||
|
||||
// Should have created date
|
||||
const createdDate = item.locator('.date-label:has-text("Created:")')
|
||||
await expect(createdDate).toBeVisible()
|
||||
}
|
||||
|
||||
console.log(`✓ All ${count} credentials displayed with proper details`)
|
||||
|
||||
// Take screenshot of credentials list
|
||||
await page.screenshot({
|
||||
path: 'test-results/credentials-list.png',
|
||||
fullPage: false,
|
||||
})
|
||||
console.log(`✓ Screenshot saved: test-results/credentials-list.png`)
|
||||
})
|
||||
|
||||
test('should show current session badge', async ({ page }) => {
|
||||
const sessionToken = getSavedSessionToken()
|
||||
test.skip(!sessionToken, 'Requires saved session token')
|
||||
|
||||
await setupSessionCookie(page, sessionToken!)
|
||||
|
||||
// Navigate to profile page
|
||||
await page.goto(`${baseUrl}/auth/`)
|
||||
await page.waitForLoadState('networkidle')
|
||||
await page.waitForSelector('.credential-list', { timeout: 10000 })
|
||||
|
||||
// Look for the "Current" badge indicating current session's credential
|
||||
const currentBadge = page.locator('.badge-current:has-text("Current")')
|
||||
const hasCurrent = await currentBadge.isVisible().catch(() => false)
|
||||
|
||||
if (hasCurrent) {
|
||||
console.log(`✓ Current session credential is marked with "Current" badge`)
|
||||
|
||||
// The current credential should have delete disabled
|
||||
const currentItem = page.locator('.credential-item.current-session')
|
||||
if (await currentItem.isVisible()) {
|
||||
const deleteBtn = currentItem.locator('.btn-card-delete')
|
||||
if (await deleteBtn.isVisible()) {
|
||||
await expect(deleteBtn).toBeDisabled()
|
||||
console.log(`✓ Delete button is disabled for current session credential`)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
console.log(`ℹ No credential marked as current (may be using different auth method)`)
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,606 @@
|
||||
import { test, expect, createVirtualAuthenticator } from './fixtures/virtual-authenticator'
|
||||
import {
|
||||
getSessionCookieName,
|
||||
getSavedSessionToken,
|
||||
saveSessionToken,
|
||||
registerPasskey,
|
||||
authenticatePasskey,
|
||||
popDeviceToken,
|
||||
getDeviceTokenCount,
|
||||
logout,
|
||||
} from './fixtures/passkey-helpers'
|
||||
import type { Page, Frame } from '@playwright/test'
|
||||
|
||||
/**
|
||||
* E2E tests for API mode authentication flows.
|
||||
*
|
||||
* These tests simulate the flow used by SPAs when making API calls:
|
||||
* 1. API call returns 401/403 with auth.iframe URL
|
||||
* 2. App shows auth iframe overlay
|
||||
* 3. User authenticates in iframe
|
||||
* 4. Iframe posts 'auth-success' message to parent
|
||||
* 5. App retries original API call
|
||||
*
|
||||
* Note: These tests depend on 10-passkey.spec.ts running first to create device tokens.
|
||||
* Each test that needs authentication uses popDeviceToken() to get a fresh token
|
||||
* and registers its own credential in its virtual authenticator.
|
||||
*/
|
||||
|
||||
const baseUrl = process.env.BASE_URL || 'http://localhost:4404'
|
||||
|
||||
/**
|
||||
* Helper to set up session cookie for a page.
|
||||
*/
|
||||
async function setupSessionCookie(page: Page, sessionToken: string): Promise<void> {
|
||||
const cookieName = getSessionCookieName()
|
||||
await page.context().addCookies([{
|
||||
name: cookieName,
|
||||
value: sessionToken,
|
||||
domain: 'localhost',
|
||||
path: '/',
|
||||
secure: true,
|
||||
httpOnly: true,
|
||||
sameSite: 'Strict' as const,
|
||||
}])
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper to clear session cookie.
|
||||
*/
|
||||
async function clearSessionCookie(page: Page): Promise<void> {
|
||||
const cookieName = getSessionCookieName()
|
||||
await page.context().clearCookies({ name: cookieName })
|
||||
}
|
||||
|
||||
/**
|
||||
* Set up the test page using the examples page directly.
|
||||
* The examples page already has iframe handling - we just add a Promise wrapper.
|
||||
*/
|
||||
async function setupTestHarness(page: Page): Promise<void> {
|
||||
// Navigate to the examples page which already has the auth iframe handling
|
||||
await page.goto(`${baseUrl}/auth/examples/`)
|
||||
}
|
||||
|
||||
/**
|
||||
* Make an API call through the examples page, returning a Promise.
|
||||
* Wraps the page's apiCall and listens for auth-success/auth-back messages.
|
||||
* Returns { status, data } on success, or throws on cancellation.
|
||||
*
|
||||
* Note: If auth is not needed (request succeeds without 401/403), this will
|
||||
* resolve after a timeout with the direct fetch result.
|
||||
*/
|
||||
async function makeApiCall(page: Page, url: string, method = 'GET'): Promise<{ status: number; data?: any }> {
|
||||
return page.evaluate(({ url, method }) => {
|
||||
return new Promise((resolve, reject) => {
|
||||
let resolved = false;
|
||||
|
||||
// Listen for auth messages
|
||||
const handler = (event: MessageEvent) => {
|
||||
const { type } = event.data || {};
|
||||
if (type === 'auth-success') {
|
||||
if (resolved) return;
|
||||
resolved = true;
|
||||
window.removeEventListener('message', handler);
|
||||
// Wait a tick for the page's handler to retry, then make our own call
|
||||
setTimeout(async () => {
|
||||
try {
|
||||
const response = await fetch(url, { method, credentials: 'include' });
|
||||
if (response.status === 204) {
|
||||
resolve({ status: 204 });
|
||||
} else if (response.ok) {
|
||||
const data = await response.json();
|
||||
resolve({ status: response.status, data });
|
||||
} else {
|
||||
resolve({ status: response.status });
|
||||
}
|
||||
} catch (e) {
|
||||
resolve({ status: 0 });
|
||||
}
|
||||
}, 200);
|
||||
} else if (type === 'auth-back') {
|
||||
if (resolved) return;
|
||||
resolved = true;
|
||||
window.removeEventListener('message', handler);
|
||||
reject(new Error('cancelled'));
|
||||
}
|
||||
};
|
||||
window.addEventListener('message', handler);
|
||||
|
||||
// Also make a direct fetch to handle the case where no auth is needed
|
||||
// (the page's apiCall won't send any message if the request succeeds)
|
||||
setTimeout(async () => {
|
||||
if (resolved) return;
|
||||
try {
|
||||
const response = await fetch(url, { method, credentials: 'include' });
|
||||
// Only resolve if this is a success or non-auth error
|
||||
if (response.status !== 401 && response.status !== 403) {
|
||||
if (resolved) return;
|
||||
resolved = true;
|
||||
window.removeEventListener('message', handler);
|
||||
if (response.status === 204) {
|
||||
resolve({ status: 204 });
|
||||
} else if (response.ok) {
|
||||
const data = await response.json();
|
||||
resolve({ status: response.status, data });
|
||||
} else {
|
||||
resolve({ status: response.status });
|
||||
}
|
||||
}
|
||||
// If 401/403, the auth iframe will appear and we wait for the message
|
||||
} catch (e) {
|
||||
// Network error - let the message handler deal with it
|
||||
}
|
||||
}, 100);
|
||||
|
||||
// Call the page's existing apiCall function
|
||||
// It will show the iframe on 401/403
|
||||
(window as any).apiCall(url, method);
|
||||
});
|
||||
}, { url, method });
|
||||
}
|
||||
|
||||
/**
|
||||
* Wait for auth iframe to appear and return a reference to it.
|
||||
*/
|
||||
async function waitForAuthIframe(page: Page, timeout = 5000): Promise<Frame> {
|
||||
await page.waitForSelector('#auth-iframe', { timeout })
|
||||
const iframe = page.frameLocator('#auth-iframe')
|
||||
// Wait for iframe content to load
|
||||
await iframe.locator('.view-root').waitFor({ timeout })
|
||||
return page.frame({ url: /\/auth\/restricted\// })!
|
||||
}
|
||||
|
||||
/**
|
||||
* Wait for auth iframe to disappear.
|
||||
*/
|
||||
async function waitForAuthIframeHidden(page: Page, timeout = 5000): Promise<void> {
|
||||
await page.waitForSelector('#auth-iframe', { state: 'detached', timeout })
|
||||
}
|
||||
|
||||
/**
|
||||
* Click Back button in auth iframe.
|
||||
*/
|
||||
async function clickBackInIframe(page: Page): Promise<void> {
|
||||
const iframe = page.frameLocator('#auth-iframe')
|
||||
await iframe.getByRole('button', { name: 'Back' }).click()
|
||||
}
|
||||
|
||||
/**
|
||||
* Click Login button in auth iframe.
|
||||
*/
|
||||
async function clickLoginInIframe(page: Page): Promise<void> {
|
||||
const iframe = page.frameLocator('#auth-iframe')
|
||||
await iframe.getByRole('button', { name: 'Login' }).click()
|
||||
}
|
||||
|
||||
/**
|
||||
* Click Verify button in auth iframe (for reauth mode).
|
||||
*/
|
||||
async function clickVerifyInIframe(page: Page): Promise<void> {
|
||||
const iframe = page.frameLocator('#auth-iframe')
|
||||
await iframe.getByRole('button', { name: 'Verify' }).click()
|
||||
}
|
||||
|
||||
/**
|
||||
* Click Logout button in auth iframe (for forbidden mode).
|
||||
*/
|
||||
async function clickLogoutInIframe(page: Page): Promise<void> {
|
||||
const iframe = page.frameLocator('#auth-iframe')
|
||||
await iframe.getByRole('button', { name: 'Logout' }).click()
|
||||
}
|
||||
|
||||
test.describe('API Mode - 401 Login Flow', () => {
|
||||
test.describe.configure({ mode: 'serial' })
|
||||
|
||||
test('should show auth iframe on 401 and allow cancellation (Back)', async ({ page }) => {
|
||||
// Set up test harness (injects our API flow handler)
|
||||
await setupTestHarness(page)
|
||||
|
||||
// Clear any existing session cookie
|
||||
await clearSessionCookie(page)
|
||||
|
||||
// Make API call that triggers 401 (don't await - it blocks until iframe resolves)
|
||||
const apiCallPromise = makeApiCall(page, '/auth/api/user-info', 'POST').catch(e => e)
|
||||
console.log('✓ Auth iframe appeared on 401')
|
||||
|
||||
// Verify it's in login mode (not reauth)
|
||||
const iframe = page.frameLocator('#auth-iframe')
|
||||
await expect(iframe.locator('h1')).toContainText('🔐')
|
||||
await expect(iframe.getByRole('button', { name: 'Login' })).toBeVisible()
|
||||
|
||||
// Take screenshot of the login iframe
|
||||
await page.screenshot({ path: 'test-results/api-401-login-iframe.png' })
|
||||
console.log('✓ Screenshot saved: test-results/api-401-login-iframe.png')
|
||||
|
||||
// Click Back to cancel authentication
|
||||
await clickBackInIframe(page)
|
||||
|
||||
// Iframe should close
|
||||
await waitForAuthIframeHidden(page)
|
||||
console.log('✓ Auth iframe closed on Back button')
|
||||
|
||||
// Wait for the API call promise to reject
|
||||
const result = await apiCallPromise
|
||||
expect(result).toBeInstanceOf(Error)
|
||||
expect(result.message).toContain('cancelled')
|
||||
|
||||
// Output should show cancellation
|
||||
const output = page.locator('#output')
|
||||
await expect(output).toContainText('cancelled')
|
||||
console.log('✓ API call was cancelled')
|
||||
})
|
||||
|
||||
test('should show auth iframe on 401 and complete login', async ({ page, virtualAuthenticator }) => {
|
||||
// Get a device token from the pool (created by 10-passkey.spec.ts)
|
||||
const deviceToken = popDeviceToken()
|
||||
test.skip(!deviceToken, 'Requires device token from passkey tests')
|
||||
console.log(`✓ Got device token: ${deviceToken} (${getDeviceTokenCount()} remaining)`)
|
||||
|
||||
// Navigate and register credential using device token
|
||||
await page.goto(`${baseUrl}/auth/`)
|
||||
const regResult = await registerPasskey(page, baseUrl, {
|
||||
resetToken: deviceToken,
|
||||
displayName: 'API Test Device',
|
||||
})
|
||||
console.log(`✓ Registered credential: ${regResult.credential_uuid}`)
|
||||
|
||||
// Logout to clear session (but keep the passkey in virtual authenticator)
|
||||
await logout(page, baseUrl, regResult.session_token)
|
||||
console.log('✓ Logged out')
|
||||
|
||||
// Set up test harness
|
||||
await setupTestHarness(page)
|
||||
|
||||
// Make API call that triggers 401
|
||||
const apiCallPromise = makeApiCall(page, '/auth/api/user-info', 'POST')
|
||||
|
||||
// Wait for auth iframe to appear
|
||||
await waitForAuthIframe(page)
|
||||
console.log('✓ Auth iframe appeared on 401')
|
||||
|
||||
// Click Login button - virtual authenticator will handle the passkey
|
||||
await clickLoginInIframe(page)
|
||||
|
||||
// Wait for authentication to complete - iframe should close
|
||||
await waitForAuthIframeHidden(page, 10000)
|
||||
console.log('✓ Authentication completed, iframe closed')
|
||||
|
||||
// Wait for API call to complete and verify result
|
||||
const result = await apiCallPromise
|
||||
expect(result.status).toBe(200)
|
||||
expect(result.data.user).toBeDefined()
|
||||
console.log('✓ API call succeeded after authentication')
|
||||
|
||||
// Save the session for other tests
|
||||
const cookies = await page.context().cookies()
|
||||
const sessionCookie = cookies.find(c => c.name === getSessionCookieName())
|
||||
if (sessionCookie) {
|
||||
saveSessionToken(sessionCookie.value)
|
||||
console.log(`✓ Saved session token for other tests`)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('API Mode - 401 Reauth Flow', () => {
|
||||
test.describe.configure({ mode: 'serial' })
|
||||
|
||||
test('should show reauth iframe on max_age violation and allow cancellation', async ({ page, virtualAuthenticator }) => {
|
||||
// Get a device token from the pool (created by 10-passkey.spec.ts)
|
||||
const deviceToken = popDeviceToken()
|
||||
test.skip(!deviceToken, 'Requires device token from passkey tests')
|
||||
console.log(`✓ Got device token: ${deviceToken} (${getDeviceTokenCount()} remaining)`)
|
||||
|
||||
// Navigate and register a credential
|
||||
await page.goto(`${baseUrl}/auth/`)
|
||||
const regResult = await registerPasskey(page, baseUrl, {
|
||||
resetToken: deviceToken,
|
||||
displayName: 'Reauth Cancel Test Device',
|
||||
})
|
||||
saveSessionToken(regResult.session_token)
|
||||
|
||||
// Wait for session to age past max_age threshold
|
||||
console.log('Waiting 3s for session to age...')
|
||||
await page.waitForTimeout(3000)
|
||||
|
||||
// Set up test harness with the session
|
||||
await setupSessionCookie(page, regResult.session_token)
|
||||
await setupTestHarness(page)
|
||||
|
||||
// Make API call with max_age=1s (session is now > 1s old)
|
||||
const apiCallPromise = makeApiCall(page, '/auth/api/forward?max_age=1s', 'GET').catch(e => e)
|
||||
|
||||
// Wait for auth iframe to appear
|
||||
await waitForAuthIframe(page)
|
||||
console.log('✓ Reauth iframe appeared (session older than max_age)')
|
||||
|
||||
// Verify it's in reauth mode
|
||||
const iframe = page.frameLocator('#auth-iframe')
|
||||
await expect(iframe.locator('h1')).toContainText('Additional Authentication')
|
||||
await expect(iframe.getByRole('button', { name: 'Verify' })).toBeVisible()
|
||||
|
||||
// Take screenshot of reauth iframe
|
||||
await page.screenshot({ path: 'test-results/api-401-reauth-iframe.png' })
|
||||
console.log('✓ Screenshot saved: test-results/api-401-reauth-iframe.png')
|
||||
|
||||
// Click Back to cancel
|
||||
await clickBackInIframe(page)
|
||||
await waitForAuthIframeHidden(page)
|
||||
console.log('✓ Reauth cancelled via Back button')
|
||||
|
||||
const result = await apiCallPromise
|
||||
expect(result).toBeInstanceOf(Error)
|
||||
})
|
||||
|
||||
test('should complete reauth flow with passkey', async ({ page, virtualAuthenticator }) => {
|
||||
// Get a device token from the pool (created by 10-passkey.spec.ts)
|
||||
const deviceToken = popDeviceToken()
|
||||
test.skip(!deviceToken, 'Requires device token from passkey tests')
|
||||
console.log(`✓ Got device token: ${deviceToken} (${getDeviceTokenCount()} remaining)`)
|
||||
|
||||
// Navigate and register a credential
|
||||
await page.goto(`${baseUrl}/auth/`)
|
||||
const regResult = await registerPasskey(page, baseUrl, {
|
||||
resetToken: deviceToken,
|
||||
displayName: 'Reauth Test Device',
|
||||
})
|
||||
|
||||
// Save the new session
|
||||
saveSessionToken(regResult.session_token)
|
||||
|
||||
// Wait for the session to be "old" (>2s for max_age=2s test)
|
||||
console.log('Waiting 3s for session to age...')
|
||||
await page.waitForTimeout(3000)
|
||||
|
||||
// Set up test harness with the session
|
||||
await setupSessionCookie(page, regResult.session_token)
|
||||
await setupTestHarness(page)
|
||||
|
||||
// Make API call with max_age=2s
|
||||
const apiCallPromise = makeApiCall(page, '/auth/api/forward?max_age=2s', 'GET')
|
||||
|
||||
// Auth iframe should appear in reauth mode
|
||||
await waitForAuthIframe(page)
|
||||
console.log('✓ Reauth iframe appeared')
|
||||
|
||||
const iframe = page.frameLocator('#auth-iframe')
|
||||
await expect(iframe.locator('h1')).toContainText('Additional Authentication')
|
||||
|
||||
// Click Verify - virtual authenticator handles passkey
|
||||
await clickVerifyInIframe(page)
|
||||
|
||||
// Wait for completion
|
||||
await waitForAuthIframeHidden(page, 10000)
|
||||
console.log('✓ Reauth completed')
|
||||
|
||||
// Wait for API call result
|
||||
const result = await apiCallPromise
|
||||
expect(result.status).toBe(204)
|
||||
console.log('✓ Forward endpoint returned 204 after reauth')
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('API Mode - 403 Forbidden Flow', () => {
|
||||
test.describe.configure({ mode: 'serial' })
|
||||
|
||||
test('should show forbidden view and allow going back', async ({ page }) => {
|
||||
const sessionToken = getSavedSessionToken()
|
||||
test.skip(!sessionToken, 'Requires saved session token')
|
||||
|
||||
// Set up test harness with valid session
|
||||
await setupSessionCookie(page, sessionToken!)
|
||||
await setupTestHarness(page)
|
||||
|
||||
// Make API call requiring admin permission
|
||||
const apiCallPromise = makeApiCall(page, '/auth/api/forward?perm=auth:admin', 'GET').catch(e => e)
|
||||
|
||||
// Check if auth iframe appeared
|
||||
const iframeAppeared = await page.waitForSelector('#auth-iframe', { timeout: 3000 }).then(() => true).catch(() => false)
|
||||
|
||||
if (!iframeAppeared) {
|
||||
// User might already have admin permission
|
||||
const result = await apiCallPromise
|
||||
if (result.status === 204) {
|
||||
console.log('✓ User has admin permission, got 204 (skipping forbidden test)')
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
await waitForAuthIframe(page)
|
||||
console.log('✓ Auth iframe appeared on permission check')
|
||||
|
||||
// Wait for view to stabilize and check mode
|
||||
await page.waitForTimeout(500)
|
||||
const iframe = page.frameLocator('#auth-iframe')
|
||||
const headingText = await iframe.locator('h1').textContent()
|
||||
console.log(` Heading: ${headingText}`)
|
||||
|
||||
if (headingText?.includes('Forbidden')) {
|
||||
console.log('✓ Forbidden view displayed (user lacks admin permission)')
|
||||
|
||||
// Should show Logout button in forbidden mode
|
||||
await expect(iframe.getByRole('button', { name: 'Logout' })).toBeVisible()
|
||||
|
||||
// Take screenshot of forbidden view
|
||||
await page.screenshot({ path: 'test-results/api-403-forbidden-iframe.png' })
|
||||
console.log('✓ Screenshot saved: test-results/api-403-forbidden-iframe.png')
|
||||
|
||||
// Click Back to close
|
||||
await clickBackInIframe(page)
|
||||
await waitForAuthIframeHidden(page)
|
||||
console.log('✓ Forbidden dialog closed via Back')
|
||||
|
||||
const result = await apiCallPromise
|
||||
expect(result).toBeInstanceOf(Error)
|
||||
} else {
|
||||
// User has admin permission, so they got through
|
||||
console.log('✓ User has admin permission, no forbidden view')
|
||||
}
|
||||
})
|
||||
|
||||
test('should allow logout from forbidden view and then login', async ({ page, virtualAuthenticator }) => {
|
||||
// Get a device token from the pool (created by 10-passkey.spec.ts)
|
||||
const deviceToken = popDeviceToken()
|
||||
test.skip(!deviceToken, 'Requires device token from passkey tests')
|
||||
console.log(`✓ Got device token: ${deviceToken} (${getDeviceTokenCount()} remaining)`)
|
||||
|
||||
// Navigate and register credential for later login
|
||||
await page.goto(`${baseUrl}/auth/`)
|
||||
const regResult = await registerPasskey(page, baseUrl, {
|
||||
resetToken: deviceToken,
|
||||
displayName: 'Forbidden Test Device',
|
||||
})
|
||||
saveSessionToken(regResult.session_token)
|
||||
|
||||
// Set up test harness with the session
|
||||
await setupSessionCookie(page, regResult.session_token)
|
||||
await setupTestHarness(page)
|
||||
|
||||
// Make API call requiring admin permission
|
||||
const apiCallPromise = makeApiCall(page, '/auth/api/forward?perm=auth:admin', 'GET').catch(e => e)
|
||||
|
||||
// Check if auth iframe appeared
|
||||
const iframeAppeared = await page.waitForSelector('#auth-iframe', { timeout: 3000 }).then(() => true).catch(() => false)
|
||||
|
||||
if (!iframeAppeared) {
|
||||
const result = await apiCallPromise
|
||||
if (result.status === 204) {
|
||||
console.log('✓ User has admin permission, skipping forbidden->login test')
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
await waitForAuthIframe(page)
|
||||
const iframe = page.frameLocator('#auth-iframe')
|
||||
await page.waitForTimeout(500)
|
||||
|
||||
const headingText = await iframe.locator('h1').textContent()
|
||||
|
||||
if (headingText?.includes('Forbidden')) {
|
||||
console.log('✓ Forbidden view displayed')
|
||||
|
||||
// Take screenshot of forbidden view before logout
|
||||
await page.screenshot({ path: 'test-results/api-403-forbidden-before-logout.png' })
|
||||
console.log('✓ Screenshot saved: test-results/api-403-forbidden-before-logout.png')
|
||||
|
||||
// Click Logout in the iframe
|
||||
await clickLogoutInIframe(page)
|
||||
|
||||
// After logout, the view should switch to login mode and show a toast
|
||||
await page.waitForTimeout(1000)
|
||||
await expect(iframe.getByRole('button', { name: 'Login' })).toBeVisible({ timeout: 5000 })
|
||||
console.log('✓ Switched to login view after logout')
|
||||
|
||||
// Verify status message appears indicating user can login with another account
|
||||
const statusMessage = iframe.locator('.global-status .status')
|
||||
await expect(statusMessage).toBeVisible({ timeout: 3000 })
|
||||
const statusText = await statusMessage.textContent()
|
||||
expect(statusText).toContain('sign in with a different account')
|
||||
console.log(`✓ Status message: ${statusText}`)
|
||||
|
||||
// Take screenshot showing login view with status message (after forbidden logout)
|
||||
await page.screenshot({ path: 'test-results/api-403-after-logout-login.png' })
|
||||
console.log('✓ Screenshot saved: test-results/api-403-after-logout-login.png')
|
||||
|
||||
// Now login with the passkey
|
||||
await clickLoginInIframe(page)
|
||||
|
||||
// Wait for auth to complete
|
||||
await waitForAuthIframeHidden(page, 10000)
|
||||
console.log('✓ Logged in successfully')
|
||||
|
||||
// The API call should have completed (but may still fail with 403 since same user)
|
||||
const result = await apiCallPromise
|
||||
console.log(` Final result status: ${result.status || 'error'}`)
|
||||
} else {
|
||||
console.log('✓ Not in forbidden mode, closing dialog')
|
||||
await clickBackInIframe(page)
|
||||
await waitForAuthIframeHidden(page)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('API Mode - Direct API Response Format', () => {
|
||||
test('should return JSON with auth.iframe on 401 (unauthenticated)', async ({ page }) => {
|
||||
// Make direct API call without session
|
||||
const response = await page.request.get(`${baseUrl}/auth/api/forward`, {
|
||||
headers: {
|
||||
'Accept': 'application/json',
|
||||
},
|
||||
})
|
||||
|
||||
expect(response.status()).toBe(401)
|
||||
|
||||
const data = await response.json()
|
||||
expect(data.auth).toBeDefined()
|
||||
expect(data.auth.iframe).toBeDefined()
|
||||
expect(data.auth.mode).toBe('login')
|
||||
expect(data.auth.iframe).toContain('/auth/restricted/')
|
||||
|
||||
console.log(`✓ 401 response includes auth.iframe: ${data.auth.iframe}`)
|
||||
})
|
||||
|
||||
test('should return JSON with auth.mode=forbidden on 403', async ({ page }) => {
|
||||
const sessionToken = getSavedSessionToken()
|
||||
test.skip(!sessionToken, 'Requires saved session token')
|
||||
|
||||
const cookieName = getSessionCookieName()
|
||||
|
||||
// Make API call with session but requesting admin permission
|
||||
const response = await page.request.get(`${baseUrl}/auth/api/forward?perm=auth:admin`, {
|
||||
headers: {
|
||||
'Accept': 'application/json',
|
||||
'Cookie': `${cookieName}=${sessionToken}`,
|
||||
},
|
||||
})
|
||||
|
||||
// Could be 403 (forbidden) or 204 (user is admin)
|
||||
if (response.status() === 403) {
|
||||
const data = await response.json()
|
||||
expect(data.auth).toBeDefined()
|
||||
expect(data.auth.mode).toBe('forbidden')
|
||||
console.log(`✓ 403 response auth.mode: ${data.auth.mode}`)
|
||||
} else if (response.status() === 204) {
|
||||
console.log('✓ User has admin permission, got 204')
|
||||
} else {
|
||||
console.log(` Unexpected status: ${response.status()}`)
|
||||
}
|
||||
})
|
||||
|
||||
test('should return JSON with auth.mode=reauth on max_age violation', async ({ page, virtualAuthenticator }) => {
|
||||
// Get a device token from the pool (created by 10-passkey.spec.ts)
|
||||
const deviceToken = popDeviceToken()
|
||||
test.skip(!deviceToken, 'Requires device token from passkey tests')
|
||||
console.log(`✓ Got device token: ${deviceToken} (${getDeviceTokenCount()} remaining)`)
|
||||
|
||||
// Navigate and create fresh session
|
||||
await page.goto(`${baseUrl}/auth/`)
|
||||
const regResult = await registerPasskey(page, baseUrl, {
|
||||
resetToken: deviceToken,
|
||||
displayName: 'Max Age Test Device',
|
||||
})
|
||||
|
||||
// Wait for session to be older than 1s
|
||||
await page.waitForTimeout(2000)
|
||||
|
||||
const cookieName = getSessionCookieName()
|
||||
|
||||
// Make API call with max_age=1s (session is now > 1s old)
|
||||
const response = await page.request.get(`${baseUrl}/auth/api/forward?max_age=1s`, {
|
||||
headers: {
|
||||
'Accept': 'application/json',
|
||||
'Cookie': `${cookieName}=${regResult.session_token}`,
|
||||
},
|
||||
})
|
||||
|
||||
expect(response.status()).toBe(401)
|
||||
|
||||
const data = await response.json()
|
||||
expect(data.auth).toBeDefined()
|
||||
expect(data.auth.mode).toBe('reauth')
|
||||
|
||||
console.log(`✓ 401 response auth.mode: ${data.auth.mode}`)
|
||||
|
||||
// Save session for cleanup
|
||||
saveSessionToken(regResult.session_token)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,33 @@
|
||||
import { test, expect } from './fixtures/virtual-authenticator'
|
||||
import {
|
||||
logout,
|
||||
getSessionCookieName,
|
||||
getSavedSessionToken,
|
||||
} from './fixtures/passkey-helpers'
|
||||
|
||||
/**
|
||||
* Logout test - runs last to clean up the session.
|
||||
* The "99-" prefix ensures this runs after all other tests.
|
||||
*/
|
||||
test.describe('Logout', () => {
|
||||
const baseUrl = process.env.BASE_URL || 'http://localhost:4404'
|
||||
|
||||
test('should logout successfully', async ({ page }) => {
|
||||
const sessionToken = getSavedSessionToken()
|
||||
test.skip(!sessionToken, 'Requires saved session token')
|
||||
|
||||
await logout(page, baseUrl, sessionToken!)
|
||||
|
||||
// Session should no longer be valid
|
||||
const cookieName = getSessionCookieName()
|
||||
const response = await page.request.post(`${baseUrl}/auth/api/validate`, {
|
||||
headers: {
|
||||
'Cookie': `${cookieName}=${sessionToken}`,
|
||||
},
|
||||
failOnStatusCode: false,
|
||||
})
|
||||
|
||||
expect(response.status()).toBe(401)
|
||||
console.log(`✓ Logout successful, session invalidated`)
|
||||
})
|
||||
})
|
||||
Vendored
+147
@@ -0,0 +1,147 @@
|
||||
import { test as base, type Page, type CDPSession } from '@playwright/test'
|
||||
import { existsSync, mkdirSync, writeFileSync, readFileSync } from 'fs'
|
||||
import { join, dirname } from 'path'
|
||||
import { fileURLToPath } from 'url'
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url))
|
||||
const coverageDir = join(__dirname, '..', '..', 'coverage-frontend')
|
||||
|
||||
// Check if frontend coverage is enabled
|
||||
const COLLECT_COVERAGE = process.env.COVERAGE === '1' || process.env.COVERAGE === 'true'
|
||||
|
||||
interface CoverageEntry {
|
||||
url: string
|
||||
scriptId: string
|
||||
source?: string
|
||||
functions: Array<{
|
||||
functionName: string
|
||||
ranges: Array<{
|
||||
startOffset: number
|
||||
endOffset: number
|
||||
count: number
|
||||
}>
|
||||
isBlockCoverage: boolean
|
||||
}>
|
||||
}
|
||||
|
||||
/**
|
||||
* Collect V8 JavaScript coverage from the page.
|
||||
*/
|
||||
async function startCoverage(page: Page): Promise<CDPSession | null> {
|
||||
if (!COLLECT_COVERAGE) return null
|
||||
|
||||
try {
|
||||
const cdp = await page.context().newCDPSession(page)
|
||||
await cdp.send('Profiler.enable')
|
||||
await cdp.send('Profiler.startPreciseCoverage', {
|
||||
callCount: true,
|
||||
detailed: true,
|
||||
})
|
||||
return cdp
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
async function stopCoverage(cdp: CDPSession | null, testName: string): Promise<void> {
|
||||
if (!cdp) return
|
||||
|
||||
try {
|
||||
const { result } = await cdp.send('Profiler.takePreciseCoverage')
|
||||
await cdp.send('Profiler.stopPreciseCoverage')
|
||||
await cdp.send('Profiler.disable')
|
||||
|
||||
// Filter to only include our app's JavaScript files
|
||||
const appCoverage = result.filter((entry: CoverageEntry) =>
|
||||
entry.url.includes('/auth/') &&
|
||||
entry.url.endsWith('.js') &&
|
||||
!entry.url.includes('node_modules')
|
||||
)
|
||||
|
||||
if (appCoverage.length > 0) {
|
||||
// Ensure coverage directory exists
|
||||
if (!existsSync(coverageDir)) {
|
||||
mkdirSync(coverageDir, { recursive: true })
|
||||
}
|
||||
|
||||
// Save coverage data for this test
|
||||
const safeName = testName.replace(/[^a-z0-9]/gi, '_').substring(0, 50)
|
||||
const coverageFile = join(coverageDir, `coverage-${safeName}-${Date.now()}.json`)
|
||||
writeFileSync(coverageFile, JSON.stringify(appCoverage, null, 2))
|
||||
}
|
||||
} catch (err) {
|
||||
// Silently ignore coverage collection errors
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Merge all coverage files into a single summary.
|
||||
*/
|
||||
export async function mergeCoverage(): Promise<void> {
|
||||
if (!COLLECT_COVERAGE || !existsSync(coverageDir)) return
|
||||
|
||||
const files = require('fs').readdirSync(coverageDir).filter((f: string) => f.startsWith('coverage-') && f.endsWith('.json'))
|
||||
if (files.length === 0) return
|
||||
|
||||
const merged: Map<string, CoverageEntry> = new Map()
|
||||
|
||||
for (const file of files) {
|
||||
const data: CoverageEntry[] = JSON.parse(readFileSync(join(coverageDir, file), 'utf-8'))
|
||||
for (const entry of data) {
|
||||
const existing = merged.get(entry.url)
|
||||
if (!existing) {
|
||||
merged.set(entry.url, entry)
|
||||
} else {
|
||||
// Merge function coverage counts
|
||||
for (const func of entry.functions) {
|
||||
const existingFunc = existing.functions.find(f => f.functionName === func.functionName)
|
||||
if (existingFunc) {
|
||||
for (let i = 0; i < func.ranges.length; i++) {
|
||||
if (existingFunc.ranges[i]) {
|
||||
existingFunc.ranges[i].count += func.ranges[i].count
|
||||
}
|
||||
}
|
||||
} else {
|
||||
existing.functions.push(func)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Write merged coverage
|
||||
writeFileSync(
|
||||
join(coverageDir, 'coverage-merged.json'),
|
||||
JSON.stringify(Array.from(merged.values()), null, 2)
|
||||
)
|
||||
|
||||
// Generate simple coverage summary
|
||||
let totalFunctions = 0
|
||||
let coveredFunctions = 0
|
||||
|
||||
for (const entry of merged.values()) {
|
||||
for (const func of entry.functions) {
|
||||
totalFunctions++
|
||||
const hasCoverage = func.ranges.some(r => r.count > 0)
|
||||
if (hasCoverage) coveredFunctions++
|
||||
}
|
||||
}
|
||||
|
||||
const percentage = totalFunctions > 0 ? Math.round((coveredFunctions / totalFunctions) * 100) : 0
|
||||
console.log(`\n 📊 Frontend JS Coverage: ${coveredFunctions}/${totalFunctions} functions (${percentage}%)`)
|
||||
console.log(` ✅ Frontend coverage data: ${coverageDir}/coverage-merged.json\n`)
|
||||
}
|
||||
|
||||
/**
|
||||
* Extended test with coverage collection.
|
||||
* This wraps each test to collect V8 coverage data.
|
||||
*/
|
||||
export const testWithCoverage = base.extend<{
|
||||
coverageSession: CDPSession | null
|
||||
}>({
|
||||
coverageSession: async ({ page }, use, testInfo) => {
|
||||
const cdp = await startCoverage(page)
|
||||
await use(cdp)
|
||||
await stopCoverage(cdp, testInfo.title)
|
||||
},
|
||||
})
|
||||
+184
-50
@@ -1,9 +1,10 @@
|
||||
import { type Page } from '@playwright/test'
|
||||
import { existsSync, readFileSync } from 'fs'
|
||||
import { existsSync, readFileSync, writeFileSync } from 'fs'
|
||||
import { join, dirname } from 'path'
|
||||
import { fileURLToPath } from 'url'
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url))
|
||||
const stateFile = join(__dirname, '..', '..', 'test-data', 'test-state.json')
|
||||
|
||||
/**
|
||||
* WebSocket helpers for passkey registration and authentication.
|
||||
@@ -26,7 +27,6 @@ export interface AuthenticationResult {
|
||||
* Get the bootstrap reset token from the test state file.
|
||||
*/
|
||||
export function getBootstrapResetToken(): string | undefined {
|
||||
const stateFile = join(__dirname, '..', '..', 'test-data', 'test-state.json')
|
||||
if (existsSync(stateFile)) {
|
||||
try {
|
||||
const state = JSON.parse(readFileSync(stateFile, 'utf-8'))
|
||||
@@ -38,6 +38,118 @@ export function getBootstrapResetToken(): string | undefined {
|
||||
return undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the session cookie name from the test state file.
|
||||
*/
|
||||
export function getSessionCookieName(): string {
|
||||
if (existsSync(stateFile)) {
|
||||
try {
|
||||
const state = JSON.parse(readFileSync(stateFile, 'utf-8'))
|
||||
return state.sessionCookie || '__Host-auth'
|
||||
} catch {
|
||||
return '__Host-auth'
|
||||
}
|
||||
}
|
||||
return '__Host-auth'
|
||||
}
|
||||
|
||||
/**
|
||||
* Save a session token to the test state file for sharing across test groups.
|
||||
*/
|
||||
export function saveSessionToken(sessionToken: string): void {
|
||||
if (existsSync(stateFile)) {
|
||||
try {
|
||||
const state = JSON.parse(readFileSync(stateFile, 'utf-8'))
|
||||
state.savedSessionToken = sessionToken
|
||||
writeFileSync(stateFile, JSON.stringify(state, null, 2))
|
||||
} catch {
|
||||
// Ignore errors
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear the saved session token from the test state file.
|
||||
* Call this after logout to prevent accidental reuse of invalidated sessions.
|
||||
*/
|
||||
export function clearSavedSessionToken(): void {
|
||||
if (existsSync(stateFile)) {
|
||||
try {
|
||||
const state = JSON.parse(readFileSync(stateFile, 'utf-8'))
|
||||
delete state.savedSessionToken
|
||||
writeFileSync(stateFile, JSON.stringify(state, null, 2))
|
||||
} catch {
|
||||
// Ignore errors
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a saved session token from the test state file.
|
||||
*/
|
||||
export function getSavedSessionToken(): string | undefined {
|
||||
if (existsSync(stateFile)) {
|
||||
try {
|
||||
const state = JSON.parse(readFileSync(stateFile, 'utf-8'))
|
||||
return state.savedSessionToken
|
||||
} catch {
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* Save device tokens to the test state file for use by other tests.
|
||||
* These tokens allow tests to register their own passkeys.
|
||||
*/
|
||||
export function saveDeviceTokens(tokens: string[]): void {
|
||||
if (existsSync(stateFile)) {
|
||||
try {
|
||||
const state = JSON.parse(readFileSync(stateFile, 'utf-8'))
|
||||
state.deviceTokens = tokens
|
||||
writeFileSync(stateFile, JSON.stringify(state, null, 2))
|
||||
} catch {
|
||||
// Ignore errors
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get and consume a device token from the pool.
|
||||
* Returns undefined if no tokens are available.
|
||||
*/
|
||||
export function popDeviceToken(): string | undefined {
|
||||
if (existsSync(stateFile)) {
|
||||
try {
|
||||
const state = JSON.parse(readFileSync(stateFile, 'utf-8'))
|
||||
if (state.deviceTokens && state.deviceTokens.length > 0) {
|
||||
const token = state.deviceTokens.pop()
|
||||
writeFileSync(stateFile, JSON.stringify(state, null, 2))
|
||||
return token
|
||||
}
|
||||
} catch {
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the count of remaining device tokens.
|
||||
*/
|
||||
export function getDeviceTokenCount(): number {
|
||||
if (existsSync(stateFile)) {
|
||||
try {
|
||||
const state = JSON.parse(readFileSync(stateFile, 'utf-8'))
|
||||
return state.deviceTokens?.length || 0
|
||||
} catch {
|
||||
return 0
|
||||
}
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
/**
|
||||
* Perform passkey registration via WebSocket.
|
||||
* This runs in the browser context using the virtual authenticator.
|
||||
@@ -57,67 +169,70 @@ export async function registerPasskey(
|
||||
|
||||
return new Promise<any>((resolve, reject) => {
|
||||
const ws = new WebSocket(wsUrl)
|
||||
|
||||
|
||||
ws.onopen = () => {
|
||||
console.log('WebSocket connected for registration')
|
||||
}
|
||||
|
||||
|
||||
ws.onmessage = async (event) => {
|
||||
const data = JSON.parse(event.data)
|
||||
|
||||
|
||||
// Check for error response
|
||||
if (data.detail) {
|
||||
ws.close()
|
||||
reject(new Error(data.detail))
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
// Check if this is the final success response
|
||||
if (data.session_token) {
|
||||
ws.close()
|
||||
resolve(data)
|
||||
return
|
||||
}
|
||||
|
||||
// This should be the registration options from server
|
||||
|
||||
// This should be the registration options from server (wrapped in optionsJSON)
|
||||
// Use the native WebAuthn API with the virtual authenticator
|
||||
try {
|
||||
// Extract options from the optionsJSON wrapper
|
||||
const opts = data.optionsJSON
|
||||
|
||||
// Convert base64url challenge to ArrayBuffer
|
||||
const challenge = Uint8Array.from(atob(data.challenge.replace(/-/g, '+').replace(/_/g, '/')), c => c.charCodeAt(0))
|
||||
|
||||
const challenge = Uint8Array.from(atob(opts.challenge.replace(/-/g, '+').replace(/_/g, '/')), c => c.charCodeAt(0))
|
||||
|
||||
// Build the credential creation options
|
||||
const publicKeyCredentialCreationOptions: CredentialCreationOptions = {
|
||||
publicKey: {
|
||||
challenge: challenge,
|
||||
rp: {
|
||||
name: data.rp.name,
|
||||
id: data.rp.id,
|
||||
name: opts.rp.name,
|
||||
id: opts.rp.id,
|
||||
},
|
||||
user: {
|
||||
id: Uint8Array.from(atob(data.user.id.replace(/-/g, '+').replace(/_/g, '/')), c => c.charCodeAt(0)),
|
||||
name: data.user.name,
|
||||
displayName: data.user.displayName,
|
||||
id: Uint8Array.from(atob(opts.user.id.replace(/-/g, '+').replace(/_/g, '/')), c => c.charCodeAt(0)),
|
||||
name: opts.user.name,
|
||||
displayName: opts.user.displayName,
|
||||
},
|
||||
pubKeyCredParams: data.pubKeyCredParams,
|
||||
authenticatorSelection: data.authenticatorSelection,
|
||||
timeout: data.timeout,
|
||||
attestation: data.attestation,
|
||||
excludeCredentials: data.excludeCredentials?.map((cred: any) => ({
|
||||
pubKeyCredParams: opts.pubKeyCredParams,
|
||||
authenticatorSelection: opts.authenticatorSelection,
|
||||
timeout: opts.timeout,
|
||||
attestation: opts.attestation,
|
||||
excludeCredentials: opts.excludeCredentials?.map((cred: any) => ({
|
||||
...cred,
|
||||
id: Uint8Array.from(atob(cred.id.replace(/-/g, '+').replace(/_/g, '/')), c => c.charCodeAt(0)),
|
||||
})) || [],
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// Create the credential using native WebAuthn API (virtual authenticator handles it)
|
||||
const credential = await navigator.credentials.create(publicKeyCredentialCreationOptions) as PublicKeyCredential
|
||||
|
||||
|
||||
if (!credential) {
|
||||
throw new Error('Failed to create credential')
|
||||
}
|
||||
|
||||
|
||||
const response = credential.response as AuthenticatorAttestationResponse
|
||||
|
||||
|
||||
// Convert response to JSON format expected by server
|
||||
const registrationResponse = {
|
||||
id: credential.id,
|
||||
@@ -131,18 +246,18 @@ export async function registerPasskey(
|
||||
clientExtensionResults: credential.getClientExtensionResults(),
|
||||
authenticatorAttachment: (credential as any).authenticatorAttachment,
|
||||
}
|
||||
|
||||
|
||||
ws.send(JSON.stringify(registrationResponse))
|
||||
} catch (error: any) {
|
||||
ws.close()
|
||||
reject(new Error(error.message || 'Registration failed'))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
ws.onerror = () => {
|
||||
reject(new Error('WebSocket error during registration'))
|
||||
}
|
||||
|
||||
|
||||
ws.onclose = (event) => {
|
||||
if (!event.wasClean && event.code !== 1000) {
|
||||
reject(new Error(`WebSocket closed unexpectedly: ${event.code}`))
|
||||
@@ -165,57 +280,60 @@ export async function authenticatePasskey(
|
||||
|
||||
return new Promise<any>((resolve, reject) => {
|
||||
const ws = new WebSocket(wsUrl)
|
||||
|
||||
|
||||
ws.onopen = () => {
|
||||
console.log('WebSocket connected for authentication')
|
||||
}
|
||||
|
||||
|
||||
ws.onmessage = async (event) => {
|
||||
const data = JSON.parse(event.data)
|
||||
|
||||
|
||||
// Check for error response
|
||||
if (data.detail) {
|
||||
ws.close()
|
||||
reject(new Error(data.detail))
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
// Check if this is the final success response
|
||||
if (data.session_token) {
|
||||
ws.close()
|
||||
resolve(data)
|
||||
return
|
||||
}
|
||||
|
||||
// This should be the authentication options from server
|
||||
|
||||
// This should be the authentication options from server (wrapped in optionsJSON)
|
||||
try {
|
||||
// Extract options from the optionsJSON wrapper
|
||||
const opts = data.optionsJSON
|
||||
|
||||
// Convert base64url challenge to ArrayBuffer
|
||||
const challenge = Uint8Array.from(atob(data.challenge.replace(/-/g, '+').replace(/_/g, '/')), c => c.charCodeAt(0))
|
||||
|
||||
const challenge = Uint8Array.from(atob(opts.challenge.replace(/-/g, '+').replace(/_/g, '/')), c => c.charCodeAt(0))
|
||||
|
||||
// Build the credential request options
|
||||
const publicKeyCredentialRequestOptions: CredentialRequestOptions = {
|
||||
publicKey: {
|
||||
challenge: challenge,
|
||||
rpId: data.rpId,
|
||||
timeout: data.timeout,
|
||||
userVerification: data.userVerification,
|
||||
allowCredentials: data.allowCredentials?.map((cred: any) => ({
|
||||
rpId: opts.rpId,
|
||||
timeout: opts.timeout,
|
||||
userVerification: opts.userVerification,
|
||||
allowCredentials: opts.allowCredentials?.map((cred: any) => ({
|
||||
type: cred.type,
|
||||
id: Uint8Array.from(atob(cred.id.replace(/-/g, '+').replace(/_/g, '/')), c => c.charCodeAt(0)),
|
||||
transports: cred.transports,
|
||||
})) || [],
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// Get the credential using native WebAuthn API (virtual authenticator handles it)
|
||||
const credential = await navigator.credentials.get(publicKeyCredentialRequestOptions) as PublicKeyCredential
|
||||
|
||||
|
||||
if (!credential) {
|
||||
throw new Error('Failed to get credential')
|
||||
}
|
||||
|
||||
|
||||
const response = credential.response as AuthenticatorAssertionResponse
|
||||
|
||||
|
||||
// Convert response to JSON format expected by server
|
||||
const authenticationResponse = {
|
||||
id: credential.id,
|
||||
@@ -230,18 +348,18 @@ export async function authenticatePasskey(
|
||||
clientExtensionResults: credential.getClientExtensionResults(),
|
||||
authenticatorAttachment: (credential as any).authenticatorAttachment,
|
||||
}
|
||||
|
||||
|
||||
ws.send(JSON.stringify(authenticationResponse))
|
||||
} catch (error: any) {
|
||||
ws.close()
|
||||
reject(new Error(error.message || 'Authentication failed'))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
ws.onerror = () => {
|
||||
reject(new Error('WebSocket error during authentication'))
|
||||
}
|
||||
|
||||
|
||||
ws.onclose = (event) => {
|
||||
if (!event.wasClean && event.code !== 1000) {
|
||||
reject(new Error(`WebSocket closed unexpectedly: ${event.code}`))
|
||||
@@ -259,9 +377,10 @@ export async function validateSession(
|
||||
baseUrl: string,
|
||||
sessionToken: string
|
||||
): Promise<{ valid: boolean; user_uuid: string; renewed: boolean }> {
|
||||
const cookieName = getSessionCookieName()
|
||||
const response = await page.request.post(`${baseUrl}/auth/api/validate`, {
|
||||
headers: {
|
||||
'Cookie': `__Host-auth=${sessionToken}`,
|
||||
'Cookie': `${cookieName}=${sessionToken}`,
|
||||
},
|
||||
})
|
||||
return await response.json()
|
||||
@@ -275,9 +394,10 @@ export async function getUserInfo(
|
||||
baseUrl: string,
|
||||
sessionToken: string
|
||||
): Promise<any> {
|
||||
const cookieName = getSessionCookieName()
|
||||
const response = await page.request.post(`${baseUrl}/auth/api/user-info`, {
|
||||
headers: {
|
||||
'Cookie': `__Host-auth=${sessionToken}`,
|
||||
'Cookie': `${cookieName}=${sessionToken}`,
|
||||
},
|
||||
})
|
||||
return await response.json()
|
||||
@@ -285,17 +405,24 @@ export async function getUserInfo(
|
||||
|
||||
/**
|
||||
* Logout via the API.
|
||||
* If the session being logged out matches the saved session token, clears it.
|
||||
*/
|
||||
export async function logout(
|
||||
page: Page,
|
||||
baseUrl: string,
|
||||
sessionToken: string
|
||||
): Promise<void> {
|
||||
const cookieName = getSessionCookieName()
|
||||
await page.request.post(`${baseUrl}/auth/api/logout`, {
|
||||
headers: {
|
||||
'Cookie': `__Host-auth=${sessionToken}`,
|
||||
'Cookie': `${cookieName}=${sessionToken}`,
|
||||
},
|
||||
})
|
||||
// Clear saved session token if it matches the one being logged out
|
||||
const savedToken = getSavedSessionToken()
|
||||
if (savedToken === sessionToken) {
|
||||
clearSavedSessionToken()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -306,12 +433,19 @@ export async function createDeviceLink(
|
||||
baseUrl: string,
|
||||
sessionToken: string
|
||||
): Promise<{ url: string; token: string }> {
|
||||
const cookieName = getSessionCookieName()
|
||||
const response = await page.request.post(`${baseUrl}/auth/api/user/create-link`, {
|
||||
headers: {
|
||||
'Cookie': `__Host-auth=${sessionToken}`,
|
||||
'Cookie': `${cookieName}=${sessionToken}`,
|
||||
},
|
||||
})
|
||||
if (!response.ok()) {
|
||||
throw new Error(`Failed to create device link: ${response.status()} - ${await response.text()}`)
|
||||
}
|
||||
const data = await response.json()
|
||||
if (!data.url) {
|
||||
throw new Error(`No URL in response: ${JSON.stringify(data)}`)
|
||||
}
|
||||
// Extract token from URL (last path segment)
|
||||
const url = new URL(data.url)
|
||||
const token = url.pathname.split('/').pop() || ''
|
||||
|
||||
+56
-5
@@ -1,4 +1,13 @@
|
||||
import { test as base, expect, type CDPSession, type Page } from '@playwright/test'
|
||||
import { existsSync, mkdirSync, writeFileSync } from 'fs'
|
||||
import { join, dirname } from 'path'
|
||||
import { fileURLToPath } from 'url'
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url))
|
||||
const coverageDir = join(__dirname, '..', '..', 'coverage-frontend')
|
||||
|
||||
// Check if frontend coverage is enabled
|
||||
const COLLECT_COVERAGE = process.env.COVERAGE === '1' || process.env.COVERAGE === 'true'
|
||||
|
||||
/**
|
||||
* Virtual Authenticator configuration for WebAuthn testing.
|
||||
@@ -27,7 +36,7 @@ export async function createVirtualAuthenticator(
|
||||
options: VirtualAuthenticatorOptions = {}
|
||||
): Promise<VirtualAuthenticator> {
|
||||
const cdpSession = await page.context().newCDPSession(page)
|
||||
|
||||
|
||||
// Enable WebAuthn in CDP
|
||||
await cdpSession.send('WebAuthn.enable', {
|
||||
enableUI: false, // Suppress any UI prompts
|
||||
@@ -73,20 +82,62 @@ export async function getCredentials(
|
||||
}
|
||||
|
||||
/**
|
||||
* Extended test fixture with virtual authenticator support.
|
||||
* Extended test fixture with virtual authenticator support and optional coverage.
|
||||
*/
|
||||
export const test = base.extend<{
|
||||
virtualAuthenticator: VirtualAuthenticator
|
||||
}>({
|
||||
virtualAuthenticator: async ({ page }, use) => {
|
||||
virtualAuthenticator: async ({ page }, use, testInfo) => {
|
||||
// Start coverage collection if enabled
|
||||
let coverageCdp: CDPSession | null = null
|
||||
if (COLLECT_COVERAGE) {
|
||||
try {
|
||||
coverageCdp = await page.context().newCDPSession(page)
|
||||
await coverageCdp.send('Profiler.enable')
|
||||
await coverageCdp.send('Profiler.startPreciseCoverage', {
|
||||
callCount: true,
|
||||
detailed: true,
|
||||
})
|
||||
} catch {
|
||||
coverageCdp = null
|
||||
}
|
||||
}
|
||||
|
||||
// Create virtual authenticator before test
|
||||
const authenticator = await createVirtualAuthenticator(page)
|
||||
|
||||
|
||||
// Run the test
|
||||
await use(authenticator)
|
||||
|
||||
|
||||
// Cleanup after test
|
||||
await removeVirtualAuthenticator(authenticator)
|
||||
|
||||
// Stop and save coverage
|
||||
if (coverageCdp) {
|
||||
try {
|
||||
const { result } = await coverageCdp.send('Profiler.takePreciseCoverage')
|
||||
await coverageCdp.send('Profiler.stopPreciseCoverage')
|
||||
await coverageCdp.send('Profiler.disable')
|
||||
|
||||
// Filter to only include our app's JavaScript files
|
||||
const appCoverage = result.filter((entry: any) =>
|
||||
entry.url.includes('/auth/') &&
|
||||
entry.url.endsWith('.js') &&
|
||||
!entry.url.includes('node_modules')
|
||||
)
|
||||
|
||||
if (appCoverage.length > 0) {
|
||||
if (!existsSync(coverageDir)) {
|
||||
mkdirSync(coverageDir, { recursive: true })
|
||||
}
|
||||
const safeName = testInfo.title.replace(/[^a-z0-9]/gi, '_').substring(0, 50)
|
||||
const coverageFile = join(coverageDir, `coverage-${safeName}-${Date.now()}.json`)
|
||||
writeFileSync(coverageFile, JSON.stringify(appCoverage, null, 2))
|
||||
}
|
||||
} catch {
|
||||
// Silently ignore coverage collection errors
|
||||
}
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
|
||||
+60
-41
@@ -1,94 +1,101 @@
|
||||
import { spawn } from 'child_process'
|
||||
import { join, dirname } from 'path'
|
||||
import { existsSync, mkdirSync, rmSync, writeFileSync } from 'fs'
|
||||
import { existsSync, mkdirSync, writeFileSync } from 'fs'
|
||||
import { fileURLToPath } from 'url'
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url))
|
||||
const testDataDir = join(__dirname, '..', 'test-data')
|
||||
const stateFile = join(testDataDir, 'test-state.json')
|
||||
const dbPath = join(testDataDir, 'test.sqlite')
|
||||
const projectRoot = join(__dirname, '..', '..')
|
||||
|
||||
// Check if coverage is enabled
|
||||
const COLLECT_COVERAGE = process.env.COVERAGE === '1' || process.env.COVERAGE === 'true'
|
||||
|
||||
interface TestState {
|
||||
resetToken?: string
|
||||
serverPid?: number
|
||||
sessionCookie?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Global setup for E2E tests.
|
||||
*
|
||||
* This creates a fresh test database and starts the server,
|
||||
* capturing the bootstrap reset token for initial user registration.
|
||||
*
|
||||
* Uses in-memory SQLite database for fast, isolated tests.
|
||||
* Captures the bootstrap reset token for initial user registration.
|
||||
*/
|
||||
export default async function globalSetup() {
|
||||
console.log('\n🔧 Setting up E2E test environment...\n')
|
||||
|
||||
// Create test data directory
|
||||
|
||||
// Create test data directory for state file
|
||||
if (!existsSync(testDataDir)) {
|
||||
mkdirSync(testDataDir, { recursive: true })
|
||||
}
|
||||
|
||||
// Remove old database for clean state
|
||||
if (existsSync(dbPath)) {
|
||||
console.log(' Removing old test database...')
|
||||
rmSync(dbPath)
|
||||
|
||||
console.log(' Starting server with in-memory database...')
|
||||
if (COLLECT_COVERAGE) {
|
||||
console.log(' 📊 Coverage collection enabled for Python backend')
|
||||
}
|
||||
|
||||
// Remove any wal/shm files too
|
||||
for (const ext of ['-wal', '-shm']) {
|
||||
const file = dbPath + ext
|
||||
if (existsSync(file)) rmSync(file)
|
||||
}
|
||||
|
||||
console.log(' Starting server with fresh database...')
|
||||
|
||||
|
||||
const state: TestState = {}
|
||||
|
||||
|
||||
// Build server command - with or without coverage
|
||||
const serverArgs = COLLECT_COVERAGE
|
||||
? [
|
||||
'run', 'coverage', 'run', '--parallel-mode',
|
||||
'-m', 'paskia.fastapi', 'serve', 'localhost:4404',
|
||||
'--rp-id', 'localhost'
|
||||
]
|
||||
: [
|
||||
'run', 'paskia', 'serve', 'localhost:4404',
|
||||
'--rp-id', 'localhost'
|
||||
]
|
||||
|
||||
// Start the server using Node's spawn
|
||||
const serverProcess = spawn('uv', [
|
||||
'run', 'passkey-auth', 'serve', ':4401',
|
||||
'--rp-id', 'localhost',
|
||||
'--origin', 'http://localhost:4401'
|
||||
], {
|
||||
cwd: testDataDir, // Run from test-data so DB is created there
|
||||
// Use in-memory SQLite for faster tests
|
||||
const serverProcess = spawn('uv', serverArgs, {
|
||||
cwd: projectRoot,
|
||||
env: {
|
||||
...process.env,
|
||||
PASKIA_DB: 'sqlite+aiosqlite:///:memory:',
|
||||
COVERAGE_FILE: join(projectRoot, '.coverage'),
|
||||
},
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
})
|
||||
|
||||
|
||||
state.serverPid = serverProcess.pid
|
||||
|
||||
|
||||
// Capture output to find reset token
|
||||
const resetTokenPromise = new Promise<string>((resolve, reject) => {
|
||||
const timeout = setTimeout(() => {
|
||||
reject(new Error('Timed out waiting for server bootstrap (30s)'))
|
||||
}, 30000)
|
||||
|
||||
|
||||
let output = ''
|
||||
|
||||
|
||||
const handleData = (data: Buffer) => {
|
||||
const text = data.toString()
|
||||
output += text
|
||||
process.stdout.write(text) // Echo to console
|
||||
|
||||
|
||||
// Look for the reset token URL in the output
|
||||
// Format: http://localhost:4401/auth/{token} where token is word.word.word.word.word (dot separated)
|
||||
const match = output.match(/http:\/\/localhost:\d+\/auth\/([a-z]+(?:\.[a-z]+)+)/)
|
||||
// Format: https://localhost/auth/{token} or http://localhost:4404/auth/{token}
|
||||
// where token is word.word.word.word.word (dot separated)
|
||||
const match = output.match(/https?:\/\/localhost(?::\d+)?\/auth\/([a-z]+(?:\.[a-z]+)+)/)
|
||||
if (match) {
|
||||
clearTimeout(timeout)
|
||||
// Wait a bit for server to fully start
|
||||
setTimeout(() => resolve(match[1]), 1000)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
serverProcess.stdout?.on('data', handleData)
|
||||
serverProcess.stderr?.on('data', handleData)
|
||||
|
||||
|
||||
serverProcess.on('error', (err) => {
|
||||
clearTimeout(timeout)
|
||||
reject(err)
|
||||
})
|
||||
|
||||
|
||||
serverProcess.on('exit', (code) => {
|
||||
if (code !== 0 && code !== null) {
|
||||
clearTimeout(timeout)
|
||||
@@ -96,7 +103,7 @@ export default async function globalSetup() {
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
try {
|
||||
state.resetToken = await resetTokenPromise
|
||||
console.log(`\n ✅ Captured reset token: ${state.resetToken}\n`)
|
||||
@@ -105,9 +112,21 @@ export default async function globalSetup() {
|
||||
serverProcess.kill()
|
||||
throw err
|
||||
}
|
||||
|
||||
|
||||
// Fetch session cookie name from server settings
|
||||
try {
|
||||
const response = await fetch('http://localhost:4404/auth/api/settings')
|
||||
const settings = await response.json()
|
||||
state.sessionCookie = settings.session_cookie
|
||||
console.log(` ✅ Session cookie name: ${state.sessionCookie}\n`)
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch settings:', err)
|
||||
serverProcess.kill()
|
||||
throw err
|
||||
}
|
||||
|
||||
// Save state for tests
|
||||
writeFileSync(stateFile, JSON.stringify(state, null, 2))
|
||||
|
||||
|
||||
console.log(' ✅ E2E test environment ready\n')
|
||||
}
|
||||
@@ -1,35 +1,49 @@
|
||||
import { join, dirname } from 'path'
|
||||
import { existsSync, rmSync, readFileSync } from 'fs'
|
||||
import { existsSync, rmSync, readFileSync, readdirSync, writeFileSync } from 'fs'
|
||||
import { fileURLToPath } from 'url'
|
||||
import { execSync } from 'child_process'
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url))
|
||||
const testDataDir = join(__dirname, '..', 'test-data')
|
||||
const stateFile = join(testDataDir, 'test-state.json')
|
||||
const projectRoot = join(__dirname, '..', '..')
|
||||
const coverageDir = join(__dirname, '..', 'coverage-frontend')
|
||||
|
||||
// Check if coverage is enabled
|
||||
const COLLECT_COVERAGE = process.env.COVERAGE === '1' || process.env.COVERAGE === 'true'
|
||||
|
||||
interface TestState {
|
||||
resetToken?: string
|
||||
serverPid?: number
|
||||
}
|
||||
|
||||
interface CoverageEntry {
|
||||
url: string
|
||||
functions: Array<{
|
||||
functionName: string
|
||||
ranges: Array<{ count: number }>
|
||||
}>
|
||||
}
|
||||
|
||||
/**
|
||||
* Global teardown for E2E tests.
|
||||
*
|
||||
*
|
||||
* This cleans up the test server and optionally removes the test database.
|
||||
*/
|
||||
export default async function globalTeardown() {
|
||||
console.log('\n🧹 Cleaning up E2E test environment...\n')
|
||||
|
||||
|
||||
// Read state file to get server PID
|
||||
if (existsSync(stateFile)) {
|
||||
try {
|
||||
const state: TestState = JSON.parse(readFileSync(stateFile, 'utf-8'))
|
||||
|
||||
|
||||
if (state.serverPid) {
|
||||
console.log(` Stopping server (PID: ${state.serverPid})...`)
|
||||
try {
|
||||
process.kill(state.serverPid, 'SIGTERM')
|
||||
// Wait a moment for graceful shutdown
|
||||
await new Promise(r => setTimeout(r, 500))
|
||||
// Wait longer for graceful shutdown and coverage data flush
|
||||
await new Promise(r => setTimeout(r, COLLECT_COVERAGE ? 2000 : 500))
|
||||
} catch (err: any) {
|
||||
// Process may already be dead
|
||||
if (err.code !== 'ESRCH') {
|
||||
@@ -40,11 +54,11 @@ export default async function globalTeardown() {
|
||||
} catch (err) {
|
||||
console.warn(' Warning: Could not read state file')
|
||||
}
|
||||
|
||||
|
||||
// Clean up state file
|
||||
rmSync(stateFile, { force: true })
|
||||
}
|
||||
|
||||
|
||||
// Optionally clean up test database (keep it for debugging by default)
|
||||
if (process.env.CLEANUP_TEST_DB === 'true') {
|
||||
const dbPath = join(testDataDir, 'test.sqlite')
|
||||
@@ -58,6 +72,77 @@ export default async function globalTeardown() {
|
||||
if (existsSync(file)) rmSync(file)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// Generate Python coverage report if coverage was collected
|
||||
if (COLLECT_COVERAGE) {
|
||||
console.log(' 📊 Generating Python coverage report...')
|
||||
try {
|
||||
// Combine parallel coverage data and generate reports
|
||||
execSync('uv run coverage combine', { cwd: projectRoot, stdio: 'inherit' })
|
||||
execSync('uv run coverage report', { cwd: projectRoot, stdio: 'inherit' })
|
||||
execSync('uv run coverage html', { cwd: projectRoot, stdio: 'inherit' })
|
||||
console.log(` ✅ Python coverage report: ${join(projectRoot, 'coverage-html', 'index.html')}\n`)
|
||||
} catch (err: any) {
|
||||
console.warn(` Warning: Failed to generate coverage report: ${err.message}`)
|
||||
}
|
||||
|
||||
// Merge and report frontend coverage
|
||||
if (existsSync(coverageDir)) {
|
||||
try {
|
||||
const files = readdirSync(coverageDir).filter(f => f.startsWith('coverage-') && f.endsWith('.json') && f !== 'coverage-merged.json')
|
||||
|
||||
if (files.length > 0) {
|
||||
const merged: Map<string, CoverageEntry> = new Map()
|
||||
|
||||
for (const file of files) {
|
||||
const data: CoverageEntry[] = JSON.parse(readFileSync(join(coverageDir, file), 'utf-8'))
|
||||
for (const entry of data) {
|
||||
const existing = merged.get(entry.url)
|
||||
if (!existing) {
|
||||
merged.set(entry.url, entry)
|
||||
} else {
|
||||
// Merge function coverage counts
|
||||
for (const func of entry.functions) {
|
||||
const existingFunc = existing.functions.find(f => f.functionName === func.functionName)
|
||||
if (existingFunc) {
|
||||
for (let i = 0; i < func.ranges.length && i < existingFunc.ranges.length; i++) {
|
||||
existingFunc.ranges[i].count += func.ranges[i].count
|
||||
}
|
||||
} else {
|
||||
existing.functions.push(func)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Write merged coverage
|
||||
writeFileSync(
|
||||
join(coverageDir, 'coverage-merged.json'),
|
||||
JSON.stringify(Array.from(merged.values()), null, 2)
|
||||
)
|
||||
|
||||
// Generate simple coverage summary
|
||||
let totalFunctions = 0
|
||||
let coveredFunctions = 0
|
||||
|
||||
for (const entry of merged.values()) {
|
||||
for (const func of entry.functions) {
|
||||
totalFunctions++
|
||||
const hasCoverage = func.ranges.some(r => r.count > 0)
|
||||
if (hasCoverage) coveredFunctions++
|
||||
}
|
||||
}
|
||||
|
||||
const percentage = totalFunctions > 0 ? Math.round((coveredFunctions / totalFunctions) * 100) : 0
|
||||
console.log(` 📊 Frontend JS Coverage: ${coveredFunctions}/${totalFunctions} functions (${percentage}%)`)
|
||||
console.log(` ✅ Frontend coverage data: ${coverageDir}/coverage-merged.json\n`)
|
||||
}
|
||||
} catch (err: any) {
|
||||
console.warn(` Warning: Failed to merge frontend coverage: ${err.message}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
console.log(' ✅ Cleanup complete\n')
|
||||
}
|
||||
@@ -1,205 +0,0 @@
|
||||
import { test, expect } from './fixtures/virtual-authenticator'
|
||||
import {
|
||||
registerPasskey,
|
||||
authenticatePasskey,
|
||||
validateSession,
|
||||
getUserInfo,
|
||||
logout,
|
||||
getBootstrapResetToken,
|
||||
createDeviceLink,
|
||||
} from './fixtures/passkey-helpers'
|
||||
|
||||
/**
|
||||
* E2E tests for PasskeyAuth using Chrome's Virtual Authenticator.
|
||||
*
|
||||
* These tests exercise the complete WebAuthn flow:
|
||||
* 1. Registration via WebSocket using bootstrap reset token
|
||||
* 2. Authentication via WebSocket
|
||||
* 3. Session validation
|
||||
* 4. User info retrieval
|
||||
* 5. Logout
|
||||
*
|
||||
* The virtual authenticator simulates a hardware passkey device,
|
||||
* allowing fully automated testing without physical hardware.
|
||||
*/
|
||||
|
||||
test.describe('Passkey Authentication E2E', () => {
|
||||
const baseUrl = process.env.BASE_URL || 'http://localhost:4401'
|
||||
|
||||
test.describe.configure({ mode: 'serial' })
|
||||
|
||||
// Shared state across tests in this describe block
|
||||
let sessionToken: string
|
||||
let userUuid: string
|
||||
let credentialUuid: string
|
||||
let resetToken: string | undefined
|
||||
|
||||
test.beforeAll(() => {
|
||||
// Get the bootstrap reset token from global setup
|
||||
resetToken = getBootstrapResetToken()
|
||||
if (!resetToken) {
|
||||
console.warn('⚠️ No reset token found - registration test may fail')
|
||||
} else {
|
||||
console.log(`📝 Using reset token: ${resetToken}`)
|
||||
}
|
||||
})
|
||||
|
||||
test('should load the auth page', async ({ page }) => {
|
||||
// Navigate to auth page to establish origin for WebAuthn
|
||||
await page.goto('/auth/')
|
||||
await expect(page).toHaveTitle(/.*/)
|
||||
|
||||
// Page should load - 401 errors are expected since user is not logged in
|
||||
await page.waitForTimeout(500)
|
||||
|
||||
// Just verify the page loaded without JS errors (network 401s are OK)
|
||||
console.log('✓ Auth page loaded successfully')
|
||||
})
|
||||
|
||||
test('should register admin passkey via WebSocket using reset token', async ({ page, virtualAuthenticator }) => {
|
||||
test.skip(!resetToken, 'No reset token available from bootstrap')
|
||||
|
||||
// Must visit the page first to establish origin
|
||||
await page.goto('/auth/')
|
||||
|
||||
// Perform registration via WebSocket with virtual authenticator
|
||||
// Using the bootstrap reset token for the admin user
|
||||
const result = await registerPasskey(page, baseUrl, {
|
||||
resetToken: resetToken,
|
||||
displayName: 'Admin User',
|
||||
})
|
||||
|
||||
// Verify registration result
|
||||
expect(result.session_token).toBeDefined()
|
||||
expect(result.session_token).toHaveLength(16)
|
||||
expect(result.user_uuid).toBeDefined()
|
||||
expect(result.credential_uuid).toBeDefined()
|
||||
expect(result.message).toContain('successfully')
|
||||
|
||||
// Store for subsequent tests
|
||||
sessionToken = result.session_token
|
||||
userUuid = result.user_uuid
|
||||
credentialUuid = result.credential_uuid
|
||||
|
||||
console.log(`✓ Registered user: ${userUuid}`)
|
||||
console.log(`✓ Credential: ${credentialUuid}`)
|
||||
console.log(`✓ Session token: ${sessionToken.substring(0, 4)}...`)
|
||||
})
|
||||
|
||||
test('should validate the session token', async ({ page }) => {
|
||||
// Skip if registration didn't run
|
||||
test.skip(!sessionToken, 'Requires successful registration')
|
||||
|
||||
const validation = await validateSession(page, baseUrl, sessionToken)
|
||||
|
||||
expect(validation.valid).toBe(true)
|
||||
expect(validation.user_uuid).toBe(userUuid)
|
||||
|
||||
console.log(`✓ Session validated for user: ${validation.user_uuid}`)
|
||||
})
|
||||
|
||||
test('should retrieve user info', async ({ page }) => {
|
||||
test.skip(!sessionToken, 'Requires successful registration')
|
||||
|
||||
const userInfo = await getUserInfo(page, baseUrl, sessionToken)
|
||||
|
||||
expect(userInfo.user.user_uuid).toBe(userUuid)
|
||||
expect(userInfo.user.user_name).toBe('Admin User')
|
||||
expect(userInfo.credentials).toBeDefined()
|
||||
expect(userInfo.credentials.length).toBeGreaterThanOrEqual(1)
|
||||
|
||||
console.log(`✓ User info retrieved: ${userInfo.user.user_name}`)
|
||||
console.log(`✓ Credentials count: ${userInfo.credentials.length}`)
|
||||
})
|
||||
|
||||
test('should authenticate with existing passkey', async ({ page, virtualAuthenticator }) => {
|
||||
test.skip(!sessionToken, 'Requires successful registration')
|
||||
|
||||
// Navigate to page (required for WebAuthn origin)
|
||||
await page.goto('/auth/')
|
||||
|
||||
// The virtual authenticator in this context is new and doesn't have credentials.
|
||||
// Create a device link using the current session, then register a new credential.
|
||||
const deviceLink = await createDeviceLink(page, baseUrl, sessionToken)
|
||||
console.log(`✓ Created device link with token: ${deviceLink.token}`)
|
||||
|
||||
// Register a new credential using the device link
|
||||
const regResult = await registerPasskey(page, baseUrl, {
|
||||
resetToken: deviceLink.token,
|
||||
displayName: 'Admin User (test device)'
|
||||
})
|
||||
|
||||
console.log(`✓ Added test credential: ${regResult.credential_uuid}`)
|
||||
|
||||
// Now logout and authenticate with the fresh credential
|
||||
await logout(page, baseUrl, regResult.session_token)
|
||||
console.log('✓ Logged out')
|
||||
|
||||
// Authenticate with the virtual authenticator (now has a valid credential)
|
||||
const result = await authenticatePasskey(page, baseUrl)
|
||||
|
||||
expect(result.session_token).toBeDefined()
|
||||
expect(result.session_token).toHaveLength(16)
|
||||
expect(result.user_uuid).toBe(userUuid)
|
||||
|
||||
// Update session token for subsequent tests
|
||||
sessionToken = result.session_token
|
||||
|
||||
console.log(`✓ Authenticated as user: ${result.user_uuid}`)
|
||||
console.log(`✓ New session token: ${sessionToken.substring(0, 4)}...`)
|
||||
})
|
||||
|
||||
test('should validate new session after authentication', async ({ page }) => {
|
||||
test.skip(!sessionToken, 'Requires successful authentication')
|
||||
|
||||
const validation = await validateSession(page, baseUrl, sessionToken)
|
||||
|
||||
expect(validation.valid).toBe(true)
|
||||
expect(validation.user_uuid).toBe(userUuid)
|
||||
|
||||
console.log(`✓ New session validated`)
|
||||
})
|
||||
|
||||
test('should logout successfully', async ({ page }) => {
|
||||
test.skip(!sessionToken, 'Requires valid session')
|
||||
|
||||
await logout(page, baseUrl, sessionToken)
|
||||
|
||||
// Session should no longer be valid
|
||||
const response = await page.request.post(`${baseUrl}/auth/api/validate`, {
|
||||
headers: {
|
||||
'Cookie': `__Host-auth=${sessionToken}`,
|
||||
},
|
||||
failOnStatusCode: false,
|
||||
})
|
||||
|
||||
expect(response.status()).toBe(401)
|
||||
console.log(`✓ Logout successful, session invalidated`)
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Session Management', () => {
|
||||
const baseUrl = process.env.BASE_URL || 'http://localhost:4401'
|
||||
|
||||
test('should reject invalid session token', async ({ page }) => {
|
||||
const response = await page.request.post(`${baseUrl}/auth/api/validate`, {
|
||||
headers: {
|
||||
'Cookie': '__Host-auth=invalid_token_123',
|
||||
},
|
||||
failOnStatusCode: false,
|
||||
})
|
||||
|
||||
// Server may return 400 (bad format) or 401 (unauthorized)
|
||||
expect([400, 401]).toContain(response.status())
|
||||
console.log(`✓ Invalid token correctly rejected`)
|
||||
})
|
||||
|
||||
test('should reject missing session token', async ({ page }) => {
|
||||
const response = await page.request.post(`${baseUrl}/auth/api/validate`, {
|
||||
failOnStatusCode: false,
|
||||
})
|
||||
|
||||
expect(response.status()).toBe(401)
|
||||
console.log(`✓ Missing token correctly rejected`)
|
||||
})
|
||||
})
|
||||
+4
-4
@@ -3,7 +3,7 @@
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>PassKey Auth - Dev Mode</title>
|
||||
<title>Paskia - Dev Mode</title>
|
||||
<style>
|
||||
:root {
|
||||
color-scheme: light dark; /* Automatic themes by browser */
|
||||
@@ -33,8 +33,8 @@
|
||||
<body>
|
||||
<div class="container">
|
||||
<header>
|
||||
<h1>🔐 PassKey Auth</h1>
|
||||
<p class="subtitle">Development server demonstration page.</p>
|
||||
<h1>🔐 Paskia - Development Server</h1>
|
||||
<p class="subtitle">The following features are available after you have registered your Admin account and logged in. You should also use the Admin Site to create non-privileged users to see the Forbidden dialog caused by missing permissions.</p>
|
||||
</header>
|
||||
|
||||
<div class="content">
|
||||
@@ -55,7 +55,7 @@
|
||||
|
||||
<div class="section">
|
||||
<h2>Browser Mode (full page)</h2>
|
||||
<p>Block access to otherwise open site - intended for forward-auth mechanism (Caddy, Nxinx):</p>
|
||||
<p>Block access to otherwise open site - intended for forward-auth mechanism (Caddy, Nginx):</p>
|
||||
<button onclick="browserNav('/auth/api/forward')">🔐 Basic Auth</button>
|
||||
<button onclick="browserNav('/auth/api/forward?max_age=10s')">🔄 Reauth (max_age=10s)</button>
|
||||
<button onclick="browserNav('/auth/api/forward?perm=auth:admin')">🛡️ Admin Only</button>
|
||||
|
||||
@@ -27,7 +27,7 @@ export default defineConfig(({ command }) => ({
|
||||
closeBundle() {
|
||||
if (command !== 'build') return
|
||||
|
||||
const outDir = resolve(__dirname, '../passkey/frontend-build')
|
||||
const outDir = resolve(__dirname, '../paskia/frontend-build')
|
||||
const moves = [
|
||||
{ from: 'auth.html', to: 'auth/index.html' },
|
||||
{ from: 'admin.html', to: 'admin/index.html' },
|
||||
@@ -69,7 +69,7 @@ export default defineConfig(({ command }) => ({
|
||||
}
|
||||
},
|
||||
build: {
|
||||
outDir: '../passkey/frontend-build',
|
||||
outDir: '../paskia/frontend-build',
|
||||
emptyOutDir: true,
|
||||
rollupOptions: {
|
||||
input: {
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
from paskia.sansio import Passkey
|
||||
|
||||
__all__ = ["Passkey"]
|
||||
@@ -14,7 +14,7 @@ from importlib.resources import files
|
||||
__ALL__ = ["AAGUID", "filter"]
|
||||
|
||||
# Path to the AAGUID JSON file
|
||||
AAGUID_FILE = files("passkey") / "aaguid" / "combined_aaguid.json"
|
||||
AAGUID_FILE = files("paskia") / "aaguid" / "combined_aaguid.json"
|
||||
AAGUID: dict[str, dict] = json.loads(AAGUID_FILE.read_text(encoding="utf-8"))
|
||||
|
||||
|
||||
File renamed without changes.
@@ -11,11 +11,11 @@ independent of any web framework:
|
||||
from datetime import datetime, timezone
|
||||
from uuid import UUID
|
||||
|
||||
from .config import SESSION_LIFETIME
|
||||
from .db import ResetToken, Session
|
||||
from .globals import db, passkey
|
||||
from .util import hostutil
|
||||
from .util.tokens import create_token, reset_key, session_key
|
||||
from paskia.config import SESSION_LIFETIME
|
||||
from paskia.db import ResetToken, Session
|
||||
from paskia.globals import db, passkey
|
||||
from paskia.util import hostutil
|
||||
from paskia.util.tokens import create_token, reset_key, session_key
|
||||
|
||||
EXPIRES = SESSION_LIFETIME
|
||||
|
||||
@@ -12,9 +12,9 @@ from datetime import datetime, timezone
|
||||
|
||||
import uuid7
|
||||
|
||||
from . import authsession, globals
|
||||
from .db import Org, Permission, Role, User
|
||||
from .util import hostutil, passphrase, tokens
|
||||
from paskia import authsession, globals
|
||||
from paskia.db import Org, Permission, Role, User
|
||||
from paskia.util import hostutil, passphrase, tokens
|
||||
|
||||
|
||||
def _init_logger() -> logging.Logger:
|
||||
@@ -53,16 +53,10 @@ async def _create_and_log_admin_reset_link(user_uuid, message, session_type) ->
|
||||
return reset_link
|
||||
|
||||
|
||||
async def bootstrap_system(
|
||||
user_name: str | None = None, org_name: str | None = None
|
||||
) -> dict:
|
||||
async def bootstrap_system() -> dict:
|
||||
"""
|
||||
Bootstrap the entire system with default data.
|
||||
|
||||
Args:
|
||||
user_name: Display name for the admin user (default: "Admin")
|
||||
org_name: Display name for the organization (default: "Organization")
|
||||
|
||||
Returns:
|
||||
dict: Contains information about created entities and reset link
|
||||
"""
|
||||
@@ -70,7 +64,7 @@ async def bootstrap_system(
|
||||
perm0 = Permission(id="auth:admin", display_name="Master Admin")
|
||||
await globals.db.instance.create_permission(perm0)
|
||||
|
||||
org = Org(uuid7.create(), org_name or "Organization")
|
||||
org = Org(uuid7.create(), "Organization")
|
||||
await globals.db.instance.create_organization(org)
|
||||
|
||||
# After creation, org.permissions now includes the auto-created org admin permission
|
||||
@@ -89,7 +83,7 @@ async def bootstrap_system(
|
||||
|
||||
user = User(
|
||||
uuid=uuid7.create(),
|
||||
display_name=user_name or "Admin",
|
||||
display_name="Admin",
|
||||
role_uuid=role.uuid,
|
||||
created_at=datetime.now(timezone.utc),
|
||||
visits=0,
|
||||
@@ -159,16 +153,10 @@ async def check_admin_credentials() -> bool:
|
||||
return False
|
||||
|
||||
|
||||
async def bootstrap_if_needed(
|
||||
default_admin: str | None = None, default_org: str | None = None
|
||||
) -> bool:
|
||||
async def bootstrap_if_needed() -> bool:
|
||||
"""
|
||||
Check if system needs bootstrapping and perform it if necessary.
|
||||
|
||||
Args:
|
||||
default_admin: Display name for the admin user
|
||||
default_org: Display name for the organization
|
||||
|
||||
Returns:
|
||||
bool: True if bootstrapping was performed, False if system was already set up
|
||||
"""
|
||||
@@ -185,35 +173,17 @@ async def bootstrap_if_needed(
|
||||
|
||||
# No admin permission found, need to bootstrap
|
||||
# Bootstrap creates the admin user AND the reset link, so no need to check credentials after
|
||||
await bootstrap_system(default_admin, default_org)
|
||||
await bootstrap_system()
|
||||
return True
|
||||
|
||||
|
||||
# CLI interface
|
||||
async def main():
|
||||
"""Main CLI entry point for bootstrapping."""
|
||||
import argparse
|
||||
|
||||
# Configure logging for CLI usage
|
||||
logging.basicConfig(level=logging.INFO, format="%(message)s", force=True)
|
||||
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Bootstrap passkey authentication system"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--user-name",
|
||||
default=None,
|
||||
help="Name for the admin user (default: Admin)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--org-name",
|
||||
default=None,
|
||||
help="Name for the organization (default: Organization)",
|
||||
)
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
await globals.init(default_admin=args.user_name, default_org=args.org_name)
|
||||
await globals.init()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
@@ -0,0 +1,25 @@
|
||||
from dataclasses import dataclass
|
||||
from datetime import timedelta
|
||||
|
||||
# Shared configuration constants for session management.
|
||||
SESSION_LIFETIME = timedelta(hours=24)
|
||||
|
||||
# Lifetime for reset links created by admins
|
||||
RESET_LIFETIME = timedelta(days=14)
|
||||
|
||||
|
||||
@dataclass
|
||||
class PaskiaConfig:
|
||||
"""Runtime configuration for the Paskia authentication server."""
|
||||
|
||||
rp_id: str
|
||||
rp_name: str | None
|
||||
origins: list[str] | None
|
||||
auth_host: str | None
|
||||
site_url: str # Base URL without trailing path (e.g. https://example.com)
|
||||
site_path: str # Path to auth UI: "/" if auth_host, else "/auth/"
|
||||
# Listen address (one of host:port or uds)
|
||||
host: str | None = None
|
||||
port: int | None = None
|
||||
uds: str | None = None
|
||||
devmode: bool = False
|
||||
File renamed without changes.
@@ -5,6 +5,7 @@ This module provides an async database layer using SQLAlchemy async mode
|
||||
for managing users and credentials in a WebAuthn authentication system.
|
||||
"""
|
||||
|
||||
import os
|
||||
from contextlib import asynccontextmanager
|
||||
from datetime import datetime, timezone
|
||||
from uuid import UUID
|
||||
@@ -26,9 +27,8 @@ from sqlalchemy.dialects.sqlite import BLOB
|
||||
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
|
||||
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column
|
||||
|
||||
from ..config import SESSION_LIFETIME
|
||||
from ..globals import db
|
||||
from . import (
|
||||
from paskia.config import SESSION_LIFETIME
|
||||
from paskia.db import (
|
||||
Credential,
|
||||
DatabaseInterface,
|
||||
Org,
|
||||
@@ -39,8 +39,9 @@ from . import (
|
||||
SessionContext,
|
||||
User,
|
||||
)
|
||||
from paskia.globals import db
|
||||
|
||||
DB_PATH = "sqlite+aiosqlite:///passkey-auth.sqlite"
|
||||
DB_PATH_DEFAULT = "sqlite+aiosqlite:///paskia.sqlite"
|
||||
|
||||
|
||||
def _normalize_dt(value: datetime | None) -> datetime | None:
|
||||
@@ -52,7 +53,8 @@ def _normalize_dt(value: datetime | None) -> datetime | None:
|
||||
|
||||
|
||||
async def init(*args, **kwargs):
|
||||
db.instance = DB()
|
||||
db_path = os.environ.get("PASKIA_DB", DB_PATH_DEFAULT)
|
||||
db.instance = DB(db_path)
|
||||
await db.instance.init_db()
|
||||
|
||||
|
||||
@@ -289,7 +291,7 @@ class RolePermission(Base):
|
||||
class DB(DatabaseInterface):
|
||||
"""Database class that handles its own connections."""
|
||||
|
||||
def __init__(self, db_path: str = DB_PATH):
|
||||
def __init__(self, db_path: str = DB_PATH_DEFAULT):
|
||||
"""Initialize with database path."""
|
||||
self.engine = create_async_engine(db_path, echo=False)
|
||||
# Ensure SQLite foreign key enforcement is ON for every new connection
|
||||
@@ -0,0 +1,3 @@
|
||||
from paskia.fastapi.mainapp import app
|
||||
|
||||
__all__ = ["app"]
|
||||
@@ -7,11 +7,10 @@ from urllib.parse import urlparse
|
||||
|
||||
import uvicorn
|
||||
|
||||
from passkey.util import frontend
|
||||
from paskia.util.hostutil import normalize_origin
|
||||
|
||||
DEFAULT_HOST = "localhost"
|
||||
DEFAULT_SERVE_PORT = 4401
|
||||
DEFAULT_DEV_PORT = 4402
|
||||
|
||||
|
||||
def is_subdomain(sub: str, domain: str) -> bool:
|
||||
@@ -114,7 +113,13 @@ def add_common_options(p: argparse.ArgumentParser) -> None:
|
||||
"--rp-id", default="localhost", help="Relying Party ID (default: localhost)"
|
||||
)
|
||||
p.add_argument("--rp-name", help="Relying Party name (default: same as rp-id)")
|
||||
p.add_argument("--origin", help="Origin URL (default: https://<rp-id>)")
|
||||
p.add_argument(
|
||||
"--origin",
|
||||
action="append",
|
||||
dest="origins",
|
||||
metavar="URL",
|
||||
help="Allowed origin URL(s). May be specified multiple times. If any are specified, only those origins are permitted for WebSocket authentication.",
|
||||
)
|
||||
p.add_argument(
|
||||
"--auth-host",
|
||||
help=(
|
||||
@@ -129,7 +134,7 @@ def main():
|
||||
logging.basicConfig(level=logging.INFO, format="%(message)s", force=True)
|
||||
|
||||
parser = argparse.ArgumentParser(
|
||||
prog="passkey-auth", description="Passkey authentication server"
|
||||
prog="paskia", description="Paskia authentication server"
|
||||
)
|
||||
sub = parser.add_subparsers(dest="command", required=True)
|
||||
|
||||
@@ -147,18 +152,6 @@ def main():
|
||||
)
|
||||
add_common_options(serve)
|
||||
|
||||
# dev subcommand
|
||||
dev = sub.add_parser("dev", help="Run the server in development (auto-reload)")
|
||||
dev.add_argument(
|
||||
"hostport",
|
||||
nargs="?",
|
||||
help=(
|
||||
"Endpoint (default: localhost:4402). Forms: host[:port] | :port | "
|
||||
"[ipv6][:port] | ipv6 | unix:/path.sock"
|
||||
),
|
||||
)
|
||||
add_common_options(dev)
|
||||
|
||||
# reset subcommand
|
||||
reset = sub.add_parser(
|
||||
"reset",
|
||||
@@ -176,66 +169,119 @@ def main():
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
if args.command in {"serve", "dev"}:
|
||||
default_port = DEFAULT_DEV_PORT if args.command == "dev" else DEFAULT_SERVE_PORT
|
||||
host, port, uds, all_ifaces = parse_endpoint(args.hostport, default_port)
|
||||
devmode = args.command == "dev"
|
||||
if args.command == "serve":
|
||||
host, port, uds, all_ifaces = parse_endpoint(args.hostport, DEFAULT_SERVE_PORT)
|
||||
else:
|
||||
host = port = uds = all_ifaces = None # type: ignore
|
||||
devmode = False
|
||||
|
||||
# Determine origin (dev mode default override)
|
||||
origin = args.origin
|
||||
if devmode and not args.origin and not args.rp_id:
|
||||
# Dev mode: Vite runs on another port, override:
|
||||
origin = "http://localhost:4403"
|
||||
|
||||
# Export configuration via environment for lifespan initialization in each process
|
||||
os.environ.setdefault("PASSKEY_RP_ID", args.rp_id)
|
||||
if args.rp_name:
|
||||
os.environ["PASSKEY_RP_NAME"] = args.rp_name
|
||||
if origin:
|
||||
os.environ["PASSKEY_ORIGIN"] = origin
|
||||
if getattr(args, "auth_host", None):
|
||||
os.environ["PASSKEY_AUTH_HOST"] = args.auth_host
|
||||
else:
|
||||
# Preserve pre-set env variable if CLI option omitted
|
||||
args.auth_host = os.environ.get("PASSKEY_AUTH_HOST")
|
||||
|
||||
# Collect and normalize origins, handle auth_host
|
||||
origins = [normalize_origin(o) for o in (getattr(args, "origins", None) or [])]
|
||||
if args.auth_host:
|
||||
# Normalize auth_host with scheme
|
||||
if "://" not in args.auth_host:
|
||||
args.auth_host = f"https://{args.auth_host}"
|
||||
|
||||
validate_auth_host(args.auth_host, args.rp_id)
|
||||
from passkey.util import hostutil as _hostutil # local import
|
||||
|
||||
_hostutil.reload_config()
|
||||
# If origins are configured, ensure auth_host is included at top
|
||||
if origins:
|
||||
# Insert auth_host at the beginning (Passkey.__init__ will dedupe)
|
||||
origins.insert(0, args.auth_host)
|
||||
|
||||
# One-time initialization + bootstrap before starting any server processes.
|
||||
# Lifespan in worker processes will call globals.init with bootstrap disabled.
|
||||
from passkey import globals as _globals # local import
|
||||
# Compute site_url and site_path for reset links
|
||||
# Priority: auth_host > first origin with localhost > http://localhost:port
|
||||
if args.auth_host:
|
||||
site_url = args.auth_host.rstrip("/")
|
||||
site_path = "/"
|
||||
elif origins:
|
||||
# Find localhost origin if rp_id is localhost, else use first origin
|
||||
localhost_origin = (
|
||||
next((o for o in origins if "://localhost" in o), None)
|
||||
if args.rp_id == "localhost"
|
||||
else None
|
||||
)
|
||||
site_url = (localhost_origin or origins[0]).rstrip("/")
|
||||
site_path = "/auth/"
|
||||
elif args.rp_id == "localhost" and port:
|
||||
# Dev mode: use http with port
|
||||
site_url = f"http://localhost:{port}"
|
||||
site_path = "/auth/"
|
||||
else:
|
||||
site_url = f"https://{args.rp_id}"
|
||||
site_path = "/auth/"
|
||||
|
||||
# Build runtime configuration
|
||||
from paskia.config import PaskiaConfig
|
||||
|
||||
config = PaskiaConfig(
|
||||
rp_id=args.rp_id,
|
||||
rp_name=args.rp_name or None,
|
||||
origins=origins or None,
|
||||
auth_host=args.auth_host or None,
|
||||
site_url=site_url,
|
||||
site_path=site_path,
|
||||
host=host,
|
||||
port=port,
|
||||
uds=uds,
|
||||
)
|
||||
|
||||
# Export configuration via single JSON env variable for worker processes
|
||||
import json
|
||||
|
||||
config_json = {
|
||||
"rp_id": config.rp_id,
|
||||
"rp_name": config.rp_name,
|
||||
"origins": config.origins,
|
||||
"auth_host": config.auth_host,
|
||||
"site_url": config.site_url,
|
||||
"site_path": config.site_path,
|
||||
}
|
||||
os.environ["PASKIA_CONFIG"] = json.dumps(config_json)
|
||||
|
||||
# Initialize globals (without bootstrap yet)
|
||||
from paskia import globals as _globals # local import
|
||||
|
||||
asyncio.run(
|
||||
_globals.init(
|
||||
rp_id=args.rp_id,
|
||||
rp_name=args.rp_name,
|
||||
origin=origin,
|
||||
default_admin=os.getenv("PASSKEY_DEFAULT_ADMIN") or None,
|
||||
default_org=os.getenv("PASSKEY_DEFAULT_ORG") or None,
|
||||
bootstrap=True,
|
||||
rp_id=config.rp_id,
|
||||
rp_name=config.rp_name,
|
||||
origins=config.origins,
|
||||
bootstrap=False,
|
||||
)
|
||||
)
|
||||
|
||||
# Print startup configuration
|
||||
from paskia.util import startupbox
|
||||
|
||||
startupbox.print_startup_config(config)
|
||||
|
||||
# Bootstrap after startup box is printed
|
||||
from paskia.bootstrap import bootstrap_if_needed
|
||||
|
||||
asyncio.run(bootstrap_if_needed())
|
||||
|
||||
# Handle recover-admin command (no server start)
|
||||
if args.command == "reset":
|
||||
from passkey.fastapi import reset as reset_cmd # local import
|
||||
from paskia.fastapi import reset as reset_cmd # local import
|
||||
|
||||
exit_code = reset_cmd.run(getattr(args, "query", None))
|
||||
raise SystemExit(exit_code)
|
||||
|
||||
if args.command in {"serve", "dev"}:
|
||||
if args.command == "serve":
|
||||
run_kwargs: dict = {
|
||||
"reload": devmode,
|
||||
"reload_dirs": ["passkey"] if devmode else None,
|
||||
"log_level": "info",
|
||||
}
|
||||
|
||||
# Dev mode: enable reload when PASKIA_DEVMODE is set
|
||||
devmode = 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:
|
||||
run_kwargs["uds"] = uds
|
||||
else:
|
||||
@@ -243,19 +289,17 @@ def main():
|
||||
run_kwargs["host"] = host
|
||||
run_kwargs["port"] = port
|
||||
|
||||
if devmode:
|
||||
os.environ["PASSKEY_DEVMODE"] = "1"
|
||||
frontend.run_dev()
|
||||
|
||||
if all_ifaces and not uds:
|
||||
# Dev mode with all interfaces: use simple single-server approach
|
||||
if devmode:
|
||||
run_kwargs["host"] = "::"
|
||||
run_kwargs["port"] = port
|
||||
uvicorn.run("passkey.fastapi:app", **run_kwargs)
|
||||
uvicorn.run("paskia.fastapi:app", **run_kwargs)
|
||||
else:
|
||||
# Production: run separate servers for IPv4 and IPv6
|
||||
from uvicorn import Config, Server # noqa: E402 local import
|
||||
|
||||
from passkey.fastapi import (
|
||||
from paskia.fastapi import (
|
||||
app as fastapi_app, # noqa: E402 local import
|
||||
)
|
||||
|
||||
@@ -278,7 +322,7 @@ def main():
|
||||
|
||||
asyncio.run(serve_both())
|
||||
else:
|
||||
uvicorn.run("passkey.fastapi:app", **run_kwargs)
|
||||
uvicorn.run("paskia.fastapi:app", **run_kwargs)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
@@ -5,9 +5,11 @@ from uuid import UUID, uuid4
|
||||
from fastapi import Body, FastAPI, HTTPException, Request, Response
|
||||
from fastapi.responses import JSONResponse
|
||||
|
||||
from ..authsession import reset_expires
|
||||
from ..globals import db
|
||||
from ..util import (
|
||||
from paskia.authsession import reset_expires
|
||||
from paskia.fastapi import authz
|
||||
from paskia.fastapi.session import AUTH_COOKIE
|
||||
from paskia.globals import db
|
||||
from paskia.util import (
|
||||
frontend,
|
||||
hostutil,
|
||||
passphrase,
|
||||
@@ -16,9 +18,7 @@ from ..util import (
|
||||
tokens,
|
||||
useragent,
|
||||
)
|
||||
from ..util.tokens import encode_session_key, session_key
|
||||
from . import authz
|
||||
from .session import AUTH_COOKIE
|
||||
from paskia.util.tokens import encode_session_key, session_key
|
||||
|
||||
app = FastAPI()
|
||||
|
||||
@@ -38,7 +38,7 @@ async def auth_exception_handler(_request, exc: authz.AuthException):
|
||||
|
||||
|
||||
@app.exception_handler(Exception)
|
||||
async def general_exception_handler(_request, exc: Exception):
|
||||
async def general_exception_handler(_request, exc: Exception): # pragma: no cover
|
||||
logging.exception("Unhandled exception in admin app")
|
||||
return JSONResponse(status_code=500, content={"detail": "Internal server error"})
|
||||
|
||||
@@ -139,7 +139,9 @@ async def admin_update_org(
|
||||
|
||||
current = await db.instance.get_organization(str(org_uuid))
|
||||
display_name = payload.get("display_name") or current.display_name
|
||||
permissions = payload.get("permissions") or current.permissions or []
|
||||
permissions = payload.get("permissions")
|
||||
if permissions is None:
|
||||
permissions = current.permissions or []
|
||||
|
||||
# Sanity check: prevent removing permissions that would break current user's admin access
|
||||
org_admin_perm = f"auth:org:{org_uuid}"
|
||||
@@ -398,7 +400,7 @@ async def admin_update_user_role(
|
||||
# Sanity check: prevent admin from removing their own access
|
||||
if ctx.user.uuid == user_uuid:
|
||||
new_role_obj = next((r for r in roles if r.display_name == new_role), None)
|
||||
if new_role_obj:
|
||||
if new_role_obj: # pragma: no branch - always true, role validated above
|
||||
has_admin_access = (
|
||||
"auth:admin" in new_role_obj.permissions
|
||||
or f"auth:org:{org_uuid}" in new_role_obj.permissions
|
||||
@@ -432,7 +434,7 @@ async def admin_create_user_registration_link(
|
||||
host=request.headers.get("host"),
|
||||
max_age="5m",
|
||||
)
|
||||
if (
|
||||
if ( # pragma: no cover - defense in depth, authz.verify already checked
|
||||
"auth:admin" not in ctx.role.permissions
|
||||
and f"auth:org:{org_uuid}" not in ctx.role.permissions
|
||||
):
|
||||
@@ -452,9 +454,7 @@ async def admin_create_user_registration_link(
|
||||
expiry=expiry,
|
||||
token_type=token_type,
|
||||
)
|
||||
url = hostutil.reset_link_url(
|
||||
token, request.url.scheme, request.headers.get("host")
|
||||
)
|
||||
url = hostutil.reset_link_url(token)
|
||||
return {
|
||||
"url": url,
|
||||
"expires": (
|
||||
@@ -484,7 +484,7 @@ async def admin_get_user_detail(
|
||||
match=permutil.has_any,
|
||||
host=request.headers.get("host"),
|
||||
)
|
||||
if (
|
||||
if ( # pragma: no cover - defense in depth, authz.verify already checked
|
||||
"auth:admin" not in ctx.role.permissions
|
||||
and f"auth:org:{org_uuid}" not in ctx.role.permissions
|
||||
):
|
||||
@@ -498,7 +498,7 @@ async def admin_get_user_detail(
|
||||
for cid in cred_ids:
|
||||
try:
|
||||
c = await db.instance.get_credential_by_id(cid)
|
||||
except ValueError:
|
||||
except ValueError: # pragma: no cover - race condition handling
|
||||
continue
|
||||
aaguid_str = str(c.aaguid)
|
||||
aaguids.add(aaguid_str)
|
||||
@@ -633,7 +633,7 @@ async def admin_update_user_display_name(
|
||||
match=permutil.has_any,
|
||||
host=request.headers.get("host"),
|
||||
)
|
||||
if (
|
||||
if ( # pragma: no cover - defense in depth, authz.verify already checked
|
||||
"auth:admin" not in ctx.role.permissions
|
||||
and f"auth:org:{org_uuid}" not in ctx.role.permissions
|
||||
):
|
||||
@@ -670,7 +670,7 @@ async def admin_delete_user_credential(
|
||||
host=request.headers.get("host"),
|
||||
max_age="5m",
|
||||
)
|
||||
if (
|
||||
if ( # pragma: no cover - defense in depth, authz.verify already checked
|
||||
"auth:admin" not in ctx.role.permissions
|
||||
and f"auth:org:{org_uuid}" not in ctx.role.permissions
|
||||
):
|
||||
@@ -701,7 +701,7 @@ async def admin_delete_user_session(
|
||||
match=permutil.has_any,
|
||||
host=request.headers.get("host"),
|
||||
)
|
||||
if (
|
||||
if ( # pragma: no cover - defense in depth, authz.verify already checked
|
||||
"auth:admin" not in ctx.role.permissions
|
||||
and f"auth:org:{org_uuid}" not in ctx.role.permissions
|
||||
):
|
||||
@@ -820,7 +820,7 @@ async def admin_rename_permission(
|
||||
perm = await db.instance.get_permission(old_id)
|
||||
display_name = perm.display_name
|
||||
rename_fn = getattr(db.instance, "rename_permission", None)
|
||||
if not rename_fn:
|
||||
if not rename_fn: # pragma: no cover - all current backends support rename
|
||||
raise ValueError("Permission renaming not supported by this backend")
|
||||
await rename_fn(old_id, new_id, display_name)
|
||||
return {"status": "ok"}
|
||||
@@ -13,21 +13,19 @@ from fastapi import (
|
||||
from fastapi.responses import JSONResponse
|
||||
from fastapi.security import HTTPBearer
|
||||
|
||||
from passkey.util import frontend
|
||||
|
||||
from ..authsession import (
|
||||
from paskia.authsession import (
|
||||
EXPIRES,
|
||||
get_reset,
|
||||
get_session,
|
||||
refresh_session_token,
|
||||
session_expiry,
|
||||
)
|
||||
from ..globals import db
|
||||
from ..globals import passkey as global_passkey
|
||||
from ..util import hostutil, htmlutil, passphrase, userinfo
|
||||
from ..util.tokens import session_key
|
||||
from . import authz, session, user
|
||||
from .session import AUTH_COOKIE
|
||||
from paskia.fastapi import authz, session, user
|
||||
from paskia.fastapi.session import AUTH_COOKIE, AUTH_COOKIE_NAME
|
||||
from paskia.globals import db
|
||||
from paskia.globals import passkey as global_passkey
|
||||
from paskia.util import frontend, hostutil, htmlutil, passphrase, userinfo
|
||||
from paskia.util.tokens import session_key
|
||||
|
||||
bearer_auth = HTTPBearer(auto_error=True)
|
||||
|
||||
@@ -67,7 +65,9 @@ async def auth_exception_handler(_request: Request, exc: authz.AuthException):
|
||||
|
||||
|
||||
@app.exception_handler(Exception)
|
||||
async def general_exception_handler(_request: Request, exc: Exception):
|
||||
async def general_exception_handler(
|
||||
_request: Request, exc: Exception
|
||||
): # pragma: no cover
|
||||
logging.exception("Unhandled exception in API app")
|
||||
return JSONResponse(status_code=500, content={"detail": "Internal server error"})
|
||||
|
||||
@@ -201,6 +201,7 @@ async def get_settings():
|
||||
"rp_name": pk.rp_name,
|
||||
"ui_base_path": base_path,
|
||||
"auth_host": hostutil.configured_auth_host(),
|
||||
"session_cookie": AUTH_COOKIE_NAME,
|
||||
}
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
from fastapi import Request, Response
|
||||
from fastapi.responses import RedirectResponse
|
||||
|
||||
from passkey.util import hostutil, passphrase
|
||||
from paskia.util import hostutil, passphrase
|
||||
|
||||
|
||||
def is_ui_path(path: str) -> bool:
|
||||
@@ -2,7 +2,7 @@ import logging
|
||||
|
||||
from fastapi import HTTPException
|
||||
|
||||
from ..util import permutil, sessionutil
|
||||
from paskia.util import permutil, sessionutil
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -1,41 +1,40 @@
|
||||
import logging
|
||||
import os
|
||||
from contextlib import asynccontextmanager
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import FastAPI, HTTPException, Request, Response
|
||||
from fastapi.responses import RedirectResponse
|
||||
from fastapi.responses import FileResponse, RedirectResponse
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
|
||||
from passkey.util import frontend, hostutil, passphrase
|
||||
from paskia.fastapi import admin, api, auth_host, ws
|
||||
from paskia.fastapi.session import AUTH_COOKIE
|
||||
from paskia.util import frontend, hostutil, passphrase
|
||||
|
||||
from . import admin, api, auth_host, ws
|
||||
from .session import AUTH_COOKIE
|
||||
# Path to examples/index.html when running from source tree
|
||||
_EXAMPLES_DIR = Path(__file__).parent.parent.parent / "examples"
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI): # pragma: no cover - startup path
|
||||
"""Application lifespan to ensure globals (DB, passkey) are initialized in each process.
|
||||
|
||||
We populate configuration from environment variables (set by the CLI entrypoint)
|
||||
Configuration is passed via PASKIA_CONFIG JSON env variable (set by the CLI entrypoint)
|
||||
so that uvicorn reload / multiprocess workers inherit the settings.
|
||||
All keys are guaranteed to exist; values are already normalized by __main__.py.
|
||||
"""
|
||||
from .. import globals
|
||||
import json
|
||||
|
||||
from paskia import globals
|
||||
|
||||
config = json.loads(os.environ["PASKIA_CONFIG"])
|
||||
|
||||
rp_id = os.getenv("PASSKEY_RP_ID", "localhost")
|
||||
rp_name = os.getenv("PASSKEY_RP_NAME") or None
|
||||
origin = os.getenv("PASSKEY_ORIGIN") or None
|
||||
default_admin = (
|
||||
os.getenv("PASSKEY_DEFAULT_ADMIN") or None
|
||||
) # still passed for context
|
||||
default_org = os.getenv("PASSKEY_DEFAULT_ORG") or None
|
||||
try:
|
||||
# CLI (__main__) performs bootstrap once; here we skip to avoid duplicate work
|
||||
await globals.init(
|
||||
rp_id=rp_id,
|
||||
rp_name=rp_name,
|
||||
origin=origin,
|
||||
default_admin=default_admin,
|
||||
default_org=default_org,
|
||||
rp_id=config["rp_id"],
|
||||
rp_name=config["rp_name"],
|
||||
origins=config["origins"],
|
||||
bootstrap=False,
|
||||
)
|
||||
except ValueError as e:
|
||||
@@ -43,15 +42,6 @@ async def lifespan(app: FastAPI): # pragma: no cover - startup path
|
||||
# Re-raise to fail fast
|
||||
raise
|
||||
|
||||
# In dev mode, Vite serves assets directly; in production, mount static files
|
||||
# This is deferred to lifespan because PASSKEY_DEVMODE is set after module import
|
||||
if not frontend.is_dev_mode():
|
||||
app.mount(
|
||||
"/auth/assets/",
|
||||
StaticFiles(directory=frontend.file("auth", "assets")),
|
||||
name="assets",
|
||||
)
|
||||
|
||||
yield
|
||||
# (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/ws/", ws.app)
|
||||
|
||||
# In dev mode (PASKIA_DEVMODE=1), Vite serves assets directly; skip static files mount
|
||||
if not frontend.is_dev_mode():
|
||||
app.mount(
|
||||
"/auth/assets/",
|
||||
StaticFiles(directory=frontend.file("auth", "assets")),
|
||||
name="assets",
|
||||
)
|
||||
|
||||
|
||||
@app.get("/auth/restricted/")
|
||||
async def restricted_view():
|
||||
@@ -98,6 +96,22 @@ async def admin_root(request: Request, auth=AUTH_COOKIE):
|
||||
return await admin.adminapp(request, auth) # Delegated to admin app
|
||||
|
||||
|
||||
@app.get("/auth/examples/", include_in_schema=False)
|
||||
async def examples_page():
|
||||
"""Serve examples/index.html when running from source tree.
|
||||
|
||||
This provides a simple test page for API mode authentication flows
|
||||
without depending on the Vue frontend build.
|
||||
"""
|
||||
index_file = _EXAMPLES_DIR / "index.html"
|
||||
if not index_file.is_file():
|
||||
raise HTTPException(
|
||||
status_code=404,
|
||||
detail="Examples not available (not running from source tree)",
|
||||
)
|
||||
return FileResponse(index_file, media_type="text/html")
|
||||
|
||||
|
||||
# Note: this catch-all handler must be the last route defined
|
||||
@app.get("/{reset}")
|
||||
@app.get("/auth/{reset}")
|
||||
@@ -1,7 +1,7 @@
|
||||
"""CLI support for creating user credential reset links.
|
||||
|
||||
Usage (via main CLI):
|
||||
passkey-auth reset [query]
|
||||
paskia reset [query]
|
||||
|
||||
If query is omitted, the master admin (first Administration role user in
|
||||
an organization granting auth:admin) is targeted. Otherwise query is
|
||||
@@ -15,10 +15,10 @@ from __future__ import annotations
|
||||
import asyncio
|
||||
from uuid import UUID
|
||||
|
||||
from passkey import authsession as _authsession
|
||||
from passkey import globals as _g
|
||||
from passkey.util import hostutil, passphrase
|
||||
from passkey.util import tokens as _tokens
|
||||
from paskia import authsession as _authsession
|
||||
from paskia import globals as _g
|
||||
from paskia.util import hostutil, passphrase
|
||||
from paskia.util import tokens as _tokens
|
||||
|
||||
|
||||
async def _resolve_targets(query: str | None):
|
||||
@@ -10,9 +10,9 @@ Generic session management functions have been moved to authsession.py
|
||||
|
||||
from fastapi import Cookie, Request, Response, WebSocket
|
||||
|
||||
from ..authsession import EXPIRES
|
||||
from paskia.authsession import EXPIRES
|
||||
|
||||
AUTH_COOKIE_NAME = "__Host-auth"
|
||||
AUTH_COOKIE_NAME = "__Host-paskia"
|
||||
AUTH_COOKIE = Cookie(None, alias=AUTH_COOKIE_NAME)
|
||||
|
||||
|
||||
@@ -10,16 +10,16 @@ from fastapi import (
|
||||
)
|
||||
from fastapi.responses import JSONResponse
|
||||
|
||||
from ..authsession import (
|
||||
from paskia.authsession import (
|
||||
delete_credential,
|
||||
expires,
|
||||
get_session,
|
||||
)
|
||||
from ..globals import db
|
||||
from ..util import hostutil, passphrase, tokens
|
||||
from ..util.tokens import decode_session_key, session_key
|
||||
from . import authz, session
|
||||
from .session import AUTH_COOKIE
|
||||
from paskia.fastapi import authz, session
|
||||
from paskia.fastapi.session import AUTH_COOKIE
|
||||
from paskia.globals import db
|
||||
from paskia.util import hostutil, passphrase, tokens
|
||||
from paskia.util.tokens import decode_session_key, session_key
|
||||
|
||||
app = FastAPI()
|
||||
|
||||
@@ -150,9 +150,7 @@ async def api_create_link(
|
||||
expiry=expiry,
|
||||
token_type="device addition",
|
||||
)
|
||||
url = hostutil.reset_link_url(
|
||||
token, request.url.scheme, request.headers.get("host")
|
||||
)
|
||||
url = hostutil.reset_link_url(token)
|
||||
return {
|
||||
"message": "Registration link generated successfully",
|
||||
"url": url,
|
||||
@@ -5,12 +5,12 @@ from uuid import UUID
|
||||
from fastapi import FastAPI, WebSocket, WebSocketDisconnect
|
||||
from webauthn.helpers.exceptions import InvalidAuthenticationResponse
|
||||
|
||||
from ..authsession import create_session, get_reset, get_session
|
||||
from ..globals import db, passkey
|
||||
from ..util import passphrase
|
||||
from ..util.tokens import create_token, session_key
|
||||
from . import authz
|
||||
from .session import AUTH_COOKIE, infodict
|
||||
from paskia.authsession import create_session, get_reset, get_session
|
||||
from paskia.fastapi import authz
|
||||
from paskia.fastapi.session import AUTH_COOKIE, infodict
|
||||
from paskia.globals import db, passkey
|
||||
from paskia.util import passphrase
|
||||
from paskia.util.tokens import create_token, session_key
|
||||
|
||||
|
||||
# WebSocket error handling decorator
|
||||
@@ -42,19 +42,30 @@ def websocket_error_handler(func):
|
||||
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(
|
||||
ws: WebSocket,
|
||||
user_uuid: UUID,
|
||||
user_name: str,
|
||||
origin: str,
|
||||
credential_ids: list[bytes] | None = None,
|
||||
origin: str | None = None,
|
||||
):
|
||||
"""Generate registration options and send them to the client."""
|
||||
options, challenge = passkey.instance.reg_generate_options(
|
||||
user_id=user_uuid,
|
||||
user_name=user_name,
|
||||
credential_ids=credential_ids,
|
||||
origin=origin,
|
||||
)
|
||||
await ws.send_json({"optionsJSON": options})
|
||||
response = await ws.receive_json()
|
||||
@@ -75,7 +86,7 @@ async def websocket_register_add(
|
||||
- Normal session via auth cookie (requires recent authentication)
|
||||
- Reset token supplied as ?reset=... (auth cookie ignored)
|
||||
"""
|
||||
origin = ws.headers["origin"]
|
||||
origin = _validate_origin(ws)
|
||||
host = origin.split("://", 1)[1]
|
||||
if reset is not None:
|
||||
if not passphrase.is_well_formed(reset):
|
||||
@@ -100,7 +111,7 @@ async def websocket_register_add(
|
||||
challenge_ids = await db.instance.get_credentials_by_user_uuid(user_uuid)
|
||||
|
||||
# WebAuthn registration
|
||||
credential = await register_chat(ws, user_uuid, user_name, challenge_ids, origin)
|
||||
credential = await register_chat(ws, user_uuid, user_name, origin, challenge_ids)
|
||||
|
||||
# Create a new session and store everything in database
|
||||
token = create_token()
|
||||
@@ -131,7 +142,7 @@ async def websocket_register_add(
|
||||
@app.websocket("/authenticate")
|
||||
@websocket_error_handler
|
||||
async def websocket_authenticate(ws: WebSocket, auth=AUTH_COOKIE):
|
||||
origin = ws.headers["origin"]
|
||||
origin = _validate_origin(ws)
|
||||
host = origin.split("://", 1)[1]
|
||||
|
||||
# If there's an existing session, restrict to that user's credentials (reauth)
|
||||
@@ -166,7 +177,7 @@ async def websocket_authenticate(ws: WebSocket, auth=AUTH_COOKIE):
|
||||
raise ValueError("This passkey belongs to a different account")
|
||||
|
||||
# Verify the credential matches the stored data
|
||||
passkey.instance.auth_verify(credential, challenge, stored_cred, origin=origin)
|
||||
passkey.instance.auth_verify(credential, challenge, stored_cred, origin)
|
||||
# Update both credential and user's last_seen timestamp
|
||||
await db.instance.login(stored_cred.user_uuid, stored_cred)
|
||||
|
||||
@@ -0,0 +1,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>
|
||||
@@ -0,0 +1 @@
|
||||
.breadcrumbs[data-v-2924b990]{margin:.25rem 0 .5rem;line-height:1.2;color:var(--color-text-muted)}.breadcrumbs ol[data-v-2924b990]{list-style:none;padding:0;margin:0;display:flex;flex-wrap:wrap;align-items:center;gap:.25rem}.breadcrumbs li[data-v-2924b990]{display:inline-flex;align-items:center;gap:.25rem;font-size:.9rem}.breadcrumbs a[data-v-2924b990]{text-decoration:none;color:var(--color-link);padding:0 .25rem;border-radius:4px;transition:color .2s ease,background .2s ease}.breadcrumbs a[data-v-2924b990]:hover,.breadcrumbs a[data-v-2924b990]:focus-visible{text-decoration:underline;color:var(--color-link-hover);outline:none}.breadcrumbs .sep[data-v-2924b990]{color:var(--color-text-muted);margin:0}.user-info[data-v-2994268a]{display:grid;grid-template-columns:auto 1fr;gap:10px}.user-info h3[data-v-2994268a]{grid-column:span 2}.org-role-sub[data-v-2994268a]{grid-column:span 2;display:flex;flex-direction:column;margin:-.15rem 0 .25rem}.org-line[data-v-2994268a]{font-size:.7rem;font-weight:600;line-height:1.1;color:var(--color-text-muted);text-transform:uppercase;letter-spacing:.05em}.role-line[data-v-2994268a]{font-size:.65rem;color:var(--color-text-muted);line-height:1.1}.user-info span[data-v-2994268a]{text-align:left}.user-name-heading[data-v-2994268a]{display:flex;align-items:center;gap:.4rem;flex-wrap:wrap;margin:0 0 .25rem}.user-name-row[data-v-2994268a]{display:inline-flex;align-items:center;gap:.35rem;max-width:100%}.user-name-row.editing[data-v-2994268a]{flex:1 1 auto}.icon[data-v-2994268a]{flex:0 0 auto}.display-name[data-v-2994268a]{font-weight:600;font-size:1.05em;line-height:1.2;max-width:14ch;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.name-input[data-v-2994268a]{width:auto;flex:1 1 140px;min-width:120px;padding:6px 8px;font-size:.9em;border:1px solid var(--color-border-strong);border-radius:6px;background:var(--color-surface);color:var(--color-text)}.user-name-heading .name-input[data-v-2994268a]{width:auto}.name-input[data-v-2994268a]:focus{outline:none;border-color:var(--color-accent);box-shadow:var(--focus-ring)}.mini-btn[data-v-2994268a]{width:auto;padding:4px 6px;margin:0;font-size:.75em;line-height:1;background:var(--color-surface-muted);border:1px solid var(--color-border-strong);border-radius:6px;cursor:pointer;transition:background .2s,transform .15s,color .2s ease;color:var(--color-text)}.mini-btn[data-v-2994268a]:hover:not(:disabled){background:var(--color-accent-soft);color:var(--color-accent)}.mini-btn[data-v-2994268a]:active:not(:disabled){transform:translateY(1px)}.mini-btn[data-v-2994268a]:disabled{opacity:.5;cursor:not-allowed}@media(max-width:480px){.user-name-heading[data-v-2994268a]{flex-direction:column;align-items:flex-start}.user-name-row.editing[data-v-2994268a]{width:100%}.display-name[data-v-2994268a]{max-width:100%}}.modal-overlay[data-v-d5e0cc32]{position:fixed;inset:0;background:transparent;backdrop-filter:blur(.1rem) brightness(.7);-webkit-backdrop-filter:blur(.1rem) brightness(.7);display:flex;align-items:center;justify-content:center;z-index:1000}.modal[data-v-d5e0cc32]{background:var(--color-surface);border:1px solid var(--color-border);border-radius:var(--radius-lg);box-shadow:var(--shadow-xl);padding:calc(var(--space-lg) - var(--space-xs));max-width:500px;width:min(500px,90vw);max-height:90vh;overflow-y:auto}.modal[data-v-d5e0cc32] .modal-title,.modal[data-v-d5e0cc32] h3{margin:0 0 var(--space-md);font-size:1.25rem;font-weight:600;color:var(--color-heading)}.modal[data-v-d5e0cc32] form,.modal[data-v-d5e0cc32] .modal-form{display:flex;flex-direction:column;gap:var(--space-md)}.modal[data-v-d5e0cc32] .modal-form label{display:flex;flex-direction:column;gap:var(--space-xs);font-weight:500}.modal[data-v-d5e0cc32] .modal-form input,.modal[data-v-d5e0cc32] .modal-form textarea{padding:var(--space-md);border:1px solid var(--color-border);border-radius:var(--radius-sm);background:var(--color-bg);color:var(--color-text);font-size:1rem;line-height:1.4;min-height:2.5rem}.modal[data-v-d5e0cc32] .modal-form input:focus,.modal[data-v-d5e0cc32] .modal-form textarea:focus{outline:none;border-color:var(--color-accent);box-shadow:0 0 0 2px #c7d2fe}.modal[data-v-d5e0cc32] .modal-actions{display:flex;justify-content:flex-end;gap:var(--space-sm);margin-top:var(--space-md);margin-bottom:var(--space-xs)}.name-edit-form[data-v-944620d3]{display:flex;flex-direction:column;gap:var(--space-md)}.error[data-v-944620d3]{color:var(--color-danger-text)}.small[data-v-944620d3]{font-size:.9rem}.session-meta-info{grid-column:span 2}[data-component=session-list-section] .session-list{display:flex;flex-direction:column;gap:1.5em}.session-group{display:flex;flex-direction:column;gap:.5em}.session-group-host{font-size:1em;font-weight:600;margin:0}.session-group-host a{color:inherit;text-decoration:none}.session-group-host a:hover{text-decoration:underline}.session-group-host.is-current-site{color:var(--color-accent)}.session-group-sessions{display:grid;grid-template-columns:repeat(Line truncated
|
||||
@@ -0,0 +1,8 @@
|
||||
import{D as Bt,r as J,E as kt,G as Ht,H as ae,I as It,J as jt,K as zt,L as $t,B as ue,M as Kt,c as z,w as Nt,N as Jt,O as Yt,q as Y,C as Gt,P as Qt,Q as xt,d as N,i as U,u as F,e as R,f as h,t as P,n as re,_ as Q,F as ne,x as le,R as Wt,S as Rt,o as At,l as ce,z as Zt,T as Xt,k as ye,U as en,V as tn}from"./_plugin-vue_export-helper-R4vr2A9I.js";import{f as G,g as Ge}from"./helpers-CU0-cyzg.js";let Tt;const de=e=>Tt=e,Mt=Symbol();function He(e){return e&&typeof e=="object"&&Object.prototype.toString.call(e)==="[object Object]"&&typeof e.toJSON!="function"}var se;(function(e){e.direct="direct",e.patchObject="patch object",e.patchFunction="patch function"})(se||(se={}));function Sr(){const e=Bt(!0),r=e.run(()=>J({}));let o=[],t=[];const n=kt({install(s){de(n),n._a=s,s.provide(Mt,n),s.config.globalProperties.$pinia=n,t.forEach(i=>o.push(i)),t=[]},use(s){return this._a?o.push(s):t.push(s),this},_p:o,_a:null,_e:e,_s:new Map,state:r});return n}const Pt=()=>{};function Qe(e,r,o,t=Pt){e.add(r);const n=()=>{e.delete(r)&&t()};return!o&&zt()&&$t(n),n}function X(e,...r){e.forEach(o=>{o(...r)})}const nn=e=>e(),xe=Symbol(),we=Symbol();function je(e,r){e instanceof Map&&r instanceof Map?r.forEach((o,t)=>e.set(t,o)):e instanceof Set&&r instanceof Set&&r.forEach(e.add,e);for(const o in r){if(!r.hasOwnProperty(o))continue;const t=r[o],n=e[o];He(n)&&He(t)&&e.hasOwnProperty(o)&&!ae(t)&&!It(t)?e[o]=je(n,t):e[o]=t}return e}const sn=Symbol();function rn(e){return!He(e)||!Object.prototype.hasOwnProperty.call(e,sn)}const{assign:K}=Object;function on(e){return!!(ae(e)&&e.effect)}function an(e,r,o,t){const{state:n,actions:s,getters:i}=r,u=o.state.value[e];let a;function c(){u||(o.state.value[e]=n?n():{});const f=Kt(o.state.value[e]);return K(f,s,Object.keys(i||{}).reduce((y,w)=>(y[w]=kt(z(()=>{de(o);const d=o._s.get(e);return i[w].call(d,d)})),y),{}))}return a=Lt(e,c,r,o,t,!0),a}function Lt(e,r,o={},t,n,s){let i;const u=K({actions:{}},o),a={deep:!0};let c,f,y=new Set,w=new Set,d;const l=t.state.value[e];!s&&!l&&(t.state.value[e]={}),J({});let _;function L(m){let g;c=f=!1,typeof m=="function"?(m(t.state.value[e]),g={type:se.patchFunction,storeId:e,events:d}):(je(t.state.value[e],m),g={type:se.patchObject,payload:m,storeId:e,events:d});const v=_=Symbol();ue().then(()=>{_===v&&(c=!0)}),f=!0,X(y,g,t.state.value[e])}const I=s?function(){const{state:g}=o,v=g?g():{};this.$patch(b=>{K(b,v)})}:Pt;function p(){i.stop(),y.clear(),w.clear(),t._s.delete(e)}const C=(m,g="")=>{if(xe in m)return m[we]=g,m;const v=function(){de(t);const b=Array.from(arguments),B=new Set,k=new Set;function T(D){B.add(D)}function V(D){k.add(D)}X(w,{args:b,name:v[we],store:M,after:T,onError:V});let q;try{q=m.apply(this&&this.$id===e?this:M,b)}catch(D){throw X(k,D),D}return q instanceof Promise?q.then(D=>(X(B,D),D)).catch(D=>(X(k,D),Promise.reject(D))):(X(B,q),q)};return v[xe]=!0,v[we]=g,v},A={_p:t,$id:e,$onAction:Qe.bind(null,w),$patch:L,$reset:I,$subscribe(m,g={}){const v=Qe(y,m,g.detached,()=>b()),b=i.run(()=>Nt(()=>t.state.value[e],B=>{(g.flush==="sync"?f:c)&&m({storeId:e,type:se.direct,events:d},B)},K({},a,g)));return v},$dispose:p},M=Ht(A);t._s.set(e,M);const E=(t._a&&t._a.runWithContext||nn)(()=>t._e.run(()=>(i=Bt()).run(()=>r({action:C}))));for(const m in E){const g=E[m];if(ae(g)&&!on(g)||It(g))s||(l&&rn(g)&&(ae(g)?g.value=l[m]:je(g,l[m])),t.state.value[e][m]=g);else if(typeof g=="function"){const v=C(g,m);E[m]=v,u.actions[m]=g}}return K(M,E),K(jt(M),E),Object.defineProperty(M,"$state",{get:()=>t.state.value[e],set:m=>{L(g=>{K(g,m)})}}),t._p.forEach(m=>{K(M,i.run(()=>m({store:M,app:t._a,pinia:t,options:u})))}),l&&s&&o.hydrate&&o.hydrate(M.$state,l),c=!0,f=!0,M}function un(e,r,o){let t;const n=typeof r=="function";t=n?o:r;function s(i,u){const a=Yt();return i=i||(a?Jt(Mt,null):null),i&&de(i),i=Tt,i._s.has(e)||(n?Lt(e,r,t,i):an(e,t,i)),i._s.get(e)}return s.$id=e,s}const ze=un("auth",{state:()=>({userInfo:null,isLoading:!1,settings:null,currentView:"login",status:{message:"",type:"info",show:!1}}),getters:{},actions:{setLoading(e){this.isLoading=!!e},showMessage(e,r="info",o=3e3){this.status={message:e,type:r,show:!0},o>0&&setTimeout(()=>{this.status.show=!1},o)},async setSessionCookie(e){if(!e?.session_token)throw console.error("setSessionCookie called with missing session_token:",e),new Error("Authentication response missing session_token");return await Y("/auth/api/set-session",{method:"POST",headers:{Authorization:`Bearer ${e.session_token}`}})},async register(){this.isLoading=!0;try{const e=await xt();return await this.setSessionCookie(e),await this.loadUserInfo(),this.selectView(),e}finally{this.isLoading=!1}},async authenticate(){this.isLoading=!0;try{const e=await Qt();return await this.setSessionCookie(e),await this.loadUserInfo(),this.selectView(),e}finally{this.isLoading=!1}},selectView(){this.userInfo?this.currentView="profile":this.currentView="login"},async loadSettings(){this.settings=await Gt()},async loadUserInfo(){try{this.userInfo=await Y("/auth/aLine truncated
|
||||
]))+`;O.KANJI=new RegExp(o,"g"),O.BYTE_KANJI=new RegExp("[^A-Z0-9 $%*+\\-./:]+","g"),O.BYTE=new RegExp(t,"g"),O.NUMERIC=new RegExp(e,"g"),O.ALPHANUMERIC=new RegExp(r,"g");const n=new RegExp("^"+o+"$"),s=new RegExp("^"+e+"$"),i=new RegExp("^[A-Z0-9 $%*+\\-./:]+$");return O.testKanji=function(a){return n.test(a)},O.testNumeric=function(a){return s.test(a)},O.testAlphanumeric=function(a){return i.test(a)},O}var dt;function W(){return dt||(dt=1,(function(e){const r=Ut(),o=qt();e.NUMERIC={id:"Numeric",bit:1,ccBits:[10,12,14]},e.ALPHANUMERIC={id:"Alphanumeric",bit:2,ccBits:[9,11,13]},e.BYTE={id:"Byte",bit:4,ccBits:[8,16,16]},e.KANJI={id:"Kanji",bit:8,ccBits:[8,10,12]},e.MIXED={bit:-1},e.getCharCountIndicator=function(s,i){if(!s.ccBits)throw new Error("Invalid mode: "+s);if(!r.isValid(i))throw new Error("Invalid version: "+i);return i>=1&&i<10?s.ccBits[0]:i<27?s.ccBits[1]:s.ccBits[2]},e.getBestModeForData=function(s){return o.testNumeric(s)?e.NUMERIC:o.testAlphanumeric(s)?e.ALPHANUMERIC:o.testKanji(s)?e.KANJI:e.BYTE},e.toString=function(s){if(s&&s.id)return s.id;throw new Error("Invalid mode")},e.isValid=function(s){return s&&s.bit&&s.ccBits};function t(n){if(typeof n!="string")throw new Error("Param is not a string");switch(n.toLowerCase()){case"numeric":return e.NUMERIC;case"alphanumeric":return e.ALPHANUMERIC;case"kanji":return e.KANJI;case"byte":return e.BYTE;default:throw new Error("Unknown mode: "+n)}}e.from=function(s,i){if(e.isValid(s))return s;try{return t(s)}catch{return i}}})(Re)),Re}var ft;function Ns(){return ft||(ft=1,(function(e){const r=x(),o=Dt(),t=$e(),n=W(),s=Ut(),i=7973,u=r.getBCHDigit(i);function a(w,d,l){for(let _=1;_<=40;_++)if(d<=e.getCapacity(_,l,w))return _}function c(w,d){return n.getCharCountIndicator(w,d)+4}function f(w,d){let l=0;return w.forEach(function(_){const L=c(_.mode,d);l+=L+_.getBitsLength()}),l}function y(w,d){for(let l=1;l<=40;l++)if(f(w,l)<=e.getCapacity(l,d,n.MIXED))return l}e.from=function(d,l){return s.isValid(d)?parseInt(d,10):l},e.getCapacity=function(d,l,_){if(!s.isValid(d))throw new Error("Invalid QR Code version");typeof _>"u"&&(_=n.BYTE);const L=r.getSymbolTotalCodewords(d),I=o.getTotalCodewordsCount(d,l),p=(L-I)*8;if(_===n.MIXED)return p;const C=p-c(_,d);switch(_){case n.NUMERIC:return Math.floor(C/10*3);case n.ALPHANUMERIC:return Math.floor(C/11*2);case n.KANJI:return Math.floor(C/13);case n.BYTE:default:return Math.floor(C/8)}},e.getBestVersionForData=function(d,l){let _;const L=t.from(l,t.M);if(Array.isArray(d)){if(d.length>1)return y(d,L);if(d.length===0)return 1;_=d[0]}else _=d;return a(_.mode,_.getLength(),L)},e.getEncodedBits=function(d){if(!s.isValid(d)||d<7)throw new Error("Invalid QR Code version");let l=d<<12;for(;r.getBCHDigit(l)-u>=0;)l^=i<<r.getBCHDigit(l)-u;return d<<12|l}})(Ne)),Ne}var Te={},ht;function Rs(){if(ht)return Te;ht=1;const e=x(),r=1335,o=21522,t=e.getBCHDigit(r);return Te.getEncodedBits=function(s,i){const u=s.bit<<3|i;let a=u<<10;for(;e.getBCHDigit(a)-t>=0;)a^=r<<e.getBCHDigit(a)-t;return(u<<10|a)^o},Te}var Me={},Pe,gt;function As(){if(gt)return Pe;gt=1;const e=W();function r(o){this.mode=e.NUMERIC,this.data=o.toString()}return r.getBitsLength=function(t){return 10*Math.floor(t/3)+(t%3?t%3*3+1:0)},r.prototype.getLength=function(){return this.data.length},r.prototype.getBitsLength=function(){return r.getBitsLength(this.data.length)},r.prototype.write=function(t){let n,s,i;for(n=0;n+3<=this.data.length;n+=3)s=this.data.substr(n,3),i=parseInt(s,10),t.put(i,10);const u=this.data.length-n;u>0&&(s=this.data.substr(n),i=parseInt(s,10),t.put(i,u*3+1))},Pe=r,Pe}var Le,mt;function Ts(){if(mt)return Le;mt=1;const e=W(),r=["0","1","2","3","4","5","6","7","8","9","A","B","C","D","E","F","G","H","I","J","K","L","M","N","O","P","Q","R","S","T","U","V","W","X","Y","Z"," ","$","%","*","+","-",".","/",":"];function o(t){this.mode=e.ALPHANUMERIC,this.data=t}return o.getBitsLength=function(n){return 11*Math.floor(n/2)+6*(n%2)},o.prototype.getLength=function(){return this.data.length},o.prototype.getBitsLength=function(){return o.getBitsLength(this.data.length)},o.prototype.write=function(n){let s;for(s=0;s+2<=this.data.length;s+=2){let i=r.indexOf(this.data[s])*45;i+=r.indexOf(this.data[s+1]),n.put(i,11)}this.data.length%2&&n.put(r.indexOf(this.data[s]),6)},Le=o,Le}var De,yt;function Ms(){if(yt)return De;yt=1;const e=W();function r(o){this.mode=e.BYTE,typeof o=="string"?this.data=new TextEncoder().encode(o):this.data=new Uint8Array(o)}return r.getBitsLength=function(t){return t*8},r.prototype.getLength=function(){return this.data.length},r.prototype.getBitsLength=function(){return r.getBitsLength(this.data.length)},r.prototype.write=function(o){for(let t=0,n=this.data.length;t<n;t++)o.put(this.data[t],8)},De=r,De}var Ue,wt;function Ps(){if(wt)return Ue;wt=1;const e=W(),r=x();function o(t){this.mode=e.KANJI,this.data=t}return o.getBitsLength=function(n){return n*13},o.prototype.getLength=function(){return this.data.length},o.prototype.getBitsLength=funct
Line truncated
|
||||
Make sure your charset is UTF-8`);s=(s>>>8&255)*192+(s&255),t.put(s,13)}},Ue=o,Ue}var qe={exports:{}},pt;function Ls(){return pt||(pt=1,(function(e){var r={single_source_shortest_paths:function(o,t,n){var s={},i={};i[t]=0;var u=r.PriorityQueue.make();u.push(t,0);for(var a,c,f,y,w,d,l,_,L;!u.empty();){a=u.pop(),c=a.value,y=a.cost,w=o[c]||{};for(f in w)w.hasOwnProperty(f)&&(d=w[f],l=y+d,_=i[f],L=typeof i[f]>"u",(L||_>l)&&(i[f]=l,u.push(f,l),s[f]=c))}if(typeof n<"u"&&typeof i[n]>"u"){var I=["Could not find a path from ",t," to ",n,"."].join("");throw new Error(I)}return s},extract_shortest_path_from_predecessor_list:function(o,t){for(var n=[],s=t;s;)n.push(s),o[s],s=o[s];return n.reverse(),n},find_path:function(o,t,n){var s=r.single_source_shortest_paths(o,t,n);return r.extract_shortest_path_from_predecessor_list(s,n)},PriorityQueue:{make:function(o){var t=r.PriorityQueue,n={},s;o=o||{};for(s in t)t.hasOwnProperty(s)&&(n[s]=t[s]);return n.queue=[],n.sorter=o.sorter||t.default_sorter,n},default_sorter:function(o,t){return o.cost-t.cost},push:function(o,t){var n={value:o,cost:t};this.queue.push(n),this.queue.sort(this.sorter)},pop:function(){return this.queue.shift()},empty:function(){return this.queue.length===0}}};e.exports=r})(qe)),qe.exports}var vt;function Ds(){return vt||(vt=1,(function(e){const r=W(),o=As(),t=Ts(),n=Ms(),s=Ps(),i=qt(),u=x(),a=Ls();function c(I){return unescape(encodeURIComponent(I)).length}function f(I,p,C){const A=[];let M;for(;(M=I.exec(C))!==null;)A.push({data:M[0],index:M.index,mode:p,length:M[0].length});return A}function y(I){const p=f(i.NUMERIC,r.NUMERIC,I),C=f(i.ALPHANUMERIC,r.ALPHANUMERIC,I);let A,M;return u.isKanjiModeEnabled()?(A=f(i.BYTE,r.BYTE,I),M=f(i.KANJI,r.KANJI,I)):(A=f(i.BYTE_KANJI,r.BYTE,I),M=[]),p.concat(C,A,M).sort(function(E,m){return E.index-m.index}).map(function(E){return{data:E.data,mode:E.mode,length:E.length}})}function w(I,p){switch(p){case r.NUMERIC:return o.getBitsLength(I);case r.ALPHANUMERIC:return t.getBitsLength(I);case r.KANJI:return s.getBitsLength(I);case r.BYTE:return n.getBitsLength(I)}}function d(I){return I.reduce(function(p,C){const A=p.length-1>=0?p[p.length-1]:null;return A&&A.mode===C.mode?(p[p.length-1].data+=C.data,p):(p.push(C),p)},[])}function l(I){const p=[];for(let C=0;C<I.length;C++){const A=I[C];switch(A.mode){case r.NUMERIC:p.push([A,{data:A.data,mode:r.ALPHANUMERIC,length:A.length},{data:A.data,mode:r.BYTE,length:A.length}]);break;case r.ALPHANUMERIC:p.push([A,{data:A.data,mode:r.BYTE,length:A.length}]);break;case r.KANJI:p.push([A,{data:A.data,mode:r.BYTE,length:c(A.data)}]);break;case r.BYTE:p.push([{data:A.data,mode:r.BYTE,length:c(A.data)}])}}return p}function _(I,p){const C={},A={start:{}};let M=["start"];for(let S=0;S<I.length;S++){const E=I[S],m=[];for(let g=0;g<E.length;g++){const v=E[g],b=""+S+g;m.push(b),C[b]={node:v,lastCount:0},A[b]={};for(let B=0;B<M.length;B++){const k=M[B];C[k]&&C[k].node.mode===v.mode?(A[k][b]=w(C[k].lastCount+v.length,v.mode)-w(C[k].lastCount,v.mode),C[k].lastCount+=v.length):(C[k]&&(C[k].lastCount=v.length),A[k][b]=w(v.length,v.mode)+4+r.getCharCountIndicator(v.mode,p))}}M=m}for(let S=0;S<M.length;S++)A[M[S]].end=0;return{map:A,table:C}}function L(I,p){let C;const A=r.getBestModeForData(I);if(C=r.from(p,A),C!==r.BYTE&&C.bit<A.bit)throw new Error('"'+I+'" cannot be encoded with mode '+r.toString(C)+`.
|
||||
Suggested mode is: `+r.toString(A));switch(C===r.KANJI&&!u.isKanjiModeEnabled()&&(C=r.BYTE),C){case r.NUMERIC:return new o(I);case r.ALPHANUMERIC:return new t(I);case r.KANJI:return new s(I);case r.BYTE:return new n(I)}}e.fromArray=function(p){return p.reduce(function(C,A){return typeof A=="string"?C.push(L(A,null)):A.data&&C.push(L(A.data,A.mode)),C},[])},e.fromString=function(p,C){const A=y(p,u.isKanjiModeEnabled()),M=l(A),S=_(M,C),E=a.find_path(S.map,"start","end"),m=[];for(let g=1;g<E.length-1;g++)m.push(S.table[E[g]].node);return e.fromArray(d(m))},e.rawSplit=function(p){return e.fromArray(y(p,u.isKanjiModeEnabled()))}})(Me)),Me}var Ct;function Us(){if(Ct)return ve;Ct=1;const e=x(),r=$e(),o=Cs(),t=bs(),n=_s(),s=Ss(),i=Es(),u=Dt(),a=Is(),c=Ns(),f=Rs(),y=W(),w=Ds();function d(S,E){const m=S.size,g=s.getPositions(E);for(let v=0;v<g.length;v++){const b=g[v][0],B=g[v][1];for(let k=-1;k<=7;k++)if(!(b+k<=-1||m<=b+k))for(let T=-1;T<=7;T++)B+T<=-1||m<=B+T||(k>=0&&k<=6&&(T===0||T===6)||T>=0&&T<=6&&(k===0||k===6)||k>=2&&k<=4&&T>=2&&T<=4?S.set(b+k,B+T,!0,!0):S.set(b+k,B+T,!1,!0))}}function l(S){const E=S.size;for(let m=8;m<E-8;m++){const g=m%2===0;S.set(m,6,g,!0),S.set(6,m,g,!0)}}function _(S,E){const m=n.getPositions(E);for(let g=0;g<m.length;g++){const v=m[g][0],b=m[g][1];for(let B=-2;B<=2;B++)for(let k=-2;k<=2;k++)B===-2||B===2||k===-2||k===2||B===0&&k===0?S.set(v+B,b+k,!0,!0):S.set(v+B,b+k,!1,!0)}}function L(S,E){const m=S.size,g=c.getEncodedBits(E);let v,b,B;for(let k=0;k<18;k++)v=Math.floor(k/3),b=k%3+m-8-3,B=(g>>k&1)===1,S.set(v,b,B,!0),S.set(b,v,B,!0)}function I(S,E,m){const g=S.size,v=f.getEncodedBits(E,m);let b,B;for(b=0;b<15;b++)B=(v>>b&1)===1,b<6?S.set(b,8,B,!0):b<8?S.set(b+1,8,B,!0):S.set(g-15+b,8,B,!0),b<8?S.set(8,g-b-1,B,!0):b<9?S.set(8,15-b-1+1,B,!0):S.set(8,15-b-1,B,!0);S.set(g-8,8,1,!0)}function p(S,E){const m=S.size;let g=-1,v=m-1,b=7,B=0;for(let k=m-1;k>0;k-=2)for(k===6&&k--;;){for(let T=0;T<2;T++)if(!S.isReserved(v,k-T)){let V=!1;B<E.length&&(V=(E[B]>>>b&1)===1),S.set(v,k-T,V),b--,b===-1&&(B++,b=7)}if(v+=g,v<0||m<=v){v-=g,g=-g;break}}}function C(S,E,m){const g=new o;m.forEach(function(T){g.put(T.mode.bit,4),g.put(T.getLength(),y.getCharCountIndicator(T.mode,S)),T.write(g)});const v=e.getSymbolTotalCodewords(S),b=u.getTotalCodewordsCount(S,E),B=(v-b)*8;for(g.getLengthInBits()+4<=B&&g.put(0,4);g.getLengthInBits()%8!==0;)g.putBit(0);const k=(B-g.getLengthInBits())/8;for(let T=0;T<k;T++)g.put(T%2?17:236,8);return A(g,S,E)}function A(S,E,m){const g=e.getSymbolTotalCodewords(E),v=u.getTotalCodewordsCount(E,m),b=g-v,B=u.getBlocksCount(E,m),k=g%B,T=B-k,V=Math.floor(g/B),q=Math.floor(b/B),D=q+1,Ke=V-q,Vt=new a(Ke);let fe=0;const oe=new Array(B),Je=new Array(B);let he=0;const Ot=new Uint8Array(S.buffer);for(let Z=0;Z<B;Z++){const me=Z<T?q:D;oe[Z]=Ot.slice(fe,fe+me),Je[Z]=Vt.encode(oe[Z]),fe+=me,he=Math.max(he,me)}const ge=new Uint8Array(g);let Ye=0,H,j;for(H=0;H<he;H++)for(j=0;j<B;j++)H<oe[j].length&&(ge[Ye++]=oe[j][H]);for(H=0;H<Ke;H++)for(j=0;j<B;j++)ge[Ye++]=Je[j][H];return ge}function M(S,E,m,g){let v;if(Array.isArray(S))v=w.fromArray(S);else if(typeof S=="string"){let V=E;if(!V){const q=w.rawSplit(S);V=c.getBestVersionForData(q,m)}v=w.fromString(S,V||40)}else throw new Error("Invalid data");const b=c.getBestVersionForData(v,m);if(!b)throw new Error("The amount of data is too big to be stored in a QR Code");if(!E)E=b;else if(E<b)throw new Error(`
|
||||
The chosen QR Code version cannot contain this amount of data.
|
||||
Minimum version required to store current data is: `+b+`.
|
||||
`);const B=C(E,m,v),k=e.getSymbolSize(E),T=new t(k);return d(T,E),l(T),_(T,E),I(T,m,0),E>=7&&L(T,E),p(T,B),isNaN(g)&&(g=i.getBestMask(T,I.bind(null,T,m))),i.applyMask(g,T),I(T,m,g),{modules:T,version:E,errorCorrectionLevel:m,maskPattern:g,segments:v}}return ve.create=function(E,m){if(typeof E>"u"||E==="")throw new Error("No input text");let g=r.M,v,b;return typeof m<"u"&&(g=r.from(m.errorCorrectionLevel,r.M),v=c.from(m.version),b=i.from(m.maskPattern),m.toSJISFunc&&e.setToSJISFunction(m.toSJISFunc)),M(E,v,g,b)},ve}var Fe={},Ve={},bt;function Ft(){return bt||(bt=1,(function(e){function r(o){if(typeof o=="number"&&(o=o.toString()),typeof o!="string")throw new Error("Color should be defined as hex string");let t=o.slice().replace("#","").split("");if(t.length<3||t.length===5||t.length>8)throw new Error("Invalid hex color: "+o);(t.length===3||t.length===4)&&(t=Array.prototype.concat.apply([],t.map(function(s){return[s,s]}))),t.length===6&&t.push("F","F");const n=parseInt(t.join(""),16);return{r:n>>24&255,g:n>>16&255,b:n>>8&255,a:n&255,hex:"#"+t.slice(0,6).join("")}}e.getOptions=function(t){t||(t={}),t.color||(t.color={});const n=typeof t.margin>"u"||t.margin===null||t.margin<0?4:t.margin,s=t.width&&t.width>=21?t.width:void 0,i=t.scale||4;return{width:s,scale:s?4:i,margin:n,color:{dark:r(t.color.dark||"#000000ff"),light:r(t.color.light||"#ffffffff")},type:t.type,rendererOpts:t.rendererOpts||{}}},e.getScale=function(t,n){return n.width&&n.width>=t+n.margin*2?n.width/(t+n.margin*2):n.scale},e.getImageWidth=function(t,n){const s=e.getScale(t,n);return Math.floor((t+n.margin*2)*s)},e.qrToImageData=function(t,n,s){const i=n.modules.size,u=n.modules.data,a=e.getScale(i,s),c=Math.floor((i+s.margin*2)*a),f=s.margin*a,y=[s.color.light,s.color.dark];for(let w=0;w<c;w++)for(let d=0;d<c;d++){let l=(w*c+d)*4,_=s.color.light;if(w>=f&&d>=f&&w<c-f&&d<c-f){const L=Math.floor((w-f)/a),I=Math.floor((d-f)/a);_=y[u[L*i+I]?1:0]}t[l++]=_.r,t[l++]=_.g,t[l++]=_.b,t[l]=_.a}}})(Ve)),Ve}var _t;function qs(){return _t||(_t=1,(function(e){const r=Ft();function o(n,s,i){n.clearRect(0,0,s.width,s.height),s.style||(s.style={}),s.height=i,s.width=i,s.style.height=i+"px",s.style.width=i+"px"}function t(){try{return document.createElement("canvas")}catch{throw new Error("You need to specify a canvas element")}}e.render=function(s,i,u){let a=u,c=i;typeof a>"u"&&(!i||!i.getContext)&&(a=i,i=void 0),i||(c=t()),a=r.getOptions(a);const f=r.getImageWidth(s.modules.size,a),y=c.getContext("2d"),w=y.createImageData(f,f);return r.qrToImageData(w.data,s,a),o(y,c,f),y.putImageData(w,0,0),c},e.renderToDataURL=function(s,i,u){let a=u;typeof a>"u"&&(!i||!i.getContext)&&(a=i,i=void 0),a||(a={});const c=e.render(s,i,a),f=a.type||"image/png",y=a.rendererOpts||{};return c.toDataURL(f,y.quality)}})(Fe)),Fe}var Oe={},St;function Fs(){if(St)return Oe;St=1;const e=Ft();function r(n,s){const i=n.a/255,u=s+'="'+n.hex+'"';return i<1?u+" "+s+'-opacity="'+i.toFixed(2).slice(1)+'"':u}function o(n,s,i){let u=n+s;return typeof i<"u"&&(u+=" "+i),u}function t(n,s,i){let u="",a=0,c=!1,f=0;for(let y=0;y<n.length;y++){const w=Math.floor(y%s),d=Math.floor(y/s);!w&&!c&&(c=!0),n[y]?(f++,y>0&&w>0&&n[y-1]||(u+=c?o("M",w+i,.5+d+i):o("m",a,0),a=0,c=!1),w+1<s&&n[y+1]||(u+=o("h",f),f=0)):a++}return u}return Oe.render=function(s,i,u){const a=e.getOptions(i),c=s.modules.size,f=s.modules.data,y=c+a.margin*2,w=a.color.light.a?"<path "+r(a.color.light,"fill")+' d="M0 0h'+y+"v"+y+'H0z"/>':"",d="<path "+r(a.color.dark,"stroke")+' d="'+t(f,c,a.margin)+'"/>',l='viewBox="0 0 '+y+" "+y+'"',L='<svg xmlns="http://www.w3.org/2000/svg" '+(a.width?'width="'+a.width+'" height="'+a.width+'" ':"")+l+' shape-rendering="crispEdges">'+w+d+`</svg>
|
||||
`;return typeof u=="function"&&u(null,L),L},Oe}var Et;function Vs(){if(Et)return ee;Et=1;const e=vs(),r=Us(),o=qs(),t=Fs();function n(s,i,u,a,c){const f=[].slice.call(arguments,1),y=f.length,w=typeof f[y-1]=="function";if(!w&&!e())throw new Error("Callback required as last argument");if(w){if(y<2)throw new Error("Too few arguments provided");y===2?(c=u,u=i,i=a=void 0):y===3&&(i.getContext&&typeof c>"u"?(c=a,a=void 0):(c=a,a=u,u=i,i=void 0))}else{if(y<1)throw new Error("Too few arguments provided");return y===1?(u=i,i=a=void 0):y===2&&!i.getContext&&(a=u,u=i,i=void 0),new Promise(function(d,l){try{const _=r.create(u,a);d(s(_,i,a))}catch(_){l(_)}})}try{const d=r.create(u,a);c(null,s(d,i,a))}catch(d){c(d)}}return ee.create=r.create,ee.toCanvas=n.bind(null,o.render),ee.toDataURL=n.bind(null,o.renderToDataURL),ee.toString=n.bind(null,function(s,i,u){return t.render(s,u)}),ee}var Os=Vs();const Hs=ps(Os),js={class:"device-dialog",role:"dialog","aria-modal":"true","aria-labelledby":"regTitle"},zs={class:"reg-header-row"},$s={id:"regTitle",class:"reg-title"},Ks={key:0},Js={key:1},Ys={class:"device-link-section"},Gs={class:"qr-container"},Qs=["href"],xs={class:"reg-help"},Ws={key:0},Zs={key:1},Xs={class:"reg-actions"},er={key:1,class:"registration-inline-wrapper"},tr={class:"registration-inline-block section-block"},nr={class:"section-header"},sr={class:"inline-heading"},rr={key:0},or={key:1},ir={class:"section-body"},ar={class:"device-link-section"},ur={class:"qr-container"},lr=["href"],cr={class:"reg-help"},dr={key:0},fr={key:1},hr={class:"button-row",style:{"margin-top":"1rem"}},gr={__name:"RegistrationLinkModal",props:{endpoint:{type:String,required:!0},autoCopy:{type:Boolean,default:!0},userName:{type:String,default:null},inline:{type:Boolean,default:!1},showCloseInInline:{type:Boolean,default:!1},prefixCopyWithUserName:{type:Boolean,default:!1}},emits:["close","generated","copied"],setup(e,{emit:r}){const o=ze(),t=e,n=r,s=J(null),i=J(null),u=J(null),a=z(()=>s.value?s.value.replace(/^[^:]+:\/\//,""):""),c=z(()=>{const d=G(i.value);return`⚠️ Expires ${d.startsWith("In ")?d.substring(3):d} and can only be used once.`});async function f(){try{const d=await Y(t.endpoint,{method:"POST"});s.value=d.url,i.value=d.expires,n("generated",{url:d.url,expires:d.expires}),await ue(),y(),t.autoCopy&&w()}catch(d){console.error("Failed to create link",d),en(d)&&o.showMessage(tn(d),"error",4e3),n("close")}}async function y(){s.value&&(await ue(),u.value&&Hs.toCanvas(u.value,s.value,{scale:8},d=>{d&&console.error(d)}))}async function w(){if(!s.value)return;let d=s.value;t.prefixCopyWithUserName&&t.userName&&(d=`${t.userName} ${d}`);try{await navigator.clipboard.writeText(d),n("copied",d),t.inline||n("close")}catch{}}return At(f),Nt(s,()=>y(),{flush:"post"}),(d,l)=>!e.inline&&s.value?(R(),N("div",{key:0,class:"dialog-overlay",onKeydown:l[2]||(l[2]=Rt(ye(_=>d.$emit("close"),["prevent"]),["esc"]))},[h("div",js,[h("div",zs,[h("h2",$s,[l[4]||(l[4]=ce(" 📱 ",-1)),e.userName?(R(),N("span",Ks,"Registration for "+P(e.userName),1)):(R(),N("span",Js,"Device Registration Link"))]),h("button",{class:"icon-btn",onClick:l[0]||(l[0]=_=>d.$emit("close")),"aria-label":"Close"},"❌")]),h("div",Ys,[h("div",Gs,[h("a",{href:s.value,onClick:ye(w,["prevent"]),class:"qr-link"},[h("canvas",{ref_key:"qrCanvas",ref:u,class:"qr-code"},null,512),h("p",null,P(a.value),1)],8,Qs),h("p",xs,[e.userName?(R(),N("span",Ws,"The user should open this link on the device where they want to register.")):(R(),N("span",Zs,"Open or scan this link on the device you wish to register to your account.")),l[5]||(l[5]=h("br",null,null,-1)),h("small",null,P(c.value),1)])])]),h("div",Xs,[h("button",{class:"btn-secondary",onClick:l[1]||(l[1]=_=>d.$emit("close"))},"Close"),h("button",{class:"btn-primary",onClick:w},"Copy Link")])])],32)):e.inline&&s.value?(R(),N("div",er,[h("div",tr,[h("div",nr,[h("h2",sr,[l[6]||(l[6]=ce("📱 ",-1)),e.userName?(R(),N("span",rr,"Registration for "+P(e.userName),1)):(R(),N("span",or,"Device Registration Link"))])]),h("div",ir,[h("div",ar,[h("div",ur,[h("a",{href:s.value,onClick:ye(w,["prevent"]),class:"qr-link"},[h("canvas",{ref_key:"qrCanvas",ref:u,class:"qr-code"},null,512),h("p",null,P(a.value),1)],8,lr),h("p",cr,[e.userName?(R(),N("span",dr,"The user should open this link on the device where they want to register.")):(R(),N("span",fr,"Open this link on the device you wish to connect with.")),l[7]||(l[7]=h("br",null,null,-1)),h("small",null,P(c.value),1)])])]),h("div",hr,[h("button",{class:"btn-primary",onClick:w},"Copy Link"),e.showCloseInInline?(R(),N("button",{key:0,class:"btn-secondary",onClick:l[3]||(l[3]=_=>d.$emit("close"))},"Close")):U("",!0)])])])])):U("",!0)}},Tr=Q(gr,[["__scopeId","data-v-5d6bcbcb"]]),mr={class:"loading-container"},yr={__name:"LoadingView",props:{message:{type:String,default:"Loading..."}},setup(e){return(r,o)=>(R(),N("div",mr,[o[0]||(o[0]=h("div",{class:"loading-spinner"},null,-1)),h("p",null,P(e.message),1)]))}},
Line truncated
|
||||
@@ -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}
|
||||
@@ -0,0 +1 @@
|
||||
:root{--font-sans: "Inter", "Inter var", "Segoe UI", system-ui, -apple-system, "Helvetica Neue", sans-serif;--font-mono: "DM Mono", "JetBrains Mono", "SFMono-Regular", Menlo, Monaco, Consolas, "Liberation Mono", monospace;--color-canvas: #f5f6f8;--color-surface: #ffffff;--color-surface-subtle: #f1f3f7;--color-border: #d0d5dd;--color-border-strong: #9aa2af;--color-heading: #101828;--color-text: #1f2933;--color-text-muted: #52616b;--color-link: #2563eb;--color-link-hover: #1d4ed8;--color-accent: #2563eb;--color-accent-strong: #1e3faa;--color-accent-contrast: #ffffff;--color-success-text: #0f5132;--color-success-bg: #d1fadf;--color-error-text: #b42318;--color-error-bg: #ffe3e3;--color-info-text: #0f609b;--color-info-bg: #d6ecff;--color-danger: #dc2626;--shadow-soft: 0 10px 30px rgba(15, 23, 42, .08);--radius-none: 0;--radius-sm: 4px;--radius-md: 6px;--radius-lg: 10px;--space-xxs: .25rem;--space-xs: .5rem;--space-sm: .75rem;--space-md: 1rem;--space-lg: 1.5rem;--space-xl: 2.25rem;--space-xxl: 3.5rem;--layout-max-width: 1400px;--layout-padding: clamp(1.5rem, 3vw + 1rem, 3.25rem);--transition-base: .16s ease}@media(prefers-color-scheme:dark){:root{--color-canvas: #0f172a;--color-surface: #141b2f;--color-surface-subtle: #1b243b;--color-border: #25304a;--color-border-strong: #3d4d6b;--color-heading: #f8fafc;--color-text: #e2e8f0;--color-text-muted: #94a3b8;--color-link: #60a5fa;--color-link-hover: #93c5fd;--color-accent: #60a5fa;--color-accent-strong: #3b82f6;--color-accent-contrast: #0b1120;--color-success-text: #34d399;--color-success-bg: #1a4d2e;--color-error-text: #fca5a5;--color-error-bg: #4a1f1f;--color-info-text: #bae6fd;--color-info-bg: #1e3a5f;--color-danger: #f87171;--shadow-soft: 0 0 0 #000000}}*,*:before,*:after{box-sizing:border-box}html{height:100%;background:var(--color-canvas)}body{height:100%;margin:0;font-family:var(--font-sans);background:none;color:var(--color-text);line-height:1.55;-webkit-font-smoothing:antialiased}body,#app,#admin-app{display:flex;flex-direction:column;min-height:100vh}#app,#admin-app{flex:1}a,a:visited{color:var(--color-link);text-decoration:none}a:hover,a:focus-visible{color:var(--color-link-hover);text-decoration:underline}a:focus-visible{outline:2px solid var(--color-accent);outline-offset:2px;border-radius:var(--radius-sm)}.app-shell{flex:1;display:flex;flex-direction:column;min-height:100vh}.app-main{flex:1;display:flex;flex-direction:column}.view-root{flex:1;width:100%;display:flex;flex-direction:column;gap:2rem;padding:var(--layout-padding);box-sizing:border-box;margin:0 auto;width:min(100%,var(--layout-max-width))}.view-root--wide{width:min(100%,1200px)}.view-root--narrow{max-width:540px}.view-header{display:flex;flex-direction:column;gap:.75rem}.view-header h1{margin:0;font-weight:600;color:var(--color-heading)}.view-lede{margin:0;color:var(--color-text-muted);font-size:1rem}.section-block{display:flex;flex-direction:column;gap:1rem}.section-block h2{margin:0;font-size:clamp(1.25rem,1.5vw + 1rem,1.65rem);font-weight:600;color:var(--color-heading)}.section-body{display:flex;flex-direction:column;gap:1rem}.button-row{display:flex;flex-wrap:wrap;gap:.75rem;justify-content:flex-start}.surface{background:var(--color-surface);border:1px solid var(--color-border);border-radius:var(--radius-md);padding:var(--space-lg);box-shadow:var(--shadow-soft)}.surface--tight{padding:var(--space-md)}button{font-family:inherit;font-size:1rem;font-weight:500;border-radius:var(--radius-sm);border:1px solid transparent;padding:.65rem 1.1rem;cursor:pointer;transition:all var(--transition-base);display:inline-flex;align-items:center;justify-content:center;gap:.4rem;background:var(--color-surface);color:var(--color-text)}button:focus-visible{outline:2px solid var(--color-accent);outline-offset:2px}button:disabled{cursor:not-allowed;filter:opacity(.6)}.btn-primary{background:var(--color-accent);color:var(--color-accent-contrast);border-color:var(--color-accent);box-shadow:var(--shadow-soft)}.btn-primary:hover:not(:disabled){background:var(--color-accent-strong);border-color:var(--color-accent-strong)}.btn-secondary{background:transparent;color:var(--color-text);border-color:var(--color-border)}.btn-secondary:hover:not(:disabled){border-color:var(--color-border-strong);background:var(--color-surface-subtle)}.btn-danger{background:var(--color-danger);color:var(--color-accent-contrast);border-color:transparent}.btn-danger:hover:not(:disabled){filter:brightness(.92)}input[type=text],input[type=search],input[type=email],textarea,select{font:inherit;width:100%;padding:.65rem .75rem;border-radius:var(--radius-sm);border:1px solid var(--color-border);background:var(--color-surface);color:var(--color-text);transition:border-color var(--transition-base),box-shadow var(--transition-base)}input:focus-visible,textarea:focus-visible,select:focus-visible{border-color:var(--color-accent);box-shadow:0 0 0 3px #c7d2fe;outline:none}label{display:flex;flex-direction:column;gap:.5rem;color:var(--color-text)}p{margiLine truncated
|
||||
@@ -0,0 +1,2 @@
|
||||
(function(){const t=document.createElement("link").relList;if(t&&t.supports&&t.supports("modulepreload"))return;for(const r of document.querySelectorAll('link[rel="modulepreload"]'))s(r);new MutationObserver(r=>{for(const i of r)if(i.type==="childList")for(const o of i.addedNodes)o.tagName==="LINK"&&o.rel==="modulepreload"&&s(o)}).observe(document,{childList:!0,subtree:!0});function n(r){const i={};return r.integrity&&(i.integrity=r.integrity),r.referrerPolicy&&(i.referrerPolicy=r.referrerPolicy),r.crossOrigin==="use-credentials"?i.credentials="include":r.crossOrigin==="anonymous"?i.credentials="omit":i.credentials="same-origin",i}function s(r){if(r.ep)return;r.ep=!0;const i=n(r);fetch(r.href,i)}})();function Zn(e){const t=Object.create(null);for(const n of e.split(","))t[n]=1;return n=>n in t}const K={},ft=[],Ae=()=>{},ir=()=>!1,un=e=>e.charCodeAt(0)===111&&e.charCodeAt(1)===110&&(e.charCodeAt(2)>122||e.charCodeAt(2)<97),es=e=>e.startsWith("onUpdate:"),re=Object.assign,ts=(e,t)=>{const n=e.indexOf(t);n>-1&&e.splice(n,1)},Si=Object.prototype.hasOwnProperty,U=(e,t)=>Si.call(e,t),T=Array.isArray,ut=e=>jt(e)==="[object Map]",bt=e=>jt(e)==="[object Set]",As=e=>jt(e)==="[object Date]",P=e=>typeof e=="function",J=e=>typeof e=="string",pe=e=>typeof e=="symbol",V=e=>e!==null&&typeof e=="object",or=e=>(V(e)||P(e))&&P(e.then)&&P(e.catch),lr=Object.prototype.toString,jt=e=>lr.call(e),xi=e=>jt(e).slice(8,-1),cr=e=>jt(e)==="[object Object]",dn=e=>J(e)&&e!=="NaN"&&e[0]!=="-"&&""+parseInt(e,10)===e,At=Zn(",key,ref,ref_for,ref_key,onVnodeBeforeMount,onVnodeMounted,onVnodeBeforeUpdate,onVnodeUpdated,onVnodeBeforeUnmount,onVnodeUnmounted"),hn=e=>{const t=Object.create(null);return(n=>t[n]||(t[n]=e(n)))},vi=/-\w/g,qe=hn(e=>e.replace(vi,t=>t.slice(1).toUpperCase())),Ai=/\B([A-Z])/g,Ye=hn(e=>e.replace(Ai,"-$1").toLowerCase()),ar=hn(e=>e.charAt(0).toUpperCase()+e.slice(1)),Rn=hn(e=>e?`on${ar(e)}`:""),Je=(e,t)=>!Object.is(e,t),zt=(e,...t)=>{for(let n=0;n<e.length;n++)e[n](...t)},fr=(e,t,n,s=!1)=>{Object.defineProperty(e,t,{configurable:!0,enumerable:!1,writable:s,value:n})},pn=e=>{const t=parseFloat(e);return isNaN(t)?e:t};let Rs;const gn=()=>Rs||(Rs=typeof globalThis<"u"?globalThis:typeof self<"u"?self:typeof window<"u"?window:typeof global<"u"?global:{});function ns(e){if(T(e)){const t={};for(let n=0;n<e.length;n++){const s=e[n],r=J(s)?Ci(s):ns(s);if(r)for(const i in r)t[i]=r[i]}return t}else if(J(e)||V(e))return e}const Ri=/;(?![^(]*\))/g,Ti=/:([^]+)/,Oi=/\/\*[^]*?\*\//g;function Ci(e){const t={};return e.replace(Oi,"").split(Ri).forEach(n=>{if(n){const s=n.split(Ti);s.length>1&&(t[s[0].trim()]=s[1].trim())}}),t}function ss(e){let t="";if(J(e))t=e;else if(T(e))for(let n=0;n<e.length;n++){const s=ss(e[n]);s&&(t+=s+" ")}else if(V(e))for(const n in e)e[n]&&(t+=n+" ");return t.trim()}const Ii="itemscope,allowfullscreen,formnovalidate,ismap,nomodule,novalidate,readonly",Pi=Zn(Ii);function ur(e){return!!e||e===""}function Mi(e,t){if(e.length!==t.length)return!1;let n=!0;for(let s=0;n&&s<e.length;s++)n=lt(e[s],t[s]);return n}function lt(e,t){if(e===t)return!0;let n=As(e),s=As(t);if(n||s)return n&&s?e.getTime()===t.getTime():!1;if(n=pe(e),s=pe(t),n||s)return e===t;if(n=T(e),s=T(t),n||s)return n&&s?Mi(e,t):!1;if(n=V(e),s=V(t),n||s){if(!n||!s)return!1;const r=Object.keys(e).length,i=Object.keys(t).length;if(r!==i)return!1;for(const o in e){const l=e.hasOwnProperty(o),c=t.hasOwnProperty(o);if(l&&!c||!l&&c||!lt(e[o],t[o]))return!1}}return String(e)===String(t)}function rs(e,t){return e.findIndex(n=>lt(n,t))}const dr=e=>!!(e&&e.__v_isRef===!0),Ni=e=>J(e)?e:e==null?"":T(e)||V(e)&&(e.toString===lr||!P(e.toString))?dr(e)?Ni(e.value):JSON.stringify(e,hr,2):String(e),hr=(e,t)=>dr(t)?hr(e,t.value):ut(t)?{[`Map(${t.size})`]:[...t.entries()].reduce((n,[s,r],i)=>(n[Tn(s,i)+" =>"]=r,n),{})}:bt(t)?{[`Set(${t.size})`]:[...t.values()].map(n=>Tn(n))}:pe(t)?Tn(t):V(t)&&!T(t)&&!cr(t)?String(t):t,Tn=(e,t="")=>{var n;return pe(e)?`Symbol(${(n=e.description)!=null?n:t})`:e};let z;class pr{constructor(t=!1){this.detached=t,this._active=!0,this._on=0,this.effects=[],this.cleanups=[],this._isPaused=!1,this.parent=z,!t&&z&&(this.index=(z.scopes||(z.scopes=[])).push(this)-1)}get active(){return this._active}pause(){if(this._active){this._isPaused=!0;let t,n;if(this.scopes)for(t=0,n=this.scopes.length;t<n;t++)this.scopes[t].pause();for(t=0,n=this.effects.length;t<n;t++)this.effects[t].pause()}}resume(){if(this._active&&this._isPaused){this._isPaused=!1;let t,n;if(this.scopes)for(t=0,n=this.scopes.length;t<n;t++)this.scopes[t].resume();for(t=0,n=this.effects.length;t<n;t++)this.effects[t].resume()}}run(t){if(this._active){const n=z;try{return z=this,t()}finally{z=n}}}on(){++this._on===1&&(this.prevScope=z,z=this)}off(){this._on>0&&--this._on===0&&(z=this.prevScope,this.prevScope=void 0)}stop(t){if(this._active){this._active=!1;let n,s;for(n=0,s=this.effects.length;n<s;n++)this.effects[n].stop();for(this.effects.length=0,n=0,s=this.cleanups.length;n<s;n++)this.cleaLine truncated
|
||||
`,t)}function ic(){if(!_s())return Kn.stubThis(new Promise(t=>t(!1)));const e=globalThis.PublicKeyCredential;return e?.isConditionalMediationAvailable===void 0?Kn.stubThis(new Promise(t=>t(!1))):Kn.stubThis(e.isConditionalMediationAvailable())}const Kn={stubThis:e=>e};function oc({error:e,options:t}){const{publicKey:n}=t;if(!n)throw Error("options was missing required publicKey property");if(e.name==="AbortError"){if(t.signal instanceof AbortSignal)return new Y({message:"Authentication ceremony was sent an abort signal",code:"ERROR_CEREMONY_ABORTED",cause:e})}else{if(e.name==="NotAllowedError")return new Y({message:e.message,code:"ERROR_PASSTHROUGH_SEE_CAUSE_PROPERTY",cause:e});if(e.name==="SecurityError"){const s=globalThis.location.hostname;if(pi(s)){if(n.rpId!==s)return new Y({message:`The RP ID "${n.rpId}" is invalid for this domain`,code:"ERROR_INVALID_RP_ID",cause:e})}else return new Y({message:`${globalThis.location.hostname} is an invalid domain`,code:"ERROR_INVALID_DOMAIN",cause:e})}else if(e.name==="UnknownError")return new Y({message:"The authenticator was unable to process the specified options, or could not create a new assertion signature",code:"ERROR_AUTHENTICATOR_GENERAL_ERROR",cause:e})}return e}async function lc(e){!e.optionsJSON&&e.challenge&&(console.warn("startAuthentication() was not called correctly. It will try to continue with the provided options, but this call should be refactored to use the expected call structure instead. See https://simplewebauthn.dev/docs/packages/browser#typeerror-cannot-read-properties-of-undefined-reading-challenge for more information."),e={optionsJSON:e});const{optionsJSON:t,useBrowserAutofill:n=!1,verifyBrowserAutofillInput:s=!0}=e;if(!_s())throw new Error("WebAuthn is not supported in this browser");let r;t.allowCredentials?.length!==0&&(r=t.allowCredentials?.map(hi));const i={...t,challenge:fn(t.challenge),allowCredentials:r},o={};if(n){if(!await ic())throw Error("Browser does not support WebAuthn autofill");if(document.querySelectorAll("input[autocomplete$='webauthn']").length<1&&s)throw Error('No <input> with "webauthn" as the only or last value in its `autocomplete` attribute was detected');o.mediation="conditional",i.allowCredentials=[]}o.publicKey=i,o.signal=gi.createNewAbortSignal();let l;try{l=await navigator.credentials.get(o)}catch(v){throw oc({error:v,options:o})}if(!l)throw new Error("Authentication was not completed");const{id:c,rawId:d,response:u,type:p}=l;let S;return u.userHandle&&(S=ve(u.userHandle)),{id:c,rawId:ve(d),response:{authenticatorData:ve(u.authenticatorData),clientDataJSON:ve(u.clientDataJSON),signature:ve(u.signature),userHandle:S},type:p,clientExtensionResults:l.getClientExtensionResults(),authenticatorAttachment:mi(l.authenticatorAttachment)}}class cc extends WebSocket{#n=[];#e=[];#t=null;#s=!1;constructor(t,n,s,r,i){super(new URL(s,document.baseURI.replace(/^http/,"ws")),r),this.binaryType=i||"blob",this.onopen=()=>{this.#s=!0,t(this)},this.onmessage=o=>{this.#e.length?this.#e.shift().resolve(o.data):this.#n.push(o.data)},this.onclose=o=>{if(!this.#s){n(new Error(`WebSocket ${this.url} failed to connect, code ${o.code}`));return}this.#t=o.wasClean?new Error(`Websocket ${this.url} closed ${o.code}`):new Error(`WebSocket ${this.url} closed with error ${o.code}`),this.#e.splice(0).forEach(l=>l.reject(this.#t))}}receive(){return this.#n.length?Promise.resolve(this.#n.shift()):this.#t?Promise.reject(this.#t):new Promise((t,n)=>this.#e.push({resolve:t,reject:n}))}async receive_bytes(){const t=await this.receive();if(typeof t=="string")throw console.error("WebSocket received text data, expected a binary message",t),new Error("WebSocket received text data, expected a binary message");return t instanceof Blob?t.bytes():new Uint8Array(t)}async receive_json(){const t=await this.receive();if(typeof t!="string")throw console.error("WebSocket received binary data, expected JSON string",t),new Error("WebSocket received binary data, expected JSON string");try{return JSON.parse(t)}catch(n){throw console.error("Failed to parse JSON from WebSocket message",t,n),new Error("Failed to parse JSON from WebSocket message")}}send_json(t){let n;try{n=JSON.stringify(t)}catch(s){throw new Error(`Failed to stringify data for WebSocket: ${s.message}`)}this.send(n)}}function bi(e,t={}){const{protocols:n,binaryType:s}=t;return new Promise((r,i)=>{new cc(r,i,e,n,s)})}let Yt=null,nt=null;async function ac(){return nt||Yt||(Yt=fetch("/auth/api/settings").then(e=>e.ok?e.json():{}).then(e=>(nt=e||{},nt)).catch(()=>(nt={},nt)),Yt)}function Pt(){const e=nt?.ui_base_path||"/auth/";return e==="/"?"/":e.endsWith("/")?e:e+"/"}function Ic(){return Pt()==="/"?"/admin/":Pt()+"admin/"}function Pc(e=""){const t=e.startsWith("/")?e.slice(1):e;return t?Pt()==="/"?"/"+t:Pt()+t:Pt()}const fc=1e3;class xn extends Error{constructor(t,n,s){super(s?.detail||`Request failed: ${n.status}`),this.name="ApiError",this.url=t,this.status=n.status,this.statusText=n.statusText,this.dat
Line truncated
|
||||
@@ -0,0 +1 @@
|
||||
import{_ as j,c as L,d as r,e as o,f as i,i as O,t as f,F as p,x as I,y as ee,k as ae,r as v,h as M,g as B,q as h,w as se,j as Ee,l as N,z,A as q,u as Q,B as X,o as Ne,C as Te,a as Ae,m as Me,b as Ie,v as Le}from"./_plugin-vue_export-helper-R4vr2A9I.js";import{u as ie,U as _e,_ as Ve,a as ze,R as qe,N as G,M as Be,b as je,L as Fe,A as xe,B as Ge,c as He}from"./AccessDenied-guOGfNm-.js";import"./helpers-CU0-cyzg.js";const Je={class:"permissions-section"},We={class:"actions"},Ye={class:"org-table"},Ze={key:0},Ke=["onClick"],Qe=["onClick"],Xe={class:"role-names"},ea={class:"center"},aa={key:0,class:"center"},sa=["onClick"],ia={key:0,class:"permissions-section"},ta={class:"matrix-wrapper"},na={class:"matrix-scroll"},oa=["title"],la=["title"],ra={class:"display-text"},da=["checked","onChange"],ua={class:"actions"},ma={class:"org-table"},ca={class:"perm-name-cell"},ga={class:"perm-title"},ya={class:"display-text"},va=["onClick"],pa={class:"perm-id-info"},fa={class:"id-text"},ha={class:"perm-members center"},$a={class:"perm-actions center"},ba=["onClick"],wa={__name:"AdminOverview",props:{info:Object,orgs:Array,permissions:Array,permissionSummary:Object},emits:["createOrg","openOrg","updateOrg","deleteOrg","toggleOrgPermission","openDialog","deletePermission","renamePermissionDisplay"],setup(a,{emit:C}){const D=a,U=L(()=>[...D.orgs].sort((m,s)=>{const t=m.display_name.localeCompare(s.display_name);return t!==0?t:m.uuid.localeCompare(s.uuid)})),k=L(()=>[...D.permissions].sort((m,s)=>m.id.localeCompare(s.id)));function R(m){return m.roles.slice().sort((s,t)=>s.display_name.localeCompare(t.display_name)).map(s=>s.display_name).join(", ")}return(m,s)=>(o(),r(p,null,[i("div",Je,[i("h2",null,f(a.info.is_global_admin?"Organizations":"Your Organizations"),1),i("div",We,[a.info.is_global_admin?(o(),r("button",{key:0,onClick:s[0]||(s[0]=t=>m.$emit("createOrg"))},"+ Create Org")):O("",!0)]),i("table",Ye,[i("thead",null,[i("tr",null,[s[2]||(s[2]=i("th",null,"Name",-1)),s[3]||(s[3]=i("th",null,"Roles",-1)),s[4]||(s[4]=i("th",null,"Members",-1)),a.info.is_global_admin?(o(),r("th",Ze,"Actions")):O("",!0)])]),i("tbody",null,[(o(!0),r(p,null,I(U.value,t=>(o(),r("tr",{key:t.uuid},[i("td",null,[i("a",{href:"#org/{{o.uuid}}",onClick:ae(u=>m.$emit("openOrg",t),["prevent"])},f(t.display_name),9,Ke),a.info.is_global_admin||a.info.is_org_admin?(o(),r("button",{key:0,onClick:u=>m.$emit("updateOrg",t),class:"icon-btn edit-org-btn","aria-label":"Rename organization",title:"Rename organization"},"✏️",8,Qe)):O("",!0)]),i("td",Xe,f(R(t)),1),i("td",ea,f(t.roles.reduce((u,c)=>u+c.users.length,0)),1),a.info.is_global_admin?(o(),r("td",aa,[i("button",{onClick:u=>m.$emit("deleteOrg",t),class:"icon-btn delete-icon","aria-label":"Delete organization",title:"Delete organization"},"❌",8,sa)])):O("",!0)]))),128))])])]),a.info.is_global_admin?(o(),r("div",ia,[s[8]||(s[8]=i("h2",null,"Permissions",-1)),i("div",ta,[i("div",na,[i("div",{class:"perm-matrix-grid",style:ee({gridTemplateColumns:"minmax(180px, 1fr) "+U.value.map(()=>"2.2rem").join(" ")})},[s[5]||(s[5]=i("div",{class:"grid-head perm-head"},"Permission",-1)),(o(!0),r(p,null,I(U.value,t=>(o(),r("div",{key:"head-"+t.uuid,class:"grid-head org-head",title:t.display_name},[i("span",null,f(t.display_name),1)],8,oa))),128)),(o(!0),r(p,null,I(k.value,t=>(o(),r(p,{key:t.id},[i("div",{class:"perm-name",title:t.id},[i("span",ra,f(t.display_name),1)],8,la),(o(!0),r(p,null,I(U.value,u=>(o(),r("div",{key:u.uuid+"-"+t.id,class:"matrix-cell"},[i("input",{type:"checkbox",checked:u.permissions.includes(t.id),onChange:c=>m.$emit("toggleOrgPermission",u,t.id,c.target.checked)},null,40,da)]))),128))],64))),128))],4)]),s[6]||(s[6]=i("p",{class:"matrix-hint muted"},"Toggle which permissions each organization can grant to its members.",-1))]),i("div",ua,[a.info.is_global_admin?(o(),r("button",{key:0,onClick:s[1]||(s[1]=t=>m.$emit("openDialog","perm-create",{display_name:"",id:""}))},"+ Create Permission")):O("",!0)]),i("table",ma,[s[7]||(s[7]=i("thead",null,[i("tr",null,[i("th",{scope:"col"},"Permission"),i("th",{scope:"col",class:"center"},"Members"),i("th",{scope:"col",class:"center"},"Actions")])],-1)),i("tbody",null,[(o(!0),r(p,null,I(k.value,t=>(o(),r("tr",{key:t.id},[i("td",ca,[i("div",ga,[i("span",ya,f(t.display_name),1),i("button",{onClick:u=>m.$emit("renamePermissionDisplay",t),class:"icon-btn edit-display-btn","aria-label":"Edit display name",title:"Edit display name"},"✏️",8,va)]),i("div",pa,[i("span",fa,f(t.id),1)])]),i("td",ha,f(a.permissionSummary[t.id]?.userCount||0),1),i("td",$a,[i("button",{onClick:u=>m.$emit("deletePermission",t),class:"icon-btn delete-icon","aria-label":"Delete permission",title:"Delete permission"},"❌",8,ba)])]))),128))])])])):O("",!0)],64))}},ka=j(wa,[["__scopeId","data-v-3b9e6d03"]]),Da=["title"],Oa={class:"org-name"},Ua={class:"matrix-wrapper"},Ca={class:"matrix-scroll"},Ra=["title"],Sa=["title"],Pa=["checked","onChange"],Ea={class:"roles-grid"},Na=["onDrop"],Ta={classLine truncated
|
||||
@@ -0,0 +1 @@
|
||||
.permissions-section[data-v-3b9e6d03]{margin-bottom:var(--space-xl)}.permissions-section h2[data-v-3b9e6d03]{margin-bottom:var(--space-md)}.actions[data-v-3b9e6d03]{display:flex;flex-wrap:wrap;gap:var(--space-sm);align-items:center}.actions button[data-v-3b9e6d03]{width:auto}.org-table a[data-v-3b9e6d03]{text-decoration:none;color:var(--color-link)}.org-table a[data-v-3b9e6d03]:hover{text-decoration:underline}.org-table .center[data-v-3b9e6d03]{width:6rem;min-width:6rem}.org-table .role-names[data-v-3b9e6d03]{max-width:200px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.perm-name-cell[data-v-3b9e6d03]{display:flex;flex-direction:column;gap:.3rem}.perm-title[data-v-3b9e6d03]{font-weight:600;color:var(--color-heading)}.perm-id-info[data-v-3b9e6d03]{font-size:.8rem;color:var(--color-text-muted)}.icon-btn[data-v-3b9e6d03]{background:none;border:none;color:var(--color-text-muted);padding:.2rem;border-radius:var(--radius-sm);cursor:pointer;transition:background .2s ease,color .2s ease}.icon-btn[data-v-3b9e6d03]:hover{color:var(--color-heading);background:var(--color-surface-muted)}.delete-icon[data-v-3b9e6d03]{color:var(--color-danger)}.delete-icon[data-v-3b9e6d03]:hover{background:var(--color-danger-bg);color:var(--color-danger-text)}.matrix-wrapper[data-v-3b9e6d03]{margin:var(--space-md) 0;padding:var(--space-lg)}.matrix-scroll[data-v-3b9e6d03]{overflow-x:auto}.matrix-hint[data-v-3b9e6d03]{font-size:.8rem;color:var(--color-text-muted)}.perm-matrix-grid[data-v-3b9e6d03]{display:inline-grid;gap:.25rem;align-items:stretch}.perm-matrix-grid[data-v-3b9e6d03]>*{padding:.35rem .45rem;font-size:.75rem}.perm-matrix-grid .grid-head[data-v-3b9e6d03]{color:var(--color-text-muted);text-transform:uppercase;font-weight:600;letter-spacing:.05em}.perm-matrix-grid .perm-head[data-v-3b9e6d03]{display:flex;align-items:flex-end;justify-content:flex-start;padding:.35rem .45rem;font-size:.75rem}.perm-matrix-grid .org-head[data-v-3b9e6d03]{display:flex;align-items:flex-end;justify-content:center}.perm-matrix-grid .org-head span[data-v-3b9e6d03]{writing-mode:vertical-rl;transform:rotate(180deg);font-size:.65rem}.perm-name[data-v-3b9e6d03]{font-weight:600;color:var(--color-heading);padding:.35rem .45rem;font-size:.75rem}.display-text[data-v-3b9e6d03]{margin-right:var(--space-xs)}.edit-display-btn[data-v-3b9e6d03]{padding:.1rem .2rem;font-size:.8rem}.edit-org-btn[data-v-3b9e6d03]{padding:.1rem .2rem;font-size:.8rem;margin-left:var(--space-xs)}.perm-actions[data-v-3b9e6d03],.center[data-v-3b9e6d03]{text-align:center}.muted[data-v-3b9e6d03]{color:var(--color-text-muted)}.card.surface[data-v-b8b4bfbc]{padding:var(--space-lg)}.org-title[data-v-b8b4bfbc]{display:flex;align-items:center;gap:var(--space-sm);margin-bottom:var(--space-lg)}.org-name[data-v-b8b4bfbc]{font-size:1.5rem;font-weight:600;color:var(--color-heading)}.icon-btn[data-v-b8b4bfbc]{background:none;border:none;color:var(--color-text-muted);padding:.2rem;border-radius:var(--radius-sm);cursor:pointer;transition:background .2s ease,color .2s ease}.icon-btn[data-v-b8b4bfbc]:hover{color:var(--color-heading);background:var(--color-surface-muted)}.matrix-wrapper[data-v-b8b4bfbc]{margin:var(--space-md) 0;padding:var(--space-lg)}.matrix-scroll[data-v-b8b4bfbc]{overflow-x:auto}.matrix-hint[data-v-b8b4bfbc]{font-size:.8rem;color:var(--color-text-muted)}.perm-matrix-grid[data-v-b8b4bfbc]{display:inline-grid;gap:.25rem;align-items:stretch}.perm-matrix-grid[data-v-b8b4bfbc]>*{padding:.35rem .45rem;font-size:.75rem}.perm-matrix-grid .grid-head[data-v-b8b4bfbc]{color:var(--color-text-muted);text-transform:uppercase;font-weight:600;letter-spacing:.05em}.perm-matrix-grid .perm-head[data-v-b8b4bfbc]{display:flex;align-items:flex-end;justify-content:flex-start;padding:.35rem .45rem;font-size:.75rem}.perm-matrix-grid .role-head[data-v-b8b4bfbc]{display:flex;align-items:flex-end;justify-content:center}.perm-matrix-grid .role-head span[data-v-b8b4bfbc]{writing-mode:vertical-rl;transform:rotate(180deg);font-size:.65rem}.perm-matrix-grid .add-role-head[data-v-b8b4bfbc]{cursor:pointer}.perm-name[data-v-b8b4bfbc]{font-weight:600;color:var(--color-heading);padding:.35rem .45rem;font-size:.75rem}.roles-grid[data-v-b8b4bfbc]{display:flex;gap:var(--space-lg);margin-top:var(--space-lg)}.role-column[data-v-b8b4bfbc]{flex:1;min-width:200px;border:1px solid var(--color-border);border-radius:var(--radius-md);padding:var(--space-md)}.role-header[data-v-b8b4bfbc]{display:flex;justify-content:space-between;align-items:center;margin-bottom:var(--space-md)}.role-name[data-v-b8b4bfbc]{display:flex;align-items:center;gap:var(--space-xs);font-size:1.1rem;color:var(--color-heading)}.role-actions[data-v-b8b4bfbc]{display:flex;gap:var(--space-xs)}.plus-btn[data-v-b8b4bfbc]{background:var(--color-accent-soft);color:var(--color-accent);border:none;border-radius:var(--radius-sm);padding:.25rem .45rem;font-size:1.1rem;cursor:pointer}.plus-btn[data-v-b8b4bfbc]:hover{background:#2563eb2e}.user-list[data-v-b8b4bfbc]{lLine truncated
|
||||
@@ -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%}}
|
||||
@@ -0,0 +1 @@
|
||||
import{_ as F,r as m,w as Y,o as T,a as z,c as v,m as O,b as j,d as k,e as l,f as t,g as N,h as $,i as C,u as o,j as J,k as K,n as G,F as Q,l as H,t as b,p as X,q as E,s as Z,v as ee}from"./_plugin-vue_export-helper-R4vr2A9I.js";import{u as x,B as se,U as R,_ as te,a as oe,N as ae,M as ne,R as ie,L as le,b as re,A as ue,c as de}from"./AccessDenied-guOGfNm-.js";import{g as U}from"./helpers-CU0-cyzg.js";const ce={class:"view-root","data-view":"profile"},ve={class:"view-header"},fe={class:"section-block"},ge={class:"section-block"},me={class:"section-body"},pe={class:"button-row"},he={class:"section-block"},ye=["disabled"],we=["disabled"],_e=["disabled"],be={key:0,class:"logout-note"},ke={key:1,class:"logout-note"},Ie={__name:"ProfileView",setup(V){const e=x(),f=m(null),r=m(!1),p=m(!1),y=m(""),w=m(!1),A=m(null),_=m(null);Y(r,d=>{d&&(y.value=e.userInfo?.user?.user_name||"")}),T(()=>{f.value=setInterval(()=>{e.userInfo&&(e.userInfo={...e.userInfo})},6e4)}),z(()=>{f.value&&clearInterval(f.value)});const c=async()=>{try{await X.register(null,null,()=>{e.showMessage("Adding new passkey...","info")}),await e.loadUserInfo(),e.showMessage("New passkey added successfully!","success",3e3)}catch(d){console.error("Failed to add new passkey:",d),e.showMessage(d.message,"error")}},S=async d=>{const s=d?.credential_uuid;if(s&&confirm("Are you sure you want to delete this passkey?"))try{await e.deleteCredential(s),e.showMessage("Passkey deleted successfully!","success",3e3)}catch(n){e.showMessage(`Failed to delete passkey: ${n.message}`,"error")}},L=v(()=>e.settings?.rp_name||"this service"),u=v(()=>e.userInfo?.sessions||[]),i=v(()=>u.value.find(s=>s.is_current)?.host||"this host"),h=m({}),B=async d=>{const s=d?.id;if(s){h.value={...h.value,[s]:!0};try{await e.terminateSession(s)}catch(n){e.showMessage(n.message||"Failed to terminate session","error",5e3)}finally{const n={...h.value};delete n[s],h.value=n}}},P=async()=>{await e.logoutEverywhere()},M=async()=>{await e.logout()},a=()=>{y.value=e.userInfo?.user?.user_name||"",r.value=!0},g=v(()=>!!(e.userInfo?.is_global_admin||e.userInfo?.is_org_admin)),I=v(()=>u.value.length>1),D=v(()=>{const d=[{label:"Auth",href:O()}];return g.value&&d.push({label:"Admin",href:j()}),d}),W=async()=>{const d=y.value.trim();if(!d){e.showMessage("Name cannot be empty","error");return}try{w.value=!0,await E("/auth/api/user/display-name",{method:"PUT",body:{display_name:d}}),r.value=!1,await e.loadUserInfo(),e.showMessage("Name updated successfully!","success",3e3)}catch(s){e.showMessage(s.message||"Failed to update name","error")}finally{w.value=!1}};return(d,s)=>(l(),k("section",ce,[t("header",ve,[s[10]||(s[10]=t("h1",null,"👋 Welcome!",-1)),N(se,{entries:D.value},null,8,["entries"]),s[11]||(s[11]=t("p",{class:"view-lede"},"Manage your account details and passkeys.",-1))]),t("section",fe,[o(e).userInfo?.user?(l(),$(R,{key:0,name:o(e).userInfo.user.user_name,visits:o(e).userInfo.user.visits||0,"created-at":o(e).userInfo.user.created_at,"last-seen":o(e).userInfo.user.last_seen,loading:o(e).isLoading,"update-endpoint":"/auth/api/user/display-name",onSaved:s[0]||(s[0]=n=>o(e).loadUserInfo()),onEditName:a},null,8,["name","visits","created-at","last-seen","loading"])):C("",!0)]),t("section",ge,[s[12]||(s[12]=t("div",{class:"section-header"},[t("h2",null,"Your Passkeys"),t("p",{class:"section-description"},"Keep at least one trusted passkey so you can always sign in.")],-1)),t("div",me,[N(te,{credentials:o(e).userInfo?.credentials||[],"aaguid-info":o(e).userInfo?.aaguid_info||{},loading:o(e).isLoading,"hovered-credential-uuid":A.value,"hovered-session-credential-uuid":_.value?.credential_uuid,"allow-delete":"",onDelete:S,onCredentialHover:s[1]||(s[1]=n=>A.value=n)},null,8,["credentials","aaguid-info","loading","hovered-credential-uuid","hovered-session-credential-uuid"]),t("div",pe,[t("button",{onClick:c,class:"btn-primary"},"Add New Passkey"),t("button",{onClick:s[2]||(s[2]=n=>p.value=!0),class:"btn-secondary"},"Add Another Device")])])]),N(oe,{sessions:u.value,"terminating-sessions":h.value,"hovered-credential-uuid":A.value,onTerminate:B,onSessionHover:s[3]||(s[3]=n=>_.value=n),"section-description":"Review where you're signed in and end any sessions you no longer recognize."},null,8,["sessions","terminating-sessions","hovered-credential-uuid"]),r.value?(l(),$(ne,{key:0,onClose:s[6]||(s[6]=n=>r.value=!1)},{default:J(()=>[s[13]||(s[13]=t("h3",null,"Edit Display Name",-1)),t("form",{onSubmit:K(W,["prevent"]),class:"modal-form"},[N(ae,{label:"Display Name",modelValue:y.value,"onUpdate:modelValue":s[4]||(s[4]=n=>y.value=n),busy:w.value,onCancel:s[5]||(s[5]=n=>r.value=!1)},null,8,["modelValue","busy"])],32)]),_:1})):C("",!0),t("section",he,[t("div",{class:G(["button-row logout-row",{single:!I.value}])},[t("button",{type:"button",class:"btn-secondary",onClick:s[7]||(s[7]=(...n)=>o(U)&&o(U)(...n))}," Back "),I.value?(l(),k(Q,{key:1},[t("button",{onClick:M,class:"btn-danger logout-button",disabled:o(e).isLLine truncated
|
||||
@@ -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 .db import DatabaseInterface
|
||||
from .sansio import Passkey
|
||||
from paskia.db import DatabaseInterface
|
||||
from paskia.sansio import Passkey
|
||||
|
||||
T = TypeVar("T")
|
||||
|
||||
@@ -29,9 +29,7 @@ class Manager(Generic[T]):
|
||||
async def init(
|
||||
rp_id: str = "localhost",
|
||||
rp_name: str | None = None,
|
||||
origin: str | None = None,
|
||||
default_admin: str | None = None,
|
||||
default_org: str | None = None,
|
||||
origins: list[str] | None = None,
|
||||
*,
|
||||
bootstrap: bool = True,
|
||||
) -> None:
|
||||
@@ -45,7 +43,7 @@ async def init(
|
||||
passkey.instance = Passkey(
|
||||
rp_id=rp_id,
|
||||
rp_name=rp_name or rp_id,
|
||||
origin=origin,
|
||||
origins=origins,
|
||||
)
|
||||
|
||||
# Test if we have a database already initialized, otherwise use SQL
|
||||
@@ -60,7 +58,7 @@ async def init(
|
||||
# Bootstrap system if needed
|
||||
from .bootstrap import bootstrap_if_needed
|
||||
|
||||
await bootstrap_if_needed(default_admin, default_org)
|
||||
await bootstrap_if_needed()
|
||||
|
||||
|
||||
# Global instances
|
||||
@@ -37,7 +37,7 @@ from webauthn.helpers.structs import (
|
||||
UserVerificationRequirement,
|
||||
)
|
||||
|
||||
from .db import Credential
|
||||
from paskia.db import Credential
|
||||
|
||||
|
||||
class Passkey:
|
||||
@@ -47,7 +47,7 @@ class Passkey:
|
||||
self,
|
||||
rp_id: str,
|
||||
rp_name: str | None = None,
|
||||
origin: str | None = None,
|
||||
origins: list[str] | None = None,
|
||||
supported_pub_key_algs: list[COSEAlgorithmIdentifier] | None = None,
|
||||
):
|
||||
"""
|
||||
@@ -56,40 +56,58 @@ class Passkey:
|
||||
Args:
|
||||
rp_id: Your security domain (e.g. "example.com")
|
||||
rp_name: The relying party display name (e.g. "Example App"). May be shown in authenticators.
|
||||
origin: The origin URL of the application (e.g. "https://app.example.com").
|
||||
If no scheme is provided, "https://" will be prepended.
|
||||
Must be a subdomain or same as rp_id, with port and scheme but no path included.
|
||||
origins: List of allowed origin URLs (e.g. ["https://app.example.com", "https://auth.example.com"]).
|
||||
Each must be a subdomain or same as rp_id. If not provided, any subdomain of rp_id is allowed.
|
||||
supported_pub_key_algs: List of supported COSE algorithms (default is EDDSA, ECDSA_SHA_256, RSASSA_PKCS1_v1_5_SHA_256).
|
||||
|
||||
Raises:
|
||||
ValueError: If the origin domain doesn't match or isn't a subdomain of rp_id.
|
||||
ValueError: If any origin domain doesn't match or isn't a subdomain of rp_id.
|
||||
"""
|
||||
self.rp_id = rp_id
|
||||
self.rp_name = rp_name or rp_id
|
||||
self.origin = self._normalize_and_validate_origin(origin, rp_id)
|
||||
self.allowed_origins: set[str] | None = None
|
||||
if origins:
|
||||
# Validate and deduplicate origins into a set for O(1) lookups
|
||||
for o in origins:
|
||||
self._validate_origin(o, rp_id)
|
||||
self.allowed_origins = set(origins)
|
||||
self.supported_pub_key_algs = supported_pub_key_algs or [
|
||||
COSEAlgorithmIdentifier.EDDSA,
|
||||
COSEAlgorithmIdentifier.ECDSA_SHA_256,
|
||||
COSEAlgorithmIdentifier.RSASSA_PKCS1_v1_5_SHA_256,
|
||||
]
|
||||
|
||||
def _normalize_and_validate_origin(self, origin: str | None, rp_id: str) -> str:
|
||||
if origin is None:
|
||||
origin = f"https://{rp_id}"
|
||||
elif "://" not in origin:
|
||||
origin = f"https://{origin}"
|
||||
|
||||
def _validate_origin(self, origin: str, rp_id: str) -> None:
|
||||
"""Validate an origin URL against the rp_id."""
|
||||
hostname = urlparse(origin).hostname
|
||||
if not hostname:
|
||||
raise ValueError(f"Invalid origin URL: no hostname found in '{origin}'")
|
||||
|
||||
if hostname == rp_id or hostname.endswith(f".{rp_id}"):
|
||||
return origin
|
||||
return
|
||||
|
||||
raise ValueError(
|
||||
f"Origin domain '{hostname}' must be the same as or a subdomain of rp_id '{rp_id}'"
|
||||
)
|
||||
|
||||
def validate_origin(self, origin: str) -> str:
|
||||
"""Validate that origin is allowed and return it.
|
||||
|
||||
Args:
|
||||
origin: The origin URL to validate (from WebSocket request header)
|
||||
|
||||
Returns:
|
||||
The validated origin URL
|
||||
|
||||
Raises:
|
||||
ValueError: If origin is not in the allowed list (when origins are configured)
|
||||
or if origin is not a valid subdomain of rp_id
|
||||
"""
|
||||
self._validate_origin(origin, self.rp_id)
|
||||
if self.allowed_origins is not None and origin not in self.allowed_origins:
|
||||
raise ValueError(f"Origin '{origin}' is not in the allowed origins list")
|
||||
return origin
|
||||
|
||||
### Registration Methods ###
|
||||
|
||||
def reg_generate_options(
|
||||
@@ -137,14 +155,16 @@ class Passkey:
|
||||
response_json: dict | str,
|
||||
expected_challenge: bytes,
|
||||
user_uuid: UUID,
|
||||
origin: str | None = None,
|
||||
origin: str,
|
||||
) -> Credential:
|
||||
"""
|
||||
Verify registration response.
|
||||
|
||||
Args:
|
||||
credential: The credential response from the client
|
||||
response_json: The credential response from the client
|
||||
expected_challenge: The expected challenge bytes
|
||||
user_uuid: The user's UUID
|
||||
origin: The origin URL (required, must be pre-validated)
|
||||
|
||||
Returns:
|
||||
Registration verification result
|
||||
@@ -153,7 +173,7 @@ class Passkey:
|
||||
registration = verify_registration_response(
|
||||
credential=credential,
|
||||
expected_challenge=expected_challenge,
|
||||
expected_origin=origin or self.origin,
|
||||
expected_origin=origin,
|
||||
expected_rp_id=self.rp_id,
|
||||
)
|
||||
return Credential(
|
||||
@@ -206,7 +226,7 @@ class Passkey:
|
||||
credential: AuthenticationCredential,
|
||||
expected_challenge: bytes,
|
||||
stored_cred: Credential,
|
||||
origin: str | None = None,
|
||||
origin: str,
|
||||
) -> VerifiedAuthentication:
|
||||
"""
|
||||
Verify authentication response against locally stored credential data.
|
||||
@@ -215,13 +235,13 @@ class Passkey:
|
||||
credential: The authentication credential response from the client
|
||||
expected_challenge: The earlier generated challenge bytes
|
||||
stored_cred: The server stored credential record (modified by this function)
|
||||
origin: The origin URL (required, must be pre-validated)
|
||||
"""
|
||||
expected_origin = origin or self.origin
|
||||
# Verify the authentication response
|
||||
verification = verify_authentication_response(
|
||||
credential=credential,
|
||||
expected_challenge=expected_challenge,
|
||||
expected_origin=expected_origin,
|
||||
expected_origin=origin,
|
||||
expected_rp_id=self.rp_id,
|
||||
credential_public_key=stored_cred.public_key,
|
||||
credential_current_sign_count=stored_cred.sign_count,
|
||||
@@ -0,0 +1,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
|
||||
File renamed without changes.
@@ -1,6 +1,6 @@
|
||||
import secrets
|
||||
|
||||
from .wordlist import words
|
||||
from paskia.util.wordlist import words
|
||||
|
||||
N_WORDS = 5
|
||||
|
||||
@@ -3,9 +3,9 @@
|
||||
from collections.abc import Sequence
|
||||
from fnmatch import fnmatchcase
|
||||
|
||||
from ..globals import db
|
||||
from .hostutil import normalize_host
|
||||
from .tokens import session_key
|
||||
from paskia.globals import db
|
||||
from paskia.util.hostutil import normalize_host
|
||||
from paskia.util.tokens import session_key
|
||||
|
||||
__all__ = ["has_any", "has_all", "session_context"]
|
||||
|
||||
File renamed without changes.
@@ -2,8 +2,8 @@
|
||||
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from ..db import SessionContext
|
||||
from .timeutil import parse_duration
|
||||
from paskia.db import SessionContext
|
||||
from paskia.util.timeutil import parse_duration
|
||||
|
||||
|
||||
def check_session_age(ctx: SessionContext, max_age: str | None) -> bool:
|
||||
@@ -0,0 +1,75 @@
|
||||
"""Startup configuration box formatting utilities."""
|
||||
|
||||
import os
|
||||
from sys import stderr
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from paskia._version import __version__
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from paskia.config import PaskiaConfig
|
||||
|
||||
BOX_WIDTH = 60 # Inner width (excluding box chars)
|
||||
|
||||
|
||||
def line(text: str = "") -> str:
|
||||
"""Format a line inside the box with proper padding, truncating if needed."""
|
||||
if len(text) > BOX_WIDTH:
|
||||
text = text[: BOX_WIDTH - 1] + "…"
|
||||
return f"┃ {text:<{BOX_WIDTH}} ┃\n"
|
||||
|
||||
|
||||
def top() -> str:
|
||||
return "┏" + "━" * (BOX_WIDTH + 2) + "┓\n"
|
||||
|
||||
|
||||
def bottom() -> str:
|
||||
return "┗" + "━" * (BOX_WIDTH + 2) + "┛\n"
|
||||
|
||||
|
||||
def print_startup_config(config: "PaskiaConfig") -> None:
|
||||
"""Print server configuration on startup."""
|
||||
lines = [top()]
|
||||
lines.append(line(" ▄▄▄▄▄"))
|
||||
lines.append(line("█ █ Paskia " + __version__))
|
||||
lines.append(line("█ █▄▄▄▄▄▄▄▄▄▄▄▄"))
|
||||
lines.append(line("█ █▀▀▀▀█▀▀█▀▀█ " + config.site_url + config.site_path))
|
||||
lines.append(line(" ▀▀▀▀▀"))
|
||||
|
||||
# Format auth host section
|
||||
if config.auth_host:
|
||||
lines.append(line(f"Auth Host: {config.auth_host}"))
|
||||
|
||||
# Show frontend URL if in dev mode
|
||||
devmode = os.environ.get("PASKIA_DEVMODE")
|
||||
if devmode:
|
||||
lines.append(line(f"Dev Frontend: {devmode}"))
|
||||
|
||||
# Format listen address with scheme
|
||||
if config.uds:
|
||||
listen = f"unix:{config.uds}"
|
||||
elif config.host:
|
||||
listen = f"http://{config.host}:{config.port}"
|
||||
else:
|
||||
listen = f"http://0.0.0.0:{config.port} + [::]:{config.port}"
|
||||
lines.append(line(f"Backend: {listen}"))
|
||||
|
||||
# Relying Party line (omit name if same as id)
|
||||
rp_id = config.rp_id
|
||||
rp_name = config.rp_name
|
||||
if rp_name and rp_name != rp_id:
|
||||
lines.append(line(f"Relying Party: {rp_id} ({rp_name})"))
|
||||
else:
|
||||
lines.append(line(f"Relying Party: {rp_id}"))
|
||||
|
||||
# Format origins section
|
||||
allowed = config.origins
|
||||
if allowed:
|
||||
lines.append(line("Permitted Origins:"))
|
||||
for origin in sorted(allowed):
|
||||
lines.append(line(f" - {origin}"))
|
||||
else:
|
||||
lines.append(line(f"Origin: {rp_id} and all subdomains allowed"))
|
||||
|
||||
lines.append(bottom())
|
||||
stderr.write("".join(lines))
|
||||
File renamed without changes.
@@ -2,7 +2,7 @@ import base64
|
||||
import hashlib
|
||||
import secrets
|
||||
|
||||
from .passphrase import is_well_formed
|
||||
from paskia.util.passphrase import is_well_formed
|
||||
|
||||
|
||||
def create_token() -> str:
|
||||
File renamed without changes.
@@ -2,12 +2,10 @@
|
||||
|
||||
from datetime import timezone
|
||||
|
||||
from passkey.util import useragent
|
||||
|
||||
from .. import aaguid
|
||||
from ..authsession import session_key
|
||||
from ..globals import db
|
||||
from . import hostutil, permutil, tokens
|
||||
from paskia import aaguid
|
||||
from paskia.authsession import session_key
|
||||
from paskia.globals import db
|
||||
from paskia.util import hostutil, permutil, tokens, useragent
|
||||
|
||||
|
||||
def _format_datetime(dt):
|
||||
File renamed without changes.
@@ -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"
|
||||
|
||||
[project]
|
||||
name = "passkey"
|
||||
name = "paskia"
|
||||
dynamic = ["version"]
|
||||
description = "Passkey Authentication for Web Services"
|
||||
description = "Passkey Auth made easy: all sites and APIs can be guarded even without any changes on the protected site."
|
||||
keywords = [ "forward_auth", "auth_request", "FastAPI" ]
|
||||
authors = [
|
||||
{name = "Leo Vasanko"},
|
||||
]
|
||||
@@ -26,11 +27,40 @@ requires-python = ">=3.10"
|
||||
source = "vcs"
|
||||
|
||||
[tool.hatch.build.hooks.vcs]
|
||||
version-file = "passkey/_version.py"
|
||||
version-file = "paskia/_version.py"
|
||||
|
||||
[project.optional-dependencies]
|
||||
dev = [
|
||||
"ruff>=0.1.0",
|
||||
"coverage[toml]>=7.0.0",
|
||||
"pytest>=8.0.0",
|
||||
"pytest-asyncio>=0.24.0",
|
||||
"httpx>=0.27.0",
|
||||
]
|
||||
|
||||
[tool.coverage.run]
|
||||
source = ["paskia"]
|
||||
branch = true
|
||||
parallel = true
|
||||
sigterm = true
|
||||
|
||||
[tool.coverage.report]
|
||||
exclude_lines = [
|
||||
"pragma: no cover",
|
||||
"if TYPE_CHECKING:",
|
||||
"if __name__ == .__main__.:",
|
||||
]
|
||||
show_missing = true
|
||||
|
||||
[tool.coverage.html]
|
||||
directory = "coverage-html"
|
||||
|
||||
[tool.pytest.ini_options]
|
||||
asyncio_mode = "auto"
|
||||
asyncio_default_fixture_loop_scope = "function"
|
||||
testpaths = ["tests"]
|
||||
filterwarnings = [
|
||||
"ignore::DeprecationWarning",
|
||||
]
|
||||
|
||||
[tool.ruff]
|
||||
@@ -40,11 +70,20 @@ line-length = 88
|
||||
[tool.ruff.lint]
|
||||
select = ["E", "F", "I", "N", "W", "UP"]
|
||||
ignore = ["E501"] # Line too long
|
||||
isort.known-first-party = ["passkey"]
|
||||
isort.known-first-party = ["paskia"]
|
||||
|
||||
[dependency-groups]
|
||||
dev = [
|
||||
"coverage>=7.12.0",
|
||||
"httpx>=0.28.1",
|
||||
"pytest>=9.0.1",
|
||||
"pytest-asyncio>=1.3.0",
|
||||
"pytest-cov>=7.0.0",
|
||||
]
|
||||
|
||||
[project.scripts]
|
||||
passkey-auth = "passkey.fastapi.__main__:main"
|
||||
paskia = "paskia.fastapi.__main__:main"
|
||||
|
||||
[tool.hatch.build]
|
||||
artifacts = ["passkey/frontend-build"]
|
||||
artifacts = ["paskia/frontend-build"]
|
||||
targets.sdist.hooks.custom.path = "scripts/build-frontend.py"
|
||||
Executable
+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