Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1bed2c39d8 | ||
|
|
83419d1845 | ||
|
|
a2fe0b6f1a | ||
|
|
a1b73711e6 | ||
|
|
df5c176bcd | ||
|
|
8937905c9c | ||
|
|
127e06179b | ||
|
|
c1204ca020 | ||
|
|
208115ebc3 | ||
|
|
8609f2fe69 | ||
|
|
0355c55fc0 | ||
|
|
ea1ddbbe6f | ||
|
|
b091744665 | ||
|
|
2cf8799c75 | ||
|
|
a72349077c | ||
|
|
e102b8383b | ||
|
|
5aa8d021e6 | ||
|
|
3d5b0aa4bf | ||
|
|
29df169a67 | ||
|
|
97dc459bfb | ||
|
|
4d4b290cc8 | ||
|
|
0e1b9f529b | ||
|
|
0c3e0d3fa5 | ||
|
|
1782547b9e | ||
|
|
9976e05696 | ||
|
|
6124fa6c01 | ||
|
|
a6591a1fbb | ||
|
|
b9b1c995f9 | ||
|
|
4482a601f3 | ||
|
|
aa4b1bfd42 | ||
|
|
2ecf8433a1 | ||
|
|
db892365dc | ||
|
|
8d02c0f615 | ||
|
|
469d606ce5 | ||
|
|
547a6cd923 | ||
|
|
deabee3b5c | ||
|
|
fd1aa11409 | ||
|
|
ca1ea9d90b | ||
|
|
2dac0be77a | ||
|
|
f63c62d9ff | ||
|
|
768a4391cf | ||
|
|
f64876e73b | ||
|
|
b6a3cdd3a4 | ||
|
|
fd9a5afc1c | ||
|
|
8714fe9319 | ||
|
|
adbab88c86 | ||
|
|
5d9d2b794d | ||
|
|
eedbd4aaa4 | ||
|
|
15916047fa | ||
|
|
643d9bafab | ||
|
|
2699aaa472 | ||
|
|
5422845192 | ||
|
|
c1ccb048f0 | ||
|
|
3030122807 | ||
|
|
d4f8e97469 | ||
|
|
a62e8ddf1e | ||
|
|
2dca6b1eec | ||
|
|
4f50974222 | ||
|
|
c218ddad61 | ||
|
|
7247f7c584 | ||
|
|
af2834b4c0 | ||
|
|
ef66baff20 | ||
|
|
08d4607d65 | ||
|
|
1ca9e3ef58 | ||
|
|
575d3cb1fb | ||
|
|
a4ac19f54c | ||
|
|
11887d15b2 | ||
|
|
cefb9c3d92 | ||
|
|
5b9a3fc27f | ||
|
|
19a6c32cf2 | ||
|
|
eaa16abe2a | ||
|
|
01bc39a0e8 | ||
|
|
fa513940c7 | ||
|
|
f24aaa295d | ||
|
|
0af7aad28c |
+6
-4
@@ -3,7 +3,9 @@ dist/
|
|||||||
.*
|
.*
|
||||||
!.gitignore
|
!.gitignore
|
||||||
*.lock
|
*.lock
|
||||||
passkey-auth.sqlite
|
package-lock.json
|
||||||
/passkey/frontend-build
|
paskia.sqlite
|
||||||
/test_*.py
|
/paskia/frontend-build
|
||||||
passkey/_version.py
|
/paskia/_version.py
|
||||||
|
coverage-html/
|
||||||
|
e2e/coverage-frontend/
|
||||||
|
|||||||
@@ -1,28 +1,105 @@
|
|||||||
# PassKey Auth API Documentation
|
# Paskia API Documentation
|
||||||
|
|
||||||
This document describes all API endpoints available in the PassKey Auth FastAPI application, that by default listens on `localhost:4401` ("for authentication required").
|
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.
|
||||||
|
|
||||||
### HTTP Endpoints
|
## Base Paths & Host Modes
|
||||||
|
|
||||||
GET /auth/ - Main authentication app
|
Two deployment modes:
|
||||||
GET /auth/admin/ - Admin app for managing organisations, users and permissions
|
|
||||||
GET /auth/{reset_token} - Process password reset/share token
|
|
||||||
POST /auth/api/user-info - Get authenticated user information
|
|
||||||
POST /auth/api/logout - Logout and delete session
|
|
||||||
POST /auth/api/set-session - Set session cookie from Authorization header
|
|
||||||
POST /auth/api/create-link - Create device addition link
|
|
||||||
DELETE /auth/api/credential/{uuid} - Delete specific credential
|
|
||||||
POST /auth/api/validate - Session validation and renewal endpoint (fetch regularly)
|
|
||||||
GET /auth/api/forward - Authentication validation for Caddy/Nginx
|
|
||||||
- On success returns `204 No Content` with [user info](Headers.md)
|
|
||||||
- Otherwise returns
|
|
||||||
* `401 Unauthorized` - authentication required
|
|
||||||
* `403 Forbidden` - missing required permissions
|
|
||||||
* Serves the authentication app for a login or permission denied page
|
|
||||||
- Does not renew session!
|
|
||||||
|
|
||||||
### WebAuthn/Passkey endpoints (WebSockets)
|
1. Multi‑host (default – no `--auth-host` provided)
|
||||||
|
- All endpoints are reachable on any host under the `/auth/` prefix.
|
||||||
|
- A convenience root (`/`) also serves the main app.
|
||||||
|
|
||||||
WS /auth/ws/register - Register new user with passkey
|
2. Dedicated auth host (`--auth-host auth.example.com`)
|
||||||
WS /auth/ws/add_credential - Add new credential for existing user
|
- The specified auth host serves the UI at the root (`/`, `/admin/`, reset tokens, etc.).
|
||||||
WS /auth/ws/authenticate - Authenticate user with passkey
|
- Other (non‑auth) hosts show a lightweight account summary at `/` or `/auth/`, while other UI routes still redirect to the auth host.
|
||||||
|
- Restricted endpoints on non‑auth hosts return `404` instead of redirecting.
|
||||||
|
|
||||||
|
### Path Mapping When Auth Host Enabled
|
||||||
|
|
||||||
|
| Purpose | On Auth Host | On Other Hosts (incoming) | Action |
|
||||||
|
|---------|--------------|---------------------------|--------|
|
||||||
|
| Main UI | `/` | `/auth/` or `/` | Serve account summary SPA (no redirect) |
|
||||||
|
| Admin UI root | `/admin/` | `/auth/admin/` or `/admin/` | Redirect -> auth host `/admin/` (strip `/auth`) |
|
||||||
|
| Reset / device addition token | `/{token}` | `/auth/{token}` | Redirect -> auth host `/{token}` (strip `/auth`) |
|
||||||
|
| Static assets | `/auth/assets/*` | `/auth/assets/*` | Served directly (no redirect) |
|
||||||
|
| Unrestricted API | `/auth/api/...` | `/auth/api/...` | Served directly |
|
||||||
|
| Restricted API (admin,user,ws namespaces) | `/auth/api/{admin|user|ws}*` | same path | 404 on non‑auth hosts |
|
||||||
|
| WebSocket (register/auth) | `/auth/ws/*` | `/auth/ws/*` | 404 on non‑auth hosts |
|
||||||
|
|
||||||
|
Notes:
|
||||||
|
- “Strip `/auth`” means only when the path starts with that exact segment.
|
||||||
|
- A reset token is a single path segment validated by server logic; malformed tokens 404.
|
||||||
|
- Method and body are preserved for UI redirects (307 Temporary Redirect).
|
||||||
|
|
||||||
|
## HTTP UI Endpoints
|
||||||
|
|
||||||
|
| Method | Path (multi‑host) | Path (auth host) | Description |
|
||||||
|
|--------|-------------------|------------------|-------------|
|
||||||
|
| GET | `/auth/` | `/` | Main authentication SPA (non-auth hosts show an account summary view) |
|
||||||
|
| GET | `/auth/admin/` | `/admin/` | Admin SPA root |
|
||||||
|
| GET | `/auth/{reset_token}` | `/{reset_token}` | Reset / device addition SPA (token validated) |
|
||||||
|
|
||||||
|
## Core API (Unrestricted – available on all hosts)
|
||||||
|
|
||||||
|
Always under `/auth/api/` (even on auth host):
|
||||||
|
|
||||||
|
| Method | Path | Description |
|
||||||
|
|--------|------|-------------|
|
||||||
|
| GET | `/auth/restricted/` | Authentication UI for iframe embedding (supports `?mode=login` or `?mode=reauth`) |
|
||||||
|
|--------|------|-------------|
|
||||||
|
| POST | `/auth/api/validate` | Validate & (conditionally) renew session |
|
||||||
|
| GET | `/auth/api/forward` | Auth proxy endpoint for reverse proxies (204 or 4xx) |
|
||||||
|
| POST | `/auth/api/set-session` | Set cookie from Bearer token |
|
||||||
|
| POST | `/auth/api/logout` | Logout current session |
|
||||||
|
| POST | `/auth/api/user-info` | Authenticated user + context info (also handles reset tokens) |
|
||||||
|
| POST | `/auth/api/create-link` | Create a device addition link (reset token) |
|
||||||
|
| DELETE | `/auth/api/credential/{uuid}` | Delete user credential |
|
||||||
|
| DELETE | `/auth/api/session/{session_id}` | Terminate a specific session |
|
||||||
|
| POST | `/auth/api/user/logout-all` | Terminate all sessions for the user |
|
||||||
|
| PUT | `/auth/api/user/display-name` | Update display name |
|
||||||
|
|
||||||
|
## Restricted API Namespaces
|
||||||
|
|
||||||
|
When `--auth-host` is set, requests to these paths on non‑auth hosts return 404:
|
||||||
|
|
||||||
|
| Namespace | Examples |
|
||||||
|
|-----------|----------|
|
||||||
|
| `/auth/api/admin` | `/auth/api/admin/orgs`, `/auth/api/admin/orgs/{uuid}` ... |
|
||||||
|
| `/auth/api/user` | Segment prefix – includes `/auth/api/user/...` endpoints (logout-all, display-name, session, credential) |
|
||||||
|
| `/auth/api/ws` | (Reserved / future) |
|
||||||
|
|
||||||
|
## WebSockets (Passkey)
|
||||||
|
|
||||||
|
| Path | Description | Host Mode Behavior |
|
||||||
|
|------|-------------|--------------------|
|
||||||
|
| `/auth/ws/register` | Register new credential (new or existing user) | 404 on non‑auth hosts when auth host configured |
|
||||||
|
| `/auth/ws/authenticate` | Authenticate user & issue session | 404 on non‑auth hosts when auth host configured |
|
||||||
|
|
||||||
|
## Redirection & Status Codes
|
||||||
|
|
||||||
|
| Scenario | Response |
|
||||||
|
|----------|----------|
|
||||||
|
| UI path on non‑auth host (auth host configured) | 307 redirect to auth host; `/auth` prefix stripped |
|
||||||
|
| Reset token UI path on non‑auth host | 307 redirect (token preserved) |
|
||||||
|
| Restricted API on non‑auth host | 404 |
|
||||||
|
| Unrestricted API on any host | Normal response |
|
||||||
|
| No auth host configured | All hosts behave like multi-host mode (no redirects; everything accessible) |
|
||||||
|
|
||||||
|
## Headers for /auth/api/forward
|
||||||
|
See `Headers.md` for details of headers returned on success (204).
|
||||||
|
|
||||||
|
## Notes for Integrators
|
||||||
|
1. Always use absolute `/auth/api/...` paths for programmatic requests (they do not move when an auth host is introduced).
|
||||||
|
2. Bookmark / deep links to UI should resolve correctly after redirection if users access via a non-auth application host.
|
||||||
|
3. Treat 404 from restricted namespaces on non-auth hosts as a signal to direct users to the central auth site.
|
||||||
|
|
||||||
|
## Environment & CLI Summary
|
||||||
|
| Option | Effect |
|
||||||
|
|--------|--------|
|
||||||
|
| `--auth-host` / `PASSKEY_AUTH_HOST` | Enables dedicated host mode, root-mounts UI there, restricts certain namespaces elsewhere |
|
||||||
|
|
||||||
|
---
|
||||||
|
This document reflects current behavior of the middleware-based host routing logic.
|
||||||
|
|||||||
@@ -1,129 +1,72 @@
|
|||||||
# PasskeyAuth
|
# Paskia
|
||||||
|
|
||||||
A minimal FastAPI WebAuthn server with WebSocket support for passkey registration. This project demonstrates WebAuthn registration flow with Resident Keys (discoverable credentials) using modern Python tooling.
|
An easy to install passkey-based authentication service that protects any web application with strong passwordless login.
|
||||||
|
|
||||||
## Features
|
## What is Paskia?
|
||||||
|
|
||||||
- 🔐 WebAuthn registration with Resident Keys support
|
- Easy to use fully featured auth&auth system (login and permissions)
|
||||||
- 🔌 WebSocket-based communication for real-time interaction
|
- Organization and role-based access control (optional)
|
||||||
- 🚀 Modern Python packaging with `pyproject.toml`
|
* Org admins control their users and roles
|
||||||
- 🎨 Clean, responsive HTML interface using @simplewebauthn/browser
|
* Master admin can create multiple independent orgs
|
||||||
- 📦 No database required - challenges stored locally per connection
|
* Master admin makes permissions available for orgs to assign
|
||||||
- 🛠️ Development tools: `ruff` for linting and formatting
|
- User Profile and Administration by API and web interface.
|
||||||
- 🧹 Clean architecture with local challenge management
|
under `/auth/` or `auth.example.com`
|
||||||
|
- Reset tokens and additional device linking via QR code or codewords.
|
||||||
|
- Pure Python, FastAPI, packaged with prebuilt Vue frontend
|
||||||
|
|
||||||
## Docs
|
Two interfaces:
|
||||||
|
- API fetch: auth checks and login without leaving your app
|
||||||
|
- Forward-auth proxy: protect any unprotected site or service (Caddy, Nginx)
|
||||||
|
|
||||||
- Caddy integration: see `CADDY.md` for short, copy-paste snippets to secure your site with Caddy.
|
The API mode is useful for applications that can be customized to run with Paskia. Forward auth can also protect your javascript and other assets. Each provides fine-grained permission control and reauthentication requests where needed, and both can be mixed where needed.
|
||||||
|
|
||||||
## Requirements
|
Single Sign-On (SSO): Users register once and authenticate across all applications under your domain name (configured rp-id).
|
||||||
|
|
||||||
- Python 3.9+
|
|
||||||
- A WebAuthn-compatible authenticator (security key, biometric device, etc.)
|
|
||||||
|
|
||||||
## Quick Start
|
## Quick Start
|
||||||
|
|
||||||
### Install (editable dev mode)
|
Install [UV](https://docs.astral.sh/uv/getting-started/installation/) and run:
|
||||||
|
|
||||||
```fish
|
```fish
|
||||||
uv pip install -e .[dev]
|
uvx paskia serve --rp-id example.com
|
||||||
```
|
```
|
||||||
|
|
||||||
### Run (new CLI)
|
On the first run it downloads the software and prints a registration link for the Admin. If you are going to be connecting `localhost` directly, for testing, leave out the rp-id.
|
||||||
|
|
||||||
`passkey-auth` now provides subcommands:
|
The server will start up on [localhost:4401](http://localhost:4401) "for authentication required", serving for `*.example.com`.
|
||||||
|
|
||||||
|
Otherwise you will need a web server such as [Caddy](https://caddyserver.com/) to serve HTTPS on your actual domain names and proxy requests to Paskia and your backend apps.
|
||||||
|
|
||||||
|
A quick example without any config file:
|
||||||
|
```fish
|
||||||
|
sudo caddy reverse-proxy --from example.com --to :4401
|
||||||
|
```
|
||||||
|
|
||||||
|
For a permanent install of `paskia` CLI command, not needing `uvx`:
|
||||||
|
|
||||||
|
```fish
|
||||||
|
uv tool install paskia
|
||||||
|
```
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
There is no config file. Pass only the options on CLI:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
passkey-auth serve [host:port] [--options]
|
paskia serve [options]
|
||||||
passkey-auth dev [--options]
|
|
||||||
```
|
```
|
||||||
|
|
||||||
Examples (fish shell shown):
|
Optional options:
|
||||||
|
|
||||||
```fish
|
- Listen address (one of):
|
||||||
# Production style (no reload)
|
* `[host]:port`: Address and port (default: `localhost:4401`)
|
||||||
passkey-auth serve
|
* `unix:/path.sock`: Unix socket
|
||||||
passkey-auth serve 0.0.0.0:8080 --rp-id example.com --origin https://example.com
|
- `--rp-id <domain>`: Main domain (required for production)
|
||||||
|
- `--rp-name "<text>"`: Name of your company or site (default: same as rp-id)
|
||||||
|
- `--origin <url>`: Explicit single site (default: `https://<rp-id>`)
|
||||||
|
- `--auth-host <domain>`: Dedicated authentication site (e.g., `auth.example.com`)
|
||||||
|
|
||||||
# Development (auto-reload)
|
## Documentation
|
||||||
passkey-auth dev # localhost:4401
|
|
||||||
passkey-auth dev :5500 # localhost on port 5500
|
|
||||||
passkey-auth dev 127.0.0.1 # host only, default port 4401
|
|
||||||
```
|
|
||||||
|
|
||||||
Available options (both subcommands):
|
- `API.md`: Complete HTTP and WebSocket API reference
|
||||||
|
- `Caddy.md`: Caddy configuration examples
|
||||||
```text
|
- `Headers.md`: HTTP headers passed to protected applications
|
||||||
--rp-id <id> Relying Party ID (default: localhost)
|
|
||||||
--rp-name <name> Relying Party name (default: same as rp-id)
|
|
||||||
--origin <url> Explicit origin (default: https://<rp-id>)
|
|
||||||
```
|
|
||||||
|
|
||||||
### Legacy Invocation
|
|
||||||
|
|
||||||
If you previously used `python -m passkey.fastapi --dev --host ...`, switch to the new form above. The old flags `--host`, `--port`, and `--dev` are replaced by the `[host:port]` positional and the `dev` subcommand.
|
|
||||||
|
|
||||||
## Usage (Web)
|
|
||||||
|
|
||||||
1. Start the server with one of the commands above
|
|
||||||
2. Open your browser to `http://localhost:4401/auth/` (or your chosen host/port)
|
|
||||||
3. Enter a username (or use the default)
|
|
||||||
4. Click "Register Passkey"
|
|
||||||
5. Follow your authenticator's prompts
|
|
||||||
|
|
||||||
Real-time status updates stream over WebSocket.
|
|
||||||
|
|
||||||
## Development
|
|
||||||
|
|
||||||
### Code Quality
|
|
||||||
|
|
||||||
```fish
|
|
||||||
# Run linting and formatting with ruff
|
|
||||||
uv run ruff check .
|
|
||||||
uv run ruff format .
|
|
||||||
|
|
||||||
# Or with hatch
|
|
||||||
hatch run ruff check .
|
|
||||||
hatch run ruff format .
|
|
||||||
```
|
|
||||||
|
|
||||||
### Project Structure
|
|
||||||
|
|
||||||
```
|
|
||||||
passkeyauth/
|
|
||||||
├── passkeyauth/
|
|
||||||
│ ├── __init__.py
|
|
||||||
│ └── main.py # FastAPI server with WebSocket support
|
|
||||||
├── static/
|
|
||||||
│ └── index.html # Frontend interface
|
|
||||||
├── pyproject.toml # Modern Python packaging configuration
|
|
||||||
└── README.md
|
|
||||||
```
|
|
||||||
|
|
||||||
## Technical Details
|
|
||||||
|
|
||||||
### WebAuthn Configuration
|
|
||||||
|
|
||||||
- **Relying Party ID**: `localhost` (for development)
|
|
||||||
- **Resident Keys**: Required (enables discoverable credentials)
|
|
||||||
- **User Verification**: Preferred
|
|
||||||
- **Supported Algorithms**: ECDSA-SHA256, RSASSA-PKCS1-v1_5-SHA256
|
|
||||||
|
|
||||||
### WebSocket Message Flow
|
|
||||||
|
|
||||||
1. Client connects to `/ws/{client_id}`
|
|
||||||
2. Client sends `registration_challenge` message
|
|
||||||
3. Server responds with `registration_challenge_response`
|
|
||||||
4. Client completes WebAuthn ceremony and sends `registration_response`
|
|
||||||
5. Server verifies and responds with `registration_success` or `error`
|
|
||||||
|
|
||||||
### Security Notes
|
|
||||||
|
|
||||||
- This is a minimal demo - challenges are stored locally per WebSocket connection
|
|
||||||
- For production use, implement proper user storage and session management
|
|
||||||
- Consider using Redis or similar for challenge storage in production with multiple server instances
|
|
||||||
- Ensure HTTPS in production environments
|
|
||||||
|
|
||||||
## License
|
|
||||||
|
|
||||||
MIT License - feel free to use this as a starting point for your own WebAuthn implementations!
|
|
||||||
|
|||||||
@@ -0,0 +1,10 @@
|
|||||||
|
localhost {
|
||||||
|
# Forwards API by caddy, bypassing the Vite dev proxy
|
||||||
|
# Avoids bug https://github.com/oven-sh/bun/issues/9882
|
||||||
|
handle /api/* {
|
||||||
|
reverse_proxy :4402 # directly to backend
|
||||||
|
}
|
||||||
|
handle {
|
||||||
|
reverse_proxy :4403 # vite dev server
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
# Dependencies
|
||||||
|
node_modules/
|
||||||
|
|
||||||
|
# Test artifacts
|
||||||
|
test-data/
|
||||||
|
test-results/
|
||||||
|
playwright-report/
|
||||||
|
|
||||||
|
# Playwright
|
||||||
|
.playwright/
|
||||||
|
|
||||||
|
# Bun
|
||||||
|
bun.lockb
|
||||||
+167
@@ -0,0 +1,167 @@
|
|||||||
|
# Paskia E2E Tests
|
||||||
|
|
||||||
|
End-to-end tests for Paskia using [Playwright](https://playwright.dev/) with Chrome's **Virtual Authenticator**.
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
These tests exercise the complete WebAuthn/passkey authentication flow without requiring physical hardware. Chrome's DevTools Protocol provides a virtual authenticator that can:
|
||||||
|
|
||||||
|
- Generate passkey credentials
|
||||||
|
- Sign authentication challenges
|
||||||
|
- Store resident keys (discoverable credentials)
|
||||||
|
- Simulate user verification (biometrics/PIN)
|
||||||
|
|
||||||
|
## Prerequisites
|
||||||
|
|
||||||
|
- Node.js 18+
|
||||||
|
- Python with `uv` (for running the backend server)
|
||||||
|
|
||||||
|
## Setup
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd e2e
|
||||||
|
npm install
|
||||||
|
npm run install:browsers
|
||||||
|
```
|
||||||
|
|
||||||
|
## Running Tests
|
||||||
|
|
||||||
|
### Basic Test Run
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm test
|
||||||
|
```
|
||||||
|
|
||||||
|
This will:
|
||||||
|
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
|
||||||
|
npm run test:ui
|
||||||
|
```
|
||||||
|
|
||||||
|
Opens Playwright's UI mode for interactive test debugging.
|
||||||
|
|
||||||
|
### Headed Mode
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run test:headed
|
||||||
|
```
|
||||||
|
|
||||||
|
Runs tests with a visible browser window.
|
||||||
|
|
||||||
|
### Debug Mode
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run test:debug
|
||||||
|
```
|
||||||
|
|
||||||
|
Runs tests with Playwright Inspector for step-by-step debugging.
|
||||||
|
|
||||||
|
## Test Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
e2e/
|
||||||
|
├── playwright.config.ts # Playwright configuration
|
||||||
|
├── package.json
|
||||||
|
├── tsconfig.json
|
||||||
|
├── test-data/ # Test database (created at runtime)
|
||||||
|
│ └── test.sqlite
|
||||||
|
└── tests/
|
||||||
|
├── global-setup.ts # Creates fresh DB, captures reset token
|
||||||
|
├── global-teardown.ts # Cleanup
|
||||||
|
├── passkey.spec.ts # Main E2E tests
|
||||||
|
└── fixtures/
|
||||||
|
├── virtual-authenticator.ts # Virtual authenticator setup
|
||||||
|
└── passkey-helpers.ts # WebSocket helpers
|
||||||
|
```
|
||||||
|
|
||||||
|
## What's Tested
|
||||||
|
|
||||||
|
### Registration Flow
|
||||||
|
- Bootstrap admin user registration via reset token
|
||||||
|
- WebSocket challenge-response with virtual authenticator
|
||||||
|
- Session token creation and validation
|
||||||
|
|
||||||
|
### Authentication Flow
|
||||||
|
- Passkey authentication via WebSocket
|
||||||
|
- Credential verification
|
||||||
|
- Session management
|
||||||
|
|
||||||
|
### Session Management
|
||||||
|
- Token validation (`/auth/api/validate`)
|
||||||
|
- User info retrieval (`/auth/api/user-info`)
|
||||||
|
- Logout (`/auth/api/logout`)
|
||||||
|
- Invalid/missing token rejection
|
||||||
|
|
||||||
|
## How Virtual Authenticator Works
|
||||||
|
|
||||||
|
The tests use Chrome DevTools Protocol (CDP) to create a virtual authenticator:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const cdpSession = await page.context().newCDPSession(page)
|
||||||
|
await cdpSession.send('WebAuthn.enable')
|
||||||
|
await cdpSession.send('WebAuthn.addVirtualAuthenticator', {
|
||||||
|
options: {
|
||||||
|
protocol: 'ctap2',
|
||||||
|
transport: 'internal',
|
||||||
|
hasResidentKey: true,
|
||||||
|
hasUserVerification: true,
|
||||||
|
isUserVerified: true,
|
||||||
|
automaticPresenceSimulation: true,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
This creates an in-browser authenticator that:
|
||||||
|
- Automatically responds to WebAuthn prompts
|
||||||
|
- Stores credentials persistently during the test session
|
||||||
|
- Simulates user verification without actual biometric input
|
||||||
|
|
||||||
|
## Environment Variables
|
||||||
|
|
||||||
|
| Variable | Description | Default |
|
||||||
|
|----------|-------------|---------|
|
||||||
|
| `BASE_URL` | Server URL | `http://localhost:4404` |
|
||||||
|
| `CI` | CI environment flag | - |
|
||||||
|
| `CLEANUP_TEST_DB` | Remove test DB after run | `false` |
|
||||||
|
|
||||||
|
## Limitations
|
||||||
|
|
||||||
|
1. **Chromium only**: Virtual authenticator is a Chrome DevTools feature
|
||||||
|
2. **No cross-origin**: Tests run on localhost; production-like origins need additional setup
|
||||||
|
3. **Single user per run**: Bootstrap creates one admin user; additional users need admin API
|
||||||
|
|
||||||
|
## Debugging Tips
|
||||||
|
|
||||||
|
1. **Check test database**: `e2e/test-data/test.sqlite` persists after tests
|
||||||
|
2. **View server output**: Global setup echoes server bootstrap to console
|
||||||
|
3. **Use trace viewer**: `npx playwright show-trace` on failure traces
|
||||||
|
|
||||||
|
## CI Integration
|
||||||
|
|
||||||
|
The tests are designed for CI environments:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
- name: Run E2E Tests
|
||||||
|
run: |
|
||||||
|
cd e2e
|
||||||
|
npm ci
|
||||||
|
npm run install:browsers
|
||||||
|
npm test
|
||||||
|
env:
|
||||||
|
CI: true
|
||||||
|
```
|
||||||
Generated
+1127
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,22 @@
|
|||||||
|
{
|
||||||
|
"name": "paskia-e2e",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"private": true,
|
||||||
|
"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",
|
||||||
|
"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',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
})
|
||||||
@@ -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:4401',
|
||||||
|
|
||||||
|
// 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',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
})
|
||||||
@@ -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)
|
||||||
|
},
|
||||||
|
})
|
||||||
+453
@@ -0,0 +1,453 @@
|
|||||||
|
import { type Page } from '@playwright/test'
|
||||||
|
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.
|
||||||
|
* These functions mirror the frontend's passkey.js but work in a Playwright context.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface RegistrationResult {
|
||||||
|
user_uuid: string
|
||||||
|
credential_uuid: string
|
||||||
|
session_token: string
|
||||||
|
message: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AuthenticationResult {
|
||||||
|
user_uuid: string
|
||||||
|
session_token: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the bootstrap reset token from the test state file.
|
||||||
|
*/
|
||||||
|
export function getBootstrapResetToken(): string | undefined {
|
||||||
|
if (existsSync(stateFile)) {
|
||||||
|
try {
|
||||||
|
const state = JSON.parse(readFileSync(stateFile, 'utf-8'))
|
||||||
|
return state.resetToken
|
||||||
|
} catch {
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the session cookie name from the test state file.
|
||||||
|
*/
|
||||||
|
export function getSessionCookieName(): string {
|
||||||
|
if (existsSync(stateFile)) {
|
||||||
|
try {
|
||||||
|
const state = JSON.parse(readFileSync(stateFile, 'utf-8'))
|
||||||
|
return state.sessionCookie || '__Host-auth'
|
||||||
|
} catch {
|
||||||
|
return '__Host-auth'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return '__Host-auth'
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Save a session token to the test state file for sharing across test groups.
|
||||||
|
*/
|
||||||
|
export function saveSessionToken(sessionToken: string): void {
|
||||||
|
if (existsSync(stateFile)) {
|
||||||
|
try {
|
||||||
|
const state = JSON.parse(readFileSync(stateFile, 'utf-8'))
|
||||||
|
state.savedSessionToken = sessionToken
|
||||||
|
writeFileSync(stateFile, JSON.stringify(state, null, 2))
|
||||||
|
} catch {
|
||||||
|
// Ignore errors
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clear the saved session token from the test state file.
|
||||||
|
* Call this after logout to prevent accidental reuse of invalidated sessions.
|
||||||
|
*/
|
||||||
|
export function clearSavedSessionToken(): void {
|
||||||
|
if (existsSync(stateFile)) {
|
||||||
|
try {
|
||||||
|
const state = JSON.parse(readFileSync(stateFile, 'utf-8'))
|
||||||
|
delete state.savedSessionToken
|
||||||
|
writeFileSync(stateFile, JSON.stringify(state, null, 2))
|
||||||
|
} catch {
|
||||||
|
// Ignore errors
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get a saved session token from the test state file.
|
||||||
|
*/
|
||||||
|
export function getSavedSessionToken(): string | undefined {
|
||||||
|
if (existsSync(stateFile)) {
|
||||||
|
try {
|
||||||
|
const state = JSON.parse(readFileSync(stateFile, 'utf-8'))
|
||||||
|
return state.savedSessionToken
|
||||||
|
} catch {
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Save device tokens to the test state file for use by other tests.
|
||||||
|
* These tokens allow tests to register their own passkeys.
|
||||||
|
*/
|
||||||
|
export function saveDeviceTokens(tokens: string[]): void {
|
||||||
|
if (existsSync(stateFile)) {
|
||||||
|
try {
|
||||||
|
const state = JSON.parse(readFileSync(stateFile, 'utf-8'))
|
||||||
|
state.deviceTokens = tokens
|
||||||
|
writeFileSync(stateFile, JSON.stringify(state, null, 2))
|
||||||
|
} catch {
|
||||||
|
// Ignore errors
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get and consume a device token from the pool.
|
||||||
|
* Returns undefined if no tokens are available.
|
||||||
|
*/
|
||||||
|
export function popDeviceToken(): string | undefined {
|
||||||
|
if (existsSync(stateFile)) {
|
||||||
|
try {
|
||||||
|
const state = JSON.parse(readFileSync(stateFile, 'utf-8'))
|
||||||
|
if (state.deviceTokens && state.deviceTokens.length > 0) {
|
||||||
|
const token = state.deviceTokens.pop()
|
||||||
|
writeFileSync(stateFile, JSON.stringify(state, null, 2))
|
||||||
|
return token
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the count of remaining device tokens.
|
||||||
|
*/
|
||||||
|
export function getDeviceTokenCount(): number {
|
||||||
|
if (existsSync(stateFile)) {
|
||||||
|
try {
|
||||||
|
const state = JSON.parse(readFileSync(stateFile, 'utf-8'))
|
||||||
|
return state.deviceTokens?.length || 0
|
||||||
|
} catch {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Perform passkey registration via WebSocket.
|
||||||
|
* This runs in the browser context using the virtual authenticator.
|
||||||
|
*/
|
||||||
|
export async function registerPasskey(
|
||||||
|
page: Page,
|
||||||
|
baseUrl: string,
|
||||||
|
options: { resetToken?: string; displayName?: string } = {}
|
||||||
|
): Promise<RegistrationResult> {
|
||||||
|
return await page.evaluate(async ({ baseUrl, resetToken, displayName }) => {
|
||||||
|
// Build WebSocket URL with query parameters
|
||||||
|
let wsUrl = `${baseUrl.replace('http', 'ws')}/auth/ws/register`
|
||||||
|
const params: string[] = []
|
||||||
|
if (resetToken) params.push(`reset=${encodeURIComponent(resetToken)}`)
|
||||||
|
if (displayName) params.push(`name=${encodeURIComponent(displayName)}`)
|
||||||
|
if (params.length) wsUrl += `?${params.join('&')}`
|
||||||
|
|
||||||
|
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 (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(opts.challenge.replace(/-/g, '+').replace(/_/g, '/')), c => c.charCodeAt(0))
|
||||||
|
|
||||||
|
// Build the credential creation options
|
||||||
|
const publicKeyCredentialCreationOptions: CredentialCreationOptions = {
|
||||||
|
publicKey: {
|
||||||
|
challenge: challenge,
|
||||||
|
rp: {
|
||||||
|
name: opts.rp.name,
|
||||||
|
id: opts.rp.id,
|
||||||
|
},
|
||||||
|
user: {
|
||||||
|
id: Uint8Array.from(atob(opts.user.id.replace(/-/g, '+').replace(/_/g, '/')), c => c.charCodeAt(0)),
|
||||||
|
name: opts.user.name,
|
||||||
|
displayName: opts.user.displayName,
|
||||||
|
},
|
||||||
|
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,
|
||||||
|
rawId: btoa(String.fromCharCode(...new Uint8Array(credential.rawId))).replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, ''),
|
||||||
|
response: {
|
||||||
|
clientDataJSON: btoa(String.fromCharCode(...new Uint8Array(response.clientDataJSON))).replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, ''),
|
||||||
|
attestationObject: btoa(String.fromCharCode(...new Uint8Array(response.attestationObject))).replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, ''),
|
||||||
|
transports: response.getTransports?.() || [],
|
||||||
|
},
|
||||||
|
type: credential.type,
|
||||||
|
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}`))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}, { baseUrl, resetToken: options.resetToken, displayName: options.displayName })
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Perform passkey authentication via WebSocket.
|
||||||
|
* This runs in the browser context using the virtual authenticator.
|
||||||
|
*/
|
||||||
|
export async function authenticatePasskey(
|
||||||
|
page: Page,
|
||||||
|
baseUrl: string
|
||||||
|
): Promise<AuthenticationResult> {
|
||||||
|
return await page.evaluate(async ({ baseUrl }) => {
|
||||||
|
const wsUrl = `${baseUrl.replace('http', 'ws')}/auth/ws/authenticate`
|
||||||
|
|
||||||
|
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 (wrapped in optionsJSON)
|
||||||
|
try {
|
||||||
|
// Extract options from the optionsJSON wrapper
|
||||||
|
const opts = data.optionsJSON
|
||||||
|
|
||||||
|
// Convert base64url challenge to ArrayBuffer
|
||||||
|
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: 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,
|
||||||
|
rawId: btoa(String.fromCharCode(...new Uint8Array(credential.rawId))).replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, ''),
|
||||||
|
response: {
|
||||||
|
clientDataJSON: btoa(String.fromCharCode(...new Uint8Array(response.clientDataJSON))).replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, ''),
|
||||||
|
authenticatorData: btoa(String.fromCharCode(...new Uint8Array(response.authenticatorData))).replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, ''),
|
||||||
|
signature: btoa(String.fromCharCode(...new Uint8Array(response.signature))).replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, ''),
|
||||||
|
userHandle: response.userHandle ? btoa(String.fromCharCode(...new Uint8Array(response.userHandle))).replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, '') : null,
|
||||||
|
},
|
||||||
|
type: credential.type,
|
||||||
|
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}`))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}, { baseUrl })
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate a session token via the API.
|
||||||
|
*/
|
||||||
|
export async function validateSession(
|
||||||
|
page: Page,
|
||||||
|
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': `${cookieName}=${sessionToken}`,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
return await response.json()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get user info via the API.
|
||||||
|
*/
|
||||||
|
export async function getUserInfo(
|
||||||
|
page: Page,
|
||||||
|
baseUrl: string,
|
||||||
|
sessionToken: string
|
||||||
|
): Promise<any> {
|
||||||
|
const cookieName = getSessionCookieName()
|
||||||
|
const response = await page.request.post(`${baseUrl}/auth/api/user-info`, {
|
||||||
|
headers: {
|
||||||
|
'Cookie': `${cookieName}=${sessionToken}`,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
return await response.json()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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': `${cookieName}=${sessionToken}`,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
// Clear saved session token if it matches the one being logged out
|
||||||
|
const savedToken = getSavedSessionToken()
|
||||||
|
if (savedToken === sessionToken) {
|
||||||
|
clearSavedSessionToken()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a device link for adding a new credential to an existing user.
|
||||||
|
*/
|
||||||
|
export async function createDeviceLink(
|
||||||
|
page: Page,
|
||||||
|
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': `${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() || ''
|
||||||
|
return { url: data.url, token }
|
||||||
|
}
|
||||||
+144
@@ -0,0 +1,144 @@
|
|||||||
|
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.
|
||||||
|
* Uses Chrome DevTools Protocol to create a software authenticator.
|
||||||
|
*/
|
||||||
|
export interface VirtualAuthenticatorOptions {
|
||||||
|
protocol?: 'ctap1/u2f' | 'ctap2'
|
||||||
|
transport?: 'usb' | 'nfc' | 'ble' | 'internal'
|
||||||
|
hasResidentKey?: boolean
|
||||||
|
hasUserVerification?: boolean
|
||||||
|
isUserVerified?: boolean
|
||||||
|
automaticPresenceSimulation?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface VirtualAuthenticator {
|
||||||
|
authenticatorId: string
|
||||||
|
cdpSession: CDPSession
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a virtual authenticator using Chrome DevTools Protocol.
|
||||||
|
* This allows fully automated passkey registration and authentication.
|
||||||
|
*/
|
||||||
|
export async function createVirtualAuthenticator(
|
||||||
|
page: Page,
|
||||||
|
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
|
||||||
|
})
|
||||||
|
|
||||||
|
// Create the virtual authenticator with resident key support
|
||||||
|
const { authenticatorId } = await cdpSession.send('WebAuthn.addVirtualAuthenticator', {
|
||||||
|
options: {
|
||||||
|
protocol: options.protocol ?? 'ctap2',
|
||||||
|
transport: options.transport ?? 'internal',
|
||||||
|
hasResidentKey: options.hasResidentKey ?? true,
|
||||||
|
hasUserVerification: options.hasUserVerification ?? true,
|
||||||
|
isUserVerified: options.isUserVerified ?? true,
|
||||||
|
automaticPresenceSimulation: options.automaticPresenceSimulation ?? true,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
return { authenticatorId, cdpSession }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove a virtual authenticator.
|
||||||
|
*/
|
||||||
|
export async function removeVirtualAuthenticator(
|
||||||
|
authenticator: VirtualAuthenticator
|
||||||
|
): Promise<void> {
|
||||||
|
await authenticator.cdpSession.send('WebAuthn.removeVirtualAuthenticator', {
|
||||||
|
authenticatorId: authenticator.authenticatorId,
|
||||||
|
})
|
||||||
|
await authenticator.cdpSession.send('WebAuthn.disable')
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all credentials stored in a virtual authenticator.
|
||||||
|
*/
|
||||||
|
export async function getCredentials(
|
||||||
|
authenticator: VirtualAuthenticator
|
||||||
|
): Promise<any[]> {
|
||||||
|
const result = await authenticator.cdpSession.send('WebAuthn.getCredentials', {
|
||||||
|
authenticatorId: authenticator.authenticatorId,
|
||||||
|
})
|
||||||
|
return result.credentials
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extended test fixture with virtual authenticator support and optional coverage.
|
||||||
|
*/
|
||||||
|
export const test = base.extend<{
|
||||||
|
virtualAuthenticator: VirtualAuthenticator
|
||||||
|
}>({
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
export { expect }
|
||||||
@@ -0,0 +1,132 @@
|
|||||||
|
import { spawn } from 'child_process'
|
||||||
|
import { join, dirname } from 'path'
|
||||||
|
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 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.
|
||||||
|
*
|
||||||
|
* 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 for state file
|
||||||
|
if (!existsSync(testDataDir)) {
|
||||||
|
mkdirSync(testDataDir, { recursive: true })
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(' Starting server with in-memory database...')
|
||||||
|
if (COLLECT_COVERAGE) {
|
||||||
|
console.log(' 📊 Coverage collection enabled for Python backend')
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
// 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: 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)
|
||||||
|
reject(new Error(`Server exited with code ${code}`))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
try {
|
||||||
|
state.resetToken = await resetTokenPromise
|
||||||
|
console.log(`\n ✅ Captured reset token: ${state.resetToken}\n`)
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to capture reset token:', err)
|
||||||
|
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')
|
||||||
|
}
|
||||||
@@ -0,0 +1,148 @@
|
|||||||
|
import { join, dirname } from 'path'
|
||||||
|
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 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') {
|
||||||
|
console.warn(` Warning: Could not kill server: ${err.message}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} 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')
|
||||||
|
if (existsSync(dbPath)) {
|
||||||
|
console.log(' Removing test database...')
|
||||||
|
rmSync(dbPath)
|
||||||
|
}
|
||||||
|
// Remove wal/shm files too
|
||||||
|
for (const ext of ['-wal', '-shm']) {
|
||||||
|
const file = dbPath + ext
|
||||||
|
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')
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ESNext",
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"strict": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"forceConsistentCasingInFileNames": true,
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"types": ["bun-types"]
|
||||||
|
},
|
||||||
|
"include": ["tests/**/*.ts", "playwright.config.ts"],
|
||||||
|
"exclude": ["node_modules"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,166 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Paskia - Dev Mode</title>
|
||||||
|
<style>
|
||||||
|
:root {
|
||||||
|
color-scheme: light dark; /* Automatic themes by browser */
|
||||||
|
}
|
||||||
|
/* Login/reauth/forbidden dialog will appear in this iframe */
|
||||||
|
#auth-iframe {
|
||||||
|
/* Full viewport overlay */
|
||||||
|
border: none;
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
z-index: 9999;
|
||||||
|
/* Optional transparent background with optional blur backdrop */
|
||||||
|
color-scheme: auto;
|
||||||
|
background: transparent;
|
||||||
|
backdrop-filter: blur(.1rem) brightness(0.7);
|
||||||
|
-webkit-backdrop-filter: blur(.1rem) brightness(0.7);
|
||||||
|
}
|
||||||
|
/* Prevent background scroll when auth-iframe is shown */
|
||||||
|
body:has(#auth-iframe) {
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="container">
|
||||||
|
<header>
|
||||||
|
<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">
|
||||||
|
<div class="section">
|
||||||
|
<h2>Management Site</h2>
|
||||||
|
<button onclick="window.open('/auth/', '_blank')">👤 User Profile</button>
|
||||||
|
<button onclick="window.open('/auth/admin/', '_blank')">⚙️ Admin Panel</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="section">
|
||||||
|
<h2>API Mode (not leaving the page)</h2>
|
||||||
|
<p>For SPAs and fetch() calls - shows auth in an iframe overlay:</p>
|
||||||
|
<button onclick="apiCall('/auth/api/user-info', 'POST')">📋 Get User Info</button>
|
||||||
|
<button onclick="apiCall('/auth/api/forward?max_age=10s')">🔄 Reauth (max_age=10s)</button>
|
||||||
|
<button onclick="apiCall('/auth/api/forward?perm=auth:admin')">🛡️ Admin Only</button>
|
||||||
|
<button onclick="logout()">🚪 Logout</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="section">
|
||||||
|
<h2>Browser Mode (full page)</h2>
|
||||||
|
<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>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<pre id="output">Click a button to test...</pre>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
const output = document.getElementById('output');
|
||||||
|
let pendingCall = null; // Stores the API call to retry after auth
|
||||||
|
|
||||||
|
// The auth iframe posts messages when authentication completes or is cancelled.
|
||||||
|
// Message types: 'auth-success' (proceed), 'auth-back' (user cancelled)
|
||||||
|
// Errors during auth stay in the dialog allowing retry, no message is sent.
|
||||||
|
window.addEventListener('message', (event) => {
|
||||||
|
const { type, message } = event.data || {};
|
||||||
|
|
||||||
|
if (type === 'auth-success') {
|
||||||
|
log('✓ Authentication successful, retrying...');
|
||||||
|
hideAuthIframe();
|
||||||
|
// Retry the original API call that triggered authentication
|
||||||
|
if (pendingCall) {
|
||||||
|
const { url, method } = pendingCall;
|
||||||
|
pendingCall = null;
|
||||||
|
apiCall(url, method);
|
||||||
|
}
|
||||||
|
} else if (type === 'auth-back') {
|
||||||
|
log(message || 'Authentication cancelled');
|
||||||
|
hideAuthIframe();
|
||||||
|
pendingCall = null;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Make an API call, handling 401/403 by showing the auth iframe.
|
||||||
|
// The server returns JSON with auth.iframe URL when authentication is needed.
|
||||||
|
async function apiCall(url, method = 'GET') {
|
||||||
|
log(`${method} ${url}...`);
|
||||||
|
|
||||||
|
const response = await fetch(url, { method, credentials: 'include' });
|
||||||
|
|
||||||
|
// Server returns 401 (login/reauth) or 403 (missing permissions)
|
||||||
|
// with a JSON body containing the iframe URL for authentication
|
||||||
|
if (response.status === 401 || response.status === 403) {
|
||||||
|
const data = await response.json();
|
||||||
|
if (data.auth?.iframe) {
|
||||||
|
const mode = data.auth.mode; // 'login' or 'reauth'
|
||||||
|
log(`${mode === 'reauth' ? 'Re-authentication' : 'Authentication'} required...`);
|
||||||
|
pendingCall = { url, method };
|
||||||
|
showAuthIframe(data.auth.iframe);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
log(`Error: ${response.status} - ${data.detail}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Forward endpoint returns 204 on success (Caddy then adds Remote-* headers)
|
||||||
|
if (response.status === 204) {
|
||||||
|
log('✓ Success (204 No Content)\nHeaders:\n' +
|
||||||
|
[...response.headers].filter(([k]) => k.startsWith('remote-'))
|
||||||
|
.map(([k, v]) => ` ${k}: ${v}`).join('\n'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
log(`Error: ${response.status} ${response.statusText}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
log('✓ Response:\n' + JSON.stringify(data, null, 2));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function logout() {
|
||||||
|
await fetch('/auth/api/logout', { method: 'POST', credentials: 'include' });
|
||||||
|
log('Logged out');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create fullscreen iframe for authentication.
|
||||||
|
// The 'allow' attribute enables WebAuthn (passkey) API inside the iframe.
|
||||||
|
function showAuthIframe(url) {
|
||||||
|
hideAuthIframe();
|
||||||
|
const iframe = document.createElement('iframe');
|
||||||
|
iframe.id = 'auth-iframe';
|
||||||
|
iframe.src = url;
|
||||||
|
document.body.appendChild(iframe);
|
||||||
|
log("Authentication dialog open...")
|
||||||
|
}
|
||||||
|
|
||||||
|
function hideAuthIframe() {
|
||||||
|
document.getElementById('auth-iframe')?.remove();
|
||||||
|
}
|
||||||
|
|
||||||
|
function log(msg) {
|
||||||
|
output.textContent = msg;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Browser mode: open the forward endpoint directly in a new window.
|
||||||
|
// When Accept: text/html, the server redirects to the login page if needed,
|
||||||
|
// then back to the original URL after authentication.
|
||||||
|
function browserNav(url) {
|
||||||
|
log('Opening in new window...\nIf not authenticated, you\'ll see the login page.\nAfter auth, you\'ll see a 204 response (blank page = success).');
|
||||||
|
window.open(url, '_blank');
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,203 @@
|
|||||||
|
<template>
|
||||||
|
<div class="app-shell">
|
||||||
|
<StatusMessage />
|
||||||
|
<main class="app-main">
|
||||||
|
<HostProfileView v-if="authenticated && isHostMode" :initializing="loading" />
|
||||||
|
<ProfileView v-else-if="authenticated" />
|
||||||
|
<LoadingView v-else-if="loading" :message="loadingMessage" />
|
||||||
|
<AuthRequiredMessage v-else-if="showBackMessage" @reload="reloadPage" />
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { computed, onMounted, onUnmounted, ref } from 'vue'
|
||||||
|
import { useAuthStore } from '@/stores/auth'
|
||||||
|
import { apiJson, getAuthIframeUrl } from '@/utils/api'
|
||||||
|
import StatusMessage from '@/components/StatusMessage.vue'
|
||||||
|
import ProfileView from '@/components/ProfileView.vue'
|
||||||
|
import HostProfileView from '@/components/HostProfileView.vue'
|
||||||
|
import LoadingView from '@/components/LoadingView.vue'
|
||||||
|
import AuthRequiredMessage from '@/components/AccessDenied.vue'
|
||||||
|
|
||||||
|
const store = useAuthStore()
|
||||||
|
const loading = ref(true)
|
||||||
|
const loadingMessage = ref('Loading...')
|
||||||
|
const authenticated = ref(false)
|
||||||
|
const showBackMessage = ref(false)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Normalize a host string for comparison (lowercase, strip default ports).
|
||||||
|
*/
|
||||||
|
function normalizeHost(raw) {
|
||||||
|
if (!raw) return null
|
||||||
|
const trimmed = raw.trim().toLowerCase()
|
||||||
|
if (!trimmed) return null
|
||||||
|
// Remove default ports
|
||||||
|
return trimmed.replace(/:80$/, '').replace(/:443$/, '')
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Host mode is active when an auth_host is configured AND the current host differs from it.
|
||||||
|
* In host mode, we show a limited profile view with logout and link to full profile.
|
||||||
|
*/
|
||||||
|
const isHostMode = computed(() => {
|
||||||
|
const authHost = store.settings?.auth_host
|
||||||
|
if (!authHost) return false
|
||||||
|
const currentHost = normalizeHost(window.location.host)
|
||||||
|
const configuredHost = normalizeHost(authHost)
|
||||||
|
return currentHost !== configuredHost
|
||||||
|
})
|
||||||
|
let validationTimer = null
|
||||||
|
let authIframe = null
|
||||||
|
|
||||||
|
async function loadUserInfo() {
|
||||||
|
try {
|
||||||
|
store.userInfo = await apiJson('/auth/api/user-info', { method: 'POST' })
|
||||||
|
authenticated.value = true
|
||||||
|
loading.value = false
|
||||||
|
startSessionValidation()
|
||||||
|
return true
|
||||||
|
} catch (e) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function showAuthIframe() {
|
||||||
|
// Remove existing iframe if any
|
||||||
|
hideAuthIframe()
|
||||||
|
|
||||||
|
// Create new iframe for authentication using src URL
|
||||||
|
const url = await getAuthIframeUrl('login')
|
||||||
|
authIframe = document.createElement('iframe')
|
||||||
|
authIframe.id = 'auth-iframe'
|
||||||
|
authIframe.title = 'Authentication'
|
||||||
|
authIframe.allow = 'publickey-credentials-get; publickey-credentials-create'
|
||||||
|
authIframe.src = url
|
||||||
|
document.body.appendChild(authIframe)
|
||||||
|
loadingMessage.value = 'Authentication required...'
|
||||||
|
}
|
||||||
|
|
||||||
|
function hideAuthIframe() {
|
||||||
|
if (authIframe) {
|
||||||
|
authIframe.remove()
|
||||||
|
authIframe = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function reloadPage() {
|
||||||
|
window.location.reload()
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleAuthMessage(event) {
|
||||||
|
const data = event.data
|
||||||
|
if (!data?.type) return
|
||||||
|
|
||||||
|
switch (data.type) {
|
||||||
|
case 'auth-success':
|
||||||
|
// Authentication successful - reload user info
|
||||||
|
hideAuthIframe()
|
||||||
|
loading.value = true
|
||||||
|
loadingMessage.value = 'Loading user profile...'
|
||||||
|
loadUserInfo()
|
||||||
|
break
|
||||||
|
|
||||||
|
case 'auth-error':
|
||||||
|
// Authentication failed - keep iframe open so user can retry
|
||||||
|
if (data.cancelled) {
|
||||||
|
console.log('Authentication cancelled by user')
|
||||||
|
} else {
|
||||||
|
store.showMessage(data.message || 'Authentication failed', 'error', 5000)
|
||||||
|
}
|
||||||
|
break
|
||||||
|
|
||||||
|
case 'auth-cancelled':
|
||||||
|
// Legacy support - treat as auth-error with cancelled flag
|
||||||
|
console.log('Authentication cancelled')
|
||||||
|
break
|
||||||
|
|
||||||
|
case 'auth-back':
|
||||||
|
// User clicked Back - show message with reload option
|
||||||
|
hideAuthIframe()
|
||||||
|
loading.value = false
|
||||||
|
showBackMessage.value = true
|
||||||
|
store.showMessage('Authentication cancelled', 'info', 3000)
|
||||||
|
break
|
||||||
|
|
||||||
|
case 'auth-close-request':
|
||||||
|
// Legacy support - treat as back
|
||||||
|
hideAuthIframe()
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function validateSession() {
|
||||||
|
try {
|
||||||
|
await apiJson('/auth/api/validate', {
|
||||||
|
method: 'POST',
|
||||||
|
credentials: 'include'
|
||||||
|
})
|
||||||
|
// If successful, session was renewed automatically
|
||||||
|
} catch (error) {
|
||||||
|
if (error.status === 401) {
|
||||||
|
// Session expired - need to re-authenticate
|
||||||
|
console.log('Session expired, requiring re-authentication')
|
||||||
|
authenticated.value = false
|
||||||
|
loading.value = true
|
||||||
|
stopSessionValidation()
|
||||||
|
showAuthIframe()
|
||||||
|
} else {
|
||||||
|
console.error('Session validation error:', error)
|
||||||
|
// Don't treat network errors as session expiry
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function startSessionValidation() {
|
||||||
|
// Validate session every 2 minutes
|
||||||
|
stopSessionValidation()
|
||||||
|
validationTimer = setInterval(validateSession, 2 * 60 * 1000)
|
||||||
|
}
|
||||||
|
|
||||||
|
function stopSessionValidation() {
|
||||||
|
if (validationTimer) {
|
||||||
|
clearInterval(validationTimer)
|
||||||
|
validationTimer = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
// Listen for postMessage from auth iframe
|
||||||
|
window.addEventListener('message', handleAuthMessage)
|
||||||
|
|
||||||
|
// Load settings
|
||||||
|
await store.loadSettings()
|
||||||
|
|
||||||
|
// Set appropriate page title based on mode
|
||||||
|
const rpName = store.settings?.rp_name
|
||||||
|
if (rpName) {
|
||||||
|
// In host mode, show "account summary" style title
|
||||||
|
// Settings are loaded but isHostMode depends on them, so check here
|
||||||
|
const authHost = store.settings?.auth_host
|
||||||
|
const inHostMode = authHost && normalizeHost(window.location.host) !== normalizeHost(authHost)
|
||||||
|
document.title = inHostMode ? `${rpName} · Account summary` : rpName
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try to load user info
|
||||||
|
const success = await loadUserInfo()
|
||||||
|
|
||||||
|
if (!success) {
|
||||||
|
// Need authentication - show login iframe
|
||||||
|
showAuthIframe()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
window.removeEventListener('message', handleAuthMessage)
|
||||||
|
stopSessionValidation()
|
||||||
|
hideAuthIframe()
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
</style>
|
||||||
@@ -1,18 +1,24 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { ref, onMounted, onBeforeUnmount, computed, watch } from 'vue'
|
import { ref, onMounted, onUnmounted, computed, watch } from 'vue'
|
||||||
import Breadcrumbs from '@/components/Breadcrumbs.vue'
|
import Breadcrumbs from '@/components/Breadcrumbs.vue'
|
||||||
import CredentialList from '@/components/CredentialList.vue'
|
import CredentialList from '@/components/CredentialList.vue'
|
||||||
import UserBasicInfo from '@/components/UserBasicInfo.vue'
|
import UserBasicInfo from '@/components/UserBasicInfo.vue'
|
||||||
import RegistrationLinkModal from '@/components/RegistrationLinkModal.vue'
|
|
||||||
import StatusMessage from '@/components/StatusMessage.vue'
|
import StatusMessage from '@/components/StatusMessage.vue'
|
||||||
import AdminOverview from './AdminOverview.vue'
|
import LoadingView from '@/components/LoadingView.vue'
|
||||||
import AdminOrgDetail from './AdminOrgDetail.vue'
|
import AuthRequiredMessage from '@/components/AccessDenied.vue'
|
||||||
import AdminUserDetail from './AdminUserDetail.vue'
|
import AdminOverview from '@/admin/AdminOverview.vue'
|
||||||
import AdminDialogs from './AdminDialogs.vue'
|
import AdminOrgDetail from '@/admin/AdminOrgDetail.vue'
|
||||||
|
import AdminUserDetail from '@/admin/AdminUserDetail.vue'
|
||||||
|
import AdminDialogs from '@/admin/AdminDialogs.vue'
|
||||||
import { useAuthStore } from '@/stores/auth'
|
import { useAuthStore } from '@/stores/auth'
|
||||||
|
import { getSettings, adminUiPath, makeUiHref } from '@/utils/settings'
|
||||||
|
import { apiJson } from '@/utils/api'
|
||||||
|
|
||||||
const info = ref(null)
|
const info = ref(null)
|
||||||
const loading = ref(true)
|
const loading = ref(true)
|
||||||
|
const loadingMessage = ref('Loading...')
|
||||||
|
const authenticated = ref(false)
|
||||||
|
const showBackMessage = ref(false)
|
||||||
const error = ref(null)
|
const error = ref(null)
|
||||||
const orgs = ref([])
|
const orgs = ref([])
|
||||||
const permissions = ref([])
|
const permissions = ref([])
|
||||||
@@ -42,11 +48,17 @@ function handleGlobalClick(e) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(async () => {
|
||||||
document.addEventListener('click', handleGlobalClick)
|
document.addEventListener('click', handleGlobalClick)
|
||||||
|
window.addEventListener('hashchange', parseHash)
|
||||||
|
const settings = await getSettings()
|
||||||
|
if (settings?.rp_name) document.title = settings.rp_name + ' Admin'
|
||||||
|
await load()
|
||||||
})
|
})
|
||||||
onBeforeUnmount(() => {
|
|
||||||
|
onUnmounted(() => {
|
||||||
document.removeEventListener('click', handleGlobalClick)
|
document.removeEventListener('click', handleGlobalClick)
|
||||||
|
window.removeEventListener('hashchange', parseHash)
|
||||||
})
|
})
|
||||||
|
|
||||||
// Build a summary: for each permission id -> { orgs: Set(org_display_name), userCount }
|
// Build a summary: for each permission id -> { orgs: Set(org_display_name), userCount }
|
||||||
@@ -98,9 +110,7 @@ async function attachPermissionToOrg(pid, orgUuid) {
|
|||||||
if (!orgUuid) return
|
if (!orgUuid) return
|
||||||
try {
|
try {
|
||||||
const params = new URLSearchParams({ permission_id: pid })
|
const params = new URLSearchParams({ permission_id: pid })
|
||||||
const res = await fetch(`/auth/admin/orgs/${orgUuid}/permission?${params.toString()}`, { method: 'POST' })
|
await apiJson(`/auth/api/admin/orgs/${orgUuid}/permission?${params.toString()}`, { method: 'POST' })
|
||||||
const data = await res.json()
|
|
||||||
if (data.detail) throw new Error(data.detail)
|
|
||||||
await loadOrgs()
|
await loadOrgs()
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
authStore.showMessage(e.message || 'Failed to add permission to org')
|
authStore.showMessage(e.message || 'Failed to add permission to org')
|
||||||
@@ -111,9 +121,7 @@ async function detachPermissionFromOrg(pid, orgUuid) {
|
|||||||
openDialog('confirm', { message: 'Remove permission from this org?', action: async () => {
|
openDialog('confirm', { message: 'Remove permission from this org?', action: async () => {
|
||||||
try {
|
try {
|
||||||
const params = new URLSearchParams({ permission_id: pid })
|
const params = new URLSearchParams({ permission_id: pid })
|
||||||
const res = await fetch(`/auth/admin/orgs/${orgUuid}/permission?${params.toString()}`, { method: 'DELETE' })
|
await apiJson(`/auth/api/admin/orgs/${orgUuid}/permission?${params.toString()}`, { method: 'DELETE' })
|
||||||
const data = await res.json()
|
|
||||||
if (data.detail) throw new Error(data.detail)
|
|
||||||
await loadOrgs()
|
await loadOrgs()
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
authStore.showMessage(e.message || 'Failed to remove permission from org')
|
authStore.showMessage(e.message || 'Failed to remove permission from org')
|
||||||
@@ -133,10 +141,7 @@ function parseHash() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function loadOrgs() {
|
async function loadOrgs() {
|
||||||
const res = await fetch('/auth/admin/orgs')
|
const data = await apiJson('/auth/api/admin/orgs')
|
||||||
const data = await res.json()
|
|
||||||
if (data.detail) throw new Error(data.detail)
|
|
||||||
// Restructure to attach users to roles instead of flat user list at org level
|
|
||||||
orgs.value = data.map(o => {
|
orgs.value = data.map(o => {
|
||||||
const roles = o.roles.map(r => ({ ...r, org_uuid: o.uuid, users: [] }))
|
const roles = o.roles.map(r => ({ ...r, org_uuid: o.uuid, users: [] }))
|
||||||
const roleMap = Object.fromEntries(roles.map(r => [r.display_name, r]))
|
const roleMap = Object.fromEntries(roles.map(r => [r.display_name, r]))
|
||||||
@@ -148,25 +153,25 @@ async function loadOrgs() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function loadPermissions() {
|
async function loadPermissions() {
|
||||||
const res = await fetch('/auth/admin/permissions')
|
permissions.value = await apiJson('/auth/api/admin/permissions')
|
||||||
const data = await res.json()
|
}
|
||||||
if (data.detail) throw new Error(data.detail)
|
|
||||||
permissions.value = data
|
async function loadUserInfo() {
|
||||||
|
info.value = await apiJson('/auth/api/user-info', { method: 'POST' })
|
||||||
|
authenticated.value = true
|
||||||
}
|
}
|
||||||
|
|
||||||
async function load() {
|
async function load() {
|
||||||
loading.value = true
|
loading.value = true
|
||||||
|
loadingMessage.value = 'Loading...'
|
||||||
error.value = null
|
error.value = null
|
||||||
try {
|
try {
|
||||||
const res = await fetch('/auth/api/user-info', { method: 'POST' })
|
// Load admin data first - apiJson will handle 401/403 with iframe authentication
|
||||||
const data = await res.json()
|
await Promise.all([loadOrgs(), loadPermissions()])
|
||||||
if (data.detail) throw new Error(data.detail)
|
// If we get here, user has admin access - now fetch user info for display
|
||||||
info.value = data
|
await loadUserInfo()
|
||||||
if (data.authenticated && (data.is_global_admin || data.is_org_admin)) {
|
|
||||||
await Promise.all([loadOrgs(), loadPermissions()])
|
if (!info.value.is_global_admin && info.value.is_org_admin && orgs.value.length === 1) {
|
||||||
}
|
|
||||||
// After loading orgs decide view if not global admin
|
|
||||||
if (!data.is_global_admin && data.is_org_admin && orgs.value.length === 1) {
|
|
||||||
if (!window.location.hash || window.location.hash === '#overview') {
|
if (!window.location.hash || window.location.hash === '#overview') {
|
||||||
currentOrgId.value = orgs.value[0].uuid
|
currentOrgId.value = orgs.value[0].uuid
|
||||||
window.location.hash = `#org/${currentOrgId.value}`
|
window.location.hash = `#org/${currentOrgId.value}`
|
||||||
@@ -176,7 +181,11 @@ async function load() {
|
|||||||
}
|
}
|
||||||
} else parseHash()
|
} else parseHash()
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
error.value = e.message
|
if (e.name === 'AuthCancelledError') {
|
||||||
|
showBackMessage.value = true
|
||||||
|
} else {
|
||||||
|
error.value = e.message
|
||||||
|
}
|
||||||
} finally {
|
} finally {
|
||||||
loading.value = false
|
loading.value = false
|
||||||
}
|
}
|
||||||
@@ -192,8 +201,7 @@ function editUserName(user) { openDialog('user-update-name', { user, name: user.
|
|||||||
function deleteOrg(org) {
|
function deleteOrg(org) {
|
||||||
if (!info.value?.is_global_admin) { authStore.showMessage('Global admin only'); return }
|
if (!info.value?.is_global_admin) { authStore.showMessage('Global admin only'); return }
|
||||||
openDialog('confirm', { message: `Delete organization ${org.display_name}?`, action: async () => {
|
openDialog('confirm', { message: `Delete organization ${org.display_name}?`, action: async () => {
|
||||||
const res = await fetch(`/auth/admin/orgs/${org.uuid}`, { method: 'DELETE' })
|
await apiJson(`/auth/api/admin/orgs/${org.uuid}`, { method: 'DELETE' })
|
||||||
const data = await res.json(); if (data.detail) throw new Error(data.detail)
|
|
||||||
await Promise.all([loadOrgs(), loadPermissions()])
|
await Promise.all([loadOrgs(), loadPermissions()])
|
||||||
} })
|
} })
|
||||||
}
|
}
|
||||||
@@ -202,14 +210,15 @@ function createUserInRole(org, role) { openDialog('user-create', { org, role })
|
|||||||
|
|
||||||
async function moveUserToRole(org, user, targetRoleDisplayName) {
|
async function moveUserToRole(org, user, targetRoleDisplayName) {
|
||||||
if (user.role === targetRoleDisplayName) return
|
if (user.role === targetRoleDisplayName) return
|
||||||
const res = await fetch(`/auth/admin/orgs/${org.uuid}/users/${user.uuid}/role`, {
|
try {
|
||||||
method: 'PUT',
|
await apiJson(`/auth/api/admin/orgs/${org.uuid}/users/${user.uuid}/role`, {
|
||||||
headers: { 'content-type': 'application/json' },
|
method: 'PUT',
|
||||||
body: JSON.stringify({ role: targetRoleDisplayName })
|
body: { role: targetRoleDisplayName }
|
||||||
})
|
})
|
||||||
const data = await res.json()
|
await loadOrgs()
|
||||||
if (data.detail) { authStore.showMessage(data.detail); return }
|
} catch (e) {
|
||||||
await loadOrgs()
|
authStore.showMessage(e.message || 'Failed to update user role')
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function onUserDragStart(e, user, org_uuid) {
|
function onUserDragStart(e, user, org_uuid) {
|
||||||
@@ -244,8 +253,7 @@ function updateRole(role) { openDialog('role-update', { role, name: role.display
|
|||||||
|
|
||||||
function deleteRole(role) {
|
function deleteRole(role) {
|
||||||
openDialog('confirm', { message: `Delete role ${role.display_name}?`, action: async () => {
|
openDialog('confirm', { message: `Delete role ${role.display_name}?`, action: async () => {
|
||||||
const res = await fetch(`/auth/admin/orgs/${role.org_uuid}/roles/${role.uuid}`, { method: 'DELETE' })
|
await apiJson(`/auth/api/admin/orgs/${role.org_uuid}/roles/${role.uuid}`, { method: 'DELETE' })
|
||||||
const data = await res.json(); if (data.detail) throw new Error(data.detail)
|
|
||||||
await loadOrgs()
|
await loadOrgs()
|
||||||
} })
|
} })
|
||||||
}
|
}
|
||||||
@@ -261,13 +269,10 @@ async function toggleRolePermission(role, pid, checked) {
|
|||||||
role.permissions = newPermissions
|
role.permissions = newPermissions
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`/auth/admin/orgs/${role.org_uuid}/roles/${role.uuid}`, {
|
await apiJson(`/auth/api/admin/orgs/${role.org_uuid}/roles/${role.uuid}`, {
|
||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
headers: { 'content-type': 'application/json' },
|
body: { display_name: role.display_name, permissions: newPermissions }
|
||||||
body: JSON.stringify({ display_name: role.display_name, permissions: newPermissions })
|
|
||||||
})
|
})
|
||||||
const data = await res.json()
|
|
||||||
if (data.detail) throw new Error(data.detail)
|
|
||||||
await loadOrgs()
|
await loadOrgs()
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
authStore.showMessage(e.message || 'Failed to update role permission')
|
authStore.showMessage(e.message || 'Failed to update role permission')
|
||||||
@@ -281,20 +286,14 @@ function updatePermission(p) { openDialog('perm-display', { permission: p }) }
|
|||||||
function deletePermission(p) {
|
function deletePermission(p) {
|
||||||
openDialog('confirm', { message: `Delete permission ${p.id}?`, action: async () => {
|
openDialog('confirm', { message: `Delete permission ${p.id}?`, action: async () => {
|
||||||
const params = new URLSearchParams({ permission_id: p.id })
|
const params = new URLSearchParams({ permission_id: p.id })
|
||||||
const res = await fetch(`/auth/admin/permission?${params.toString()}`, { method: 'DELETE' })
|
await apiJson(`/auth/api/admin/permission?${params.toString()}`, { method: 'DELETE' })
|
||||||
const data = await res.json(); if (data.detail) throw new Error(data.detail)
|
|
||||||
await loadPermissions()
|
await loadPermissions()
|
||||||
} })
|
} })
|
||||||
}
|
}
|
||||||
|
|
||||||
onMounted(async () => {
|
function reloadPage() {
|
||||||
window.addEventListener('hashchange', parseHash)
|
window.location.reload()
|
||||||
await authStore.loadSettings()
|
}
|
||||||
if (authStore.settings?.rp_name) {
|
|
||||||
document.title = authStore.settings.rp_name + ' Admin'
|
|
||||||
}
|
|
||||||
load()
|
|
||||||
})
|
|
||||||
|
|
||||||
const selectedOrg = computed(() => orgs.value.find(o => o.uuid === currentOrgId.value) || null)
|
const selectedOrg = computed(() => orgs.value.find(o => o.uuid === currentOrgId.value) || null)
|
||||||
|
|
||||||
@@ -324,14 +323,14 @@ const selectedUser = computed(() => {
|
|||||||
const pageHeading = computed(() => {
|
const pageHeading = computed(() => {
|
||||||
if (selectedUser.value) return 'Admin: User'
|
if (selectedUser.value) return 'Admin: User'
|
||||||
if (selectedOrg.value) return 'Admin: Org'
|
if (selectedOrg.value) return 'Admin: Org'
|
||||||
return (authStore.settings?.rp_name || 'Master') + ' Admin'
|
return ((authStore.settings?.rp_name) || 'Master') + ' Admin'
|
||||||
})
|
})
|
||||||
|
|
||||||
// Breadcrumb entries for admin app.
|
// Breadcrumb entries for admin app.
|
||||||
const breadcrumbEntries = computed(() => {
|
const breadcrumbEntries = computed(() => {
|
||||||
const entries = [
|
const entries = [
|
||||||
{ label: 'Auth', href: authStore.uiHref() },
|
{ label: 'Auth', href: makeUiHref() },
|
||||||
{ label: 'Admin', href: authStore.adminHomeHref() }
|
{ label: 'Admin', href: adminUiPath() }
|
||||||
]
|
]
|
||||||
// Determine organization for user view if selectedOrg not explicitly chosen.
|
// Determine organization for user view if selectedOrg not explicitly chosen.
|
||||||
let orgForUser = null
|
let orgForUser = null
|
||||||
@@ -351,10 +350,7 @@ const breadcrumbEntries = computed(() => {
|
|||||||
watch(selectedUser, async (u) => {
|
watch(selectedUser, async (u) => {
|
||||||
if (!u) { userDetail.value = null; return }
|
if (!u) { userDetail.value = null; return }
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`/auth/admin/orgs/${u.org_uuid}/users/${u.uuid}`)
|
userDetail.value = await apiJson(`/auth/api/admin/orgs/${u.org_uuid}/users/${u.uuid}`)
|
||||||
const data = await res.json()
|
|
||||||
if (data.detail) throw new Error(data.detail)
|
|
||||||
userDetail.value = data
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
userDetail.value = { error: e.message }
|
userDetail.value = { error: e.message }
|
||||||
}
|
}
|
||||||
@@ -390,9 +386,7 @@ async function toggleOrgPermission(org, permId, checked) {
|
|||||||
org.permissions = next
|
org.permissions = next
|
||||||
try {
|
try {
|
||||||
const params = new URLSearchParams({ permission_id: permId })
|
const params = new URLSearchParams({ permission_id: permId })
|
||||||
const res = await fetch(`/auth/admin/orgs/${org.uuid}/permission?${params.toString()}`, { method: checked ? 'POST' : 'DELETE' })
|
await apiJson(`/auth/api/admin/orgs/${org.uuid}/permission?${params.toString()}`, { method: checked ? 'POST' : 'DELETE' })
|
||||||
const data = await res.json()
|
|
||||||
if (data.detail) throw new Error(data.detail)
|
|
||||||
await loadOrgs()
|
await loadOrgs()
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
authStore.showMessage(e.message || 'Failed to update organization permission')
|
authStore.showMessage(e.message || 'Failed to update organization permission')
|
||||||
@@ -403,16 +397,17 @@ async function toggleOrgPermission(org, permId, checked) {
|
|||||||
function openDialog(type, data) { dialog.value = { type, data, busy: false, error: '' } }
|
function openDialog(type, data) { dialog.value = { type, data, busy: false, error: '' } }
|
||||||
function closeDialog() { dialog.value = { type: null, data: null, busy: false, error: '' } }
|
function closeDialog() { dialog.value = { type: null, data: null, busy: false, error: '' } }
|
||||||
|
|
||||||
async function onUserNameSaved() {
|
async function refreshUserDetail() {
|
||||||
await loadOrgs()
|
await loadOrgs()
|
||||||
if (selectedUser.value) {
|
if (selectedUser.value) {
|
||||||
try {
|
try {
|
||||||
const r = await fetch(`/auth/admin/orgs/${selectedUser.value.org_uuid}/users/${selectedUser.value.uuid}`)
|
userDetail.value = await apiJson(`/auth/api/admin/orgs/${selectedUser.value.org_uuid}/users/${selectedUser.value.uuid}`)
|
||||||
const jd = await r.json()
|
|
||||||
if (!r.ok || jd.detail) throw new Error(jd.detail || 'Reload failed')
|
|
||||||
userDetail.value = jd
|
|
||||||
} catch (e) { authStore.showMessage(e.message || 'Failed to reload user', 'error') }
|
} catch (e) { authStore.showMessage(e.message || 'Failed to reload user', 'error') }
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onUserNameSaved() {
|
||||||
|
await refreshUserDetail()
|
||||||
authStore.showMessage('User renamed', 'success', 1500)
|
authStore.showMessage('User renamed', 'success', 1500)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -423,28 +418,28 @@ async function submitDialog() {
|
|||||||
const t = dialog.value.type
|
const t = dialog.value.type
|
||||||
if (t === 'org-create') {
|
if (t === 'org-create') {
|
||||||
const name = dialog.value.data.name?.trim(); if (!name) throw new Error('Name required')
|
const name = dialog.value.data.name?.trim(); if (!name) throw new Error('Name required')
|
||||||
const res = await fetch('/auth/admin/orgs', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ display_name: name, permissions: [] }) })
|
await apiJson('/auth/api/admin/orgs', { method: 'POST', body: { display_name: name, permissions: [] } })
|
||||||
const d = await res.json(); if (d.detail) throw new Error(d.detail); await Promise.all([loadOrgs(), loadPermissions()])
|
await Promise.all([loadOrgs(), loadPermissions()])
|
||||||
} else if (t === 'org-update') {
|
} else if (t === 'org-update') {
|
||||||
const { org } = dialog.value.data; const name = dialog.value.data.name?.trim(); if (!name) throw new Error('Name required')
|
const { org } = dialog.value.data; const name = dialog.value.data.name?.trim(); if (!name) throw new Error('Name required')
|
||||||
const res = await fetch(`/auth/admin/orgs/${org.uuid}`, { method: 'PUT', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ display_name: name, permissions: org.permissions }) })
|
await apiJson(`/auth/api/admin/orgs/${org.uuid}`, { method: 'PUT', body: { display_name: name, permissions: org.permissions } })
|
||||||
const d = await res.json(); if (d.detail) throw new Error(d.detail); await loadOrgs()
|
await loadOrgs()
|
||||||
} else if (t === 'role-create') {
|
} else if (t === 'role-create') {
|
||||||
const { org } = dialog.value.data; const name = dialog.value.data.name?.trim(); if (!name) throw new Error('Name required')
|
const { org } = dialog.value.data; const name = dialog.value.data.name?.trim(); if (!name) throw new Error('Name required')
|
||||||
const res = await fetch(`/auth/admin/orgs/${org.uuid}/roles`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ display_name: name, permissions: [] }) })
|
await apiJson(`/auth/api/admin/orgs/${org.uuid}/roles`, { method: 'POST', body: { display_name: name, permissions: [] } })
|
||||||
const d = await res.json(); if (d.detail) throw new Error(d.detail); await loadOrgs()
|
await loadOrgs()
|
||||||
} else if (t === 'role-update') {
|
} else if (t === 'role-update') {
|
||||||
const { role } = dialog.value.data; const name = dialog.value.data.name?.trim(); if (!name) throw new Error('Name required')
|
const { role } = dialog.value.data; const name = dialog.value.data.name?.trim(); if (!name) throw new Error('Name required')
|
||||||
const res = await fetch(`/auth/admin/orgs/${role.org_uuid}/roles/${role.uuid}`, { method: 'PUT', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ display_name: name, permissions: role.permissions }) })
|
await apiJson(`/auth/api/admin/orgs/${role.org_uuid}/roles/${role.uuid}`, { method: 'PUT', body: { display_name: name, permissions: role.permissions } })
|
||||||
const d = await res.json(); if (d.detail) throw new Error(d.detail); await loadOrgs()
|
await loadOrgs()
|
||||||
} else if (t === 'user-create') {
|
} else if (t === 'user-create') {
|
||||||
const { org, role } = dialog.value.data; const name = dialog.value.data.name?.trim(); if (!name) throw new Error('Name required')
|
const { org, role } = dialog.value.data; const name = dialog.value.data.name?.trim(); if (!name) throw new Error('Name required')
|
||||||
const res = await fetch(`/auth/admin/orgs/${org.uuid}/users`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ display_name: name, role: role.display_name }) })
|
await apiJson(`/auth/api/admin/orgs/${org.uuid}/users`, { method: 'POST', body: { display_name: name, role: role.display_name } })
|
||||||
const d = await res.json(); if (d.detail) throw new Error(d.detail); await loadOrgs()
|
await loadOrgs()
|
||||||
} else if (t === 'user-update-name') {
|
} else if (t === 'user-update-name') {
|
||||||
const { user } = dialog.value.data; const name = dialog.value.data.name?.trim(); if (!name) throw new Error('Name required')
|
const { user } = dialog.value.data; const name = dialog.value.data.name?.trim(); if (!name) throw new Error('Name required')
|
||||||
const res = await fetch(`/auth/admin/orgs/${user.org_uuid}/users/${user.uuid}/display-name`, { method: 'PUT', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ display_name: name }) })
|
await apiJson(`/auth/api/admin/orgs/${user.org_uuid}/users/${user.uuid}/display-name`, { method: 'PUT', body: { display_name: name } })
|
||||||
const d = await res.json(); if (d.detail) throw new Error(d.detail); await onUserNameSaved()
|
await onUserNameSaved()
|
||||||
} else if (t === 'perm-display') {
|
} else if (t === 'perm-display') {
|
||||||
const { permission } = dialog.value.data
|
const { permission } = dialog.value.data
|
||||||
const newId = dialog.value.data.id?.trim()
|
const newId = dialog.value.data.id?.trim()
|
||||||
@@ -454,22 +449,17 @@ async function submitDialog() {
|
|||||||
|
|
||||||
if (newId !== permission.id) {
|
if (newId !== permission.id) {
|
||||||
// ID changed, use rename endpoint
|
// ID changed, use rename endpoint
|
||||||
const body = { old_id: permission.id, new_id: newId, display_name: newDisplay }
|
await apiJson('/auth/api/admin/permission/rename', { method: 'POST', body: { old_id: permission.id, new_id: newId, display_name: newDisplay } })
|
||||||
const res = await fetch('/auth/admin/permission/rename', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) })
|
|
||||||
let data; try { data = await res.json() } catch(_) { data = {} }
|
|
||||||
if (!res.ok || data.detail) throw new Error(data.detail || data.error || `Failed (${res.status})`)
|
|
||||||
} else if (newDisplay !== permission.display_name) {
|
} else if (newDisplay !== permission.display_name) {
|
||||||
// Only display name changed
|
// Only display name changed
|
||||||
const params = new URLSearchParams({ permission_id: permission.id, display_name: newDisplay })
|
const params = new URLSearchParams({ permission_id: permission.id, display_name: newDisplay })
|
||||||
const res = await fetch(`/auth/admin/permission?${params.toString()}`, { method: 'PUT' })
|
await apiJson(`/auth/api/admin/permission?${params.toString()}`, { method: 'PUT' })
|
||||||
const d = await res.json(); if (d.detail) throw new Error(d.detail)
|
|
||||||
}
|
}
|
||||||
await loadPermissions()
|
await loadPermissions()
|
||||||
} else if (t === 'perm-create') {
|
} else if (t === 'perm-create') {
|
||||||
const id = dialog.value.data.id?.trim(); if (!id) throw new Error('ID required')
|
const id = dialog.value.data.id?.trim(); if (!id) throw new Error('ID required')
|
||||||
const display_name = dialog.value.data.display_name?.trim(); if (!display_name) throw new Error('Display name required')
|
const display_name = dialog.value.data.display_name?.trim(); if (!display_name) throw new Error('Display name required')
|
||||||
const res = await fetch('/auth/admin/permissions', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ id, display_name }) })
|
await apiJson('/auth/api/admin/permissions', { method: 'POST', body: { id, display_name } })
|
||||||
const data = await res.json(); if (data.detail) throw new Error(data.detail)
|
|
||||||
await loadPermissions(); dialog.value.data.display_name = ''; dialog.value.data.id = ''
|
await loadPermissions(); dialog.value.data.display_name = ''; dialog.value.data.id = ''
|
||||||
} else if (t === 'confirm') {
|
} else if (t === 'confirm') {
|
||||||
const action = dialog.value.data.action; if (action) await action()
|
const action = dialog.value.data.action; if (action) await action()
|
||||||
@@ -485,76 +475,71 @@ async function submitDialog() {
|
|||||||
<div class="app-shell admin-shell">
|
<div class="app-shell admin-shell">
|
||||||
<StatusMessage />
|
<StatusMessage />
|
||||||
<main class="app-main">
|
<main class="app-main">
|
||||||
<section class="view-root view-admin">
|
<LoadingView v-if="loading" :message="loadingMessage" />
|
||||||
<div class="view-content view-content--wide">
|
<AuthRequiredMessage
|
||||||
<header class="view-header">
|
v-else-if="showBackMessage"
|
||||||
<h1>{{ pageHeading }}</h1>
|
@reload="reloadPage"
|
||||||
<Breadcrumbs :entries="breadcrumbEntries" />
|
/>
|
||||||
</header>
|
<section v-else-if="authenticated && (info?.is_global_admin || info?.is_org_admin)" class="view-root view-root--wide view-admin">
|
||||||
|
<header class="view-header">
|
||||||
|
<h1>{{ pageHeading }}</h1>
|
||||||
|
<Breadcrumbs :entries="breadcrumbEntries" />
|
||||||
|
</header>
|
||||||
|
|
||||||
<section class="section-block admin-section">
|
<section class="section-block admin-section">
|
||||||
<div class="section-body admin-section-body">
|
<div class="section-body admin-section-body">
|
||||||
<div v-if="loading" class="surface surface--tight">Loading…</div>
|
<div v-if="error" class="surface surface--tight error">{{ error }}</div>
|
||||||
<div v-else-if="error" class="surface surface--tight error">{{ error }}</div>
|
<div v-else class="admin-panels">
|
||||||
<template v-else>
|
<AdminOverview
|
||||||
<div v-if="!info?.authenticated" class="surface surface--tight">
|
v-if="!selectedUser && !selectedOrg && (info.is_global_admin || info.is_org_admin)"
|
||||||
<p>You must be authenticated.</p>
|
:info="info"
|
||||||
</div>
|
:orgs="orgs"
|
||||||
<div v-else-if="!(info?.is_global_admin || info?.is_org_admin)" class="surface surface--tight">
|
:permissions="permissions"
|
||||||
<p>Insufficient permissions.</p>
|
:permission-summary="permissionSummary"
|
||||||
</div>
|
@create-org="createOrg"
|
||||||
<div v-else class="admin-panels">
|
@open-org="openOrg"
|
||||||
<AdminOverview
|
@update-org="updateOrg"
|
||||||
v-if="!selectedUser && !selectedOrg && (info.is_global_admin || info.is_org_admin)"
|
@delete-org="deleteOrg"
|
||||||
:info="info"
|
@toggle-org-permission="toggleOrgPermission"
|
||||||
:orgs="orgs"
|
@open-dialog="openDialog"
|
||||||
:permissions="permissions"
|
@delete-permission="deletePermission"
|
||||||
:permission-summary="permissionSummary"
|
@rename-permission-display="renamePermissionDisplay"
|
||||||
@create-org="createOrg"
|
/>
|
||||||
@open-org="openOrg"
|
|
||||||
@update-org="updateOrg"
|
|
||||||
@delete-org="deleteOrg"
|
|
||||||
@toggle-org-permission="toggleOrgPermission"
|
|
||||||
@open-dialog="openDialog"
|
|
||||||
@delete-permission="deletePermission"
|
|
||||||
@rename-permission-display="renamePermissionDisplay"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<AdminUserDetail
|
<AdminUserDetail
|
||||||
v-else-if="selectedUser"
|
v-else-if="selectedUser"
|
||||||
:selected-user="selectedUser"
|
:selected-user="selectedUser"
|
||||||
:user-detail="userDetail"
|
:user-detail="userDetail"
|
||||||
:selected-org="selectedOrg"
|
:selected-org="selectedOrg"
|
||||||
:loading="loading"
|
:loading="loading"
|
||||||
:show-reg-modal="showRegModal"
|
:show-reg-modal="showRegModal"
|
||||||
@generate-user-registration-link="generateUserRegistrationLink"
|
@generate-user-registration-link="generateUserRegistrationLink"
|
||||||
@go-overview="goOverview"
|
@go-overview="goOverview"
|
||||||
@open-org="openOrg"
|
@open-org="openOrg"
|
||||||
@on-user-name-saved="onUserNameSaved"
|
@on-user-name-saved="onUserNameSaved"
|
||||||
@edit-user-name="editUserName"
|
@refresh-user-detail="refreshUserDetail"
|
||||||
@close-reg-modal="showRegModal = false"
|
@edit-user-name="editUserName"
|
||||||
/>
|
@close-reg-modal="showRegModal = false"
|
||||||
<AdminOrgDetail
|
/>
|
||||||
v-else-if="selectedOrg"
|
<AdminOrgDetail
|
||||||
:selected-org="selectedOrg"
|
v-else-if="selectedOrg"
|
||||||
:permissions="permissions"
|
:selected-org="selectedOrg"
|
||||||
@update-org="updateOrg"
|
:permissions="permissions"
|
||||||
@create-role="createRole"
|
@update-org="updateOrg"
|
||||||
@update-role="updateRole"
|
@create-role="createRole"
|
||||||
@delete-role="deleteRole"
|
@update-role="updateRole"
|
||||||
@create-user-in-role="createUserInRole"
|
@delete-role="deleteRole"
|
||||||
@open-user="openUser"
|
@create-user-in-role="createUserInRole"
|
||||||
@toggle-role-permission="toggleRolePermission"
|
@open-user="openUser"
|
||||||
@on-role-drag-over="onRoleDragOver"
|
@toggle-role-permission="toggleRolePermission"
|
||||||
@on-role-drop="onRoleDrop"
|
@on-role-drag-over="onRoleDragOver"
|
||||||
@on-user-drag-start="onUserDragStart"
|
@on-role-drop="onRoleDrop"
|
||||||
/>
|
@on-user-drag-start="onUserDragStart"
|
||||||
|
/>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</div>
|
||||||
</div>
|
</section>
|
||||||
</section>
|
|
||||||
</div>
|
|
||||||
</section>
|
</section>
|
||||||
</main>
|
</main>
|
||||||
<AdminDialogs
|
<AdminDialogs
|
||||||
@@ -7,6 +7,6 @@
|
|||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="admin-app"></div>
|
<div id="admin-app"></div>
|
||||||
<script type="module" src="/src/admin/main.js"></script>
|
<script type="module" src="./main.js"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import '../assets/style.css'
|
import '@/assets/style.css'
|
||||||
|
|
||||||
import { createApp } from 'vue'
|
import { createApp } from 'vue'
|
||||||
import { createPinia } from 'pinia'
|
import { createPinia } from 'pinia'
|
||||||
@@ -7,6 +7,6 @@
|
|||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="app"></div>
|
<div id="app"></div>
|
||||||
<script type="module" src="/src/main.js"></script>
|
<script type="module" src="main.js"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import './assets/style.css'
|
import '@/assets/style.css'
|
||||||
|
|
||||||
import { createApp } from 'vue'
|
import { createApp } from 'vue'
|
||||||
import { createPinia } from 'pinia'
|
import { createPinia } from 'pinia'
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
<template>
|
||||||
|
<RestrictedAuth
|
||||||
|
:mode="authMode"
|
||||||
|
:remote-auth-token="remoteAuthToken"
|
||||||
|
@authenticated="handleAuthenticated"
|
||||||
|
@back="handleBack"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { computed, onMounted, ref } from 'vue'
|
||||||
|
import RestrictedAuth from '@/components/RestrictedAuth.vue'
|
||||||
|
|
||||||
|
// Check if this is a remote auth URL: /auth/{token}
|
||||||
|
// The token is a 5-word passphrase like "word1.word2.word3.word4.word5"
|
||||||
|
const remoteAuthToken = ref(null)
|
||||||
|
|
||||||
|
function extractRemoteToken() {
|
||||||
|
const path = window.location.pathname
|
||||||
|
// Match /auth/{token} where token is a passphrase with dots
|
||||||
|
const match = path.match(/\/auth\/([^/]+)$/)
|
||||||
|
if (match) {
|
||||||
|
const token = match[1]
|
||||||
|
// Validate it looks like a 5-word passphrase
|
||||||
|
const parts = token.split('.')
|
||||||
|
if (parts.length === 5 && parts.every(p => p.length > 0)) {
|
||||||
|
return token
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
// Detect mode from URL hash fragment
|
||||||
|
const authMode = computed(() => {
|
||||||
|
const params = new URLSearchParams(window.location.hash.slice(1))
|
||||||
|
const mode = params.get('mode')
|
||||||
|
if (mode === 'reauth') return 'reauth'
|
||||||
|
if (mode === 'forbidden') return 'forbidden'
|
||||||
|
return 'login'
|
||||||
|
})
|
||||||
|
|
||||||
|
function postToParent(message) {
|
||||||
|
if (window.parent && window.parent !== window) {
|
||||||
|
window.parent.postMessage(message, '*')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleAuthenticated(result) {
|
||||||
|
postToParent({
|
||||||
|
type: 'auth-success',
|
||||||
|
authenticated: true,
|
||||||
|
sessionToken: result.session_token
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleBack() {
|
||||||
|
postToParent({
|
||||||
|
type: 'auth-back'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
// Check for remote auth token in URL
|
||||||
|
remoteAuthToken.value = extractRemoteToken()
|
||||||
|
|
||||||
|
postToParent({
|
||||||
|
type: 'auth-ready'
|
||||||
|
})
|
||||||
|
|
||||||
|
window.addEventListener('keydown', (event) => {
|
||||||
|
if (event.key === 'Escape') {
|
||||||
|
handleBack()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
<html style="background: transparent"><meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<div id="app"></div>
|
||||||
|
<script type="module" src="/auth/restricted/main.js"></script>
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
import { createApp } from 'vue'
|
||||||
|
import RestrictedApi from './RestrictedApi.vue'
|
||||||
|
import '@/assets/style.css'
|
||||||
|
|
||||||
|
createApp(RestrictedApi).mount('#app')
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
<template>
|
||||||
|
<RestrictedAuth
|
||||||
|
:mode="authMode"
|
||||||
|
@authenticated="handleAuthenticated"
|
||||||
|
@back="goBack"
|
||||||
|
@home="returnHome"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { computed, onMounted } from 'vue'
|
||||||
|
import RestrictedAuth from '@/components/RestrictedAuth.vue'
|
||||||
|
import { uiBasePath } from '@/utils/settings'
|
||||||
|
import { goBack } from '@/utils/helpers'
|
||||||
|
|
||||||
|
const basePath = computed(() => uiBasePath())
|
||||||
|
|
||||||
|
// Detect mode from data attribute on html tag only
|
||||||
|
// (RestrictedApi uses URL query, RestrictedForward uses data injected by server)
|
||||||
|
const authMode = computed(() => {
|
||||||
|
const htmlElement = document.documentElement
|
||||||
|
const dataMode = htmlElement.getAttribute('data-mode')
|
||||||
|
if (dataMode === 'reauth') return 'reauth'
|
||||||
|
if (dataMode === 'forbidden') return 'forbidden'
|
||||||
|
return 'login'
|
||||||
|
})
|
||||||
|
|
||||||
|
function handleAuthenticated() {
|
||||||
|
// Reload page to re-trigger forward auth validation
|
||||||
|
location.reload()
|
||||||
|
}
|
||||||
|
|
||||||
|
function returnHome() {
|
||||||
|
const target = basePath.value || '/auth/'
|
||||||
|
if (window.location.pathname !== target) history.replaceState(null, '', target)
|
||||||
|
window.location.href = target
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
// Handle Escape key to trigger back navigation
|
||||||
|
window.addEventListener('keydown', (event) => {
|
||||||
|
if (event.key === 'Escape') goBack()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
</script>
|
||||||
@@ -7,6 +7,6 @@
|
|||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="app"></div>
|
<div id="app"></div>
|
||||||
<script type="module" src="/src/restricted/main.js"></script>
|
<script type="module" src="/int/forward/main.js"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
import { createApp } from 'vue'
|
||||||
|
import App from './RestrictedForward.vue'
|
||||||
|
import '@/assets/style.css'
|
||||||
|
|
||||||
|
createApp(App).mount('#app')
|
||||||
@@ -7,54 +7,49 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<main class="view-root">
|
<main class="view-root">
|
||||||
<div class="view-content">
|
<div class="surface surface--tight" style="max-width: 560px; margin: 0 auto; width: 100%;">
|
||||||
<div class="surface surface--tight" style="max-width: 560px; margin: 0 auto; width: 100%;">
|
<header class="view-header" style="text-align: center;">
|
||||||
<header class="view-header" style="text-align: center;">
|
<h1>🔑 Registration</h1>
|
||||||
<h1>🔑 Complete Your Passkey Setup</h1>
|
<p class="view-lede">
|
||||||
<p class="view-lede">
|
{{ subtitleMessage }}
|
||||||
{{ subtitleMessage }}
|
</p>
|
||||||
</p>
|
</header>
|
||||||
</header>
|
|
||||||
|
|
||||||
<section class="section-block" v-if="initializing">
|
<section class="section-block" v-if="initializing">
|
||||||
<div class="section-body center">
|
<div class="section-body center">
|
||||||
<p>Loading reset details…</p>
|
<p>Loading reset details…</p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="section-block" v-else-if="!canRegister">
|
||||||
|
<div class="section-body center">
|
||||||
|
<div class="button-row center" style="justify-content: center;">
|
||||||
|
<button class="btn-secondary" @click="goHome">Return to sign-in</button>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
<section class="section-block" v-else-if="!canRegister">
|
<section class="section-block" v-else>
|
||||||
<div class="section-body center">
|
<div class="section-body">
|
||||||
<p>{{ errorMessage }}</p>
|
<label class="name-edit">
|
||||||
<div class="button-row center" style="justify-content: center;">
|
<span>👤 Name</span>
|
||||||
<button class="btn-secondary" @click="goHome">Return to sign-in</button>
|
<input
|
||||||
</div>
|
type="text"
|
||||||
</div>
|
v-model="displayName"
|
||||||
</section>
|
|
||||||
|
|
||||||
<section class="section-block" v-else>
|
|
||||||
<div class="section-body">
|
|
||||||
<label class="name-edit">
|
|
||||||
<span>👤 Name</span>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
v-model="displayName"
|
|
||||||
:placeholder="namePlaceholder"
|
|
||||||
:disabled="loading"
|
|
||||||
maxlength="64"
|
|
||||||
@keyup.enter="registerPasskey"
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
<p>Click below to finish {{ sessionDescriptor }}.</p>
|
|
||||||
<button
|
|
||||||
class="btn-primary"
|
|
||||||
:disabled="loading"
|
:disabled="loading"
|
||||||
@click="registerPasskey"
|
maxlength="64"
|
||||||
>
|
@keyup.enter="registerPasskey"
|
||||||
{{ loading ? 'Registering…' : 'Register Passkey' }}
|
/>
|
||||||
</button>
|
</label>
|
||||||
</div>
|
<button
|
||||||
</section>
|
class="btn-primary"
|
||||||
</div>
|
:disabled="loading"
|
||||||
|
@click="registerPasskey"
|
||||||
|
>
|
||||||
|
{{ loading ? 'Registering…' : 'Register Passkey' }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
@@ -63,6 +58,8 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { computed, onMounted, reactive, ref } from 'vue'
|
import { computed, onMounted, reactive, ref } from 'vue'
|
||||||
import passkey from '@/utils/passkey'
|
import passkey from '@/utils/passkey'
|
||||||
|
import { getSettings, uiBasePath } from '@/utils/settings'
|
||||||
|
import { apiJson, ApiError, getUserFriendlyErrorMessage } from '@/utils/api'
|
||||||
|
|
||||||
const status = reactive({
|
const status = reactive({
|
||||||
show: false,
|
show: false,
|
||||||
@@ -80,18 +77,13 @@ const errorMessage = ref('')
|
|||||||
let statusTimer = null
|
let statusTimer = null
|
||||||
|
|
||||||
const sessionDescriptor = computed(() => userInfo.value?.session_type || 'your enrollment')
|
const sessionDescriptor = computed(() => userInfo.value?.session_type || 'your enrollment')
|
||||||
const namePlaceholder = computed(() => userInfo.value?.user?.user_name || 'Your name')
|
|
||||||
const subtitleMessage = computed(() => {
|
const subtitleMessage = computed(() => {
|
||||||
if (initializing.value) return 'Preparing your secure enrollment…'
|
if (initializing.value) return 'Preparing your secure enrollment…'
|
||||||
if (!canRegister.value) return 'This reset link is no longer valid.'
|
if (!canRegister.value) return 'This authentication link is no longer valid.'
|
||||||
return `Finish setting up a passkey for ${userInfo.value?.user?.user_name || 'your account'}.`
|
return `Finish up ${sessionDescriptor.value}. You may edit the name below if needed, and it will be saved to your passkey.`
|
||||||
})
|
})
|
||||||
|
|
||||||
const uiBasePath = computed(() => {
|
const basePath = computed(() => uiBasePath())
|
||||||
const base = settings.value?.ui_base_path || '/auth/'
|
|
||||||
if (base === '/') return '/'
|
|
||||||
return base.endsWith('/') ? base : `${base}/`
|
|
||||||
})
|
|
||||||
|
|
||||||
const canRegister = computed(() => !!(token.value && userInfo.value))
|
const canRegister = computed(() => !!(token.value && userInfo.value))
|
||||||
|
|
||||||
@@ -109,13 +101,9 @@ function showMessage(message, type = 'info', duration = 3000) {
|
|||||||
|
|
||||||
async function fetchSettings() {
|
async function fetchSettings() {
|
||||||
try {
|
try {
|
||||||
const res = await fetch('/auth/api/settings')
|
const data = await getSettings()
|
||||||
if (!res.ok) return
|
|
||||||
const data = await res.json()
|
|
||||||
settings.value = data
|
settings.value = data
|
||||||
if (data?.rp_name) {
|
if (data?.rp_name) document.title = `${data.rp_name} · Passkey Setup`
|
||||||
document.title = `${data.rp_name} · Passkey Setup`
|
|
||||||
}
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.warn('Unable to load settings', error)
|
console.warn('Unable to load settings', error)
|
||||||
}
|
}
|
||||||
@@ -124,22 +112,16 @@ async function fetchSettings() {
|
|||||||
async function fetchUserInfo() {
|
async function fetchUserInfo() {
|
||||||
if (!token.value) return
|
if (!token.value) return
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`/auth/api/user-info?reset=${encodeURIComponent(token.value)}`, {
|
userInfo.value = await apiJson(`/auth/api/user-info?reset=${encodeURIComponent(token.value)}`, {
|
||||||
method: 'POST'
|
method: 'POST'
|
||||||
})
|
})
|
||||||
if (!res.ok) {
|
displayName.value = userInfo.value?.user?.user_name || ''
|
||||||
const payload = await safeParseJson(res)
|
|
||||||
const detail = payload?.detail || 'Reset link is invalid or expired.'
|
|
||||||
errorMessage.value = detail
|
|
||||||
showMessage(detail, 'error', 0)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
userInfo.value = await res.json()
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to load user info', error)
|
console.error('Failed to load user info', error)
|
||||||
const message = 'We could not load your reset details. Try refreshing the page.'
|
const message = error instanceof ApiError
|
||||||
|
? (error.data?.detail || 'The authentication link is invalid or expired.')
|
||||||
|
: getUserFriendlyErrorMessage(error)
|
||||||
errorMessage.value = message
|
errorMessage.value = message
|
||||||
showMessage(message, 'error', 0)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -161,7 +143,7 @@ async function registerPasskey() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await setSessionCookie(result.session_token)
|
await setSessionCookie(result)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
loading.value = false
|
loading.value = false
|
||||||
const message = error?.message || 'Failed to establish session'
|
const message = error?.message || 'Failed to establish session'
|
||||||
@@ -169,29 +151,23 @@ async function registerPasskey() {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
showMessage('Passkey registered successfully!', 'success', 2000)
|
showMessage('Passkey registered successfully!', 'success', 800)
|
||||||
setTimeout(() => {
|
setTimeout(() => { loading.value = false; goHome() }, 800)
|
||||||
loading.value = false
|
|
||||||
redirectHome()
|
|
||||||
}, 800)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function setSessionCookie(sessionToken) {
|
async function setSessionCookie(result) {
|
||||||
const response = await fetch('/auth/api/set-session', {
|
if (!result?.session_token) {
|
||||||
|
throw new Error('Registration response missing session_token')
|
||||||
|
}
|
||||||
|
return await apiJson('/auth/api/set-session', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers: {
|
||||||
Authorization: `Bearer ${sessionToken}`
|
Authorization: `Bearer ${result.session_token}`
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
const payload = await safeParseJson(response)
|
|
||||||
if (!response.ok || payload?.detail) {
|
|
||||||
const detail = payload?.detail || 'Session could not be established.'
|
|
||||||
throw new Error(detail)
|
|
||||||
}
|
|
||||||
return payload
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function redirectHome() {
|
function goHome() {
|
||||||
const target = uiBasePath.value || '/auth/'
|
const target = uiBasePath.value || '/auth/'
|
||||||
if (window.location.pathname !== target) {
|
if (window.location.pathname !== target) {
|
||||||
history.replaceState(null, '', target)
|
history.replaceState(null, '', target)
|
||||||
@@ -199,10 +175,6 @@ function redirectHome() {
|
|||||||
window.location.reload()
|
window.location.reload()
|
||||||
}
|
}
|
||||||
|
|
||||||
function goHome() {
|
|
||||||
redirectHome()
|
|
||||||
}
|
|
||||||
|
|
||||||
function extractTokenFromPath() {
|
function extractTokenFromPath() {
|
||||||
const segments = window.location.pathname.split('/').filter(Boolean)
|
const segments = window.location.pathname.split('/').filter(Boolean)
|
||||||
if (!segments.length) return ''
|
if (!segments.length) return ''
|
||||||
@@ -214,14 +186,6 @@ function extractTokenFromPath() {
|
|||||||
return candidate
|
return candidate
|
||||||
}
|
}
|
||||||
|
|
||||||
async function safeParseJson(response) {
|
|
||||||
try {
|
|
||||||
return await response.json()
|
|
||||||
} catch (error) {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
token.value = extractTokenFromPath()
|
token.value = extractTokenFromPath()
|
||||||
await fetchSettings()
|
await fetchSettings()
|
||||||
@@ -7,6 +7,6 @@
|
|||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="app"></div>
|
<div id="app"></div>
|
||||||
<script type="module" src="/src/reset/main.js"></script>
|
<script type="module" src="/int/reset/main.js"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
@@ -12,6 +12,7 @@
|
|||||||
"@simplewebauthn/browser": "^13.1.2",
|
"@simplewebauthn/browser": "^13.1.2",
|
||||||
"pinia": "^3.0.3",
|
"pinia": "^3.0.3",
|
||||||
"qrcode": "^1.5.4",
|
"qrcode": "^1.5.4",
|
||||||
|
"sirv": "^3.0.2",
|
||||||
"vue": "^3.5.17"
|
"vue": "^3.5.17"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|||||||
@@ -1,78 +0,0 @@
|
|||||||
<template>
|
|
||||||
<div class="app-shell">
|
|
||||||
<StatusMessage />
|
|
||||||
<main class="app-main">
|
|
||||||
<!-- Only render views after authentication status is determined -->
|
|
||||||
<template v-if="initialized">
|
|
||||||
<LoginView v-if="store.currentView === 'login'" />
|
|
||||||
<ProfileView v-if="store.currentView === 'profile'" />
|
|
||||||
<DeviceLinkView v-if="store.currentView === 'device-link'" />
|
|
||||||
</template>
|
|
||||||
<!-- Show loading state while determining auth status -->
|
|
||||||
<div v-else class="loading-container">
|
|
||||||
<div class="loading-spinner"></div>
|
|
||||||
<p>Loading...</p>
|
|
||||||
</div>
|
|
||||||
</main>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup>
|
|
||||||
import { onMounted, ref } from 'vue'
|
|
||||||
import { useAuthStore } from '@/stores/auth'
|
|
||||||
import StatusMessage from '@/components/StatusMessage.vue'
|
|
||||||
import LoginView from '@/components/LoginView.vue'
|
|
||||||
import ProfileView from '@/components/ProfileView.vue'
|
|
||||||
import DeviceLinkView from '@/components/DeviceLinkView.vue'
|
|
||||||
const store = useAuthStore()
|
|
||||||
const initialized = ref(false)
|
|
||||||
|
|
||||||
onMounted(async () => {
|
|
||||||
// Load branding / settings first (non-blocking for auth flow)
|
|
||||||
await store.loadSettings()
|
|
||||||
// Was an error message passed in the URL hash?
|
|
||||||
const message = location.hash.substring(1)
|
|
||||||
if (message) {
|
|
||||||
store.showMessage(decodeURIComponent(message), 'error')
|
|
||||||
history.replaceState(null, '', location.pathname)
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
await store.loadUserInfo()
|
|
||||||
} catch (error) {
|
|
||||||
console.log('Failed to load user info:', error)
|
|
||||||
} finally {
|
|
||||||
initialized.value = true
|
|
||||||
store.selectView()
|
|
||||||
}
|
|
||||||
})
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
.loading-container {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
height: 100vh;
|
|
||||||
gap: 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.loading-spinner {
|
|
||||||
width: 40px;
|
|
||||||
height: 40px;
|
|
||||||
border: 4px solid var(--color-border);
|
|
||||||
border-top: 4px solid var(--color-primary);
|
|
||||||
border-radius: 50%;
|
|
||||||
animation: spin 1s linear infinite;
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes spin {
|
|
||||||
0% { transform: rotate(0deg); }
|
|
||||||
100% { transform: rotate(360deg); }
|
|
||||||
}
|
|
||||||
|
|
||||||
.loading-container p {
|
|
||||||
color: var(--color-text-muted);
|
|
||||||
margin: 0;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -3,7 +3,9 @@ import { ref } from 'vue'
|
|||||||
import UserBasicInfo from '@/components/UserBasicInfo.vue'
|
import UserBasicInfo from '@/components/UserBasicInfo.vue'
|
||||||
import CredentialList from '@/components/CredentialList.vue'
|
import CredentialList from '@/components/CredentialList.vue'
|
||||||
import RegistrationLinkModal from '@/components/RegistrationLinkModal.vue'
|
import RegistrationLinkModal from '@/components/RegistrationLinkModal.vue'
|
||||||
|
import SessionList from '@/components/SessionList.vue'
|
||||||
import { useAuthStore } from '@/stores/auth'
|
import { useAuthStore } from '@/stores/auth'
|
||||||
|
import { apiJson } from '@/utils/api'
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
selectedUser: Object,
|
selectedUser: Object,
|
||||||
@@ -13,9 +15,12 @@ const props = defineProps({
|
|||||||
showRegModal: Boolean
|
showRegModal: Boolean
|
||||||
})
|
})
|
||||||
|
|
||||||
const emit = defineEmits(['generateUserRegistrationLink', 'goOverview', 'openOrg', 'onUserNameSaved', 'closeRegModal', 'editUserName'])
|
const emit = defineEmits(['generateUserRegistrationLink', 'goOverview', 'openOrg', 'onUserNameSaved', 'closeRegModal', 'editUserName', 'refreshUserDetail'])
|
||||||
|
|
||||||
const authStore = useAuthStore()
|
const authStore = useAuthStore()
|
||||||
|
const terminatingSessions = ref({})
|
||||||
|
const hoveredCredentialUuid = ref(null)
|
||||||
|
const hoveredSession = ref(null)
|
||||||
|
|
||||||
function onLinkCopied() {
|
function onLinkCopied() {
|
||||||
authStore.showMessage('Link copied to clipboard!')
|
authStore.showMessage('Link copied to clipboard!')
|
||||||
@@ -25,17 +30,44 @@ function handleEditName() {
|
|||||||
emit('editUserName', props.selectedUser)
|
emit('editUserName', props.selectedUser)
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleDelete(credential) {
|
async function handleDelete(credential) {
|
||||||
fetch(`/auth/admin/orgs/${props.selectedUser.org_uuid}/users/${props.selectedUser.uuid}/credentials/${credential.credential_uuid}`, { method: 'DELETE' })
|
try {
|
||||||
.then(res => res.json())
|
const data = await apiJson(`/auth/api/admin/orgs/${props.selectedUser.org_uuid}/users/${props.selectedUser.uuid}/credentials/${credential.credential_uuid}`, { method: 'DELETE' })
|
||||||
.then(data => {
|
if (data.status === 'ok') {
|
||||||
if (data.status === 'ok') {
|
emit('onUserNameSaved') // Reuse to refresh user detail
|
||||||
emit('onUserNameSaved') // Reuse to refresh user detail
|
} else {
|
||||||
} else {
|
console.error('Failed to delete credential', data)
|
||||||
console.error('Failed to delete credential', data)
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Delete credential error', err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleTerminateSession(session) {
|
||||||
|
const sessionId = session?.id
|
||||||
|
if (!sessionId) return
|
||||||
|
terminatingSessions.value = { ...terminatingSessions.value, [sessionId]: true }
|
||||||
|
try {
|
||||||
|
const data = await apiJson(`/auth/api/admin/orgs/${props.selectedUser.org_uuid}/users/${props.selectedUser.uuid}/sessions/${sessionId}`, { method: 'DELETE' })
|
||||||
|
if (data.status === 'ok') {
|
||||||
|
if (data.current_session_terminated) {
|
||||||
|
sessionStorage.clear()
|
||||||
|
location.reload()
|
||||||
|
return
|
||||||
}
|
}
|
||||||
})
|
emit('refreshUserDetail') // Refresh without showing rename message
|
||||||
.catch(err => console.error('Delete credential error', err))
|
authStore.showMessage('Session terminated', 'success', 2500)
|
||||||
|
} else {
|
||||||
|
authStore.showMessage(data.detail || 'Failed to terminate session', 'error')
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Terminate session error', err)
|
||||||
|
authStore.showMessage(err.message || 'Failed to terminate session', 'error')
|
||||||
|
} finally {
|
||||||
|
const next = { ...terminatingSessions.value }
|
||||||
|
delete next[sessionId]
|
||||||
|
terminatingSessions.value = next
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
</script>
|
</script>
|
||||||
@@ -51,24 +83,56 @@ function handleDelete(credential) {
|
|||||||
:loading="loading"
|
:loading="loading"
|
||||||
:org-display-name="userDetail.org.display_name"
|
:org-display-name="userDetail.org.display_name"
|
||||||
:role-name="userDetail.role"
|
:role-name="userDetail.role"
|
||||||
:update-endpoint="`/auth/admin/orgs/${selectedUser.org_uuid}/users/${selectedUser.uuid}/display-name`"
|
:update-endpoint="`/auth/api/admin/orgs/${selectedUser.org_uuid}/users/${selectedUser.uuid}/display-name`"
|
||||||
@saved="$emit('onUserNameSaved')"
|
@saved="$emit('onUserNameSaved')"
|
||||||
@edit-name="handleEditName"
|
@edit-name="handleEditName"
|
||||||
/>
|
/>
|
||||||
<div v-else-if="userDetail?.error" class="error small">{{ userDetail.error }}</div>
|
<div v-else-if="userDetail?.error" class="error small">{{ userDetail.error }}</div>
|
||||||
<template v-if="userDetail && !userDetail.error">
|
<template v-if="userDetail && !userDetail.error">
|
||||||
<h3 class="cred-title">Registered Passkeys</h3>
|
<div class="registration-actions">
|
||||||
<CredentialList :credentials="userDetail.credentials" :aaguid-info="userDetail.aaguid_info" :allow-delete="true" @delete="handleDelete" />
|
<button
|
||||||
|
class="btn-secondary reg-token-btn"
|
||||||
|
@click="$emit('generateUserRegistrationLink', selectedUser)"
|
||||||
|
:disabled="loading"
|
||||||
|
>Generate Registration Token</button>
|
||||||
|
<p class="matrix-hint muted">
|
||||||
|
Generate a one-time registration link so this user can register or add another passkey.
|
||||||
|
Copy the link from the dialog and send it to the user, or have the user scan the QR code on their device.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<section class="section-block" data-section="registered-passkeys">
|
||||||
|
<div class="section-header">
|
||||||
|
<h2>Registered Passkeys</h2>
|
||||||
|
</div>
|
||||||
|
<div class="section-body">
|
||||||
|
<CredentialList
|
||||||
|
:credentials="userDetail.credentials"
|
||||||
|
:aaguid-info="userDetail.aaguid_info"
|
||||||
|
:allow-delete="true"
|
||||||
|
:hovered-credential-uuid="hoveredCredentialUuid"
|
||||||
|
:hovered-session-credential-uuid="hoveredSession?.credential_uuid"
|
||||||
|
@delete="handleDelete"
|
||||||
|
@credential-hover="hoveredCredentialUuid = $event"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
<SessionList
|
||||||
|
:sessions="userDetail.sessions || []"
|
||||||
|
:terminating-sessions="terminatingSessions"
|
||||||
|
:hovered-credential-uuid="hoveredCredentialUuid"
|
||||||
|
:empty-message="'This user has no active sessions.'"
|
||||||
|
:section-description="'View and manage the active sessions for this user.'"
|
||||||
|
@terminate="handleTerminateSession"
|
||||||
|
@session-hover="hoveredSession = $event"
|
||||||
|
/>
|
||||||
</template>
|
</template>
|
||||||
<div class="actions">
|
<div class="actions ancillary-actions">
|
||||||
<button @click="$emit('generateUserRegistrationLink', selectedUser)">Generate Registration Token</button>
|
|
||||||
<button v-if="selectedOrg" @click="$emit('openOrg', selectedOrg)" class="icon-btn" title="Back to Org">↩️</button>
|
<button v-if="selectedOrg" @click="$emit('openOrg', selectedOrg)" class="icon-btn" title="Back to Org">↩️</button>
|
||||||
</div>
|
</div>
|
||||||
<p class="matrix-hint muted">Use the token dialog to register a new credential for the member.</p>
|
|
||||||
<RegistrationLinkModal
|
<RegistrationLinkModal
|
||||||
v-if="showRegModal"
|
v-if="showRegModal"
|
||||||
:endpoint="`/auth/admin/orgs/${selectedUser.org_uuid}/users/${selectedUser.uuid}/create-link`"
|
:endpoint="`/auth/api/admin/orgs/${selectedUser.org_uuid}/users/${selectedUser.uuid}/create-link`"
|
||||||
:auto-copy="false"
|
:user-name="userDetail?.display_name || selectedUser.display_name"
|
||||||
@close="$emit('closeRegModal')"
|
@close="$emit('closeRegModal')"
|
||||||
@copied="onLinkCopied"
|
@copied="onLinkCopied"
|
||||||
/>
|
/>
|
||||||
@@ -77,9 +141,10 @@ function handleDelete(credential) {
|
|||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.user-detail { display: flex; flex-direction: column; gap: var(--space-lg); }
|
.user-detail { display: flex; flex-direction: column; gap: var(--space-lg); }
|
||||||
.cred-title { font-size: 1.25rem; font-weight: 600; color: var(--color-heading); margin-bottom: var(--space-md); }
|
|
||||||
.actions { display: flex; flex-wrap: wrap; gap: var(--space-sm); align-items: center; }
|
.actions { display: flex; flex-wrap: wrap; gap: var(--space-sm); align-items: center; }
|
||||||
.actions button { width: auto; }
|
.ancillary-actions { margin-top: -0.5rem; }
|
||||||
|
.reg-token-btn { align-self: flex-start; }
|
||||||
|
.registration-actions { display: flex; flex-direction: column; gap: 0.5rem; }
|
||||||
.icon-btn { background: none; border: none; color: var(--color-text-muted); padding: 0.2rem; border-radius: var(--radius-sm); cursor: pointer; transition: background 0.2s ease, color 0.2s ease; }
|
.icon-btn { background: none; border: none; color: var(--color-text-muted); padding: 0.2rem; border-radius: var(--radius-sm); cursor: pointer; transition: background 0.2s ease, color 0.2s ease; }
|
||||||
.icon-btn:hover { color: var(--color-heading); background: var(--color-surface-muted); }
|
.icon-btn:hover { color: var(--color-heading); background: var(--color-surface-muted); }
|
||||||
.matrix-hint { font-size: 0.8rem; color: var(--color-text-muted); }
|
.matrix-hint { font-size: 0.8rem; color: var(--color-text-muted); }
|
||||||
|
|||||||
+169
-73
@@ -1,7 +1,5 @@
|
|||||||
/* Passkey Authentication – Unified Layout */
|
|
||||||
|
|
||||||
:root {
|
:root {
|
||||||
color-scheme: light dark;
|
|
||||||
--font-sans: "Inter", "Inter var", "Segoe UI", system-ui, -apple-system, "Helvetica Neue", sans-serif;
|
--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;
|
--font-mono: "DM Mono", "JetBrains Mono", "SFMono-Regular", Menlo, Monaco, Consolas, "Liberation Mono", monospace;
|
||||||
--color-canvas: #f5f6f8;
|
--color-canvas: #f5f6f8;
|
||||||
@@ -36,14 +34,13 @@
|
|||||||
--space-lg: 1.5rem;
|
--space-lg: 1.5rem;
|
||||||
--space-xl: 2.25rem;
|
--space-xl: 2.25rem;
|
||||||
--space-xxl: 3.5rem;
|
--space-xxl: 3.5rem;
|
||||||
--layout-max-width: 1080px;
|
--layout-max-width: 1400px;
|
||||||
--layout-padding: clamp(1.5rem, 3vw + 1rem, 3.25rem);
|
--layout-padding: clamp(1.5rem, 3vw + 1rem, 3.25rem);
|
||||||
--transition-base: 160ms ease;
|
--transition-base: 160ms ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (prefers-color-scheme: dark) {
|
@media (prefers-color-scheme: dark) {
|
||||||
:root {
|
:root {
|
||||||
color-scheme: dark;
|
|
||||||
--color-canvas: #0f172a;
|
--color-canvas: #0f172a;
|
||||||
--color-surface: #141b2f;
|
--color-surface: #141b2f;
|
||||||
--color-surface-subtle: #1b243b;
|
--color-surface-subtle: #1b243b;
|
||||||
@@ -58,13 +55,13 @@
|
|||||||
--color-accent-strong: #3b82f6;
|
--color-accent-strong: #3b82f6;
|
||||||
--color-accent-contrast: #0b1120;
|
--color-accent-contrast: #0b1120;
|
||||||
--color-success-text: #34d399;
|
--color-success-text: #34d399;
|
||||||
--color-success-bg: rgba(34, 197, 94, 0.12);
|
--color-success-bg: #1a4d2e;
|
||||||
--color-error-text: #fca5a5;
|
--color-error-text: #fca5a5;
|
||||||
--color-error-bg: rgba(248, 113, 113, 0.16);
|
--color-error-bg: #4a1f1f;
|
||||||
--color-info-text: #bae6fd;
|
--color-info-text: #bae6fd;
|
||||||
--color-info-bg: rgba(59, 130, 246, 0.16);
|
--color-info-bg: #1e3a5f;
|
||||||
--color-danger: #f87171;
|
--color-danger: #f87171;
|
||||||
--shadow-soft: 0 0 0 rgba(0, 0, 0, 0);
|
--shadow-soft: 0 0 0 #000000;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -74,15 +71,16 @@
|
|||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
}
|
}
|
||||||
|
|
||||||
html,
|
html {
|
||||||
body {
|
|
||||||
height: 100%;
|
height: 100%;
|
||||||
|
background: var(--color-canvas);
|
||||||
}
|
}
|
||||||
|
|
||||||
body {
|
body {
|
||||||
|
height: 100%;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
font-family: var(--font-sans);
|
font-family: var(--font-sans);
|
||||||
background: var(--color-canvas);
|
background: none;
|
||||||
color: var(--color-text);
|
color: var(--color-text);
|
||||||
line-height: 1.55;
|
line-height: 1.55;
|
||||||
-webkit-font-smoothing: antialiased;
|
-webkit-font-smoothing: antialiased;
|
||||||
@@ -124,7 +122,6 @@ a:focus-visible {
|
|||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
min-height: 100vh;
|
min-height: 100vh;
|
||||||
background: var(--color-canvas);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.app-main {
|
.app-main {
|
||||||
@@ -137,23 +134,22 @@ a:focus-visible {
|
|||||||
flex: 1;
|
flex: 1;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
display: flex;
|
display: flex;
|
||||||
padding: var(--layout-padding);
|
|
||||||
box-sizing: border-box;
|
|
||||||
}
|
|
||||||
|
|
||||||
.view-content {
|
|
||||||
flex: 1;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 2rem;
|
gap: 2rem;
|
||||||
|
padding: var(--layout-padding);
|
||||||
|
box-sizing: border-box;
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
width: min(100%, var(--layout-max-width));
|
width: min(100%, var(--layout-max-width));
|
||||||
}
|
}
|
||||||
|
|
||||||
.view-content--wide {
|
.view-root--wide {
|
||||||
width: min(100%, 1200px);
|
width: min(100%, 1200px);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.view-root--narrow {
|
||||||
|
max-width: 540px;
|
||||||
|
}
|
||||||
|
|
||||||
.view-header {
|
.view-header {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
@@ -233,8 +229,8 @@ button:focus-visible {
|
|||||||
}
|
}
|
||||||
|
|
||||||
button:disabled {
|
button:disabled {
|
||||||
opacity: 0.6;
|
|
||||||
cursor: not-allowed;
|
cursor: not-allowed;
|
||||||
|
filter: opacity(0.6);
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-primary {
|
.btn-primary {
|
||||||
@@ -289,7 +285,7 @@ input:focus-visible,
|
|||||||
textarea:focus-visible,
|
textarea:focus-visible,
|
||||||
select:focus-visible {
|
select:focus-visible {
|
||||||
border-color: var(--color-accent);
|
border-color: var(--color-accent);
|
||||||
box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.15);
|
box-shadow: 0 0 0 3px #c7d2fe;
|
||||||
outline: none;
|
outline: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -373,19 +369,19 @@ th {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.status.info {
|
.status.info {
|
||||||
border-color: rgba(14, 96, 155, 0.28);
|
border-color: #3b82f6;
|
||||||
color: var(--color-info-text);
|
color: var(--color-info-text);
|
||||||
background: var(--color-info-bg);
|
background: var(--color-info-bg);
|
||||||
}
|
}
|
||||||
|
|
||||||
.status.success {
|
.status.success {
|
||||||
border-color: rgba(6, 118, 71, 0.22);
|
border-color: #16a34a;
|
||||||
color: var(--color-success-text);
|
color: var(--color-success-text);
|
||||||
background: var(--color-success-bg);
|
background: var(--color-success-bg);
|
||||||
}
|
}
|
||||||
|
|
||||||
.status.error {
|
.status.error {
|
||||||
border-color: rgba(180, 35, 24, 0.28);
|
border-color: #dc2626;
|
||||||
color: var(--color-error-text);
|
color: var(--color-error-text);
|
||||||
background: var(--color-error-bg);
|
background: var(--color-error-bg);
|
||||||
}
|
}
|
||||||
@@ -393,8 +389,9 @@ th {
|
|||||||
.dialog-overlay {
|
.dialog-overlay {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
inset: 0;
|
inset: 0;
|
||||||
background: rgba(9, 14, 24, 0.55);
|
background: transparent;
|
||||||
backdrop-filter: blur(6px);
|
backdrop-filter: blur(.1rem) brightness(0.7);
|
||||||
|
-webkit-backdrop-filter: blur(.1rem) brightness(0.7);
|
||||||
z-index: 1100;
|
z-index: 1100;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -425,9 +422,9 @@ th {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.qr-code {
|
.qr-code {
|
||||||
border: 1px solid var(--color-border);
|
padding: 1rem;
|
||||||
padding: 0.75rem;
|
background: #fff;
|
||||||
background: var(--color-surface);
|
box-shadow: var(--shadow-soft);
|
||||||
}
|
}
|
||||||
|
|
||||||
.link-container,
|
.link-container,
|
||||||
@@ -440,60 +437,118 @@ th {
|
|||||||
color: var(--color-text);
|
color: var(--color-text);
|
||||||
}
|
}
|
||||||
|
|
||||||
.credential-list {
|
:root { --card-width: 22rem; }
|
||||||
|
|
||||||
|
.record-list,
|
||||||
|
.credential-list,
|
||||||
|
.session-list {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
|
grid-auto-flow: row;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(var(--card-width), 1fr));
|
||||||
|
justify-content: start;
|
||||||
gap: 1rem 1.25rem;
|
gap: 1rem 1.25rem;
|
||||||
align-items: stretch;
|
align-items: stretch;
|
||||||
|
margin: 0 auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
.credential-item {
|
@media (max-width: 720px) {
|
||||||
|
.record-list { display: flex; flex-direction: column; max-width: 100%; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.record-item,
|
||||||
|
.credential-item,
|
||||||
|
.session-item {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 0.75rem;
|
gap: 0.75rem;
|
||||||
padding: 0.85rem 1rem;
|
padding: 1rem;
|
||||||
border: 1px solid var(--color-border);
|
border: 1px solid var(--color-border);
|
||||||
border-radius: var(--radius-sm);
|
border-radius: var(--radius-md);
|
||||||
background: var(--color-surface);
|
background: var(--color-surface);
|
||||||
height: 100%;
|
height: 100%;
|
||||||
|
transition: border-color 0.2s ease, box-shadow 0.2s ease, transform 0.2s ease;
|
||||||
|
position: relative;
|
||||||
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
|
||||||
.credential-item.current-session {
|
.record-item:hover,
|
||||||
border-color: var(--color-accent);
|
.credential-item:hover,
|
||||||
background: rgba(37, 99, 235, 0.08);
|
.session-item:hover {
|
||||||
|
border-color: var(--color-border-strong);
|
||||||
|
box-shadow: 0 10px 24px rgba(15, 23, 42, 0.12);
|
||||||
|
transform: translateY(-1px);
|
||||||
}
|
}
|
||||||
|
|
||||||
.credential-header {
|
.record-item.is-current,
|
||||||
|
.credential-item.current-session,
|
||||||
|
.credential-item.is-hovered,
|
||||||
|
.session-item.is-current,
|
||||||
|
.session-item.is-hovered { border-color: var(--color-accent); }
|
||||||
|
|
||||||
|
.credential-item.is-linked-session,
|
||||||
|
.session-item.is-linked-credential { border-color: var(--color-accent); background-color: var(--color-surface-subtle); }
|
||||||
|
|
||||||
|
.item-top {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
gap: 1rem;
|
gap: 1rem;
|
||||||
align-items: flex-start;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
flex: 1 1 auto;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.credential-icon {
|
.item-icon {
|
||||||
width: 40px;
|
width: 40px;
|
||||||
height: 40px;
|
height: 40px;
|
||||||
display: grid;
|
display: grid;
|
||||||
place-items: center;
|
place-items: center;
|
||||||
|
background: var(--color-surface-subtle, transparent);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.credential-info {
|
.auth-icon {
|
||||||
flex: 1 1 auto;
|
border-radius: var(--radius-sm);
|
||||||
}
|
}
|
||||||
|
|
||||||
.credential-info h4 {
|
.item-title {
|
||||||
|
flex: 1;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
font-size: 1rem;
|
font-size: 1rem;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: var(--color-heading);
|
color: var(--color-heading);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.item-actions {
|
||||||
|
flex-shrink: 0;
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.item-actions .badge + .btn-card-delete { margin-left: 0.25rem; }
|
||||||
|
.item-actions .badge + .badge { margin-left: 0.25rem; }
|
||||||
|
|
||||||
|
.item-details {
|
||||||
|
margin-left: calc(40px + 1rem);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
.credential-dates {
|
.credential-dates {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-auto-flow: row;
|
grid-auto-flow: row;
|
||||||
grid-template-columns: auto 1fr;
|
grid-template-columns: 7rem 1fr;
|
||||||
|
gap: 0.35rem 0.5rem;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.session-dates {
|
||||||
|
display: grid;
|
||||||
|
grid-auto-flow: row;
|
||||||
|
grid-template-columns: 7rem 1fr;
|
||||||
gap: 0.35rem 0.5rem;
|
gap: 0.35rem 0.5rem;
|
||||||
font-size: 0.75rem;
|
font-size: 0.75rem;
|
||||||
color: var(--color-text-muted);
|
color: var(--color-text-muted);
|
||||||
@@ -509,27 +564,49 @@ th {
|
|||||||
color: var(--color-text);
|
color: var(--color-text);
|
||||||
}
|
}
|
||||||
|
|
||||||
.credential-actions {
|
.btn-card-delete { background: transparent; border: none; color: var(--color-danger); padding: 0.35rem 0.5rem; font-size: 1.05rem; line-height: 1; border-radius: var(--radius-sm); cursor: pointer; display: inline-flex; align-items: center; justify-content: center; }
|
||||||
margin-left: auto;
|
.btn-card-delete:hover:not(:disabled) { background: #fee; }
|
||||||
display: flex;
|
.btn-card-delete:disabled { filter: opacity(0.4); cursor: not-allowed; }
|
||||||
align-items: center;
|
|
||||||
|
|
||||||
|
.session-emoji {
|
||||||
|
font-size: 1.2rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-delete-credential {
|
.badge {
|
||||||
background: transparent;
|
padding: 0.2rem 0.5rem;
|
||||||
border: none;
|
border-radius: var(--radius-sm);
|
||||||
color: var(--color-danger);
|
font-size: 0.8rem;
|
||||||
padding: 0.25rem 0.35rem;
|
font-weight: 500;
|
||||||
font-size: 1.05rem;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-delete-credential:hover:not(:disabled) {
|
.badge-current {
|
||||||
background: rgba(220, 38, 38, 0.08);
|
background: var(--color-accent);
|
||||||
|
color: var(--color-accent-contrast);
|
||||||
|
box-shadow: 0 0 0 1px var(--color-accent) inset;
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-delete-credential:disabled {
|
.badge:not(.badge-current) {
|
||||||
opacity: 0.35;
|
background: var(--color-surface-subtle);
|
||||||
cursor: not-allowed;
|
color: var(--color-text-muted);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
.session-meta-info {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
font-family: monospace;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-state {
|
||||||
|
text-align: center;
|
||||||
|
padding: var(--space-lg);
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-state p {
|
||||||
|
margin: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.user-info {
|
.user-info {
|
||||||
@@ -573,9 +650,6 @@ th {
|
|||||||
@media (max-width: 720px) {
|
@media (max-width: 720px) {
|
||||||
.view-root {
|
.view-root {
|
||||||
padding: clamp(1rem, 3vw + 0.75rem, 2rem);
|
padding: clamp(1rem, 3vw + 0.75rem, 2rem);
|
||||||
}
|
|
||||||
|
|
||||||
.view-content {
|
|
||||||
gap: 1.75rem;
|
gap: 1.75rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -584,6 +658,13 @@ th {
|
|||||||
grid-template-columns: auto auto;
|
grid-template-columns: auto auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.global-status {
|
||||||
|
top: 1rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Mobile portrait (touch) or very narrow screens: stack buttons */
|
||||||
|
@media (max-width: 500px) and (orientation: portrait) and (pointer: coarse), (max-width: 350px) {
|
||||||
button {
|
button {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
@@ -591,21 +672,17 @@ th {
|
|||||||
.button-row {
|
.button-row {
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
}
|
}
|
||||||
|
|
||||||
.global-status {
|
|
||||||
top: 1rem;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Dialog styles for auth views */
|
|
||||||
.dialog-backdrop {
|
.dialog-backdrop {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
top: 0;
|
top: 0;
|
||||||
left: 0;
|
left: 0;
|
||||||
width: 100vw;
|
width: 100vw;
|
||||||
height: 100vh;
|
height: 100vh;
|
||||||
background: rgba(0, 0, 0, 0.5);
|
background: transparent;
|
||||||
backdrop-filter: blur(4px);
|
backdrop-filter: blur(.1rem) brightness(0.7);
|
||||||
|
-webkit-backdrop-filter: blur(.1rem) brightness(0.7);
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
@@ -625,7 +702,7 @@ th {
|
|||||||
padding: 2rem;
|
padding: 2rem;
|
||||||
background: var(--color-surface);
|
background: var(--color-surface);
|
||||||
border-radius: var(--radius-lg);
|
border-radius: var(--radius-lg);
|
||||||
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3);
|
box-shadow: 0 20px 60px #1e293b;
|
||||||
border: 1px solid var(--color-border);
|
border: 1px solid var(--color-border);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -642,3 +719,22 @@ th {
|
|||||||
padding: 1.5rem;
|
padding: 1.5rem;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Auth iframe overlay styles */
|
||||||
|
body:has(#auth-iframe) {
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
#auth-iframe {
|
||||||
|
border: none;
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
z-index: 9999;
|
||||||
|
color-scheme: auto;
|
||||||
|
background: transparent;
|
||||||
|
backdrop-filter: blur(.1rem) brightness(0.7);
|
||||||
|
-webkit-backdrop-filter: blur(.1rem) brightness(0.7);
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,44 @@
|
|||||||
|
<template>
|
||||||
|
<div class="message-container">
|
||||||
|
<div class="message-content">
|
||||||
|
<h2>🔒 Access Denied</h2>
|
||||||
|
<div class="button-row">
|
||||||
|
<button class="btn-secondary" @click="goBack">Back</button>
|
||||||
|
<button class="btn-primary" @click="$emit('reload')">Reload Page</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { goBack } from '@/utils/helpers'
|
||||||
|
|
||||||
|
defineEmits(['reload'])
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.message-container {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
height: 100vh;
|
||||||
|
padding: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-content {
|
||||||
|
text-align: center;
|
||||||
|
max-width: 480px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-content h2 {
|
||||||
|
margin: 0 0 1.5rem;
|
||||||
|
color: var(--color-heading);
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-content .button-row {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.75rem;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -6,10 +6,17 @@
|
|||||||
<div
|
<div
|
||||||
v-for="credential in credentials"
|
v-for="credential in credentials"
|
||||||
:key="credential.credential_uuid"
|
:key="credential.credential_uuid"
|
||||||
:class="['credential-item', { 'current-session': credential.is_current_session }]"
|
:class="['credential-item', {
|
||||||
|
'current-session': credential.is_current_session && !hoveredCredentialUuid && !hoveredSessionCredentialUuid,
|
||||||
|
'is-hovered': hoveredCredentialUuid === credential.credential_uuid,
|
||||||
|
'is-linked-session': hoveredSessionCredentialUuid === credential.credential_uuid
|
||||||
|
}]"
|
||||||
|
tabindex="0"
|
||||||
|
@focusin="handleCredentialFocus(credential.credential_uuid)"
|
||||||
|
@focusout="handleCredentialBlur($event)"
|
||||||
>
|
>
|
||||||
<div class="credential-header">
|
<div class="item-top">
|
||||||
<div class="credential-icon">
|
<div class="item-icon">
|
||||||
<img
|
<img
|
||||||
v-if="getCredentialAuthIcon(credential)"
|
v-if="getCredentialAuthIcon(credential)"
|
||||||
:src="getCredentialAuthIcon(credential)"
|
:src="getCredentialAuthIcon(credential)"
|
||||||
@@ -20,31 +27,37 @@
|
|||||||
>
|
>
|
||||||
<span v-else class="auth-emoji">🔑</span>
|
<span v-else class="auth-emoji">🔑</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="credential-info">
|
<h4 class="item-title">{{ getCredentialAuthName(credential) }}</h4>
|
||||||
<h4>{{ getCredentialAuthName(credential) }}</h4>
|
<div class="item-actions">
|
||||||
</div>
|
<span v-if="credential.is_current_session && !hoveredCredentialUuid && !hoveredSessionCredentialUuid" class="badge badge-current">Current</span>
|
||||||
<div class="credential-dates">
|
<span v-else-if="hoveredCredentialUuid === credential.credential_uuid" class="badge badge-current">Selected</span>
|
||||||
<span class="date-label">Created:</span>
|
<span v-else-if="hoveredSessionCredentialUuid === credential.credential_uuid" class="badge badge-current">Linked</span>
|
||||||
<span class="date-value">{{ formatDate(credential.created_at) }}</span>
|
|
||||||
<span class="date-label" v-if="credential.last_used">Last used:</span>
|
|
||||||
<span class="date-value" v-if="credential.last_used">{{ formatDate(credential.last_used) }}</span>
|
|
||||||
</div>
|
|
||||||
<div class="credential-actions" v-if="allowDelete">
|
|
||||||
<button
|
<button
|
||||||
|
v-if="allowDelete"
|
||||||
@click="$emit('delete', credential)"
|
@click="$emit('delete', credential)"
|
||||||
class="btn-delete-credential"
|
class="btn-card-delete"
|
||||||
:disabled="credential.is_current_session"
|
:disabled="credential.is_current_session"
|
||||||
:title="credential.is_current_session ? 'Cannot delete current session credential' : 'Delete passkey'"
|
:title="credential.is_current_session ? 'Cannot delete current session credential' : 'Delete passkey'"
|
||||||
>🗑️</button>
|
>🗑️</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="item-details">
|
||||||
|
<div class="credential-dates">
|
||||||
|
<span class="date-label">Created:</span>
|
||||||
|
<span class="date-value">{{ formatDate(credential.created_at) }}</span>
|
||||||
|
<span class="date-label">Last used:</span>
|
||||||
|
<span class="date-value">{{ formatDate(credential.last_used) }}</span>
|
||||||
|
<span class="date-label">Last verified:</span>
|
||||||
|
<span class="date-value">{{ formatDate(credential.last_verified) }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { computed } from 'vue'
|
import { computed, ref } from 'vue'
|
||||||
import { formatDate } from '@/utils/helpers'
|
import { formatDate } from '@/utils/helpers'
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
@@ -52,8 +65,23 @@ const props = defineProps({
|
|||||||
aaguidInfo: { type: Object, default: () => ({}) },
|
aaguidInfo: { type: Object, default: () => ({}) },
|
||||||
loading: { type: Boolean, default: false },
|
loading: { type: Boolean, default: false },
|
||||||
allowDelete: { type: Boolean, default: false },
|
allowDelete: { type: Boolean, default: false },
|
||||||
|
hoveredCredentialUuid: { type: String, default: null },
|
||||||
|
hoveredSessionCredentialUuid: { type: String, default: null },
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const emit = defineEmits(['delete', 'credentialHover'])
|
||||||
|
|
||||||
|
const handleCredentialFocus = (uuid) => {
|
||||||
|
emit('credentialHover', uuid)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleCredentialBlur = (event) => {
|
||||||
|
// Only clear if focus moved outside this element
|
||||||
|
if (!event.currentTarget.contains(event.relatedTarget)) {
|
||||||
|
emit('credentialHover', null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const getCredentialAuthName = (credential) => {
|
const getCredentialAuthName = (credential) => {
|
||||||
const info = props.aaguidInfo?.[credential.aaguid]
|
const info = props.aaguidInfo?.[credential.aaguid]
|
||||||
return info ? info.name : 'Unknown Authenticator'
|
return info ? info.name : 'Unknown Authenticator'
|
||||||
@@ -67,121 +95,3 @@ const getCredentialAuthIcon = (credential) => {
|
|||||||
return info[iconKey] || null
|
return info[iconKey] || null
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
.credential-list {
|
|
||||||
width: 100%;
|
|
||||||
margin-top: var(--space-sm);
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
|
|
||||||
gap: 1rem 1.25rem;
|
|
||||||
align-items: stretch;
|
|
||||||
}
|
|
||||||
|
|
||||||
.credential-item {
|
|
||||||
border: 1px solid var(--color-border);
|
|
||||||
border-radius: var(--radius-sm);
|
|
||||||
padding: 0.85rem 1rem;
|
|
||||||
background: var(--color-surface);
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 0.75rem;
|
|
||||||
width: 28rem;
|
|
||||||
height: 100%;
|
|
||||||
transition: border-color 0.2s ease, box-shadow 0.2s ease, transform 0.2s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.credential-item:hover {
|
|
||||||
border-color: var(--color-border-strong);
|
|
||||||
box-shadow: 0 10px 24px rgba(15, 23, 42, 0.12);
|
|
||||||
transform: translateY(-1px);
|
|
||||||
}
|
|
||||||
|
|
||||||
.credential-item.current-session {
|
|
||||||
border-color: var(--color-accent);
|
|
||||||
background: rgba(37, 99, 235, 0.08);
|
|
||||||
}
|
|
||||||
|
|
||||||
.credential-header {
|
|
||||||
display: flex;
|
|
||||||
align-items: flex-start;
|
|
||||||
gap: 1rem;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
flex: 1 1 auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.credential-icon {
|
|
||||||
width: 40px;
|
|
||||||
height: 40px;
|
|
||||||
display: grid;
|
|
||||||
place-items: center;
|
|
||||||
background: var(--color-surface-subtle, transparent);
|
|
||||||
border-radius: var(--radius-sm);
|
|
||||||
border: 1px solid var(--color-border);
|
|
||||||
}
|
|
||||||
|
|
||||||
.auth-icon {
|
|
||||||
border-radius: var(--radius-sm);
|
|
||||||
}
|
|
||||||
|
|
||||||
.credential-info {
|
|
||||||
flex: 1 1 150px;
|
|
||||||
min-width: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.credential-info h4 {
|
|
||||||
margin: 0;
|
|
||||||
font-size: 1rem;
|
|
||||||
font-weight: 600;
|
|
||||||
color: var(--color-heading);
|
|
||||||
}
|
|
||||||
|
|
||||||
.credential-dates {
|
|
||||||
display: grid;
|
|
||||||
grid-auto-flow: row;
|
|
||||||
grid-template-columns: auto 1fr;
|
|
||||||
gap: 0.35rem 0.5rem;
|
|
||||||
font-size: 0.75rem;
|
|
||||||
align-items: center;
|
|
||||||
color: var(--color-text-muted);
|
|
||||||
}
|
|
||||||
|
|
||||||
.date-label {
|
|
||||||
font-weight: 600;
|
|
||||||
}
|
|
||||||
|
|
||||||
.date-value {
|
|
||||||
color: var(--color-text);
|
|
||||||
}
|
|
||||||
|
|
||||||
.credential-actions {
|
|
||||||
margin-left: auto;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-delete-credential {
|
|
||||||
background: none;
|
|
||||||
border: none;
|
|
||||||
cursor: pointer;
|
|
||||||
font-size: 1rem;
|
|
||||||
color: var(--color-danger);
|
|
||||||
padding: 0.25rem 0.35rem;
|
|
||||||
border-radius: var(--radius-sm);
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-delete-credential:hover:not(:disabled) {
|
|
||||||
background: rgba(220, 38, 38, 0.08);
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-delete-credential:disabled {
|
|
||||||
opacity: 0.35;
|
|
||||||
cursor: not-allowed;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 600px) {
|
|
||||||
.credential-list {
|
|
||||||
grid-template-columns: 1fr;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
|
|||||||
@@ -1,87 +1,46 @@
|
|||||||
<template>
|
<template>
|
||||||
<section class="view-root view-device-link">
|
<section class="view-root view-root--narrow view-device-link">
|
||||||
<div class="view-content view-content--narrow">
|
<header class="view-header">
|
||||||
<header class="view-header">
|
<h1>📱 Add Another Device</h1>
|
||||||
<h1>📱 Add Another Device</h1>
|
<p class="view-lede">Generate a one-time link to set up passkeys on a new device.</p>
|
||||||
<p class="view-lede">Generate a one-time link to set up passkeys on a new device.</p>
|
</header>
|
||||||
</header>
|
<div class="button-row" style="margin-top:1rem;">
|
||||||
<section class="section-block">
|
<button @click="showModal = true" class="btn-primary">Generate Registration Link</button>
|
||||||
<div class="section-body">
|
<button @click="authStore.currentView = 'profile'" class="btn-secondary">Back to Profile</button>
|
||||||
<div class="device-link-section">
|
|
||||||
<div class="qr-container">
|
|
||||||
<a :href="url" class="qr-link" @click="copyLink">
|
|
||||||
<canvas ref="qrCanvas" class="qr-code"></canvas>
|
|
||||||
<p v-if="url">
|
|
||||||
{{ url.replace(/^[^:]+:\/\//, '') }}
|
|
||||||
</p>
|
|
||||||
<p v-else>
|
|
||||||
<em>Generating link...</em>
|
|
||||||
</p>
|
|
||||||
</a>
|
|
||||||
<p>
|
|
||||||
<strong>Scan and visit the URL on another device.</strong><br>
|
|
||||||
<small>⚠️ Expires in 24 hours and can only be used once.</small>
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="button-row">
|
|
||||||
<button @click="authStore.currentView = 'profile'" class="btn-secondary">
|
|
||||||
Back to Profile
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
</div>
|
</div>
|
||||||
|
<RegistrationLinkModal
|
||||||
|
v-if="showModal"
|
||||||
|
endpoint="/auth/api/user/create-link"
|
||||||
|
:user-name="userName"
|
||||||
|
@close="showModal = false"
|
||||||
|
@copied="onCopied"
|
||||||
|
/>
|
||||||
</section>
|
</section>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { ref, onMounted, nextTick } from 'vue'
|
import { ref, onMounted } from 'vue'
|
||||||
import { useAuthStore } from '@/stores/auth'
|
import { useAuthStore } from '@/stores/auth'
|
||||||
import QRCode from 'qrcode/lib/browser'
|
import RegistrationLinkModal from '@/components/RegistrationLinkModal.vue'
|
||||||
|
|
||||||
const authStore = useAuthStore()
|
const authStore = useAuthStore()
|
||||||
const url = ref(null)
|
const userName = ref(null)
|
||||||
const qrCanvas = ref(null)
|
const showModal = ref(false)
|
||||||
|
|
||||||
const copyLink = async (event) => {
|
const onCopied = () => {
|
||||||
event.preventDefault()
|
authStore.showMessage('Link copied to clipboard!', 'success', 2500)
|
||||||
if (url.value) {
|
|
||||||
await navigator.clipboard.writeText(url.value)
|
|
||||||
authStore.showMessage('Link copied to clipboard!')
|
|
||||||
authStore.currentView = 'profile'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function drawQr() {
|
|
||||||
if (!url.value || !qrCanvas.value) return
|
|
||||||
await nextTick()
|
|
||||||
QRCode.toCanvas(qrCanvas.value, url.value, { scale: 8 }, (error) => {
|
|
||||||
if (error) console.error('Failed to generate QR code:', error)
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
try {
|
// Extract optional admin-provided query parameters (?user=Name&emoji=😀)
|
||||||
const response = await fetch('/auth/api/create-link', { method: 'POST' })
|
const params = new URLSearchParams(location.search)
|
||||||
const result = await response.json()
|
const qUser = params.get('user')
|
||||||
if (result.detail) throw new Error(result.detail)
|
if (qUser) userName.value = qUser.trim()
|
||||||
|
|
||||||
url.value = result.url
|
|
||||||
await drawQr()
|
|
||||||
} catch (error) {
|
|
||||||
authStore.showMessage(`Failed to create device link: ${error.message}`, 'error')
|
|
||||||
authStore.currentView = 'profile'
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.view-content--narrow {
|
|
||||||
max-width: 540px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.view-lede {
|
.view-lede {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
color: var(--color-text-muted);
|
color: var(--color-text-muted);
|
||||||
|
|||||||
@@ -0,0 +1,121 @@
|
|||||||
|
<template>
|
||||||
|
<section class="view-root host-view" data-view="host-profile">
|
||||||
|
<header class="view-header">
|
||||||
|
<h1>{{ headingTitle }}</h1>
|
||||||
|
<p class="view-lede">{{ subheading }}</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<section class="section-block">
|
||||||
|
<div class="section-body">
|
||||||
|
<UserBasicInfo
|
||||||
|
v-if="user"
|
||||||
|
:name="user.user_name"
|
||||||
|
:visits="user.visits || 0"
|
||||||
|
:created-at="user.created_at"
|
||||||
|
:last-seen="user.last_seen"
|
||||||
|
:org-display-name="orgDisplayName"
|
||||||
|
:role-name="roleDisplayName"
|
||||||
|
:can-edit="false"
|
||||||
|
/>
|
||||||
|
<p v-else class="empty-state">
|
||||||
|
{{ initializing ? 'Loading your account…' : 'No active session found.' }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="section-block">
|
||||||
|
<div class="section-body host-actions">
|
||||||
|
<div class="button-row">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="btn-secondary"
|
||||||
|
@click="goBack"
|
||||||
|
>
|
||||||
|
Back
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="btn-danger"
|
||||||
|
:disabled="authStore.isLoading"
|
||||||
|
@click="logout"
|
||||||
|
>
|
||||||
|
{{ authStore.isLoading ? 'Signing out…' : 'Logout' }}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
v-if="authSiteUrl"
|
||||||
|
type="button"
|
||||||
|
class="btn-primary"
|
||||||
|
:disabled="authStore.isLoading"
|
||||||
|
@click="goToAuthSite"
|
||||||
|
>
|
||||||
|
Full Profile
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<p class="note"><strong>Logout</strong> from {{ currentHost }}, or access your <strong>Full Profile</strong> at {{ authSiteHost }} (you may need to sign in again).</p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { computed } from 'vue'
|
||||||
|
import UserBasicInfo from '@/components/UserBasicInfo.vue'
|
||||||
|
import { useAuthStore } from '@/stores/auth'
|
||||||
|
import { goBack } from '@/utils/helpers'
|
||||||
|
|
||||||
|
defineProps({
|
||||||
|
initializing: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const authStore = useAuthStore()
|
||||||
|
const currentHost = window.location.host
|
||||||
|
|
||||||
|
const user = computed(() => authStore.userInfo?.user || null)
|
||||||
|
const orgDisplayName = computed(() => authStore.userInfo?.org?.display_name || '')
|
||||||
|
const roleDisplayName = computed(() => authStore.userInfo?.role?.display_name || '')
|
||||||
|
|
||||||
|
const headingTitle = computed(() => {
|
||||||
|
const service = authStore.settings?.rp_name
|
||||||
|
return service ? `${service} account` : 'Account overview'
|
||||||
|
})
|
||||||
|
|
||||||
|
const subheading = computed(() => {
|
||||||
|
return `You're signed in to ${currentHost}.`
|
||||||
|
})
|
||||||
|
|
||||||
|
const authSiteHost = computed(() => authStore.settings?.auth_host || '')
|
||||||
|
const authSiteUrl = computed(() => {
|
||||||
|
const host = authSiteHost.value
|
||||||
|
if (!host) return ''
|
||||||
|
let path = authStore.settings?.ui_base_path ?? '/auth/'
|
||||||
|
if (!path.startsWith('/')) path = `/${path}`
|
||||||
|
if (!path.endsWith('/')) path = `${path}/`
|
||||||
|
const protocol = window.location.protocol || 'https:'
|
||||||
|
return `${protocol}//${host}${path}`
|
||||||
|
})
|
||||||
|
|
||||||
|
const goToAuthSite = () => {
|
||||||
|
if (!authSiteUrl.value) return
|
||||||
|
window.location.href = authSiteUrl.value
|
||||||
|
}
|
||||||
|
|
||||||
|
const logout = async () => {
|
||||||
|
await authStore.logout()
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.host-view { padding: 3rem 1.5rem 4rem; }
|
||||||
|
.host-actions { display: flex; flex-direction: column; gap: 0.75rem; }
|
||||||
|
.host-actions .button-row { gap: 0.75rem; flex-wrap: wrap; }
|
||||||
|
.host-actions .button-row button { flex: 0 0 auto; }
|
||||||
|
.note { margin: 0; color: var(--color-text-muted); }
|
||||||
|
.empty-state { margin: 0; color: var(--color-text-muted); }
|
||||||
|
@media (max-width: 600px) {
|
||||||
|
.host-actions .button-row { flex-direction: column; }
|
||||||
|
.host-actions .button-row button { width: 100%; }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
<template>
|
||||||
|
<div class="loading-container">
|
||||||
|
<div class="loading-spinner"></div>
|
||||||
|
<p>{{ message }}</p>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
defineProps({
|
||||||
|
message: {
|
||||||
|
type: String,
|
||||||
|
default: 'Loading...'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.loading-container {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
height: 100vh;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading-spinner {
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
border: 4px solid var(--color-border);
|
||||||
|
border-top: 4px solid var(--color-primary);
|
||||||
|
border-radius: 50%;
|
||||||
|
animation: spin 1s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes spin {
|
||||||
|
0% { transform: rotate(0deg); }
|
||||||
|
100% { transform: rotate(360deg); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading-container p {
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -1,57 +0,0 @@
|
|||||||
<template>
|
|
||||||
<div class="dialog-backdrop">
|
|
||||||
<div class="dialog-container">
|
|
||||||
<div class="dialog-content dialog-content--narrow">
|
|
||||||
<header class="view-header">
|
|
||||||
<h1>🔐 {{ (authStore.settings?.rp_name || location.origin)}}</h1>
|
|
||||||
<p class="view-lede">User authentication is required for access.</p>
|
|
||||||
</header>
|
|
||||||
<section class="section-block">
|
|
||||||
<form class="section-body" @submit.prevent="handleLogin">
|
|
||||||
<button
|
|
||||||
type="submit"
|
|
||||||
class="btn-primary"
|
|
||||||
:disabled="authStore.isLoading"
|
|
||||||
>
|
|
||||||
{{ authStore.isLoading ? 'Authenticating...' : 'Login with Your Device' }}
|
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
</section>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup>
|
|
||||||
import { useAuthStore } from '@/stores/auth'
|
|
||||||
|
|
||||||
const authStore = useAuthStore()
|
|
||||||
|
|
||||||
const handleLogin = async () => {
|
|
||||||
try {
|
|
||||||
authStore.showMessage('Starting authentication...', 'info')
|
|
||||||
await authStore.authenticate()
|
|
||||||
authStore.showMessage('Authentication successful!', 'success', 2000)
|
|
||||||
authStore.currentView = 'profile'
|
|
||||||
} catch (error) {
|
|
||||||
authStore.showMessage(error.message, 'error')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
.view-lede {
|
|
||||||
margin: 0;
|
|
||||||
color: var(--color-text-muted);
|
|
||||||
}
|
|
||||||
|
|
||||||
.section-body {
|
|
||||||
gap: 1.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 720px) {
|
|
||||||
button {
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -17,8 +17,9 @@ defineEmits(['close'])
|
|||||||
left: 0;
|
left: 0;
|
||||||
right: 0;
|
right: 0;
|
||||||
bottom: 0;
|
bottom: 0;
|
||||||
background: rgba(0, 0, 0, 0.5);
|
background: transparent;
|
||||||
backdrop-filter: blur(.1rem);
|
backdrop-filter: blur(.1rem) brightness(0.7);
|
||||||
|
-webkit-backdrop-filter: blur(.1rem) brightness(0.7);
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
@@ -80,7 +81,7 @@ defineEmits(['close'])
|
|||||||
.modal :deep(.modal-form textarea:focus) {
|
.modal :deep(.modal-form textarea:focus) {
|
||||||
outline: none;
|
outline: none;
|
||||||
border-color: var(--color-accent);
|
border-color: var(--color-accent);
|
||||||
box-shadow: 0 0 0 2px rgba(37, 99, 235, 0.1);
|
box-shadow: 0 0 0 2px #c7d2fe;
|
||||||
}
|
}
|
||||||
|
|
||||||
.modal :deep(.modal-actions) {
|
.modal :deep(.modal-actions) {
|
||||||
|
|||||||
@@ -1,71 +1,105 @@
|
|||||||
<template>
|
<template>
|
||||||
<section class="view-root" data-view="profile">
|
<section class="view-root" data-view="profile">
|
||||||
<div class="view-content">
|
<header class="view-header">
|
||||||
<header class="view-header">
|
<h1>User Profile</h1>
|
||||||
<h1>👋 Welcome!</h1>
|
<Breadcrumbs :entries="breadcrumbEntries" />
|
||||||
<Breadcrumbs :entries="breadcrumbEntries" />
|
<p class="view-lede">Account dashboard for managing credentials and authenticating with other devices.</p>
|
||||||
<p class="view-lede">Manage your account details and passkeys.</p>
|
</header>
|
||||||
</header>
|
|
||||||
|
|
||||||
<section class="section-block">
|
<section class="section-block">
|
||||||
<UserBasicInfo
|
<UserBasicInfo
|
||||||
v-if="authStore.userInfo?.user"
|
v-if="authStore.userInfo?.user"
|
||||||
:name="authStore.userInfo.user.user_name"
|
:name="authStore.userInfo.user.user_name"
|
||||||
:visits="authStore.userInfo.user.visits || 0"
|
:visits="authStore.userInfo.user.visits || 0"
|
||||||
:created-at="authStore.userInfo.user.created_at"
|
:created-at="authStore.userInfo.user.created_at"
|
||||||
:last-seen="authStore.userInfo.user.last_seen"
|
:last-seen="authStore.userInfo.user.last_seen"
|
||||||
|
:loading="authStore.isLoading"
|
||||||
|
update-endpoint="/auth/api/user/display-name"
|
||||||
|
@saved="authStore.loadUserInfo()"
|
||||||
|
@edit-name="openNameDialog"
|
||||||
|
>
|
||||||
|
<div class="remote-auth-inline">
|
||||||
|
<label v-if="!showDeviceInfo" class="remote-auth-label">Code words from remote device:</label>
|
||||||
|
<RemoteAuth
|
||||||
|
ref="pairingEntry"
|
||||||
|
title=""
|
||||||
|
description=""
|
||||||
|
placeholder="word word word"
|
||||||
|
@completed="handlePairingCompleted"
|
||||||
|
@error="handlePairingError"
|
||||||
|
@device-info-visible="showDeviceInfo = $event"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</UserBasicInfo>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="section-block">
|
||||||
|
<div class="section-header">
|
||||||
|
<h2>Your Passkeys</h2>
|
||||||
|
<p class="section-description">Keep at least one trusted passkey so you can always sign in.</p>
|
||||||
|
</div>
|
||||||
|
<div class="section-body">
|
||||||
|
<CredentialList
|
||||||
|
:credentials="authStore.userInfo?.credentials || []"
|
||||||
|
:aaguid-info="authStore.userInfo?.aaguid_info || {}"
|
||||||
:loading="authStore.isLoading"
|
:loading="authStore.isLoading"
|
||||||
update-endpoint="/auth/api/user/display-name"
|
:hovered-credential-uuid="hoveredCredentialUuid"
|
||||||
@saved="authStore.loadUserInfo()"
|
:hovered-session-credential-uuid="hoveredSession?.credential_uuid"
|
||||||
@edit-name="openNameDialog"
|
allow-delete
|
||||||
|
@delete="handleDelete"
|
||||||
|
@credential-hover="hoveredCredentialUuid = $event"
|
||||||
/>
|
/>
|
||||||
</section>
|
|
||||||
|
|
||||||
<section class="section-block">
|
|
||||||
<div class="section-header">
|
|
||||||
<h2>Your Passkeys</h2>
|
|
||||||
<p class="section-description">Keep at least one trusted passkey so you can always sign in.</p>
|
|
||||||
</div>
|
|
||||||
<div class="section-body">
|
|
||||||
<CredentialList
|
|
||||||
:credentials="authStore.userInfo?.credentials || []"
|
|
||||||
:aaguid-info="authStore.userInfo?.aaguid_info || {}"
|
|
||||||
:loading="authStore.isLoading"
|
|
||||||
allow-delete
|
|
||||||
@delete="handleDelete"
|
|
||||||
/>
|
|
||||||
<div class="button-row">
|
|
||||||
<button @click="addNewCredential" class="btn-primary">
|
|
||||||
Add New Passkey
|
|
||||||
</button>
|
|
||||||
<button @click="authStore.currentView = 'device-link'" class="btn-secondary">
|
|
||||||
Add Another Device
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section class="section-block">
|
|
||||||
<div class="button-row">
|
<div class="button-row">
|
||||||
<button @click="logout" class="btn-danger logout-button">
|
<button @click="addNewCredential" class="btn-primary">Add New Passkey</button>
|
||||||
Logout
|
<button @click="showRegLink = true" class="btn-secondary">Add Another Device</button>
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
<!-- Name Edit Dialog -->
|
<SessionList
|
||||||
<Modal v-if="showNameDialog" @close="showNameDialog = false">
|
:sessions="sessions"
|
||||||
<h3>Edit Display Name</h3>
|
:terminating-sessions="terminatingSessions"
|
||||||
<form @submit.prevent="saveName" class="modal-form">
|
:hovered-credential-uuid="hoveredCredentialUuid"
|
||||||
<NameEditForm
|
@terminate="terminateSession"
|
||||||
label="Display Name"
|
@session-hover="hoveredSession = $event"
|
||||||
v-model="newName"
|
section-description="Review where you're signed in and end any sessions you no longer recognize."
|
||||||
:busy="saving"
|
/>
|
||||||
@cancel="showNameDialog = false"
|
|
||||||
/>
|
<Modal v-if="showNameDialog" @close="showNameDialog = false">
|
||||||
</form>
|
<h3>Edit Display Name</h3>
|
||||||
</Modal>
|
<form @submit.prevent="saveName" class="modal-form">
|
||||||
</div>
|
<NameEditForm
|
||||||
|
label="Display Name"
|
||||||
|
v-model="newName"
|
||||||
|
:busy="saving"
|
||||||
|
@cancel="showNameDialog = false"
|
||||||
|
/>
|
||||||
|
</form>
|
||||||
|
</Modal>
|
||||||
|
|
||||||
|
<section class="section-block">
|
||||||
|
<div class="button-row logout-row" :class="{ single: !hasMultipleSessions }">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="btn-secondary"
|
||||||
|
@click="goBack"
|
||||||
|
>
|
||||||
|
Back
|
||||||
|
</button>
|
||||||
|
<button v-if="!hasMultipleSessions" @click="logoutEverywhere" class="btn-danger logout-button" :disabled="authStore.isLoading">Logout</button>
|
||||||
|
<template v-else>
|
||||||
|
<button @click="logout" class="btn-danger logout-button" :disabled="authStore.isLoading">Logout</button>
|
||||||
|
<button @click="logoutEverywhere" class="btn-danger logout-button" :disabled="authStore.isLoading">All</button>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
<p class="logout-note" v-if="!hasMultipleSessions"><strong>Logout</strong> from {{ currentSessionHost }}.</p>
|
||||||
|
<p class="logout-note" v-else><strong>Logout</strong> this session on {{ currentSessionHost }}, or <strong>All</strong> sessions across all sites and devices for {{ rpName }}. You'll need to log in again with your passkey afterwards.</p>
|
||||||
|
</section>
|
||||||
|
<RegistrationLinkModal
|
||||||
|
v-if="showRegLink"
|
||||||
|
endpoint="/auth/api/user/create-link"
|
||||||
|
@close="showRegLink = false"
|
||||||
|
/>
|
||||||
</section>
|
</section>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -76,48 +110,57 @@ import CredentialList from '@/components/CredentialList.vue'
|
|||||||
import UserBasicInfo from '@/components/UserBasicInfo.vue'
|
import UserBasicInfo from '@/components/UserBasicInfo.vue'
|
||||||
import Modal from '@/components/Modal.vue'
|
import Modal from '@/components/Modal.vue'
|
||||||
import NameEditForm from '@/components/NameEditForm.vue'
|
import NameEditForm from '@/components/NameEditForm.vue'
|
||||||
|
import SessionList from '@/components/SessionList.vue'
|
||||||
|
import RegistrationLinkModal from '@/components/RegistrationLinkModal.vue'
|
||||||
|
import RemoteAuth from '@/components/RemoteAuthPermit.vue'
|
||||||
import { useAuthStore } from '@/stores/auth'
|
import { useAuthStore } from '@/stores/auth'
|
||||||
|
import { adminUiPath, makeUiHref } from '@/utils/settings'
|
||||||
import passkey from '@/utils/passkey'
|
import passkey from '@/utils/passkey'
|
||||||
|
import { goBack } from '@/utils/helpers'
|
||||||
|
import { apiJson } from '@/utils/api'
|
||||||
|
|
||||||
const authStore = useAuthStore()
|
const authStore = useAuthStore()
|
||||||
const updateInterval = ref(null)
|
const updateInterval = ref(null)
|
||||||
const showNameDialog = ref(false)
|
const showNameDialog = ref(false)
|
||||||
|
const showRegLink = ref(false)
|
||||||
const newName = ref('')
|
const newName = ref('')
|
||||||
const saving = ref(false)
|
const saving = ref(false)
|
||||||
|
const hoveredCredentialUuid = ref(null)
|
||||||
|
const hoveredSession = ref(null)
|
||||||
|
const showDeviceInfo = ref(false)
|
||||||
|
const pairingEntry = ref(null)
|
||||||
|
|
||||||
watch(showNameDialog, (newVal) => {
|
watch(showNameDialog, (newVal) => { if (newVal) newName.value = authStore.userInfo?.user?.user_name || '' })
|
||||||
if (newVal) {
|
|
||||||
newName.value = authStore.userInfo?.user?.user_name || ''
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
updateInterval.value = setInterval(() => {
|
updateInterval.value = setInterval(() => { if (authStore.userInfo) authStore.userInfo = { ...authStore.userInfo } }, 60000)
|
||||||
// Trigger Vue reactivity to update formatDate fields
|
|
||||||
if (authStore.userInfo) {
|
|
||||||
authStore.userInfo = { ...authStore.userInfo }
|
|
||||||
}
|
|
||||||
}, 60000) // Update every minute
|
|
||||||
})
|
})
|
||||||
|
|
||||||
onUnmounted(() => {
|
onUnmounted(() => { if (updateInterval.value) clearInterval(updateInterval.value) })
|
||||||
if (updateInterval.value) {
|
|
||||||
clearInterval(updateInterval.value)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
const addNewCredential = async () => {
|
const addNewCredential = async () => {
|
||||||
try {
|
try {
|
||||||
authStore.isLoading = true
|
await passkey.register(null, null, () => {
|
||||||
authStore.showMessage('Adding new passkey...', 'info')
|
authStore.showMessage('Adding new passkey...', 'info')
|
||||||
await passkey.register()
|
})
|
||||||
await authStore.loadUserInfo()
|
await authStore.loadUserInfo()
|
||||||
authStore.showMessage('New passkey added successfully!', 'success', 3000)
|
authStore.showMessage('New passkey added successfully!', 'success', 3000)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to add new passkey:', error)
|
console.error('Failed to add new passkey:', error)
|
||||||
authStore.showMessage(error.message, 'error')
|
authStore.showMessage(error.message, 'error')
|
||||||
} finally {
|
}
|
||||||
authStore.isLoading = false
|
}
|
||||||
|
|
||||||
|
const handlePairingCompleted = () => {
|
||||||
|
authStore.showMessage('The other device is now signed in!', 'success', 4000)
|
||||||
|
// Reset the form after a delay
|
||||||
|
setTimeout(() => pairingEntry.value?.reset(), 3000)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handlePairingError = (message) => {
|
||||||
|
// Error is already shown in the component, optionally show global message for severe errors
|
||||||
|
if (!message.includes('cancelled')) {
|
||||||
|
authStore.showMessage(message, 'error', 4000)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -128,80 +171,61 @@ const handleDelete = async (credential) => {
|
|||||||
try {
|
try {
|
||||||
await authStore.deleteCredential(credentialId)
|
await authStore.deleteCredential(credentialId)
|
||||||
authStore.showMessage('Passkey deleted successfully!', 'success', 3000)
|
authStore.showMessage('Passkey deleted successfully!', 'success', 3000)
|
||||||
} catch (error) {
|
} catch (error) { authStore.showMessage(`Failed to delete passkey: ${error.message}`, 'error') }
|
||||||
authStore.showMessage(`Failed to delete passkey: ${error.message}`, 'error')
|
}
|
||||||
|
|
||||||
|
const rpName = computed(() => authStore.settings?.rp_name || 'this service')
|
||||||
|
const sessions = computed(() => authStore.userInfo?.sessions || [])
|
||||||
|
const currentSessionHost = computed(() => {
|
||||||
|
const currentSession = sessions.value.find(session => session.is_current)
|
||||||
|
return currentSession?.host || 'this host'
|
||||||
|
})
|
||||||
|
const terminatingSessions = ref({})
|
||||||
|
|
||||||
|
const terminateSession = async (session) => {
|
||||||
|
const sessionId = session?.id
|
||||||
|
if (!sessionId) return
|
||||||
|
terminatingSessions.value = { ...terminatingSessions.value, [sessionId]: true }
|
||||||
|
try { await authStore.terminateSession(sessionId) }
|
||||||
|
catch (error) { authStore.showMessage(error.message || 'Failed to terminate session', 'error', 5000) }
|
||||||
|
finally {
|
||||||
|
const next = { ...terminatingSessions.value }
|
||||||
|
delete next[sessionId]
|
||||||
|
terminatingSessions.value = next
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const logout = async () => {
|
const logoutEverywhere = async () => { await authStore.logoutEverywhere() }
|
||||||
await authStore.logout()
|
const logout = async () => { await authStore.logout() }
|
||||||
}
|
const openNameDialog = () => { newName.value = authStore.userInfo?.user?.user_name || ''; showNameDialog.value = true }
|
||||||
|
|
||||||
const openNameDialog = () => {
|
|
||||||
newName.value = authStore.userInfo?.user?.user_name || ''
|
|
||||||
showNameDialog.value = true
|
|
||||||
}
|
|
||||||
|
|
||||||
const isAdmin = computed(() => !!(authStore.userInfo?.is_global_admin || authStore.userInfo?.is_org_admin))
|
const isAdmin = computed(() => !!(authStore.userInfo?.is_global_admin || authStore.userInfo?.is_org_admin))
|
||||||
|
const hasMultipleSessions = computed(() => sessions.value.length > 1)
|
||||||
const breadcrumbEntries = computed(() => {
|
const breadcrumbEntries = computed(() => { const entries = [{ label: 'Auth', href: makeUiHref() }]; if (isAdmin.value) entries.push({ label: 'Admin', href: adminUiPath() }); return entries })
|
||||||
const entries = [{ label: 'Auth', href: authStore.uiHref() }]
|
|
||||||
if (isAdmin.value) entries.push({ label: 'Admin', href: authStore.adminHomeHref() })
|
|
||||||
return entries
|
|
||||||
})
|
|
||||||
|
|
||||||
const saveName = async () => {
|
const saveName = async () => {
|
||||||
const name = newName.value.trim()
|
const name = newName.value.trim()
|
||||||
if (!name) {
|
if (!name) { authStore.showMessage('Name cannot be empty', 'error'); return }
|
||||||
authStore.showMessage('Name cannot be empty', 'error')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
try {
|
try {
|
||||||
saving.value = true
|
saving.value = true
|
||||||
const res = await fetch('/auth/api/user/display-name', {
|
await apiJson('/auth/api/user/display-name', { method: 'PUT', body: { display_name: name } })
|
||||||
method: 'PUT',
|
showNameDialog.value = false
|
||||||
headers: { 'content-type': 'application/json' },
|
|
||||||
body: JSON.stringify({ display_name: name })
|
|
||||||
})
|
|
||||||
const data = await res.json()
|
|
||||||
if (!res.ok || data.detail) throw new Error(data.detail || 'Update failed')
|
|
||||||
showNameDialog.value = false
|
|
||||||
await authStore.loadUserInfo()
|
await authStore.loadUserInfo()
|
||||||
authStore.showMessage('Name updated successfully!', 'success', 3000)
|
authStore.showMessage('Name updated successfully!', 'success', 3000)
|
||||||
} catch (e) {
|
} catch (e) { authStore.showMessage(e.message || 'Failed to update name', 'error') }
|
||||||
authStore.showMessage(e.message || 'Failed to update name', 'error')
|
finally { saving.value = false }
|
||||||
} finally {
|
|
||||||
saving.value = false
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.view-lede {
|
.view-lede { margin: 0; color: var(--color-text-muted); font-size: 1rem; }
|
||||||
margin: 0;
|
.section-header { display: flex; flex-direction: column; gap: 0.4rem; }
|
||||||
color: var(--color-text-muted);
|
.section-description { margin: 0; color: var(--color-text-muted); }
|
||||||
font-size: 1rem;
|
.empty-state { margin: 0; color: var(--color-text-muted); text-align: center; padding: 1rem 0; }
|
||||||
}
|
.logout-button { align-self: flex-start; }
|
||||||
|
.logout-row { gap: 1rem; }
|
||||||
.section-header {
|
.logout-row.single { justify-content: flex-start; }
|
||||||
display: flex;
|
.logout-note { margin: 0.75rem 0 0; color: var(--color-text-muted); font-size: 0.875rem; }
|
||||||
flex-direction: column;
|
.remote-auth-inline { display: flex; flex-direction: column; gap: 0.5rem; }
|
||||||
gap: 0.4rem;
|
.remote-auth-label { display: block; margin: 0; font-size: 0.875rem; color: var(--color-text-muted); font-weight: 500; }
|
||||||
}
|
@media (max-width: 720px) { .logout-button { width: 100%; } }
|
||||||
|
|
||||||
.section-description {
|
|
||||||
margin: 0;
|
|
||||||
color: var(--color-text-muted);
|
|
||||||
}
|
|
||||||
|
|
||||||
.logout-button {
|
|
||||||
align-self: flex-start;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 720px) {
|
|
||||||
.logout-button {
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</style>
|
</style>
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,161 @@
|
|||||||
|
<template>
|
||||||
|
<div class="qr-display">
|
||||||
|
<div class="qr-section">
|
||||||
|
<a :href="url" @click.prevent="copyLink" class="qr-link" title="Click to copy link">
|
||||||
|
<canvas ref="qrCanvas" class="qr-code"></canvas>
|
||||||
|
<div v-if="showLink && url" class="link-text">{{ displayUrl }}</div>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="showCopyToast" class="copy-toast">
|
||||||
|
✓ Link copied to clipboard
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, watch, nextTick, computed } from 'vue'
|
||||||
|
import QRCode from 'qrcode/lib/browser'
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
url: { type: String, required: true },
|
||||||
|
showLink: { type: Boolean, default: false }
|
||||||
|
})
|
||||||
|
|
||||||
|
const emit = defineEmits(['copied'])
|
||||||
|
|
||||||
|
const qrCanvas = ref(null)
|
||||||
|
const showCopyToast = ref(false)
|
||||||
|
|
||||||
|
let copyToastTimer = null
|
||||||
|
|
||||||
|
const displayUrl = computed(() => {
|
||||||
|
if (!props.url) return ''
|
||||||
|
return props.url.replace(/^https?:\/\//, '')
|
||||||
|
})
|
||||||
|
|
||||||
|
function drawQR() {
|
||||||
|
if (!props.url || !qrCanvas.value) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Clear the canvas first
|
||||||
|
const ctx = qrCanvas.value.getContext('2d')
|
||||||
|
ctx.clearRect(0, 0, qrCanvas.value.width, qrCanvas.value.height)
|
||||||
|
|
||||||
|
// Generate QR code synchronously
|
||||||
|
QRCode.toCanvas(qrCanvas.value, props.url, {
|
||||||
|
scale: 6,
|
||||||
|
margin: 0,
|
||||||
|
color: {
|
||||||
|
dark: '#000000',
|
||||||
|
light: '#FFFFFF'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Remove any inline styles added by QRCode library immediately
|
||||||
|
qrCanvas.value.removeAttribute('style')
|
||||||
|
} catch (err) {
|
||||||
|
console.error('QR code generation failed:', err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function copyLink() {
|
||||||
|
if (!props.url) return
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(props.url)
|
||||||
|
showCopyToast.value = true
|
||||||
|
emit('copied')
|
||||||
|
|
||||||
|
if (copyToastTimer) clearTimeout(copyToastTimer)
|
||||||
|
copyToastTimer = setTimeout(() => {
|
||||||
|
showCopyToast.value = false
|
||||||
|
}, 2000)
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to copy link:', err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Watch for URL changes
|
||||||
|
watch(() => props.url, () => {
|
||||||
|
drawQR()
|
||||||
|
}, { immediate: true })
|
||||||
|
|
||||||
|
// Watch for canvas ref becoming available
|
||||||
|
watch(qrCanvas, () => {
|
||||||
|
if (qrCanvas.value && props.url) {
|
||||||
|
drawQR()
|
||||||
|
}
|
||||||
|
}, { immediate: true })
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.qr-display {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.qr-section {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.qr-link {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
text-decoration: none;
|
||||||
|
color: inherit;
|
||||||
|
border-radius: var(--radius-sm, 6px);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.qr-code {
|
||||||
|
display: block;
|
||||||
|
width: 200px;
|
||||||
|
height: 200px;
|
||||||
|
max-width: 100%;
|
||||||
|
object-fit: contain;
|
||||||
|
border-radius: var(--radius-sm, 6px);
|
||||||
|
background: #ffffff;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.link-text {
|
||||||
|
padding: 0.5rem;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
font-family: monospace;
|
||||||
|
word-break: break-all;
|
||||||
|
line-height: 1.2;
|
||||||
|
transition: color 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.qr-link:hover .link-text {
|
||||||
|
color: var(--color-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.copy-toast {
|
||||||
|
position: absolute;
|
||||||
|
top: -2rem;
|
||||||
|
left: 50%;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
background: var(--color-success);
|
||||||
|
color: white;
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
font-size: 0.875rem;
|
||||||
|
z-index: 10;
|
||||||
|
animation: fadeInOut 2s ease-in-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes fadeInOut {
|
||||||
|
0%, 100% { opacity: 0; }
|
||||||
|
10%, 90% { opacity: 1; }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -1,87 +1,109 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="dialog-overlay" @keydown.esc.prevent="$emit('close')">
|
<div class="dialog-overlay" @keydown.esc.prevent="$emit('close')">
|
||||||
<div class="device-dialog" role="dialog" aria-modal="true" aria-labelledby="regTitle">
|
<div class="device-dialog" role="dialog" aria-modal="true" aria-labelledby="regTitle">
|
||||||
<div style="display:flex; justify-content:space-between; align-items:center; margin-bottom:10px;">
|
<div class="reg-header-row">
|
||||||
<h2 id="regTitle" style="margin:0; font-size:1.25rem;">📱 Device Registration Link</h2>
|
<h2 id="regTitle" class="reg-title">
|
||||||
|
📱 <span v-if="userName">Registration for {{ userName }}</span><span v-else>Add Another Device</span>
|
||||||
|
</h2>
|
||||||
<button class="icon-btn" @click="$emit('close')" aria-label="Close">❌</button>
|
<button class="icon-btn" @click="$emit('close')" aria-label="Close">❌</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="device-link-section">
|
<div class="device-link-section">
|
||||||
<div class="qr-container">
|
<!-- Loading state -->
|
||||||
<a v-if="url" :href="url" @click.prevent="copy" class="qr-link">
|
<div v-if="loading" class="loading-state">
|
||||||
<canvas ref="qrCanvas" class="qr-code"></canvas>
|
<div class="spinner-small"></div>
|
||||||
<p>{{ displayUrl }}</p>
|
<span>Generating registration link...</span>
|
||||||
</a>
|
|
||||||
<div v-else>
|
|
||||||
<em>Generating link...</em>
|
|
||||||
</div>
|
|
||||||
<p>
|
|
||||||
<strong>Scan and visit the URL on another device.</strong><br>
|
|
||||||
<small>⚠️ Expires in 24 hours and one-time use.</small>
|
|
||||||
</p>
|
|
||||||
<div v-if="expires" style="font-size:12px; margin-top:6px;">Expires: {{ new Date(expires).toLocaleString() }}</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Error state -->
|
||||||
|
<div v-else-if="error" class="error-state">
|
||||||
|
<p class="error-message">{{ error }}</p>
|
||||||
|
<button class="btn-secondary" @click="generateLink">Retry</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Success state with QR code and link -->
|
||||||
|
<template v-else-if="linkUrl">
|
||||||
|
<p class="reg-help">
|
||||||
|
Scan this QR code on the new device, or copy the link and open it there.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<QRCodeDisplay
|
||||||
|
:url="linkUrl"
|
||||||
|
:show-link="true"
|
||||||
|
@copied="onCopied"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<p class="expiry-note" v-if="expiresAt">
|
||||||
|
This link expires {{ formatDate(expiresAt).toLowerCase() }}.
|
||||||
|
</p>
|
||||||
|
</template>
|
||||||
</div>
|
</div>
|
||||||
<div style="display:flex; justify-content:flex-end; gap:.5rem; margin-top:10px;">
|
|
||||||
|
<div class="reg-actions">
|
||||||
<button class="btn-secondary" @click="$emit('close')">Close</button>
|
<button class="btn-secondary" @click="$emit('close')">Close</button>
|
||||||
<button class="btn-primary" :disabled="!url" @click="copy">Copy Link</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { ref, onMounted, watch, computed, nextTick } from 'vue'
|
import { ref, onMounted } from 'vue'
|
||||||
import QRCode from 'qrcode/lib/browser'
|
import QRCodeDisplay from '@/components/QRCodeDisplay.vue'
|
||||||
|
import { apiJson } from '@/utils/api'
|
||||||
|
import { formatDate } from '@/utils/helpers'
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
endpoint: { type: String, required: true }, // POST endpoint returning {url, expires}
|
endpoint: { type: String, required: true },
|
||||||
autoCopy: { type: Boolean, default: true }
|
userName: { type: String, default: '' }
|
||||||
})
|
})
|
||||||
|
|
||||||
const emit = defineEmits(['close','generated','copied'])
|
const emit = defineEmits(['close', 'copied'])
|
||||||
|
|
||||||
const url = ref(null)
|
const loading = ref(true)
|
||||||
const expires = ref(null)
|
const error = ref(null)
|
||||||
const qrCanvas = ref(null)
|
const linkUrl = ref(null)
|
||||||
|
const expiresAt = ref(null)
|
||||||
|
|
||||||
const displayUrl = computed(() => url.value ? url.value.replace(/^[^:]+:\/\//,'') : '')
|
async function generateLink() {
|
||||||
|
loading.value = true
|
||||||
|
error.value = null
|
||||||
|
linkUrl.value = null
|
||||||
|
expiresAt.value = null
|
||||||
|
|
||||||
async function fetchLink() {
|
|
||||||
try {
|
try {
|
||||||
const res = await fetch(props.endpoint, { method: 'POST' })
|
const data = await apiJson(props.endpoint, { method: 'POST' })
|
||||||
const data = await res.json()
|
if (data.url) {
|
||||||
if (data.detail) throw new Error(data.detail)
|
linkUrl.value = data.url
|
||||||
url.value = data.url
|
expiresAt.value = data.expires ? new Date(data.expires) : null
|
||||||
expires.value = data.expires
|
} else {
|
||||||
emit('generated', { url: data.url, expires: data.expires })
|
error.value = data.detail || 'Failed to generate link'
|
||||||
await nextTick()
|
}
|
||||||
drawQR()
|
} catch (err) {
|
||||||
if (props.autoCopy) copy()
|
error.value = err.message || 'Failed to generate link'
|
||||||
} catch (e) {
|
} finally {
|
||||||
url.value = null
|
loading.value = false
|
||||||
expires.value = null
|
|
||||||
console.error('Failed to create link', e)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function drawQR() {
|
function onCopied() {
|
||||||
if (!url.value) return
|
emit('copied')
|
||||||
await nextTick()
|
|
||||||
if (!qrCanvas.value) return
|
|
||||||
QRCode.toCanvas(qrCanvas.value, url.value, { scale: 8 }, err => { if (err) console.error(err) })
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function copy() {
|
onMounted(() => {
|
||||||
if (!url.value) return
|
generateLink()
|
||||||
try { await navigator.clipboard.writeText(url.value); emit('copied', url.value); emit('close') } catch (_) { /* ignore */ }
|
})
|
||||||
}
|
|
||||||
|
|
||||||
onMounted(fetchLink)
|
|
||||||
watch(url, () => drawQR(), { flush: 'post' })
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.icon-btn { background:none; border:none; cursor:pointer; font-size:1rem; opacity:.6; }
|
.icon-btn { background: none; border: none; cursor: pointer; font-size: 1rem; opacity: .6; }
|
||||||
.icon-btn:hover { opacity:1; }
|
.icon-btn:hover { opacity: 1; }
|
||||||
/* Minimal extra styling; main look comes from global styles */
|
.reg-header-row { display: flex; justify-content: space-between; align-items: center; gap: .75rem; margin-bottom: .75rem; }
|
||||||
.qr-link { text-decoration:none; color:inherit; }
|
.reg-title { margin: 0; font-size: 1.25rem; font-weight: 600; }
|
||||||
|
.device-dialog { background: var(--color-surface); padding: 1.25rem 1.25rem 1rem; border-radius: var(--radius-md); max-width: 480px; width: 100%; box-shadow: 0 6px 28px rgba(0,0,0,.25); }
|
||||||
|
.reg-help { margin: .5rem 0 .75rem; font-size: .85rem; line-height: 1.4; text-align: center; color: var(--color-text-muted); }
|
||||||
|
.reg-actions { display: flex; justify-content: flex-end; gap: .5rem; margin-top: 1rem; }
|
||||||
|
.loading-state { display: flex; align-items: center; justify-content: center; gap: .5rem; padding: 2rem 0; color: var(--color-text-muted); }
|
||||||
|
.error-state { text-align: center; padding: 1rem 0; }
|
||||||
|
.error-message { color: var(--color-danger-text); margin-bottom: 1rem; }
|
||||||
|
.expiry-note { font-size: .75rem; color: var(--color-text-muted); text-align: center; margin-top: .75rem; }
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -0,0 +1,894 @@
|
|||||||
|
<template>
|
||||||
|
<div class="pairing-entry">
|
||||||
|
<form @submit.prevent="submitCode" class="pairing-form">
|
||||||
|
<!-- Code input (shown when device info not yet received) -->
|
||||||
|
<div v-if="!deviceInfo" class="input-row">
|
||||||
|
<div class="input-wrapper" :class="{ 'has-error': serverError, 'is-complete': deviceInfo && !serverError, 'focused': isFocused }">
|
||||||
|
<!-- Visual slot-machine display overlay -->
|
||||||
|
<div class="slot-machine" :class="{ 'has-error': serverError, 'is-complete': deviceInfo && !serverError }" aria-hidden="true">
|
||||||
|
<div v-for="(word, index) in displayWords" :key="index" class="slot-reel" :class="{ 'invalid-word': word.invalid, 'empty': !word.text && !word.typedPrefix }">
|
||||||
|
<div class="slot-word">
|
||||||
|
<template v-if="word.typedPrefix">
|
||||||
|
<span class="typed-prefix">{{ word.typedPrefix }}</span><span class="hint-suffix">{{ word.hintSuffix }}</span>
|
||||||
|
<span v-if="word.hasCursor" class="cursor-overlay" :style="{ '--cursor-pos': word.cursorCharIndex, '--word-len': word.wordLen }"></span>
|
||||||
|
</template>
|
||||||
|
<template v-else-if="word.text">
|
||||||
|
{{ word.text }}
|
||||||
|
<span v-if="word.hasCursor" class="cursor-overlay" :style="{ '--cursor-pos': word.cursorCharIndex, '--word-len': word.wordLen }"></span>
|
||||||
|
</template>
|
||||||
|
<template v-else>
|
||||||
|
<span v-if="word.hasCursor" class="cursor-overlay" :style="{ '--cursor-pos': 0, '--word-len': 0 }"></span>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- Hidden input for actual text entry -->
|
||||||
|
<input
|
||||||
|
ref="inputRef"
|
||||||
|
v-model="code"
|
||||||
|
type="text"
|
||||||
|
:placeholder="placeholder"
|
||||||
|
autocomplete="off"
|
||||||
|
autocapitalize="none"
|
||||||
|
autocorrect="off"
|
||||||
|
spellcheck="false"
|
||||||
|
class="pairing-input hidden-input"
|
||||||
|
@input="handleInput"
|
||||||
|
@keydown="deferUpdateCursor"
|
||||||
|
@mouseup="updateCursorPos"
|
||||||
|
@focus="isFocused = true"
|
||||||
|
@blur="isFocused = false"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<!-- Processing status beside input -->
|
||||||
|
<div v-if="processingStatus" class="processing-status">
|
||||||
|
<span class="processing-icon">{{ processingStatus === 'pow' ? '🔐' : '📡' }}</span>
|
||||||
|
<span class="processing-spinner-small"></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Device info display (shown when 3 words match a request) -->
|
||||||
|
<div v-else-if="deviceInfo" class="device-info">
|
||||||
|
<p class="device-permit-text">Permit {{ deviceInfo.action === 'register' ? 'registration' : 'login' }} to <strong>{{ deviceInfo.host }}</strong></p>
|
||||||
|
<p class="device-meta">{{ deviceInfo.user_agent_pretty }}</p>
|
||||||
|
|
||||||
|
<p v-if="error" class="error-message" style="margin-top: 0.5rem;">{{ error }}</p>
|
||||||
|
|
||||||
|
<div class="button-row" style="margin-top: 0.75rem; display: flex; gap: 0.5rem;">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="btn-secondary"
|
||||||
|
:disabled="loading"
|
||||||
|
@click="deny"
|
||||||
|
style="flex: 1;"
|
||||||
|
>
|
||||||
|
Deny
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
ref="submitBtnRef"
|
||||||
|
type="submit"
|
||||||
|
:disabled="loading"
|
||||||
|
class="btn-primary"
|
||||||
|
style="flex: 1;"
|
||||||
|
>
|
||||||
|
{{ loading ? 'Authenticating…' : 'Authorize' }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p v-if="error && !deviceInfo" class="error-message">{{ error }}</p>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { computed, nextTick, onMounted, onUnmounted, ref, watch } from 'vue'
|
||||||
|
import { startAuthentication } from '@simplewebauthn/browser'
|
||||||
|
import aWebSocket from '@/utils/awaitable-websocket'
|
||||||
|
import { dec as b64dec, enc as b64enc } from '@/utils/base64url'
|
||||||
|
import { getSettings } from '@/utils/settings'
|
||||||
|
import { getUniqueMatch, isValidWord, isValidPrefix } from '@/utils/wordlist'
|
||||||
|
import { solvePoW } from '@/utils/pow'
|
||||||
|
import { useAuthStore } from '@/stores/auth'
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
title: { type: String, default: 'Help Another Device Sign In' },
|
||||||
|
description: { type: String, default: 'Enter the code shown on the device that needs to sign in.' },
|
||||||
|
placeholder: { type: String, default: 'Enter three words' },
|
||||||
|
action: { type: String, default: 'login' } // 'login' or 'register'
|
||||||
|
})
|
||||||
|
|
||||||
|
const emit = defineEmits(['completed', 'error', 'cancelled', 'back', 'register', 'deviceInfoVisible'])
|
||||||
|
|
||||||
|
// State
|
||||||
|
const loading = ref(false)
|
||||||
|
const error = ref(null)
|
||||||
|
const settings = ref(null)
|
||||||
|
let ws = null
|
||||||
|
let authStore = null
|
||||||
|
|
||||||
|
// Try to get authStore (might fail if Pinia not installed in this app instance)
|
||||||
|
try { authStore = useAuthStore() } catch (e) { /* ignore */ }
|
||||||
|
|
||||||
|
const inputRef = ref(null)
|
||||||
|
const submitBtnRef = ref(null)
|
||||||
|
const code = ref('')
|
||||||
|
const isProcessing = ref(false)
|
||||||
|
const processingStatus = ref('')
|
||||||
|
const deviceInfo = ref(null)
|
||||||
|
const autocompleteHint = ref('')
|
||||||
|
|
||||||
|
// Watch deviceInfo and emit visibility change
|
||||||
|
watch(deviceInfo, (newVal) => {
|
||||||
|
emit('deviceInfoVisible', !!newVal)
|
||||||
|
})
|
||||||
|
|
||||||
|
const hasInvalidWord = ref(false)
|
||||||
|
const serverError = ref(false)
|
||||||
|
const cursorPos = ref(0)
|
||||||
|
const isFocused = ref(false)
|
||||||
|
let wsConnecting = false
|
||||||
|
let currentChallenge = null
|
||||||
|
let currentWork = null
|
||||||
|
let powPromise = null
|
||||||
|
let powSolution = null
|
||||||
|
let lookupTimeout = null
|
||||||
|
let lastLookedUpCode = null
|
||||||
|
|
||||||
|
// --- Helpers ---
|
||||||
|
|
||||||
|
function showMessage(message, type = 'info', duration = 3000) {
|
||||||
|
if (authStore) {
|
||||||
|
authStore.showMessage(message, type, duration)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchSettings() {
|
||||||
|
try {
|
||||||
|
const data = await getSettings()
|
||||||
|
settings.value = data
|
||||||
|
} catch (err) {
|
||||||
|
console.warn('Unable to load settings', err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Input Mode Logic ---
|
||||||
|
|
||||||
|
function getWordAtCursor(input, cursor) {
|
||||||
|
if (!input || cursor < 0) return { word: '', start: 0, end: 0 }
|
||||||
|
let start = cursor, end = cursor
|
||||||
|
while (start > 0 && /[a-zA-Z]/.test(input[start - 1])) start--
|
||||||
|
while (end < input.length && /[a-zA-Z]/.test(input[end])) end++
|
||||||
|
return { word: input.slice(start, end), start, end }
|
||||||
|
}
|
||||||
|
|
||||||
|
function getWords(input) {
|
||||||
|
return input.trim().split(/[.\s]+/).filter(w => w.length > 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
function countCompleteWords(input) {
|
||||||
|
const endsWithSeparator = /[.\s]$/.test(input)
|
||||||
|
const words = getWords(input)
|
||||||
|
return endsWithSeparator ? words.length : Math.max(0, words.length - 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
function analyzeWords(input) {
|
||||||
|
if (!input) return { valid: true, segments: [] }
|
||||||
|
const segments = []
|
||||||
|
const endsWithSeparator = /[.\s]$/.test(input)
|
||||||
|
let match, regex = /([a-zA-Z]+)|([.\s]+)/g
|
||||||
|
while ((match = regex.exec(input)) !== null) {
|
||||||
|
if (match[1]) segments.push({ text: match[1], isWord: true, start: match.index })
|
||||||
|
else if (match[2]) segments.push({ text: match[2], isWord: false, start: match.index })
|
||||||
|
}
|
||||||
|
const words = segments.filter(s => s.isWord)
|
||||||
|
let allValid = true
|
||||||
|
words.forEach((wordSeg, idx) => {
|
||||||
|
const isLastWord = idx === words.length - 1
|
||||||
|
const word = wordSeg.text.toLowerCase()
|
||||||
|
if (isLastWord && !endsWithSeparator) wordSeg.invalid = !isValidPrefix(word)
|
||||||
|
else wordSeg.invalid = !isValidWord(word)
|
||||||
|
if (wordSeg.invalid) allValid = false
|
||||||
|
})
|
||||||
|
return { valid: allValid, segments }
|
||||||
|
}
|
||||||
|
|
||||||
|
const coloredSegments = computed(() => {
|
||||||
|
const { segments } = analyzeWords(code.value)
|
||||||
|
return segments.map(s => ({ text: s.text, invalid: s.invalid || false }))
|
||||||
|
})
|
||||||
|
|
||||||
|
function checkWordsValidity(input) { return analyzeWords(input).valid }
|
||||||
|
function allWordsValid(input) { return getWords(input).length > 0 && getWords(input).every(w => isValidWord(w)) }
|
||||||
|
|
||||||
|
// Get the current partial word being typed (not yet a complete word)
|
||||||
|
function getCurrentPartialWord(input) {
|
||||||
|
const endsWithSeparator = /[.\s]$/.test(input)
|
||||||
|
if (endsWithSeparator) return ''
|
||||||
|
const match = input.match(/[a-zA-Z]+$/)
|
||||||
|
return match ? match[0].toLowerCase() : ''
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate cursor position in the normalized display (wordIndex, charIndex within word)
|
||||||
|
// Returns { wordIndex: number, charIndex: number } where charIndex is position within the word text
|
||||||
|
function calcDisplayCursor(input, rawCursorPos) {
|
||||||
|
if (!input || rawCursorPos === 0) {
|
||||||
|
return { wordIndex: 0, charIndex: 0 }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse input to find word boundaries
|
||||||
|
const beforeCursor = input.slice(0, rawCursorPos)
|
||||||
|
const wordMatches = [...beforeCursor.matchAll(/[a-zA-Z]+/g)]
|
||||||
|
|
||||||
|
// Check if cursor is in whitespace after words
|
||||||
|
const endsWithSeparator = /[.\s]$/.test(beforeCursor)
|
||||||
|
|
||||||
|
if (wordMatches.length === 0) {
|
||||||
|
// No words before cursor, cursor is at start of first word
|
||||||
|
return { wordIndex: 0, charIndex: 0 }
|
||||||
|
}
|
||||||
|
|
||||||
|
const lastMatch = wordMatches[wordMatches.length - 1]
|
||||||
|
const lastMatchEnd = lastMatch.index + lastMatch[0].length
|
||||||
|
|
||||||
|
if (endsWithSeparator || rawCursorPos > lastMatchEnd) {
|
||||||
|
// Cursor is after the last word (in whitespace), so it's at start of next word
|
||||||
|
return { wordIndex: Math.min(wordMatches.length, 2), charIndex: 0 }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cursor is within the last word
|
||||||
|
const charIndex = rawCursorPos - lastMatch.index
|
||||||
|
return { wordIndex: wordMatches.length - 1, charIndex: charIndex }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Compute display words for slot-machine overlay (always 3 slots)
|
||||||
|
const displayWords = computed(() => {
|
||||||
|
const words = getWords(code.value)
|
||||||
|
const result = []
|
||||||
|
|
||||||
|
// Get analysis for validation
|
||||||
|
const { segments } = analyzeWords(code.value)
|
||||||
|
const wordSegments = segments.filter(s => s.isWord)
|
||||||
|
|
||||||
|
// Get current partial word and autocomplete hint
|
||||||
|
const partialWord = getCurrentPartialWord(code.value)
|
||||||
|
const hint = autocompleteHint.value
|
||||||
|
const endsWithSeparator = /[.\s]$/.test(code.value)
|
||||||
|
|
||||||
|
// Calculate where cursor should be displayed
|
||||||
|
const cursor = calcDisplayCursor(code.value, cursorPos.value)
|
||||||
|
|
||||||
|
// Always show exactly 3 slots
|
||||||
|
for (let i = 0; i < 3; i++) {
|
||||||
|
const isCursorSlot = cursor.wordIndex === i
|
||||||
|
|
||||||
|
if (i < words.length) {
|
||||||
|
const word = words[i].toLowerCase()
|
||||||
|
const isInvalid = wordSegments[i]?.invalid || false
|
||||||
|
const isLastWord = i === words.length - 1
|
||||||
|
|
||||||
|
if (isLastWord && !endsWithSeparator && hint && partialWord) {
|
||||||
|
// Show typed prefix + hint suffix in the same slot
|
||||||
|
// Total visible length is the full hint word
|
||||||
|
const totalLen = hint.length
|
||||||
|
result.push({
|
||||||
|
text: '',
|
||||||
|
typedPrefix: partialWord,
|
||||||
|
hintSuffix: hint.slice(partialWord.length),
|
||||||
|
invalid: isInvalid,
|
||||||
|
hasCursor: isCursorSlot,
|
||||||
|
cursorCharIndex: isCursorSlot ? cursor.charIndex : -1,
|
||||||
|
wordLen: totalLen
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
// Complete word - show cursor at appropriate position
|
||||||
|
result.push({
|
||||||
|
text: word,
|
||||||
|
invalid: isInvalid,
|
||||||
|
hasCursor: isCursorSlot,
|
||||||
|
cursorCharIndex: isCursorSlot ? cursor.charIndex : -1,
|
||||||
|
wordLen: word.length
|
||||||
|
})
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Empty slot
|
||||||
|
result.push({
|
||||||
|
text: '',
|
||||||
|
invalid: false,
|
||||||
|
hasCursor: isCursorSlot,
|
||||||
|
cursorCharIndex: 0,
|
||||||
|
wordLen: 0
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result
|
||||||
|
})
|
||||||
|
|
||||||
|
const hasThreeValidWords = computed(() => {
|
||||||
|
const words = getWords(code.value)
|
||||||
|
return words.length === 3 && words.every(w => isValidWord(w))
|
||||||
|
})
|
||||||
|
|
||||||
|
function normalizeCode(input) {
|
||||||
|
return input.trim().toLowerCase().split(/[.\s]+/).filter(w => w).join('.')
|
||||||
|
}
|
||||||
|
|
||||||
|
function startPowSolving() {
|
||||||
|
if (!currentChallenge || powPromise) return
|
||||||
|
const challenge = b64dec(currentChallenge)
|
||||||
|
powPromise = solvePoW(challenge, currentWork).then(solution => {
|
||||||
|
powSolution = solution
|
||||||
|
powPromise = null
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getPowSolution() {
|
||||||
|
if (powSolution) { const s = powSolution; powSolution = null; return s }
|
||||||
|
if (powPromise) { await powPromise; const s = powSolution; powSolution = null; return s }
|
||||||
|
if (!currentChallenge) throw new Error('No PoW challenge available')
|
||||||
|
const challenge = b64dec(currentChallenge)
|
||||||
|
return await solvePoW(challenge, currentWork)
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateChallenge(pow) {
|
||||||
|
if (pow?.challenge) {
|
||||||
|
currentChallenge = pow.challenge
|
||||||
|
currentWork = pow.work
|
||||||
|
powSolution = null
|
||||||
|
powPromise = null
|
||||||
|
startPowSolving()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function ensureConnection() {
|
||||||
|
if (ws || wsConnecting) return
|
||||||
|
wsConnecting = true
|
||||||
|
try {
|
||||||
|
const authHost = settings.value?.auth_host
|
||||||
|
const wsPath = '/auth/ws/remote-auth/pair'
|
||||||
|
const wsUrl = authHost && location.host !== authHost ? `//${authHost}${wsPath}` : wsPath
|
||||||
|
ws = await aWebSocket(wsUrl)
|
||||||
|
const msg = await ws.receive_json()
|
||||||
|
if (msg.status && msg.detail) throw new Error(msg.detail)
|
||||||
|
if (!msg.pow?.challenge) throw new Error('Server did not send PoW challenge')
|
||||||
|
updateChallenge(msg.pow)
|
||||||
|
} catch (err) {
|
||||||
|
console.error('WebSocket connection error:', err)
|
||||||
|
ws = null
|
||||||
|
throw err
|
||||||
|
} finally {
|
||||||
|
wsConnecting = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Defer cursor position update to after browser processes the key
|
||||||
|
function deferUpdateCursor(event) {
|
||||||
|
// Handle Tab/Space for autocomplete immediately
|
||||||
|
if (event.key === 'Tab' || event.key === ' ') {
|
||||||
|
handleKeydown(event)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Defer cursor update to next tick
|
||||||
|
setTimeout(updateCursorPos, 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update cursor position from input
|
||||||
|
function updateCursorPos() {
|
||||||
|
cursorPos.value = inputRef.value?.selectionStart ?? code.value.length
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateAutocomplete() {
|
||||||
|
cursorPos.value = inputRef.value?.selectionStart ?? code.value.length
|
||||||
|
const { word, end } = getWordAtCursor(code.value, cursorPos.value)
|
||||||
|
const completeWordCount = countCompleteWords(code.value)
|
||||||
|
if (completeWordCount >= 3 || !word || word.length < 1 || cursorPos.value !== end) {
|
||||||
|
autocompleteHint.value = ''
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const match = getUniqueMatch(word.toLowerCase())
|
||||||
|
if (match && match !== word.toLowerCase()) autocompleteHint.value = match
|
||||||
|
else autocompleteHint.value = ''
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyAutocomplete() {
|
||||||
|
if (!autocompleteHint.value) return false
|
||||||
|
const { word, start, end } = getWordAtCursor(code.value, cursorPos.value)
|
||||||
|
if (!word) return false
|
||||||
|
const before = code.value.slice(0, start)
|
||||||
|
const wordsBefore = getWords(before).length
|
||||||
|
const isThirdWord = wordsBefore === 2
|
||||||
|
const suffix = isThirdWord ? '' : ' '
|
||||||
|
const after = code.value.slice(end)
|
||||||
|
code.value = before + autocompleteHint.value + suffix + after.trimStart()
|
||||||
|
const newPos = start + autocompleteHint.value.length + suffix.length
|
||||||
|
nextTick(() => {
|
||||||
|
inputRef.value?.setSelectionRange(newPos, newPos)
|
||||||
|
cursorPos.value = newPos
|
||||||
|
})
|
||||||
|
autocompleteHint.value = ''
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try to split concatenated words (e.g., "alienalien" -> "alien alien")
|
||||||
|
function trySplitWords(input) {
|
||||||
|
// Only process if there's a continuous string of letters at the end
|
||||||
|
const match = input.match(/^(.*?)([a-zA-Z]+)$/)
|
||||||
|
if (!match) return input
|
||||||
|
|
||||||
|
const prefix = match[1] // Everything before the letter sequence
|
||||||
|
const letters = match[2].toLowerCase()
|
||||||
|
|
||||||
|
// Try to find valid word boundaries in the letter sequence
|
||||||
|
const foundWords = []
|
||||||
|
let remaining = letters
|
||||||
|
|
||||||
|
while (remaining.length > 0) {
|
||||||
|
let foundWord = null
|
||||||
|
|
||||||
|
// Try to find the longest valid word from the start
|
||||||
|
for (let len = Math.min(remaining.length, 6); len >= 3; len--) {
|
||||||
|
const candidate = remaining.slice(0, len)
|
||||||
|
if (isValidWord(candidate)) {
|
||||||
|
foundWord = candidate
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (foundWord) {
|
||||||
|
foundWords.push(foundWord)
|
||||||
|
remaining = remaining.slice(foundWord.length)
|
||||||
|
|
||||||
|
// Stop after 3 words
|
||||||
|
if (foundWords.length >= 3) {
|
||||||
|
remaining = ''
|
||||||
|
break
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// No valid word found, keep the remaining as-is
|
||||||
|
foundWords.push(remaining)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only return split version if we found at least one complete word
|
||||||
|
// and there's a clear boundary (more than one segment, or the segment is a complete word)
|
||||||
|
if (foundWords.length > 1 || (foundWords.length === 1 && isValidWord(foundWords[0]) && remaining === '')) {
|
||||||
|
return prefix + foundWords.join(' ')
|
||||||
|
}
|
||||||
|
|
||||||
|
return input
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleInput() {
|
||||||
|
// Immediately update cursor position
|
||||||
|
cursorPos.value = inputRef.value?.selectionStart ?? code.value.length
|
||||||
|
|
||||||
|
// First, try to auto-split concatenated words
|
||||||
|
const splitCode = trySplitWords(code.value)
|
||||||
|
if (splitCode !== code.value) {
|
||||||
|
code.value = splitCode
|
||||||
|
nextTick(() => {
|
||||||
|
const newLen = splitCode.length
|
||||||
|
inputRef.value?.setSelectionRange(newLen, newLen)
|
||||||
|
cursorPos.value = newLen
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const words = getWords(code.value)
|
||||||
|
if (words.length >= 3) {
|
||||||
|
const normalized = words.slice(0, 3).join(' ')
|
||||||
|
if (code.value !== normalized) {
|
||||||
|
const cursorWasAtEnd = cursorPos.value >= code.value.length
|
||||||
|
code.value = normalized
|
||||||
|
if (cursorWasAtEnd) {
|
||||||
|
nextTick(() => {
|
||||||
|
inputRef.value?.setSelectionRange(normalized.length, normalized.length)
|
||||||
|
cursorPos.value = normalized.length
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
updateAutocomplete()
|
||||||
|
if (lookupTimeout) { clearTimeout(lookupTimeout); lookupTimeout = null }
|
||||||
|
deviceInfo.value = null
|
||||||
|
error.value = null
|
||||||
|
serverError.value = false
|
||||||
|
hasInvalidWord.value = !checkWordsValidity(code.value)
|
||||||
|
const currentWords = getWords(code.value)
|
||||||
|
if (currentWords.length >= 1 && !ws && !wsConnecting) ensureConnection()
|
||||||
|
if (currentWords.length === 3) {
|
||||||
|
if (!allWordsValid(code.value)) return
|
||||||
|
lookupTimeout = setTimeout(() => { lookupDeviceInfo() }, 150)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function lookupDeviceInfo() {
|
||||||
|
if (isProcessing.value || loading.value) return
|
||||||
|
if (!hasThreeValidWords.value) return
|
||||||
|
const normalizedCode = normalizeCode(code.value)
|
||||||
|
if (normalizedCode === lastLookedUpCode && deviceInfo.value) return
|
||||||
|
|
||||||
|
isProcessing.value = true
|
||||||
|
processingStatus.value = 'pow'
|
||||||
|
error.value = null
|
||||||
|
serverError.value = false
|
||||||
|
|
||||||
|
try {
|
||||||
|
await ensureConnection()
|
||||||
|
if (!ws) throw new Error('Failed to connect')
|
||||||
|
const solution = await getPowSolution()
|
||||||
|
const powB64 = b64enc(solution)
|
||||||
|
const currentCode = normalizeCode(code.value)
|
||||||
|
if (!hasThreeValidWords.value) return
|
||||||
|
processingStatus.value = 'server'
|
||||||
|
ws.send_json({ code: currentCode, pow: powB64 })
|
||||||
|
const res = await ws.receive_json()
|
||||||
|
updateChallenge(res.pow)
|
||||||
|
if (typeof res.status === 'number' && res.status >= 400) {
|
||||||
|
error.value = res.detail || 'Request failed'
|
||||||
|
serverError.value = true
|
||||||
|
deviceInfo.value = null
|
||||||
|
lastLookedUpCode = null
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (res.status === 'found' && res.host) {
|
||||||
|
code.value = currentCode.replace(/\./g, ' ')
|
||||||
|
deviceInfo.value = {
|
||||||
|
host: res.host,
|
||||||
|
user_agent_pretty: res.user_agent_pretty,
|
||||||
|
client_ip: res.client_ip,
|
||||||
|
action: res.action || 'login'
|
||||||
|
}
|
||||||
|
lastLookedUpCode = currentCode
|
||||||
|
nextTick(() => { submitBtnRef.value?.focus() })
|
||||||
|
} else {
|
||||||
|
error.value = 'Unexpected response from server'
|
||||||
|
serverError.value = true
|
||||||
|
deviceInfo.value = null
|
||||||
|
lastLookedUpCode = null
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Lookup error:', err)
|
||||||
|
error.value = err.message || 'Lookup failed'
|
||||||
|
serverError.value = true
|
||||||
|
deviceInfo.value = null
|
||||||
|
lastLookedUpCode = null
|
||||||
|
if (ws) { ws.close(); ws = null }
|
||||||
|
} finally {
|
||||||
|
isProcessing.value = false
|
||||||
|
processingStatus.value = ''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleKeydown(event) {
|
||||||
|
if (event.key === 'Tab') {
|
||||||
|
if (autocompleteHint.value) {
|
||||||
|
const applied = applyAutocomplete()
|
||||||
|
if (applied) { event.preventDefault(); handleInput(); return }
|
||||||
|
}
|
||||||
|
if (code.value.trim()) event.preventDefault()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (event.key === ' ' && autocompleteHint.value) {
|
||||||
|
const applied = applyAutocomplete()
|
||||||
|
if (applied) { event.preventDefault(); handleInput() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submitCode() {
|
||||||
|
if (!deviceInfo.value || loading.value) return
|
||||||
|
loading.value = true
|
||||||
|
error.value = null
|
||||||
|
try {
|
||||||
|
if (!ws) await ensureConnection()
|
||||||
|
if (!ws) throw new Error('Failed to connect')
|
||||||
|
const solution = await getPowSolution()
|
||||||
|
const powB64 = b64enc(solution)
|
||||||
|
ws.send_json({ authenticate: true, pow: powB64 })
|
||||||
|
const res = await ws.receive_json()
|
||||||
|
if (typeof res.status === 'number' && res.status >= 400) throw new Error(res.detail || 'Authentication failed')
|
||||||
|
if (!res.optionsJSON) throw new Error(res.detail || 'Failed to get authentication options')
|
||||||
|
const authResponse = await startAuthentication(res)
|
||||||
|
ws.send_json(authResponse)
|
||||||
|
const result = await ws.receive_json()
|
||||||
|
if (typeof result.status === 'number' && result.status >= 400) throw new Error(result.detail || 'Authentication failed')
|
||||||
|
if (result.status === 'success') {
|
||||||
|
showMessage('Device authenticated successfully!', 'success', 3000)
|
||||||
|
emit('completed')
|
||||||
|
reset()
|
||||||
|
} else {
|
||||||
|
throw new Error(result.detail || 'Authentication failed')
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Pairing error:', err)
|
||||||
|
const message = err.name === 'NotAllowedError'
|
||||||
|
? 'Passkey authentication was cancelled'
|
||||||
|
: (err.message || 'Authentication failed')
|
||||||
|
error.value = message
|
||||||
|
// Don't show toast - error is shown in dialog
|
||||||
|
emit('error', message)
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
if (ws) { ws.close(); ws = null }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deny() {
|
||||||
|
// Send deny message to server before closing websocket
|
||||||
|
if (ws) {
|
||||||
|
try {
|
||||||
|
ws.send_json({ deny: true })
|
||||||
|
// Give the server a moment to process the denial
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 100))
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Error sending deny message:', e)
|
||||||
|
}
|
||||||
|
ws.close()
|
||||||
|
ws = null
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reset to initial state
|
||||||
|
reset()
|
||||||
|
}
|
||||||
|
|
||||||
|
function reset() {
|
||||||
|
code.value = ''
|
||||||
|
error.value = null
|
||||||
|
serverError.value = false
|
||||||
|
deviceInfo.value = null
|
||||||
|
isProcessing.value = false
|
||||||
|
processingStatus.value = ''
|
||||||
|
autocompleteHint.value = ''
|
||||||
|
hasInvalidWord.value = false
|
||||||
|
lastLookedUpCode = null
|
||||||
|
if (ws) { ws.close(); ws = null }
|
||||||
|
currentChallenge = null
|
||||||
|
currentWork = null
|
||||||
|
powPromise = null
|
||||||
|
powSolution = null
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Lifecycle ---
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
await fetchSettings()
|
||||||
|
inputRef.value?.focus()
|
||||||
|
// Initialize cursor position
|
||||||
|
nextTick(() => {
|
||||||
|
cursorPos.value = inputRef.value?.selectionStart ?? 0
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
if (lookupTimeout) { clearTimeout(lookupTimeout); lookupTimeout = null }
|
||||||
|
if (ws) { ws.close(); ws = null }
|
||||||
|
})
|
||||||
|
|
||||||
|
defineExpose({ reset, deny, code, handleInput, loading, error })
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
/* Input Mode Styles */
|
||||||
|
.pairing-entry {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pairing-form {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.input-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.input-wrapper {
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
width: 280px;
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Slot machine visual display (matches RemoteAuthInline) */
|
||||||
|
.slot-machine {
|
||||||
|
position: absolute;
|
||||||
|
left: 0;
|
||||||
|
top: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
padding: 0.875rem 1rem;
|
||||||
|
background: var(--color-surface-hover, rgba(0, 0, 0, 0.03));
|
||||||
|
border: 2px solid var(--color-border);
|
||||||
|
border-radius: var(--radius-sm, 6px);
|
||||||
|
font-family: 'SF Mono', Monaco, 'Cascadia Code', 'Roboto Mono', Consolas, 'Courier New', monospace;
|
||||||
|
display: flex;
|
||||||
|
gap: 0;
|
||||||
|
align-items: center;
|
||||||
|
user-select: none;
|
||||||
|
pointer-events: none;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
box-sizing: border-box;
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.slot-machine.has-error {
|
||||||
|
border-color: var(--color-error, #ef4444);
|
||||||
|
background: var(--color-error-bg, rgba(239, 68, 68, 0.05));
|
||||||
|
}
|
||||||
|
|
||||||
|
.slot-machine.is-complete {
|
||||||
|
border-color: var(--color-success, #10b981);
|
||||||
|
}
|
||||||
|
|
||||||
|
.slot-reel {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
flex: 1 1 33.333%;
|
||||||
|
min-width: 0;
|
||||||
|
height: 1.8em;
|
||||||
|
overflow: visible;
|
||||||
|
position: relative;
|
||||||
|
border-radius: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.slot-reel:not(:last-child) {
|
||||||
|
margin-right: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.slot-word {
|
||||||
|
font-size: 1.25rem;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
text-align: center;
|
||||||
|
width: 100%;
|
||||||
|
color: var(--color-text);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.slot-word .typed-prefix {
|
||||||
|
color: var(--color-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.slot-word .hint-suffix {
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
opacity: 0.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cursor-overlay {
|
||||||
|
position: absolute;
|
||||||
|
width: 2px;
|
||||||
|
height: 1.2em;
|
||||||
|
background: var(--color-text);
|
||||||
|
animation: none;
|
||||||
|
pointer-events: none;
|
||||||
|
/* Position based on character index - calculate from center of slot */
|
||||||
|
left: calc(50% + (var(--cursor-pos) - var(--word-len, 0) / 2) * 0.65em);
|
||||||
|
transform: translateX(-1px);
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.input-wrapper.focused .cursor-overlay {
|
||||||
|
opacity: 1;
|
||||||
|
animation: cursorBlink 1s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes cursorBlink {
|
||||||
|
0%, 49% {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
50%, 100% {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.slot-reel.invalid-word .slot-word {
|
||||||
|
color: var(--color-error, #ef4444);
|
||||||
|
}
|
||||||
|
|
||||||
|
.slot-reel.invalid-word .slot-word .typed-prefix {
|
||||||
|
color: var(--color-error, #ef4444);
|
||||||
|
}
|
||||||
|
|
||||||
|
.slot-reel.invalid-word .cursor-overlay {
|
||||||
|
background: var(--color-error, #ef4444);
|
||||||
|
}
|
||||||
|
|
||||||
|
.slot-reel.empty .slot-word {
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Hidden input - keeps focus and handles keyboard input */
|
||||||
|
.pairing-input {
|
||||||
|
flex: 1;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
padding: 0.875rem 1rem;
|
||||||
|
font-size: 1rem;
|
||||||
|
font-family: inherit;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
border-radius: var(--radius-sm, 6px);
|
||||||
|
background: transparent;
|
||||||
|
color: transparent;
|
||||||
|
caret-color: transparent;
|
||||||
|
outline: none;
|
||||||
|
box-sizing: border-box;
|
||||||
|
position: relative;
|
||||||
|
z-index: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pairing-input.hidden-input {
|
||||||
|
color: transparent;
|
||||||
|
caret-color: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pairing-input:disabled {
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pairing-input::placeholder {
|
||||||
|
color: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.processing-status {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.25rem;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.processing-icon {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.processing-spinner-small {
|
||||||
|
width: 12px;
|
||||||
|
height: 12px;
|
||||||
|
border: 2px solid var(--color-border);
|
||||||
|
border-top-color: var(--color-primary);
|
||||||
|
border-radius: 50%;
|
||||||
|
animation: spin 0.8s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes spin {
|
||||||
|
to { transform: rotate(360deg); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.device-info {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.device-permit-text {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
color: var(--color-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.device-meta {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
font-family: 'SF Mono', Monaco, 'Cascadia Code', 'Roboto Mono', Consolas, 'Courier New', monospace;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-message {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
color: var(--color-error, #ef4444);
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,535 @@
|
|||||||
|
<template>
|
||||||
|
<div class="remote-auth-inline">
|
||||||
|
<!-- Success state -->
|
||||||
|
<div v-if="completed" class="success-section">
|
||||||
|
<p class="success-message">✅ {{ successMessage }}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Error state -->
|
||||||
|
<div v-else-if="error" class="error-section">
|
||||||
|
<p class="error-message">{{ error }}</p>
|
||||||
|
<button class="btn-primary" @click="retry" style="margin-top: 0.75rem;">Try Again</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Connecting phase -->
|
||||||
|
<div v-else-if="phase === 'connecting'" class="auth-display">
|
||||||
|
<div class="auth-content">
|
||||||
|
<div class="pairing-code-section">
|
||||||
|
<p class="pairing-label">Enter the code words:</p>
|
||||||
|
<div class="slot-machine" aria-hidden="true">
|
||||||
|
<div class="slot-reel" v-for="(word, index) in animatedWords" :key="index">
|
||||||
|
<div class="slot-word">{{ word }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p class="site-url">{{ siteUrlDisplay }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="waiting-indicator">
|
||||||
|
<div class="spinner-small"></div>
|
||||||
|
<span>Generating code…</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Waiting/Authenticating phase - show codes -->
|
||||||
|
<div v-else class="auth-display">
|
||||||
|
<div class="auth-content">
|
||||||
|
<div v-if="pairingCode" class="pairing-code-section">
|
||||||
|
<p class="pairing-label">Enter the code words:</p>
|
||||||
|
<div class="slot-machine stopped">
|
||||||
|
<div class="slot-reel" v-for="(word, index) in displayCode.split(' ')" :key="index">
|
||||||
|
<div class="slot-word">{{ word }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p class="site-url">{{ siteUrlDisplay }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="waiting-indicator">
|
||||||
|
<div class="spinner-small"></div>
|
||||||
|
<span>{{ waitingMessage }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, computed, watch, onMounted, onUnmounted } from 'vue'
|
||||||
|
import aWebSocket from '@/utils/awaitable-websocket'
|
||||||
|
import { dec as b64dec, enc as b64enc } from '@/utils/base64url'
|
||||||
|
import { getSettings } from '@/utils/settings'
|
||||||
|
import { solvePoW } from '@/utils/pow'
|
||||||
|
import { words } from '@/utils/wordlist'
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
active: { type: Boolean, default: false }
|
||||||
|
})
|
||||||
|
|
||||||
|
const emit = defineEmits(['authenticated', 'cancelled', 'error', 'register'])
|
||||||
|
|
||||||
|
const pairingCode = ref(null)
|
||||||
|
const completed = ref(false)
|
||||||
|
const error = ref(null)
|
||||||
|
const phase = ref('connecting')
|
||||||
|
const settings = ref(null)
|
||||||
|
const animatedWords = ref(['', '', ''])
|
||||||
|
let ws = null
|
||||||
|
let wordAnimationTimer = null
|
||||||
|
|
||||||
|
const displayCode = computed(() => pairingCode.value ? pairingCode.value.replace(/\./g, ' ') : '')
|
||||||
|
|
||||||
|
const siteUrlDisplay = computed(() => {
|
||||||
|
if (!settings.value) return ''
|
||||||
|
const authSiteUrl = settings.value.auth_site_url || `${location.protocol}//${location.host}/auth/`
|
||||||
|
// Remove the protocol and any trailing slash
|
||||||
|
const withoutProtocol = authSiteUrl.replace(/^https?:\/\//, '')
|
||||||
|
return withoutProtocol.endsWith('/') ? withoutProtocol.slice(0, -1) : withoutProtocol
|
||||||
|
})
|
||||||
|
|
||||||
|
const waitingMessage = computed(() => {
|
||||||
|
return phase.value === 'authenticating'
|
||||||
|
? 'Complete on another device…'
|
||||||
|
: 'Waiting for authentication…'
|
||||||
|
})
|
||||||
|
|
||||||
|
const successMessage = computed(() => 'Authenticated successfully!')
|
||||||
|
|
||||||
|
function getRandomWord() {
|
||||||
|
return words[Math.floor(Math.random() * words.length)]
|
||||||
|
}
|
||||||
|
|
||||||
|
function startWordAnimation() {
|
||||||
|
// Initialize with random words
|
||||||
|
animatedWords.value = [getRandomWord(), getRandomWord(), getRandomWord()]
|
||||||
|
|
||||||
|
let updateCount = 0
|
||||||
|
const maxUpdates = 20 // Number of cycles before stopping
|
||||||
|
|
||||||
|
// Different intervals for each slot to spin independently
|
||||||
|
const intervals = [
|
||||||
|
setInterval(() => {
|
||||||
|
const newWords = [...animatedWords.value]
|
||||||
|
newWords[0] = getRandomWord()
|
||||||
|
animatedWords.value = newWords
|
||||||
|
}, 140),
|
||||||
|
setInterval(() => {
|
||||||
|
const newWords = [...animatedWords.value]
|
||||||
|
newWords[1] = getRandomWord()
|
||||||
|
animatedWords.value = newWords
|
||||||
|
}, 170),
|
||||||
|
setInterval(() => {
|
||||||
|
const newWords = [...animatedWords.value]
|
||||||
|
newWords[2] = getRandomWord()
|
||||||
|
animatedWords.value = newWords
|
||||||
|
}, 200)
|
||||||
|
]
|
||||||
|
|
||||||
|
wordAnimationTimer = intervals
|
||||||
|
|
||||||
|
// Stop all after max updates
|
||||||
|
setTimeout(() => {
|
||||||
|
intervals.forEach(interval => clearInterval(interval))
|
||||||
|
wordAnimationTimer = null
|
||||||
|
}, maxUpdates * 170) // Average interval time
|
||||||
|
}
|
||||||
|
|
||||||
|
function stopWordAnimation() {
|
||||||
|
if (wordAnimationTimer) {
|
||||||
|
if (Array.isArray(wordAnimationTimer)) {
|
||||||
|
wordAnimationTimer.forEach(interval => clearInterval(interval))
|
||||||
|
} else {
|
||||||
|
clearInterval(wordAnimationTimer)
|
||||||
|
}
|
||||||
|
wordAnimationTimer = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function startRemoteAuth() {
|
||||||
|
error.value = null
|
||||||
|
completed.value = false
|
||||||
|
pairingCode.value = null
|
||||||
|
phase.value = 'connecting'
|
||||||
|
|
||||||
|
// Start word animation
|
||||||
|
startWordAnimation()
|
||||||
|
|
||||||
|
try {
|
||||||
|
settings.value = await getSettings()
|
||||||
|
const authHost = settings.value?.auth_host
|
||||||
|
const wsPath = '/auth/ws/remote-auth/request'
|
||||||
|
const wsUrl = authHost && location.host !== authHost ? `//${authHost}${wsPath}` : wsPath
|
||||||
|
|
||||||
|
ws = await aWebSocket(wsUrl)
|
||||||
|
|
||||||
|
// PoW challenge
|
||||||
|
const powChallenge = await ws.receive_json()
|
||||||
|
if (powChallenge.pow) {
|
||||||
|
const challenge = b64dec(powChallenge.pow.challenge)
|
||||||
|
const nonces = await solvePoW(challenge, powChallenge.pow.work)
|
||||||
|
ws.send_json({ pow: b64enc(nonces), action: 'login' })
|
||||||
|
}
|
||||||
|
|
||||||
|
// Receive the pairing code
|
||||||
|
const res = await ws.receive_json()
|
||||||
|
|
||||||
|
if (res.status) {
|
||||||
|
throw new Error(res.detail || `Failed to create remote auth request: ${res.status}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
pairingCode.value = res.pairing_code
|
||||||
|
|
||||||
|
// Stop word animation
|
||||||
|
stopWordAnimation()
|
||||||
|
|
||||||
|
phase.value = 'waiting'
|
||||||
|
|
||||||
|
// Wait for authentication
|
||||||
|
while (true) {
|
||||||
|
const msg = await ws.receive_json()
|
||||||
|
|
||||||
|
if (msg.status === 'locked') {
|
||||||
|
// Someone has entered the code and is authenticating
|
||||||
|
phase.value = 'authenticating'
|
||||||
|
} else if (msg.status === 'paired') {
|
||||||
|
// Legacy/compatibility: Device paired, now authenticating
|
||||||
|
phase.value = 'authenticating'
|
||||||
|
} else if (msg.status === 'authenticated') {
|
||||||
|
// Success
|
||||||
|
completed.value = true
|
||||||
|
emit('authenticated', { session_token: msg.session_token })
|
||||||
|
break
|
||||||
|
} else if (msg.status === 'denied') {
|
||||||
|
// Explicitly denied by the authenticating device
|
||||||
|
throw new Error('Access denied')
|
||||||
|
} else if (msg.status === 'completed') {
|
||||||
|
// Registration flow
|
||||||
|
if (msg.reset_token) {
|
||||||
|
completed.value = true
|
||||||
|
emit('register', msg.reset_token)
|
||||||
|
}
|
||||||
|
break
|
||||||
|
} else if (msg.status === 'error' || msg.detail) {
|
||||||
|
throw new Error(msg.detail || 'Remote authentication failed')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Remote authentication error:', err)
|
||||||
|
const message = err.message || 'Authentication failed'
|
||||||
|
error.value = message
|
||||||
|
emit('error', message)
|
||||||
|
} finally {
|
||||||
|
if (ws) {
|
||||||
|
ws.close()
|
||||||
|
ws = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function retry() {
|
||||||
|
startRemoteAuth()
|
||||||
|
}
|
||||||
|
|
||||||
|
function cancel() {
|
||||||
|
if (ws) {
|
||||||
|
ws.close()
|
||||||
|
ws = null
|
||||||
|
}
|
||||||
|
emit('cancelled')
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(() => props.active, (newVal) => {
|
||||||
|
if (newVal && !pairingCode.value && !error.value && !completed.value) {
|
||||||
|
startRemoteAuth()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
if (props.active) {
|
||||||
|
startRemoteAuth()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
if (ws) {
|
||||||
|
ws.close()
|
||||||
|
ws = null
|
||||||
|
}
|
||||||
|
stopWordAnimation()
|
||||||
|
})
|
||||||
|
|
||||||
|
defineExpose({ retry, cancel })
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.remote-auth-inline {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1rem;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading-section {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.75rem;
|
||||||
|
padding: 2rem 1rem;
|
||||||
|
min-height: 180px;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading-section p {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
font-size: 0.95rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.spinner {
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
border: 3px solid var(--color-border);
|
||||||
|
border-top-color: var(--color-primary);
|
||||||
|
border-radius: 50%;
|
||||||
|
animation: spin 0.8s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes spin {
|
||||||
|
to { transform: rotate(360deg); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-display {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1.25rem;
|
||||||
|
width: 100%;
|
||||||
|
min-height: 180px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-content {
|
||||||
|
display: flex;
|
||||||
|
gap: 2rem;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
flex-wrap: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading-placeholder {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.75rem;
|
||||||
|
width: 100%;
|
||||||
|
padding: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading-placeholder p {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
font-size: 0.95rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pairing-code-section {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.5rem;
|
||||||
|
width: 280px;
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pairing-label {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
font-weight: 500;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.slot-machine {
|
||||||
|
padding: 0.875rem 1rem;
|
||||||
|
background: var(--color-surface-hover, rgba(0, 0, 0, 0.03));
|
||||||
|
border: 2px solid var(--color-border);
|
||||||
|
border-radius: var(--radius-sm, 6px);
|
||||||
|
font-family: 'SF Mono', Monaco, 'Cascadia Code', 'Roboto Mono', Consolas, 'Courier New', monospace;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
user-select: none;
|
||||||
|
pointer-events: none;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.slot-reel {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
height: 1.8em;
|
||||||
|
overflow: hidden;
|
||||||
|
position: relative;
|
||||||
|
background: var(--color-surface, rgba(255, 255, 255, 0.5));
|
||||||
|
border-radius: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.slot-machine:not(.stopped) .slot-reel:nth-child(1) {
|
||||||
|
animation: slotSpin 0.14s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.slot-machine:not(.stopped) .slot-reel:nth-child(2) {
|
||||||
|
animation: slotSpin 0.17s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.slot-machine:not(.stopped) .slot-reel:nth-child(3) {
|
||||||
|
animation: slotSpin 0.20s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.slot-word {
|
||||||
|
font-size: 1.25rem;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
text-align: center;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.slot-machine:not(.stopped) .slot-reel:nth-child(1) .slot-word {
|
||||||
|
animation: wordRoll 0.14s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.slot-machine:not(.stopped) .slot-reel:nth-child(2) .slot-word {
|
||||||
|
animation: wordRoll 0.17s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.slot-machine:not(.stopped) .slot-reel:nth-child(3) .slot-word {
|
||||||
|
animation: wordRoll 0.20s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes slotSpin {
|
||||||
|
0% {
|
||||||
|
box-shadow: inset 0 2px 4px rgba(0, 0, 0, 0.1);
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
box-shadow: inset 0 4px 8px rgba(0, 0, 0, 0.2);
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
box-shadow: inset 0 2px 4px rgba(0, 0, 0, 0.1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes wordRoll {
|
||||||
|
0% {
|
||||||
|
transform: translateY(-30%) scale(0.9);
|
||||||
|
opacity: 0.4;
|
||||||
|
filter: blur(1.5px);
|
||||||
|
}
|
||||||
|
25% {
|
||||||
|
transform: translateY(-10%) scale(0.95);
|
||||||
|
opacity: 0.6;
|
||||||
|
filter: blur(1px);
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
transform: translateY(0) scale(1);
|
||||||
|
opacity: 1;
|
||||||
|
filter: blur(0);
|
||||||
|
}
|
||||||
|
75% {
|
||||||
|
transform: translateY(10%) scale(0.95);
|
||||||
|
opacity: 0.6;
|
||||||
|
filter: blur(1px);
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
transform: translateY(30%) scale(0.9);
|
||||||
|
opacity: 0.4;
|
||||||
|
filter: blur(1.5px);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.site-url {
|
||||||
|
margin: 0.5rem 0 0;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
text-align: center;
|
||||||
|
font-family: 'SF Mono', Monaco, 'Cascadia Code', 'Roboto Mono', Consolas, 'Courier New', monospace;
|
||||||
|
opacity: 0.8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.waiting-indicator {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
padding: 0.75rem;
|
||||||
|
background: var(--color-surface-hover, rgba(0, 0, 0, 0.02));
|
||||||
|
border-radius: var(--radius-sm, 6px);
|
||||||
|
font-size: 0.875rem;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.spinner-small {
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
border: 2px solid var(--color-border);
|
||||||
|
border-top-color: var(--color-primary);
|
||||||
|
border-radius: 50%;
|
||||||
|
animation: spin 0.8s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.success-section {
|
||||||
|
padding: 1rem;
|
||||||
|
text-align: center;
|
||||||
|
min-height: 180px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.success-message {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 1rem;
|
||||||
|
color: var(--color-success, #10b981);
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-section {
|
||||||
|
padding: 1rem;
|
||||||
|
text-align: center;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 0.75rem;
|
||||||
|
min-height: 180px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-message {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
color: var(--color-error, #ef4444);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Responsive adjustments */
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
.auth-content {
|
||||||
|
gap: 1.5rem;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pairing-code-section {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 280px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 480px) {
|
||||||
|
.pairing-code {
|
||||||
|
font-size: 1.1rem;
|
||||||
|
padding: 0.75rem 0.875rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pairing-code-section {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,310 @@
|
|||||||
|
<template>
|
||||||
|
<div class="app-shell">
|
||||||
|
<div v-if="status.show" class="global-status" style="display: block;">
|
||||||
|
<div :class="['status', status.type]">
|
||||||
|
{{ status.message }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<main class="view-root">
|
||||||
|
<div v-if="!initializing" class="surface surface--tight">
|
||||||
|
<header class="view-header center">
|
||||||
|
<h1>{{ headingTitle }}</h1>
|
||||||
|
<p v-if="isAuthenticated" class="user-line">👤 {{ userDisplayName }}</p>
|
||||||
|
<p class="view-lede" v-html="headerMessage"></p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<section class="section-block">
|
||||||
|
<div class="section-body center">
|
||||||
|
<!-- Local passkey authentication view -->
|
||||||
|
<div v-if="authView === 'local'" class="auth-view">
|
||||||
|
<div class="button-row center">
|
||||||
|
<slot name="actions"
|
||||||
|
:loading="loading"
|
||||||
|
:can-authenticate="canAuthenticate"
|
||||||
|
:is-authenticated="isAuthenticated"
|
||||||
|
:authenticate="authenticateUser"
|
||||||
|
:logout="logoutUser"
|
||||||
|
:mode="mode">
|
||||||
|
<!-- Default actions -->
|
||||||
|
<button class="btn-secondary" :disabled="loading" @click="$emit('back')">Back</button>
|
||||||
|
<button v-if="canAuthenticate" class="btn-primary" :disabled="loading" @click="authenticateUser">
|
||||||
|
{{ loading ? (mode === 'reauth' ? 'Verifying…' : 'Signing in…') : (mode === 'reauth' ? 'Verify' : 'Login') }}
|
||||||
|
</button>
|
||||||
|
<button v-if="isAuthenticated && mode !== 'reauth'" class="btn-danger" :disabled="loading" @click="logoutUser">Logout</button>
|
||||||
|
<button v-if="isAuthenticated && mode !== 'reauth'" class="btn-primary" :disabled="loading" @click="openProfile">Profile</button>
|
||||||
|
</slot>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Remote authentication view (request new remote auth) -->
|
||||||
|
<div v-else-if="authView === 'remote'" class="auth-view">
|
||||||
|
<RemoteAuthInline
|
||||||
|
:active="authView === 'remote'"
|
||||||
|
@authenticated="handleRemoteAuthenticated"
|
||||||
|
@register="handleRemoteRegistration"
|
||||||
|
@cancelled="switchToLocal"
|
||||||
|
@error="handleRemoteAuthError"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { computed, onMounted, onUnmounted, reactive, ref } from 'vue'
|
||||||
|
import passkey from '@/utils/passkey'
|
||||||
|
import { getSettings, uiBasePath } from '@/utils/settings'
|
||||||
|
import { fetchJson, getUserFriendlyErrorMessage } from '@/utils/api'
|
||||||
|
import RemoteAuthInline from '@/components/RemoteAuthRequest.vue'
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
mode: {
|
||||||
|
type: String,
|
||||||
|
default: 'login',
|
||||||
|
validator: (value) => ['login', 'reauth', 'forbidden'].includes(value)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const emit = defineEmits(['authenticated', 'forbidden', 'logout', 'back', 'home', 'auth-error'])
|
||||||
|
|
||||||
|
const status = reactive({ show: false, message: '', type: 'info' })
|
||||||
|
const initializing = ref(true)
|
||||||
|
const loading = ref(false)
|
||||||
|
const settings = ref(null)
|
||||||
|
const userInfo = ref(null)
|
||||||
|
const currentView = ref('initial') // 'initial', 'login', 'forbidden'
|
||||||
|
const authView = ref('local') // 'local' or 'remote'
|
||||||
|
let statusTimer = null
|
||||||
|
|
||||||
|
const isAuthenticated = computed(() => !!userInfo.value?.authenticated)
|
||||||
|
|
||||||
|
const canAuthenticate = computed(() => {
|
||||||
|
if (initializing.value) return false
|
||||||
|
if (props.mode === 'reauth') return true
|
||||||
|
if (currentView.value === 'forbidden') return false
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
|
||||||
|
const headingTitle = computed(() => {
|
||||||
|
if (props.mode === 'reauth') {
|
||||||
|
return `🔐 Additional Authentication`
|
||||||
|
}
|
||||||
|
if (currentView.value === 'forbidden') return '🚫 Forbidden'
|
||||||
|
return `🔐 ${settings.value?.rp_name || location.origin}`
|
||||||
|
})
|
||||||
|
|
||||||
|
const headerMessage = computed(() => {
|
||||||
|
if (props.mode === 'reauth') {
|
||||||
|
return 'Please verify your identity to continue with this action.'
|
||||||
|
}
|
||||||
|
if (currentView.value === 'forbidden') {
|
||||||
|
return 'You lack the required permissions.'
|
||||||
|
}
|
||||||
|
if (authView.value === 'remote') {
|
||||||
|
return 'Confirm from your other device. Or <a href="#" class="inline-link" data-action="local">this device</a>.'
|
||||||
|
}
|
||||||
|
if (canAuthenticate.value && props.mode !== 'reauth') {
|
||||||
|
return 'Please sign in with your passkey. Or use <a href="#" class="inline-link" data-action="remote">another device</a>.'
|
||||||
|
}
|
||||||
|
return 'Please sign in with your passkey.'
|
||||||
|
})
|
||||||
|
|
||||||
|
const userDisplayName = computed(() => userInfo.value?.user?.user_name || 'User')
|
||||||
|
|
||||||
|
function showMessage(message, type = 'info', duration = 3000) {
|
||||||
|
status.show = true
|
||||||
|
status.message = message
|
||||||
|
status.type = type
|
||||||
|
if (statusTimer) clearTimeout(statusTimer)
|
||||||
|
if (duration > 0) statusTimer = setTimeout(() => { status.show = false }, duration)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchSettings() {
|
||||||
|
try {
|
||||||
|
const data = await getSettings()
|
||||||
|
settings.value = data
|
||||||
|
if (data?.rp_name) {
|
||||||
|
const titleSuffix = props.mode === 'reauth'
|
||||||
|
? 'Verify Identity'
|
||||||
|
: (isAuthenticated.value ? 'Forbidden' : 'Sign In')
|
||||||
|
document.title = `${data.rp_name} · ${titleSuffix}`
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('Unable to load settings', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchUserInfo() {
|
||||||
|
try {
|
||||||
|
userInfo.value = await fetchJson('/auth/api/user-info', { method: 'POST' })
|
||||||
|
if (isAuthenticated.value && props.mode !== 'reauth') {
|
||||||
|
currentView.value = 'forbidden'
|
||||||
|
emit('forbidden', userInfo.value)
|
||||||
|
} else {
|
||||||
|
currentView.value = 'login'
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to load user info', error)
|
||||||
|
if (error.status !== 401 && error.status !== 403) {
|
||||||
|
showMessage(getUserFriendlyErrorMessage(error), 'error', 4000)
|
||||||
|
}
|
||||||
|
userInfo.value = null
|
||||||
|
currentView.value = 'login'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function authenticateUser() {
|
||||||
|
if (!canAuthenticate.value || loading.value) return
|
||||||
|
loading.value = true
|
||||||
|
showMessage('Starting authentication…', 'info')
|
||||||
|
let result
|
||||||
|
try { result = await passkey.authenticate() } catch (error) {
|
||||||
|
loading.value = false
|
||||||
|
const message = error?.message || 'Passkey authentication cancelled'
|
||||||
|
const cancelled = message === 'Passkey authentication cancelled'
|
||||||
|
showMessage(message, cancelled ? 'info' : 'error', 4000)
|
||||||
|
emit('auth-error', { message, cancelled })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
try { await setSessionCookie(result) } catch (error) {
|
||||||
|
loading.value = false
|
||||||
|
const message = error?.message || 'Failed to establish session'
|
||||||
|
showMessage(message, 'error', 4000)
|
||||||
|
emit('auth-error', { message, cancelled: false })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
loading.value = false
|
||||||
|
emit('authenticated', result)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function logoutUser() {
|
||||||
|
if (loading.value) return
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
await fetchJson('/auth/api/logout', { method: 'POST' })
|
||||||
|
userInfo.value = null
|
||||||
|
currentView.value = 'login'
|
||||||
|
showMessage('Logged out. You can sign in with a different account.', 'info', 3000)
|
||||||
|
} catch (error) {
|
||||||
|
showMessage(getUserFriendlyErrorMessage(error), 'error', 4000)
|
||||||
|
}
|
||||||
|
finally { loading.value = false }
|
||||||
|
emit('logout')
|
||||||
|
}
|
||||||
|
|
||||||
|
function openProfile() {
|
||||||
|
const profileWindow = window.open('/auth/', 'passkey_auth_profile')
|
||||||
|
if (profileWindow) profileWindow.focus()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function setSessionCookie(result) {
|
||||||
|
if (!result?.session_token) {
|
||||||
|
console.error('setSessionCookie called with missing session_token:', result)
|
||||||
|
throw new Error('Authentication response missing session_token')
|
||||||
|
}
|
||||||
|
return await fetchJson('/auth/api/set-session', {
|
||||||
|
method: 'POST', headers: { Authorization: `Bearer ${result.session_token}` }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function switchToRemote() {
|
||||||
|
authView.value = 'remote'
|
||||||
|
}
|
||||||
|
|
||||||
|
function switchToLocal() {
|
||||||
|
authView.value = 'local'
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleRemoteAuthenticated(result) {
|
||||||
|
showMessage('Authenticated from another device!', 'success', 2000)
|
||||||
|
try {
|
||||||
|
await setSessionCookie(result)
|
||||||
|
} catch (error) {
|
||||||
|
const message = error?.message || 'Failed to establish session'
|
||||||
|
showMessage(message, 'error', 4000)
|
||||||
|
emit('auth-error', { message, cancelled: false })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
emit('authenticated', result)
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleRemoteRegistration(token) {
|
||||||
|
showMessage('Registration approved! Redirecting...', 'success', 2000)
|
||||||
|
const basePath = uiBasePath() || '/auth/'
|
||||||
|
window.location.href = `${basePath}${token}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleRemoteAuthError(errorMsg) {
|
||||||
|
// Error is already shown in the RemoteAuth component, don't show toast
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleHeaderLinkClick(event) {
|
||||||
|
const target = event.target
|
||||||
|
if (target.tagName === 'A' && target.classList.contains('inline-link')) {
|
||||||
|
event.preventDefault()
|
||||||
|
const action = target.dataset.action
|
||||||
|
if (action === 'remote') {
|
||||||
|
switchToRemote()
|
||||||
|
} else if (action === 'local') {
|
||||||
|
switchToLocal()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
await fetchSettings()
|
||||||
|
await fetchUserInfo()
|
||||||
|
initializing.value = false
|
||||||
|
|
||||||
|
// Add click handler for inline links
|
||||||
|
document.addEventListener('click', handleHeaderLinkClick)
|
||||||
|
})
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
document.removeEventListener('click', handleHeaderLinkClick)
|
||||||
|
})
|
||||||
|
|
||||||
|
defineExpose({
|
||||||
|
showMessage,
|
||||||
|
isAuthenticated,
|
||||||
|
userInfo
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.button-row.center { display: flex; justify-content: center; gap: 0.75rem; flex-wrap: wrap; }
|
||||||
|
.user-line { margin: 0.5rem 0 0; font-weight: 500; color: var(--color-text); }
|
||||||
|
main.view-root { min-height: 100vh; align-items: center; justify-content: center; padding: 2rem 1rem; }
|
||||||
|
.surface.surface--tight {
|
||||||
|
max-width: 520px;
|
||||||
|
margin: 0 auto;
|
||||||
|
width: 100%;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-view {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 1rem;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.view-lede :deep(.inline-link) {
|
||||||
|
color: var(--color-primary);
|
||||||
|
text-decoration: none;
|
||||||
|
transition: opacity 0.15s;
|
||||||
|
font-weight: 400;
|
||||||
|
}
|
||||||
|
|
||||||
|
.view-lede :deep(.inline-link:hover) {
|
||||||
|
opacity: 0.8;
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,206 @@
|
|||||||
|
<template>
|
||||||
|
<section class="section-block" data-component="session-list-section">
|
||||||
|
<div class="section-header">
|
||||||
|
<h2>Active Sessions</h2>
|
||||||
|
<p class="section-description">{{ sectionDescription }}</p>
|
||||||
|
</div>
|
||||||
|
<div class="section-body">
|
||||||
|
<div :class="['session-list']">
|
||||||
|
<template v-if="Array.isArray(sessions) && sessions.length">
|
||||||
|
<div v-for="(group, host) in groupedSessions" :key="host" class="session-group">
|
||||||
|
<h3 :class="['session-group-host', { 'is-current-site': group.isCurrentSite }]">
|
||||||
|
<template v-if="host"><a :href="hostUrl(host)">🌐 {{ host }}</a></template>
|
||||||
|
<template v-else>🌐 Unbound host</template>
|
||||||
|
</h3>
|
||||||
|
<div class="session-group-sessions">
|
||||||
|
<div
|
||||||
|
v-for="session in group.sessions"
|
||||||
|
:key="session.id"
|
||||||
|
:class="['session-item', {
|
||||||
|
'is-current': session.is_current && !hoveredIp && !hoveredCredentialUuid,
|
||||||
|
'is-hovered': hoveredSession?.id === session.id,
|
||||||
|
'is-linked-credential': hoveredCredentialUuid === session.credential_uuid
|
||||||
|
}]"
|
||||||
|
tabindex="0"
|
||||||
|
@focusin="handleSessionFocus(session)"
|
||||||
|
@focusout="handleSessionBlur($event)"
|
||||||
|
>
|
||||||
|
<div class="item-top">
|
||||||
|
<h4 class="item-title">{{ session.user_agent }}</h4>
|
||||||
|
<div class="item-actions">
|
||||||
|
<span v-if="session.is_current && !hoveredIp && !hoveredCredentialUuid" class="badge badge-current">Current</span>
|
||||||
|
<span v-else-if="hoveredSession?.id === session.id" class="badge badge-current">Selected</span>
|
||||||
|
<span v-else-if="hoveredCredentialUuid === session.credential_uuid" class="badge badge-current">Linked</span>
|
||||||
|
<span v-else-if="!hoveredCredentialUuid && isSameNetwork(session.ip)" class="badge">Same IP</span>
|
||||||
|
<button
|
||||||
|
@click="$emit('terminate', session)"
|
||||||
|
class="btn-card-delete"
|
||||||
|
:disabled="isTerminating(session.id)"
|
||||||
|
:title="isTerminating(session.id) ? 'Terminating...' : 'Terminate session'"
|
||||||
|
>🗑️</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="item-details">
|
||||||
|
<div class="session-dates">
|
||||||
|
<span class="date-label">{{ formatDate(session.last_renewed) }}</span>
|
||||||
|
<span class="date-value">{{ session.ip }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<div v-else class="empty-state"><p>{{ emptyMessage }}</p></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { computed, ref } from 'vue'
|
||||||
|
import { formatDate } from '@/utils/helpers'
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
sessions: { type: Array, default: () => [] },
|
||||||
|
emptyMessage: { type: String, default: 'You currently have no other active sessions.' },
|
||||||
|
sectionDescription: { type: String, default: "Review where you're signed in and end any sessions you no longer recognize." },
|
||||||
|
terminatingSessions: { type: Object, default: () => ({}) },
|
||||||
|
hoveredCredentialUuid: { type: String, default: null },
|
||||||
|
})
|
||||||
|
|
||||||
|
const emit = defineEmits(['terminate', 'sessionHover'])
|
||||||
|
|
||||||
|
const hoveredIp = ref(null)
|
||||||
|
const hoveredSession = ref(null)
|
||||||
|
|
||||||
|
const handleSessionFocus = (session) => {
|
||||||
|
hoveredSession.value = session
|
||||||
|
hoveredIp.value = session.ip || null
|
||||||
|
emit('sessionHover', session)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSessionBlur = (event) => {
|
||||||
|
// Only clear if focus moved outside this element
|
||||||
|
if (!event.currentTarget.contains(event.relatedTarget)) {
|
||||||
|
hoveredSession.value = null
|
||||||
|
hoveredIp.value = null
|
||||||
|
emit('sessionHover', null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const isTerminating = (sessionId) => !!props.terminatingSessions[sessionId]
|
||||||
|
|
||||||
|
const hostUrl = (host) => {
|
||||||
|
// Assume http if there's a port number, https otherwise
|
||||||
|
const protocol = host.includes(':') ? 'http' : 'https'
|
||||||
|
return `${protocol}://${host}`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract /64 prefix for IPv6, or return full IP for IPv4
|
||||||
|
const getNetworkPrefix = ip => {
|
||||||
|
if (!ip) return null
|
||||||
|
|
||||||
|
// IPv4?
|
||||||
|
if (!ip.includes(':')) return ip
|
||||||
|
|
||||||
|
// Normalize IPv6 using URL
|
||||||
|
// Wrap in brackets so URL accepts it
|
||||||
|
const norm = new URL(`http://[${ip}]/`).hostname
|
||||||
|
|
||||||
|
// norm is now fully expanded, e.g. "2001:0db8:0000:0000:0000:0000:0000:0001"
|
||||||
|
const parts = norm.split(':')
|
||||||
|
return parts.slice(0, 4).join(':')
|
||||||
|
}
|
||||||
|
|
||||||
|
const currentNetworkPrefix = computed(() => {
|
||||||
|
// Use hovered IP if available, otherwise fall back to current session
|
||||||
|
if (hoveredIp.value) return getNetworkPrefix(hoveredIp.value)
|
||||||
|
const current = props.sessions.find(s => s.is_current)
|
||||||
|
return current ? getNetworkPrefix(current.ip) : null
|
||||||
|
})
|
||||||
|
|
||||||
|
const isSameNetwork = (ip) => {
|
||||||
|
if (!currentNetworkPrefix.value || !ip) return false
|
||||||
|
return getNetworkPrefix(ip) === currentNetworkPrefix.value
|
||||||
|
}
|
||||||
|
|
||||||
|
const groupedSessions = computed(() => {
|
||||||
|
const groups = {}
|
||||||
|
for (const session of props.sessions) {
|
||||||
|
const host = session.host || ''
|
||||||
|
if (!groups[host]) {
|
||||||
|
groups[host] = { sessions: [], isCurrentSite: false }
|
||||||
|
}
|
||||||
|
groups[host].sessions.push(session)
|
||||||
|
if (session.is_current_host) {
|
||||||
|
groups[host].isCurrentSite = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Sort sessions within each group by last_renewed descending
|
||||||
|
for (const host in groups) {
|
||||||
|
groups[host].sessions.sort((a, b) => new Date(b.last_renewed) - new Date(a.last_renewed))
|
||||||
|
}
|
||||||
|
// Sort groups by host name (natural sort)
|
||||||
|
const collator = new Intl.Collator(undefined, { numeric: true, sensitivity: 'base' })
|
||||||
|
const sortedHosts = Object.keys(groups).sort(collator.compare)
|
||||||
|
const sortedGroups = {}
|
||||||
|
for (const host of sortedHosts) {
|
||||||
|
sortedGroups[host] = groups[host]
|
||||||
|
}
|
||||||
|
return sortedGroups
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.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: 0.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(auto-fill, minmax(var(--card-width), 1fr));
|
||||||
|
gap: 0.5em;
|
||||||
|
align-items: start;
|
||||||
|
}
|
||||||
|
.session-group-sessions .session-item {
|
||||||
|
width: auto;
|
||||||
|
height: auto;
|
||||||
|
padding: 0.75rem;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
.session-group-sessions .session-item .item-title {
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.session-group-sessions .session-item .item-details {
|
||||||
|
margin-left: 0;
|
||||||
|
}
|
||||||
|
.session-group-sessions .session-item .session-dates {
|
||||||
|
grid-template-columns: auto 1fr;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
<template>
|
<template>
|
||||||
<div v-if="userLoaded" class="user-info">
|
<div v-if="userLoaded" class="user-info" :class="{ 'has-extra': $slots.default }">
|
||||||
<h3 class="user-name-heading">
|
<h3 class="user-name-heading">
|
||||||
<span class="icon">👤</span>
|
<span class="icon">👤</span>
|
||||||
<span class="user-name-row">
|
<span class="user-name-row">
|
||||||
@@ -11,12 +11,15 @@
|
|||||||
<div class="org-line" v-if="orgDisplayName">{{ orgDisplayName }}</div>
|
<div class="org-line" v-if="orgDisplayName">{{ orgDisplayName }}</div>
|
||||||
<div class="role-line" v-if="roleName">{{ roleName }}</div>
|
<div class="role-line" v-if="roleName">{{ roleName }}</div>
|
||||||
</div>
|
</div>
|
||||||
<span><strong>Visits:</strong></span>
|
<span class="info-label"><strong>Visits:</strong></span>
|
||||||
<span>{{ visits || 0 }}</span>
|
<span class="info-value">{{ visits || 0 }}</span>
|
||||||
<span><strong>Registered:</strong></span>
|
<span class="info-label"><strong>Registered:</strong></span>
|
||||||
<span>{{ formatDate(createdAt) }}</span>
|
<span class="info-value">{{ formatDate(createdAt) }}</span>
|
||||||
<span><strong>Last seen:</strong></span>
|
<span class="info-label"><strong>Last seen:</strong></span>
|
||||||
<span>{{ formatDate(lastSeen) }}</span>
|
<span class="info-value">{{ formatDate(lastSeen) }}</span>
|
||||||
|
<div v-if="$slots.default" class="user-info-extra">
|
||||||
|
<slot></slot>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -44,13 +47,50 @@ const userLoaded = computed(() => !!props.name)
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.user-info { display: grid; grid-template-columns: auto 1fr; gap: 10px; }
|
.user-info.has-extra {
|
||||||
.user-info h3 { grid-column: span 2; }
|
grid-template-columns: auto 1fr;
|
||||||
.org-role-sub { grid-column: span 2; display:flex; flex-direction:column; margin: -0.15rem 0 0.25rem; }
|
grid-template-areas:
|
||||||
|
"heading heading"
|
||||||
|
"org org"
|
||||||
|
"label1 value1"
|
||||||
|
"label2 value2"
|
||||||
|
"label3 value3"
|
||||||
|
"extra extra";
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-info:not(.has-extra) {
|
||||||
|
grid-template-columns: auto 1fr;
|
||||||
|
grid-template-areas:
|
||||||
|
"heading heading"
|
||||||
|
"org org"
|
||||||
|
"label1 value1"
|
||||||
|
"label2 value2"
|
||||||
|
"label3 value3";
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 769px) {
|
||||||
|
.user-info.has-extra {
|
||||||
|
grid-template-columns: auto 1fr 2fr;
|
||||||
|
grid-template-areas:
|
||||||
|
"heading heading extra"
|
||||||
|
"org org extra"
|
||||||
|
"label1 value1 extra"
|
||||||
|
"label2 value2 extra"
|
||||||
|
"label3 value3 extra";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-name-heading { grid-area: heading; display: flex; align-items: center; gap: 0.4rem; flex-wrap: wrap; margin: 0 0 0.25rem 0; }
|
||||||
|
.org-role-sub { grid-area: org; display:flex; flex-direction:column; margin: -0.15rem 0 0.25rem; }
|
||||||
.org-line { font-size: .7rem; font-weight:600; line-height:1.1; color: var(--color-text-muted); text-transform: uppercase; letter-spacing: 0.05em; }
|
.org-line { font-size: .7rem; font-weight:600; line-height:1.1; color: var(--color-text-muted); text-transform: uppercase; letter-spacing: 0.05em; }
|
||||||
.role-line { font-size:.65rem; color: var(--color-text-muted); line-height:1.1; }
|
.role-line { font-size:.65rem; color: var(--color-text-muted); line-height:1.1; }
|
||||||
.user-info span { text-align: left; }
|
.info-label:nth-of-type(1) { grid-area: label1; }
|
||||||
.user-name-heading { display: flex; align-items: center; gap: 0.4rem; flex-wrap: wrap; margin: 0 0 0.25rem 0; }
|
.info-value:nth-of-type(2) { grid-area: value1; }
|
||||||
|
.info-label:nth-of-type(3) { grid-area: label2; }
|
||||||
|
.info-value:nth-of-type(4) { grid-area: value2; }
|
||||||
|
.info-label:nth-of-type(5) { grid-area: label3; }
|
||||||
|
.info-value:nth-of-type(6) { grid-area: value3; }
|
||||||
|
.user-info-extra { grid-area: extra; padding-left: 2rem; border-left: 1px solid var(--color-border); }
|
||||||
.user-name-row { display: inline-flex; align-items: center; gap: 0.35rem; max-width: 100%; }
|
.user-name-row { display: inline-flex; align-items: center; gap: 0.35rem; max-width: 100%; }
|
||||||
.user-name-row.editing { flex: 1 1 auto; }
|
.user-name-row.editing { flex: 1 1 auto; }
|
||||||
.icon { flex: 0 0 auto; }
|
.icon { flex: 0 0 auto; }
|
||||||
@@ -62,5 +102,6 @@ const userLoaded = computed(() => !!props.name)
|
|||||||
.mini-btn:hover:not(:disabled) { background: var(--color-accent-soft); color: var(--color-accent); }
|
.mini-btn:hover:not(:disabled) { background: var(--color-accent-soft); color: var(--color-accent); }
|
||||||
.mini-btn:active:not(:disabled) { transform: translateY(1px); }
|
.mini-btn:active:not(:disabled) { transform: translateY(1px); }
|
||||||
.mini-btn:disabled { opacity: 0.5; cursor: not-allowed; }
|
.mini-btn:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||||
|
@media (max-width: 768px) { .user-info-extra { padding-left: 0; padding-top: 1rem; border-left: none; border-top: 1px solid var(--color-border); } }
|
||||||
@media (max-width: 480px) { .user-name-heading { flex-direction: column; align-items: flex-start; } .user-name-row.editing { width: 100%; } .display-name { max-width: 100%; } }
|
@media (max-width: 480px) { .user-name-heading { flex-direction: column; align-items: flex-start; } .user-name-row.editing { width: 100%; } .display-name { max-width: 100%; } }
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -1,207 +0,0 @@
|
|||||||
<template>
|
|
||||||
<div class="app-shell">
|
|
||||||
<div v-if="status.show" class="global-status" style="display: block;">
|
|
||||||
<div :class="['status', status.type]">
|
|
||||||
{{ status.message }}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<main class="view-root">
|
|
||||||
<div class="view-content">
|
|
||||||
<div class="surface surface--tight" style="max-width: 520px; margin: 0 auto; width: 100%;">
|
|
||||||
<header class="view-header" style="text-align: center;">
|
|
||||||
<h1>🚫 Access Restricted</h1>
|
|
||||||
<p class="view-lede">{{ headerMessage }}</p>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<section class="section-block" v-if="initializing">
|
|
||||||
<div class="section-body center">
|
|
||||||
<p>Checking your session…</p>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section class="section-block" v-else>
|
|
||||||
<div class="section-body center" style="gap: 1.75rem;">
|
|
||||||
<p>{{ detailText }}</p>
|
|
||||||
|
|
||||||
<div class="button-row center" style="justify-content: center;">
|
|
||||||
<button v-if="canAuthenticate" class="btn-primary" :disabled="loading" @click="authenticateUser">
|
|
||||||
{{ loading ? 'Signing in…' : 'Sign in with Passkey' }}
|
|
||||||
</button>
|
|
||||||
<button class="btn-secondary" :disabled="loading" @click="returnHome">
|
|
||||||
Go back to Auth Home
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</main>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup>
|
|
||||||
import { computed, onMounted, reactive, ref } from 'vue'
|
|
||||||
import passkey from '@/utils/passkey'
|
|
||||||
|
|
||||||
const status = reactive({
|
|
||||||
show: false,
|
|
||||||
message: '',
|
|
||||||
type: 'info'
|
|
||||||
})
|
|
||||||
|
|
||||||
const initializing = ref(true)
|
|
||||||
const loading = ref(false)
|
|
||||||
const settings = ref(null)
|
|
||||||
const userInfo = ref(null)
|
|
||||||
const fallbackDetail = ref('')
|
|
||||||
let statusTimer = null
|
|
||||||
|
|
||||||
const isAuthenticated = computed(() => !!userInfo.value?.authenticated)
|
|
||||||
const canAuthenticate = computed(() => !initializing.value && !isAuthenticated.value)
|
|
||||||
const uiBasePath = computed(() => {
|
|
||||||
const base = settings.value?.ui_base_path || '/auth/'
|
|
||||||
if (base === '/') return '/'
|
|
||||||
return base.endsWith('/') ? base : `${base}/`
|
|
||||||
})
|
|
||||||
|
|
||||||
const headerMessage = computed(() => {
|
|
||||||
if (initializing.value) return 'Checking your access permissions…'
|
|
||||||
if (isAuthenticated.value) {
|
|
||||||
return 'Your account is signed in, but this resource needs extra permissions.'
|
|
||||||
}
|
|
||||||
return 'Sign in to continue to the requested resource.'
|
|
||||||
})
|
|
||||||
|
|
||||||
const detailText = computed(() => {
|
|
||||||
if (isAuthenticated.value) {
|
|
||||||
return fallbackDetail.value || 'You do not have the required permissions to view this page.'
|
|
||||||
}
|
|
||||||
return fallbackDetail.value || 'Use your registered passkey to sign in securely.'
|
|
||||||
})
|
|
||||||
|
|
||||||
function showMessage(message, type = 'info', duration = 3000) {
|
|
||||||
status.show = true
|
|
||||||
status.message = message
|
|
||||||
status.type = type
|
|
||||||
if (statusTimer) clearTimeout(statusTimer)
|
|
||||||
if (duration > 0) {
|
|
||||||
statusTimer = setTimeout(() => {
|
|
||||||
status.show = false
|
|
||||||
}, duration)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function fetchSettings() {
|
|
||||||
try {
|
|
||||||
const res = await fetch('/auth/api/settings')
|
|
||||||
if (!res.ok) return
|
|
||||||
const data = await res.json()
|
|
||||||
settings.value = data
|
|
||||||
if (data?.rp_name) {
|
|
||||||
document.title = `${data.rp_name} · Access Restricted`
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.warn('Unable to load settings', error)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function fetchUserInfo() {
|
|
||||||
try {
|
|
||||||
const res = await fetch('/auth/api/user-info', { method: 'POST' })
|
|
||||||
if (!res.ok) {
|
|
||||||
const payload = await safeParseJson(res)
|
|
||||||
fallbackDetail.value = payload?.detail || 'Please sign in to continue.'
|
|
||||||
return
|
|
||||||
}
|
|
||||||
userInfo.value = await res.json()
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to load user info', error)
|
|
||||||
fallbackDetail.value = 'We were unable to verify your session. Try again shortly.'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function authenticateUser() {
|
|
||||||
if (!canAuthenticate.value || loading.value) return
|
|
||||||
loading.value = true
|
|
||||||
showMessage('Starting authentication…', 'info')
|
|
||||||
|
|
||||||
let result
|
|
||||||
try {
|
|
||||||
result = await passkey.authenticate()
|
|
||||||
} catch (error) {
|
|
||||||
loading.value = false
|
|
||||||
const message = error?.message || 'Passkey authentication cancelled'
|
|
||||||
const cancelled = message === 'Passkey authentication cancelled'
|
|
||||||
showMessage(cancelled ? message : `Authentication failed: ${message}`, cancelled ? 'info' : 'error', 4000)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
await setSessionCookie(result.session_token)
|
|
||||||
} catch (error) {
|
|
||||||
loading.value = false
|
|
||||||
const message = error?.message || 'Failed to establish session'
|
|
||||||
showMessage(message, 'error', 4000)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
showMessage('Signed in successfully!', 'success', 2000)
|
|
||||||
setTimeout(() => {
|
|
||||||
loading.value = false
|
|
||||||
window.location.reload()
|
|
||||||
}, 800)
|
|
||||||
}
|
|
||||||
|
|
||||||
async function setSessionCookie(sessionToken) {
|
|
||||||
const response = await fetch('/auth/api/set-session', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: {
|
|
||||||
Authorization: `Bearer ${sessionToken}`
|
|
||||||
}
|
|
||||||
})
|
|
||||||
const payload = await safeParseJson(response)
|
|
||||||
if (!response.ok || payload?.detail) {
|
|
||||||
const detail = payload?.detail || 'Session could not be established.'
|
|
||||||
throw new Error(detail)
|
|
||||||
}
|
|
||||||
return payload
|
|
||||||
}
|
|
||||||
|
|
||||||
function returnHome() {
|
|
||||||
const target = uiBasePath.value || '/auth/'
|
|
||||||
if (window.location.pathname !== target) {
|
|
||||||
history.replaceState(null, '', target)
|
|
||||||
}
|
|
||||||
window.location.href = target
|
|
||||||
}
|
|
||||||
|
|
||||||
async function safeParseJson(response) {
|
|
||||||
try {
|
|
||||||
return await response.json()
|
|
||||||
} catch (error) {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
onMounted(async () => {
|
|
||||||
await fetchSettings()
|
|
||||||
await fetchUserInfo()
|
|
||||||
if (!canAuthenticate.value && !isAuthenticated.value && !fallbackDetail.value) {
|
|
||||||
fallbackDetail.value = 'Please try signing in again.'
|
|
||||||
}
|
|
||||||
initializing.value = false
|
|
||||||
})
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
.center {
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.button-row.center {
|
|
||||||
display: flex;
|
|
||||||
justify-content: center;
|
|
||||||
gap: 0.75rem;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
import { createApp } from 'vue'
|
|
||||||
import RestrictedApp from './RestrictedApp.vue'
|
|
||||||
import '@/assets/style.css'
|
|
||||||
|
|
||||||
createApp(RestrictedApp).mount('#app')
|
|
||||||
+63
-67
@@ -1,13 +1,17 @@
|
|||||||
import { defineStore } from 'pinia'
|
import { defineStore } from 'pinia'
|
||||||
import { register, authenticate } from '@/utils/passkey'
|
import { register, authenticate } from '@/utils/passkey'
|
||||||
|
import { getSettings } from '@/utils/settings'
|
||||||
|
import { apiJson } from '@/utils/api'
|
||||||
|
|
||||||
export const useAuthStore = defineStore('auth', {
|
export const useAuthStore = defineStore('auth', {
|
||||||
state: () => ({
|
state: () => ({
|
||||||
// Auth State
|
// Auth State
|
||||||
userInfo: null, // Contains the full user info response: {user, credentials, aaguid_info, session_type, authenticated}
|
userInfo: null, // Contains the full user info response: {user, credentials, aaguid_info}
|
||||||
settings: null, // Server provided settings (/auth/settings)
|
|
||||||
isLoading: false,
|
isLoading: false,
|
||||||
|
|
||||||
|
// Settings
|
||||||
|
settings: null,
|
||||||
|
|
||||||
// UI State
|
// UI State
|
||||||
currentView: 'login',
|
currentView: 'login',
|
||||||
status: {
|
status: {
|
||||||
@@ -17,15 +21,6 @@ export const useAuthStore = defineStore('auth', {
|
|||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
getters: {
|
getters: {
|
||||||
uiBasePath(state) {
|
|
||||||
const configured = state.settings?.ui_base_path || '/auth/'
|
|
||||||
if (!configured.endsWith('/')) return `${configured}/`
|
|
||||||
return configured
|
|
||||||
},
|
|
||||||
adminUiPath() {
|
|
||||||
const base = this.uiBasePath
|
|
||||||
return base === '/' ? '/admin/' : `${base}admin/`
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
actions: {
|
actions: {
|
||||||
setLoading(flag) {
|
setLoading(flag) {
|
||||||
@@ -43,31 +38,21 @@ export const useAuthStore = defineStore('auth', {
|
|||||||
}, duration)
|
}, duration)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
uiHref(suffix = '') {
|
async setSessionCookie(result) {
|
||||||
const trimmed = suffix.startsWith('/') ? suffix.slice(1) : suffix
|
if (!result?.session_token) {
|
||||||
if (!trimmed) return this.uiBasePath
|
console.error('setSessionCookie called with missing session_token:', result)
|
||||||
if (this.uiBasePath === '/') return `/${trimmed}`
|
throw new Error('Authentication response missing session_token')
|
||||||
return `${this.uiBasePath}${trimmed}`
|
|
||||||
},
|
|
||||||
adminHomeHref() {
|
|
||||||
return this.adminUiPath
|
|
||||||
},
|
|
||||||
async setSessionCookie(sessionToken) {
|
|
||||||
const response = await fetch('/auth/api/set-session', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: {'Authorization': `Bearer ${sessionToken}`},
|
|
||||||
})
|
|
||||||
const result = await response.json()
|
|
||||||
if (result.detail) {
|
|
||||||
throw new Error(result.detail)
|
|
||||||
}
|
}
|
||||||
return result
|
return await apiJson('/auth/api/set-session', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {'Authorization': `Bearer ${result.session_token}`},
|
||||||
|
})
|
||||||
},
|
},
|
||||||
async register() {
|
async register() {
|
||||||
this.isLoading = true
|
this.isLoading = true
|
||||||
try {
|
try {
|
||||||
const result = await register()
|
const result = await register()
|
||||||
await this.setSessionCookie(result.session_token)
|
await this.setSessionCookie(result)
|
||||||
await this.loadUserInfo()
|
await this.loadUserInfo()
|
||||||
this.selectView()
|
this.selectView()
|
||||||
return result
|
return result
|
||||||
@@ -80,7 +65,7 @@ export const useAuthStore = defineStore('auth', {
|
|||||||
try {
|
try {
|
||||||
const result = await authenticate()
|
const result = await authenticate()
|
||||||
|
|
||||||
await this.setSessionCookie(result.session_token)
|
await this.setSessionCookie(result)
|
||||||
await this.loadUserInfo()
|
await this.loadUserInfo()
|
||||||
this.selectView()
|
this.selectView()
|
||||||
|
|
||||||
@@ -91,57 +76,68 @@ export const useAuthStore = defineStore('auth', {
|
|||||||
},
|
},
|
||||||
selectView() {
|
selectView() {
|
||||||
if (!this.userInfo) this.currentView = 'login'
|
if (!this.userInfo) this.currentView = 'login'
|
||||||
else if (this.userInfo.authenticated) this.currentView = 'profile'
|
else this.currentView = 'profile'
|
||||||
else this.currentView = 'login'
|
|
||||||
},
|
|
||||||
async loadUserInfo() {
|
|
||||||
const response = await fetch('/auth/api/user-info', { method: 'POST' })
|
|
||||||
let result = null
|
|
||||||
try {
|
|
||||||
result = await response.json()
|
|
||||||
} catch (_) {
|
|
||||||
// ignore JSON parse errors (unlikely)
|
|
||||||
}
|
|
||||||
if (response.status === 401 && result?.detail) {
|
|
||||||
this.showMessage(result.detail, 'error', 5000)
|
|
||||||
throw new Error(result.detail)
|
|
||||||
}
|
|
||||||
if (result?.detail) {
|
|
||||||
// Other error style
|
|
||||||
this.showMessage(result.detail, 'error', 5000)
|
|
||||||
throw new Error(result.detail)
|
|
||||||
}
|
|
||||||
this.userInfo = result
|
|
||||||
console.log('User info loaded:', result)
|
|
||||||
},
|
},
|
||||||
async loadSettings() {
|
async loadSettings() {
|
||||||
|
this.settings = await getSettings()
|
||||||
|
},
|
||||||
|
async loadUserInfo() {
|
||||||
try {
|
try {
|
||||||
const res = await fetch('/auth/api/settings')
|
this.userInfo = await apiJson('/auth/api/user-info', { method: 'POST' })
|
||||||
if (!res.ok) return
|
console.log('User info loaded:', this.userInfo)
|
||||||
const data = await res.json()
|
} catch (error) {
|
||||||
this.settings = data
|
// Suppress toast for 401/403 errors - the auth iframe will handle these
|
||||||
if (data?.rp_name) {
|
if (error.status === 401 || error.status === 403) {
|
||||||
document.title = data.rp_name
|
console.log('Authentication required:', error.message)
|
||||||
|
} else {
|
||||||
|
this.showMessage(error.message || 'Failed to load user info', 'error', 5000)
|
||||||
}
|
}
|
||||||
} catch (_) {
|
throw error
|
||||||
// ignore
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
async deleteCredential(uuid) {
|
async deleteCredential(uuid) {
|
||||||
const response = await fetch(`/auth/api/credential/${uuid}`, {method: 'Delete'})
|
await apiJson(`/auth/api/user/credential/${uuid}`, { method: 'DELETE' })
|
||||||
const result = await response.json()
|
|
||||||
if (result.detail) throw new Error(`Server: ${result.detail}`)
|
|
||||||
|
|
||||||
await this.loadUserInfo()
|
await this.loadUserInfo()
|
||||||
},
|
},
|
||||||
|
async terminateSession(sessionId) {
|
||||||
|
try {
|
||||||
|
const payload = await apiJson(`/auth/api/user/session/${sessionId}`, { method: 'DELETE' })
|
||||||
|
if (payload?.current_session_terminated) {
|
||||||
|
sessionStorage.clear()
|
||||||
|
location.reload()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
await this.loadUserInfo()
|
||||||
|
this.showMessage('Session terminated', 'success', 2500)
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Terminate session error:', error)
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
},
|
||||||
async logout() {
|
async logout() {
|
||||||
try {
|
try {
|
||||||
await fetch('/auth/api/logout', {method: 'POST'})
|
await apiJson('/auth/api/logout', {method: 'POST'})
|
||||||
sessionStorage.clear()
|
sessionStorage.clear()
|
||||||
location.reload()
|
location.reload()
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Logout error:', error)
|
console.error('Logout error:', error)
|
||||||
this.showMessage(error.message, 'error')
|
// Suppress toast for 401/403 errors - the auth iframe will handle these
|
||||||
|
if (error.status !== 401 && error.status !== 403) {
|
||||||
|
this.showMessage(error.message, 'error')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
async logoutEverywhere() {
|
||||||
|
try {
|
||||||
|
await apiJson('/auth/api/user/logout-all', {method: 'POST'})
|
||||||
|
sessionStorage.clear()
|
||||||
|
location.reload()
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Logout-all error:', error)
|
||||||
|
// Suppress toast for 401/403 errors - the auth iframe will handle these
|
||||||
|
if (error.status !== 401 && error.status !== 403) {
|
||||||
|
this.showMessage(error.message, 'error')
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,378 @@
|
|||||||
|
/**
|
||||||
|
* API fetch wrapper that handles authentication errors with iframe-based re-authentication.
|
||||||
|
*
|
||||||
|
* When a 401 or 403 response is received with an `auth` object containing `iframe` URL,
|
||||||
|
* this wrapper shows an authentication iframe and retries the original request after
|
||||||
|
* successful authentication.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** Default timeout for API requests in milliseconds */
|
||||||
|
const DEFAULT_TIMEOUT_MS = 1000
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Custom error class for API errors with full response context.
|
||||||
|
*/
|
||||||
|
export class ApiError extends Error {
|
||||||
|
constructor(url, response, data) {
|
||||||
|
super(data?.detail || `Request failed: ${response.status}`)
|
||||||
|
this.name = 'ApiError'
|
||||||
|
this.url = url
|
||||||
|
this.status = response.status
|
||||||
|
this.statusText = response.statusText
|
||||||
|
this.data = data
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Custom error class for network/timeout errors.
|
||||||
|
*/
|
||||||
|
export class NetworkError extends Error {
|
||||||
|
constructor(message, originalError = null) {
|
||||||
|
super(message)
|
||||||
|
this.name = 'NetworkError'
|
||||||
|
this.originalError = originalError
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Error thrown when user cancels authentication.
|
||||||
|
*/
|
||||||
|
export class AuthCancelledError extends Error {
|
||||||
|
constructor() {
|
||||||
|
super('Authentication cancelled')
|
||||||
|
this.name = 'AuthCancelledError'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let authIframe = null
|
||||||
|
let authPromise = null
|
||||||
|
let authResolve = null
|
||||||
|
let authReject = null
|
||||||
|
|
||||||
|
// Cache for auth iframe URL by mode
|
||||||
|
const authIframeUrlCache = {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the auth iframe URL for a given mode.
|
||||||
|
* Fetches from /auth/api/forward which returns URL in the auth.iframe field.
|
||||||
|
* Results are cached per mode.
|
||||||
|
* @param {string} mode - The auth mode ('login', 'reauth', 'forbidden')
|
||||||
|
* @returns {Promise<string>} - The URL for the iframe
|
||||||
|
*/
|
||||||
|
export async function getAuthIframeUrl(mode = 'login') {
|
||||||
|
if (authIframeUrlCache[mode]) {
|
||||||
|
return authIframeUrlCache[mode]
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch from forward endpoint - it returns URL in auth.iframe on 401/403
|
||||||
|
const response = await fetch('/auth/api/forward', { credentials: 'include' })
|
||||||
|
if (response.status === 401 || response.status === 403) {
|
||||||
|
const data = await response.json()
|
||||||
|
if (data.auth?.iframe) {
|
||||||
|
// The iframe field now contains a URL with hash fragment
|
||||||
|
// If mode differs, update the hash param
|
||||||
|
let url = data.auth.iframe
|
||||||
|
if (mode !== data.auth.mode) {
|
||||||
|
url = url.replace(/mode=[^&]*/, `mode=${mode}`)
|
||||||
|
}
|
||||||
|
authIframeUrlCache[mode] = url
|
||||||
|
return url
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw new Error('Unable to fetch auth iframe URL')
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if an auth iframe is already open (from any source).
|
||||||
|
* @returns {boolean}
|
||||||
|
*/
|
||||||
|
export function isAuthIframeOpen() {
|
||||||
|
return !!document.getElementById('auth-iframe')
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Show the authentication iframe and return a promise that resolves on success.
|
||||||
|
* If an auth iframe is already open (from any source), hooks into its completion.
|
||||||
|
* @param {string} iframeUrl - The URL for the iframe src
|
||||||
|
* @returns {Promise<void>}
|
||||||
|
* @throws {AuthCancelledError} - If authentication is cancelled by user
|
||||||
|
*/
|
||||||
|
export function showAuthIframe(iframeUrl) {
|
||||||
|
// If we already have a promise (from us), return it
|
||||||
|
if (authPromise) return authPromise
|
||||||
|
|
||||||
|
// If there's already an iframe in the DOM (from App.vue or elsewhere),
|
||||||
|
// create a promise that hooks into the message handler
|
||||||
|
if (document.getElementById('auth-iframe')) {
|
||||||
|
authPromise = new Promise((resolve, reject) => {
|
||||||
|
authResolve = resolve
|
||||||
|
authReject = reject
|
||||||
|
})
|
||||||
|
return authPromise
|
||||||
|
}
|
||||||
|
|
||||||
|
authPromise = new Promise((resolve, reject) => {
|
||||||
|
authResolve = resolve
|
||||||
|
authReject = reject
|
||||||
|
})
|
||||||
|
|
||||||
|
// Remove existing iframe if any
|
||||||
|
hideAuthIframe()
|
||||||
|
|
||||||
|
// Create new iframe for authentication using src URL
|
||||||
|
authIframe = document.createElement('iframe')
|
||||||
|
authIframe.id = 'auth-iframe'
|
||||||
|
authIframe.title = 'Authentication'
|
||||||
|
authIframe.allow = 'publickey-credentials-get; publickey-credentials-create'
|
||||||
|
authIframe.src = iframeUrl
|
||||||
|
document.body.appendChild(authIframe)
|
||||||
|
|
||||||
|
return authPromise
|
||||||
|
}
|
||||||
|
|
||||||
|
function hideAuthIframe() {
|
||||||
|
if (authIframe) {
|
||||||
|
authIframe.remove()
|
||||||
|
authIframe = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleAuthMessage(event) {
|
||||||
|
const data = event.data
|
||||||
|
if (!data?.type) return
|
||||||
|
|
||||||
|
switch (data.type) {
|
||||||
|
case 'auth-success':
|
||||||
|
hideAuthIframe()
|
||||||
|
if (authResolve) {
|
||||||
|
authResolve()
|
||||||
|
authPromise = null
|
||||||
|
authResolve = null
|
||||||
|
authReject = null
|
||||||
|
}
|
||||||
|
break
|
||||||
|
|
||||||
|
case 'auth-back':
|
||||||
|
case 'auth-close-request':
|
||||||
|
hideAuthIframe()
|
||||||
|
if (authReject) {
|
||||||
|
authReject(new AuthCancelledError())
|
||||||
|
authPromise = null
|
||||||
|
authResolve = null
|
||||||
|
authReject = null
|
||||||
|
}
|
||||||
|
break
|
||||||
|
|
||||||
|
case 'auth-error':
|
||||||
|
// Keep iframe open for retry, but if cancelled, treat as back
|
||||||
|
if (data.cancelled && authReject) {
|
||||||
|
hideAuthIframe()
|
||||||
|
authReject(new AuthCancelledError())
|
||||||
|
authPromise = null
|
||||||
|
authResolve = null
|
||||||
|
authReject = null
|
||||||
|
}
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Install global message listener
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
|
window.addEventListener('message', handleAuthMessage)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch wrapper that handles auth errors with iframe-based re-authentication.
|
||||||
|
* Loops until successful or user cancels authentication.
|
||||||
|
*
|
||||||
|
* @param {string|URL} url - The URL to fetch
|
||||||
|
* @param {RequestInit} [options] - Fetch options
|
||||||
|
* @param {number} [options.timeout] - Timeout in ms (default: 10000, use 0 to disable)
|
||||||
|
* @returns {Promise<Response>} - The fetch response
|
||||||
|
* @throws {AuthCancelledError} - If authentication is cancelled by user
|
||||||
|
* @throws {NetworkError} - If network error or timeout occurs
|
||||||
|
*/
|
||||||
|
export async function apiFetch(url, options = {}) {
|
||||||
|
const { timeout = DEFAULT_TIMEOUT_MS, ...fetchOptions } = options
|
||||||
|
|
||||||
|
// Ensure credentials are included for cookie-based auth
|
||||||
|
fetchOptions.credentials = fetchOptions.credentials || 'include'
|
||||||
|
|
||||||
|
while (true) {
|
||||||
|
let response
|
||||||
|
try {
|
||||||
|
response = await fetch(url, {...fetchOptions, signal: timeout && AbortSignal.timeout(timeout)})
|
||||||
|
} catch (error) {
|
||||||
|
// Handle network errors and timeouts
|
||||||
|
if (error.name === 'TimeoutError') {
|
||||||
|
throw new NetworkError('Request timed out', error)
|
||||||
|
}
|
||||||
|
if (error.name === 'AbortError') {
|
||||||
|
// Re-throw abort errors as-is (user-initiated cancellation)
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
if (error.name === 'TypeError' && error.message === 'Failed to fetch') {
|
||||||
|
throw new NetworkError('Unable to connect to server', error)
|
||||||
|
}
|
||||||
|
throw new NetworkError(error.message || 'Network error', error)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for auth errors (401/403)
|
||||||
|
if (response.status === 401 || response.status === 403) {
|
||||||
|
// Try to parse the response to get the iframe URL
|
||||||
|
let authInfo = null
|
||||||
|
try {
|
||||||
|
const data = await response.clone().json()
|
||||||
|
authInfo = data.auth
|
||||||
|
} catch {
|
||||||
|
// If we can't parse JSON, no iframe available
|
||||||
|
}
|
||||||
|
|
||||||
|
// Authenticate via iframe (only in top-level window)
|
||||||
|
if (authInfo?.iframe && window === window.top) {
|
||||||
|
// Show auth iframe (or wait for existing one) and retry on success
|
||||||
|
// showAuthIframe returns existing promise if iframe is already open
|
||||||
|
await showAuthIframe(authInfo.iframe)
|
||||||
|
continue // Retry the original request
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return response
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convenience method for JSON API calls.
|
||||||
|
* Automatically sets Accept and Content-Type headers.
|
||||||
|
* Returns parsed JSON directly if response is ok, throws ApiError otherwise.
|
||||||
|
*
|
||||||
|
* @param {string|URL} url - The URL to fetch
|
||||||
|
* @param {RequestInit} [options] - Fetch options
|
||||||
|
* @returns {Promise<any>} - Parsed JSON response
|
||||||
|
* @throws {ApiError} - If response is not ok
|
||||||
|
* @throws {NetworkError} - If network error or timeout occurs
|
||||||
|
* @throws {AuthCancelledError} - If authentication is cancelled by user
|
||||||
|
*/
|
||||||
|
export async function apiJson(url, options = {}) {
|
||||||
|
const fetchOptions = { ...options }
|
||||||
|
|
||||||
|
// Set default headers, allowing caller overrides
|
||||||
|
fetchOptions.headers = {
|
||||||
|
'Accept': 'application/json',
|
||||||
|
...fetchOptions.headers,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set Content-Type for requests with JSON body
|
||||||
|
if (fetchOptions.body && typeof fetchOptions.body === 'object' && !(fetchOptions.body instanceof FormData)) {
|
||||||
|
fetchOptions.headers = {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
...fetchOptions.headers,
|
||||||
|
}
|
||||||
|
fetchOptions.body = JSON.stringify(fetchOptions.body)
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await apiFetch(url, fetchOptions)
|
||||||
|
const data = await response.json()
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new ApiError(url, response, data)
|
||||||
|
}
|
||||||
|
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Simple JSON fetch without auto-auth iframe handling.
|
||||||
|
* Use this in contexts where showing an auth iframe would be inappropriate
|
||||||
|
* (e.g., inside the auth iframe itself).
|
||||||
|
*
|
||||||
|
* @param {string|URL} url - The URL to fetch
|
||||||
|
* @param {RequestInit} [options] - Fetch options
|
||||||
|
* @returns {Promise<any>} - Parsed JSON response
|
||||||
|
* @throws {ApiError} - If response is not ok
|
||||||
|
*/
|
||||||
|
export async function fetchJson(url, options = {}) {
|
||||||
|
const fetchOptions = {
|
||||||
|
credentials: 'include',
|
||||||
|
...options,
|
||||||
|
headers: {
|
||||||
|
'Accept': 'application/json',
|
||||||
|
...options.headers,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await fetch(url, fetchOptions)
|
||||||
|
const data = await response.json()
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new ApiError(url, response, data)
|
||||||
|
}
|
||||||
|
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert an error to a user-friendly message.
|
||||||
|
* @param {Error} error - The error to convert
|
||||||
|
* @returns {string} - User-friendly error message
|
||||||
|
*/
|
||||||
|
export function getUserFriendlyErrorMessage(error) {
|
||||||
|
if (error instanceof NetworkError) {
|
||||||
|
return error.message
|
||||||
|
}
|
||||||
|
if (error instanceof ApiError) {
|
||||||
|
return error.message
|
||||||
|
}
|
||||||
|
if (error.name === 'TimeoutError') {
|
||||||
|
return 'Request timed out'
|
||||||
|
}
|
||||||
|
if (error.name === 'TypeError' && error.message === 'Failed to fetch') {
|
||||||
|
return 'Unable to connect to server'
|
||||||
|
}
|
||||||
|
return error.message || 'An error occurred'
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if an error should show a toast to the user.
|
||||||
|
* @param {Error} error - The error to check
|
||||||
|
* @returns {boolean} - Whether to show a toast
|
||||||
|
*/
|
||||||
|
export function shouldShowErrorToast(error) {
|
||||||
|
// Don't show toast for user cancellations
|
||||||
|
if (error instanceof AuthCancelledError) return false
|
||||||
|
if (error.name === 'AbortError') return false
|
||||||
|
// Don't show toast for 401/403 errors - the auth iframe will handle these
|
||||||
|
if (error instanceof ApiError && (error.status === 401 || error.status === 403)) return false
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create an API caller with error handling (toast + console.error).
|
||||||
|
* Wraps apiJson calls with consistent error handling for apps.
|
||||||
|
*
|
||||||
|
* @param {Function} showMessage - Function to show toast messages: (message, type, duration) => void
|
||||||
|
* @returns {Function} - Wrapped apiJson that handles errors
|
||||||
|
*/
|
||||||
|
export function createApiCaller(showMessage) {
|
||||||
|
/**
|
||||||
|
* @param {string|URL} url - The URL to fetch
|
||||||
|
* @param {RequestInit} [options] - Fetch options
|
||||||
|
* @returns {Promise<any>} - Parsed JSON response, or undefined on error
|
||||||
|
*/
|
||||||
|
return async function apiCall(url, options = {}) {
|
||||||
|
try {
|
||||||
|
return await apiJson(url, options)
|
||||||
|
} catch (error) {
|
||||||
|
if (!shouldShowErrorToast(error)) {
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
// Log full error details
|
||||||
|
console.error(`API error for ${url}:`, error instanceof ApiError ? { status: error.status, statusText: error.statusText, data: error.data } : error)
|
||||||
|
// Show user-friendly toast
|
||||||
|
showMessage(getUserFriendlyErrorMessage(error), 'error', 4000)
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default apiFetch
|
||||||
@@ -5,8 +5,8 @@ class AwaitableWebSocket extends WebSocket {
|
|||||||
#opened = false
|
#opened = false
|
||||||
|
|
||||||
constructor(resolve, reject, url, protocols, binaryType) {
|
constructor(resolve, reject, url, protocols, binaryType) {
|
||||||
// Support relative URLs even on old browsers that don't
|
// Support relative URLs even on old browsers that don't natively support them
|
||||||
super(new URL(url, location.href.replace(/^http/, 'ws')), protocols)
|
super(new URL(url, document.baseURI.replace(/^http/, 'ws')), protocols)
|
||||||
this.binaryType = binaryType || 'blob'
|
this.binaryType = binaryType || 'blob'
|
||||||
this.onopen = () => {
|
this.onopen = () => {
|
||||||
this.#opened = true
|
this.#opened = true
|
||||||
@@ -18,12 +18,36 @@ class AwaitableWebSocket extends WebSocket {
|
|||||||
}
|
}
|
||||||
this.onclose = e => {
|
this.onclose = e => {
|
||||||
if (!this.#opened) {
|
if (!this.#opened) {
|
||||||
reject(new Error(`WebSocket ${this.url} failed to connect, code ${e.code}`))
|
reject(new Error(`Failed to connect to server (code ${e.code})`))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
this.#err = e.wasClean
|
// Create user-friendly close messages
|
||||||
? new Error(`Websocket ${this.url} closed ${e.code}`)
|
let message
|
||||||
: new Error(`WebSocket ${this.url} closed with error ${e.code}`)
|
if (e.wasClean) {
|
||||||
|
// Standard close codes
|
||||||
|
switch (e.code) {
|
||||||
|
case 1000: message = 'Connection closed normally'; break
|
||||||
|
case 1001: message = 'Server is going away'; break
|
||||||
|
case 1002: message = 'Protocol error'; break
|
||||||
|
case 1003: message = 'Unsupported data received'; break
|
||||||
|
case 1006: message = 'Connection lost unexpectedly'; break
|
||||||
|
case 1007: message = 'Invalid data received'; break
|
||||||
|
case 1008: message = 'Policy violation'; break
|
||||||
|
case 1009: message = 'Message too large'; break
|
||||||
|
case 1010: message = 'Extension negotiation failed'; break
|
||||||
|
case 1011: message = 'Server encountered an error'; break
|
||||||
|
case 1012: message = 'Server is restarting'; break
|
||||||
|
case 1013: message = 'Server is overloaded, try again later'; break
|
||||||
|
case 1014: message = 'Bad gateway'; break
|
||||||
|
case 1015: message = 'TLS handshake failed'; break
|
||||||
|
default: message = `Connection closed (code ${e.code})`
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
message = e.code === 1006
|
||||||
|
? 'Connection lost unexpectedly'
|
||||||
|
: `Connection closed with error (code ${e.code})`
|
||||||
|
}
|
||||||
|
this.#err = new Error(message)
|
||||||
this.#waiting.splice(0).forEach(p => p.reject(this.#err))
|
this.#waiting.splice(0).forEach(p => p.reject(this.#err))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -51,17 +75,12 @@ class AwaitableWebSocket extends WebSocket {
|
|||||||
console.error("WebSocket received binary data, expected JSON string", data)
|
console.error("WebSocket received binary data, expected JSON string", data)
|
||||||
throw new Error("WebSocket received binary data, expected JSON string")
|
throw new Error("WebSocket received binary data, expected JSON string")
|
||||||
}
|
}
|
||||||
let parsed
|
|
||||||
try {
|
try {
|
||||||
parsed = JSON.parse(data)
|
return JSON.parse(data)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("Failed to parse JSON from WebSocket message", data, err)
|
console.error("Failed to parse JSON from WebSocket message", data, err)
|
||||||
throw new Error("Failed to parse JSON from WebSocket message")
|
throw new Error("Failed to parse JSON from WebSocket message")
|
||||||
}
|
}
|
||||||
if (parsed.detail) {
|
|
||||||
throw new Error(`Server: ${parsed.detail}`)
|
|
||||||
}
|
|
||||||
return parsed
|
|
||||||
}
|
}
|
||||||
|
|
||||||
send_json(data) {
|
send_json(data) {
|
||||||
|
|||||||
@@ -0,0 +1,33 @@
|
|||||||
|
/**
|
||||||
|
* URL-safe Base64 encoding/decoding utilities.
|
||||||
|
*
|
||||||
|
* These functions handle base64url format (RFC 4648) which uses:
|
||||||
|
* - '-' instead of '+'
|
||||||
|
* - '_' instead of '/'
|
||||||
|
* - No padding '=' characters
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Decode a base64url string to Uint8Array.
|
||||||
|
* Handles both standard base64 and URL-safe base64 (with or without padding).
|
||||||
|
* @param {string} str - Base64url encoded string
|
||||||
|
* @returns {Uint8Array} - Decoded bytes
|
||||||
|
*/
|
||||||
|
export function dec(str) {
|
||||||
|
// Convert URL-safe characters to standard base64
|
||||||
|
const base64 = str.replace(/-/g, '+').replace(/_/g, '/')
|
||||||
|
// Add padding if needed
|
||||||
|
const padded = base64 + '='.repeat((4 - base64.length % 4) % 4)
|
||||||
|
return Uint8Array.from(atob(padded), c => c.charCodeAt(0))
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Encode a Uint8Array to base64url string.
|
||||||
|
* @param {Uint8Array} bytes - Bytes to encode
|
||||||
|
* @returns {string} - Base64url encoded string (no padding)
|
||||||
|
*/
|
||||||
|
export function enc(bytes) {
|
||||||
|
const base64 = btoa(String.fromCharCode(...bytes))
|
||||||
|
// Convert to URL-safe and remove padding
|
||||||
|
return base64.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '')
|
||||||
|
}
|
||||||
@@ -5,16 +5,18 @@ export function formatDate(dateString) {
|
|||||||
|
|
||||||
const date = new Date(dateString)
|
const date = new Date(dateString)
|
||||||
const now = new Date()
|
const now = new Date()
|
||||||
const diffMs = now - date
|
const diffMs = date - now // Changed to date - now for future/past
|
||||||
const diffMinutes = Math.floor(diffMs / (1000 * 60))
|
const isFuture = diffMs > 0
|
||||||
const diffHours = Math.floor(diffMs / (1000 * 60 * 60))
|
const absDiffMs = Math.abs(diffMs)
|
||||||
const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24))
|
const diffMinutes = Math.round(absDiffMs / (1000 * 60))
|
||||||
|
const diffHours = Math.round(absDiffMs / (1000 * 60 * 60))
|
||||||
|
const diffDays = Math.round(absDiffMs / (1000 * 60 * 60 * 24))
|
||||||
|
|
||||||
if (diffMs < 0 || diffDays > 7) return date.toLocaleDateString()
|
if (absDiffMs < 1000 * 60) return 'Now'
|
||||||
if (diffMinutes === 0) return 'Just now'
|
if (diffMinutes <= 60) return isFuture ? `In ${diffMinutes} minute${diffMinutes === 1 ? '' : 's'}` : diffMinutes === 1 ? 'a minute ago' : `${diffMinutes} minutes ago`
|
||||||
if (diffMinutes < 60) return diffMinutes === 1 ? 'a minute ago' : `${diffMinutes} minutes ago`
|
if (diffHours <= 24) return isFuture ? `In ${diffHours} hour${diffHours === 1 ? '' : 's'}` : diffHours === 1 ? 'an hour ago' : `${diffHours} hours ago`
|
||||||
if (diffHours < 24) return diffHours === 1 ? 'an hour ago' : `${diffHours} hours ago`
|
if (diffDays <= 14) return isFuture ? `In ${diffDays} day${diffDays === 1 ? '' : 's'}` : diffDays === 1 ? 'a day ago' : `${diffDays} days ago`
|
||||||
return diffDays === 1 ? 'a day ago' : `${diffDays} days ago`
|
return date.toLocaleDateString(undefined, { year: 'numeric', month: 'long', day: 'numeric' })
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getCookie(name) {
|
export function getCookie(name) {
|
||||||
@@ -22,3 +24,5 @@ export function getCookie(name) {
|
|||||||
const parts = value.split(`; ${name}=`)
|
const parts = value.split(`; ${name}=`)
|
||||||
if (parts.length === 2) return parts.pop().split(';').shift()
|
if (parts.length === 2) return parts.pop().split(';').shift()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const goBack = () => history.back() || window.close()
|
||||||
|
|||||||
@@ -1,34 +1,76 @@
|
|||||||
import { startRegistration, startAuthentication } from '@simplewebauthn/browser'
|
import { startRegistration, startAuthentication } from '@simplewebauthn/browser'
|
||||||
import aWebSocket from '@/utils/awaitable-websocket'
|
import aWebSocket from '@/utils/awaitable-websocket'
|
||||||
|
import { getSettings } from '@/utils/settings'
|
||||||
|
import { showAuthIframe } from '@/utils/api'
|
||||||
|
|
||||||
export async function register(resetToken = null, displayName = null) {
|
// Generic path normalizer: if an auth_host is configured and differs from current
|
||||||
|
// host, return absolute URL (scheme derived by aWebSocket). Otherwise, keep as-is.
|
||||||
|
async function makeUrl(path) {
|
||||||
|
const s = await getSettings()
|
||||||
|
const h = s?.auth_host
|
||||||
|
return h && location.host !== h ? `//${h}${path}` : path
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function register(resetToken = null, displayName = null, onstartreg = null) {
|
||||||
let params = []
|
let params = []
|
||||||
if (resetToken) params.push(`reset=${encodeURIComponent(resetToken)}`)
|
if (resetToken) params.push(`reset=${encodeURIComponent(resetToken)}`)
|
||||||
if (displayName) params.push(`name=${encodeURIComponent(displayName)}`)
|
if (displayName) params.push(`name=${encodeURIComponent(displayName)}`)
|
||||||
const qs = params.length ? `?${params.join('&')}` : ''
|
const qs = params.length ? `?${params.join('&')}` : ''
|
||||||
const url = `/auth/ws/register${qs}`
|
|
||||||
const ws = await aWebSocket(url)
|
while (true) {
|
||||||
try {
|
const ws = await aWebSocket(await makeUrl(`/auth/ws/register${qs}`))
|
||||||
const optionsJSON = await ws.receive_json()
|
try {
|
||||||
const registrationResponse = await startRegistration({ optionsJSON })
|
const res = await ws.receive_json()
|
||||||
ws.send_json(registrationResponse)
|
|
||||||
return await ws.receive_json()
|
// Handle auth errors (401/403) with iframe
|
||||||
} catch (error) {
|
if ((res.status === 401 || res.status === 403) && res.auth?.iframe) {
|
||||||
console.error('Registration error:', error)
|
ws.close()
|
||||||
// Replace useless and ugly error message from startRegistration
|
await showAuthIframe(res.auth.iframe)
|
||||||
throw Error(error.name === "NotAllowedError" ? 'Passkey registration cancelled' : error.message)
|
continue
|
||||||
} finally {
|
}
|
||||||
ws.close()
|
|
||||||
|
// Handle other errors (status field present means error)
|
||||||
|
if (res.status) {
|
||||||
|
throw new Error(res.detail || `Registration failed: ${res.status}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Notify caller that we're about to show the browser prompt
|
||||||
|
if (onstartreg) onstartreg()
|
||||||
|
|
||||||
|
const registrationResponse = await startRegistration(res)
|
||||||
|
ws.send_json(registrationResponse)
|
||||||
|
|
||||||
|
const result = await ws.receive_json()
|
||||||
|
if (result.status) {
|
||||||
|
throw new Error(result.detail || `Registration failed: ${result.status}`)
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
} catch (error) {
|
||||||
|
ws.close()
|
||||||
|
console.error('Registration error:', error)
|
||||||
|
// Replace useless and ugly error message from startRegistration
|
||||||
|
throw Error(error.name === "NotAllowedError" ? 'Passkey registration cancelled' : error.message)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function authenticate() {
|
export async function authenticate() {
|
||||||
const ws = await aWebSocket('/auth/ws/authenticate')
|
const ws = await aWebSocket(await makeUrl('/auth/ws/authenticate'))
|
||||||
try {
|
try {
|
||||||
const optionsJSON = await ws.receive_json()
|
const res = await ws.receive_json()
|
||||||
const authResponse = await startAuthentication({ optionsJSON })
|
console.log('Authentication options:', res)
|
||||||
|
// status field present means error
|
||||||
|
if (res.status) {
|
||||||
|
throw new Error(res.detail || `Authentication failed: ${res.status}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
const authResponse = await startAuthentication(res)
|
||||||
ws.send_json(authResponse)
|
ws.send_json(authResponse)
|
||||||
|
|
||||||
const result = await ws.receive_json()
|
const result = await ws.receive_json()
|
||||||
|
if (result.status) {
|
||||||
|
throw new Error(result.detail || `Authentication failed: ${result.status}`)
|
||||||
|
}
|
||||||
return result
|
return result
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Authentication error:', error)
|
console.error('Authentication error:', error)
|
||||||
|
|||||||
@@ -0,0 +1,37 @@
|
|||||||
|
import { solvePoW, verifyPoW } from './pow.js'
|
||||||
|
|
||||||
|
const TRIALS = 5
|
||||||
|
const WORK = 10
|
||||||
|
|
||||||
|
async function test() {
|
||||||
|
console.log(`Running ${TRIALS} trials with ${WORK} work units...\n`)
|
||||||
|
|
||||||
|
const times = []
|
||||||
|
|
||||||
|
for (let trial = 1; trial <= TRIALS; trial++) {
|
||||||
|
const challenge = crypto.getRandomValues(new Uint8Array(8))
|
||||||
|
|
||||||
|
const start = performance.now()
|
||||||
|
const solution = await solvePoW(challenge, WORK)
|
||||||
|
const elapsed = performance.now() - start
|
||||||
|
|
||||||
|
const valid = await verifyPoW(challenge, solution, WORK)
|
||||||
|
|
||||||
|
times.push(elapsed)
|
||||||
|
|
||||||
|
console.log(`Trial ${trial.toString().padStart(2)}: ${(elapsed / 1000).toFixed(3)}s, valid=${valid}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
const avgTime = times.reduce((a, b) => a + b, 0) / times.length
|
||||||
|
const minTime = Math.min(...times)
|
||||||
|
const maxTime = Math.max(...times)
|
||||||
|
|
||||||
|
console.log('\n--- Summary ---')
|
||||||
|
console.log(`Trials: ${TRIALS}`)
|
||||||
|
console.log(`Work units: ${WORK}`)
|
||||||
|
console.log(`Avg time: ${(avgTime / 1000).toFixed(3)}s`)
|
||||||
|
console.log(`Min time: ${(minTime / 1000).toFixed(3)}s`)
|
||||||
|
console.log(`Max time: ${(maxTime / 1000).toFixed(3)}s`)
|
||||||
|
}
|
||||||
|
|
||||||
|
test()
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
/**
|
||||||
|
* Proof of Work utility using PBKDF2-SHA512
|
||||||
|
*
|
||||||
|
* The PoW requires finding nonces where PBKDF2(challenge, nonce) produces
|
||||||
|
* output with a zero first byte. Each work unit requires finding one such nonce.
|
||||||
|
* All valid nonces are concatenated into a solution for server verification.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Solve a Proof of Work challenge
|
||||||
|
*
|
||||||
|
* @param {Uint8Array|ArrayBuffer} challenge - 8-byte server-provided challenge
|
||||||
|
* @param {number} work - Number of PBKDF2 work units required
|
||||||
|
* @param {object} [options] - Optional parameters
|
||||||
|
* @param {AbortSignal} [options.signal] - AbortSignal to cancel the operation
|
||||||
|
* @returns {Promise<Uint8Array>} Solution: concatenated 8-byte nonces (8 * work bytes)
|
||||||
|
* @throws {Error} If challenge is invalid or operation is aborted
|
||||||
|
*/
|
||||||
|
export async function solvePoW(challenge, work, options = {}) {
|
||||||
|
const { signal } = options
|
||||||
|
const startTime = performance.now()
|
||||||
|
|
||||||
|
// Validate inputs
|
||||||
|
const challengeBytes = challenge instanceof ArrayBuffer
|
||||||
|
? new Uint8Array(challenge)
|
||||||
|
: challenge
|
||||||
|
|
||||||
|
if (!(challengeBytes instanceof Uint8Array) || challengeBytes.length !== 8) {
|
||||||
|
throw new Error('Challenge must be exactly 8 bytes')
|
||||||
|
}
|
||||||
|
|
||||||
|
// Import challenge as PBKDF2 key material
|
||||||
|
const baseKey = await crypto.subtle.importKey('raw', challengeBytes, 'PBKDF2', false, ['deriveBits'])
|
||||||
|
|
||||||
|
// Build solution from found nonces
|
||||||
|
const solution = new Uint8Array(8 * work)
|
||||||
|
let totalIterations = 0
|
||||||
|
const mask = 0x7FF // The client must work 2048x harder than the server
|
||||||
|
|
||||||
|
// Sequential nonce starting at zero (little-endian, using Uint32Array for efficient increment)
|
||||||
|
const nonce = new Uint32Array(2)
|
||||||
|
|
||||||
|
for (let i = 0; i < work; i++) {
|
||||||
|
if (signal?.aborted) {
|
||||||
|
throw new DOMException('PoW operation aborted', 'AbortError')
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find a nonce where PBKDF2 output passes the mask check
|
||||||
|
let result
|
||||||
|
do {
|
||||||
|
totalIterations++
|
||||||
|
if (++nonce[0] === 0x100000000) ++nonce[1] // Increment 64-bit little-endian nonce
|
||||||
|
result = new Uint32Array(await crypto.subtle.deriveBits(
|
||||||
|
{ name: 'PBKDF2', salt: nonce, iterations: 128, hash: 'SHA-512'},
|
||||||
|
baseKey,
|
||||||
|
32
|
||||||
|
))
|
||||||
|
} while (result[0] & mask)
|
||||||
|
solution.set(new Uint8Array(nonce.buffer), i * 8)
|
||||||
|
}
|
||||||
|
|
||||||
|
const elapsed = (performance.now() - startTime) / 1000
|
||||||
|
const expectedIterations = work * (mask + 1)
|
||||||
|
const luckRatio = (totalIterations / expectedIterations).toFixed(1)
|
||||||
|
const bench = totalIterations / ((mask + 1) * elapsed)
|
||||||
|
console.log(`PoW work=${work} solved in ${elapsed.toFixed(2)}s (${luckRatio}x expected ${bench.toFixed(1)} work/s)`)
|
||||||
|
return solution
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
let _settingsPromise = null
|
||||||
|
let _settings = null
|
||||||
|
|
||||||
|
export function getSettingsCached() { return _settings }
|
||||||
|
|
||||||
|
export async function getSettings() {
|
||||||
|
if (_settings) return _settings
|
||||||
|
if (_settingsPromise) return _settingsPromise
|
||||||
|
_settingsPromise = fetch('/auth/api/settings')
|
||||||
|
.then(r => (r.ok ? r.json() : {}))
|
||||||
|
.then(obj => { _settings = obj || {}; return _settings })
|
||||||
|
.catch(() => { _settings = {}; return _settings })
|
||||||
|
return _settingsPromise
|
||||||
|
}
|
||||||
|
|
||||||
|
export function uiBasePath() {
|
||||||
|
const base = _settings?.ui_base_path || '/auth/'
|
||||||
|
if (base === '/') return '/'
|
||||||
|
return base.endsWith('/') ? base : base + '/'
|
||||||
|
}
|
||||||
|
|
||||||
|
export function adminUiPath() { return uiBasePath() === '/' ? '/admin/' : uiBasePath() + 'admin/' }
|
||||||
|
|
||||||
|
export function makeUiHref(suffix = '') {
|
||||||
|
const trimmed = suffix.startsWith('/') ? suffix.slice(1) : suffix
|
||||||
|
if (!trimmed) return uiBasePath()
|
||||||
|
if (uiBasePath() === '/') return '/' + trimmed
|
||||||
|
return uiBasePath() + trimmed
|
||||||
|
}
|
||||||
File diff suppressed because one or more lines are too long
+78
-36
@@ -1,61 +1,103 @@
|
|||||||
import { fileURLToPath, URL } from 'node:url'
|
import { fileURLToPath, URL } from 'node:url'
|
||||||
|
|
||||||
import { defineConfig } from 'vite'
|
import { defineConfig } from 'vite'
|
||||||
import { resolve } from 'node:path'
|
import { resolve } from 'node:path'
|
||||||
import vue from '@vitejs/plugin-vue'
|
import vue from '@vitejs/plugin-vue'
|
||||||
|
import { existsSync, renameSync, mkdirSync } from 'node:fs'
|
||||||
|
import sirv from 'sirv'
|
||||||
|
|
||||||
// https://vite.dev/config/
|
export default defineConfig(({ command }) => ({
|
||||||
export default defineConfig(({ command, mode }) => ({
|
appType: 'mpa',
|
||||||
|
publicDir: 'public',
|
||||||
plugins: [
|
plugins: [
|
||||||
vue(),
|
vue(),
|
||||||
|
{
|
||||||
|
name: 'serve-examples',
|
||||||
|
configureServer(server) {
|
||||||
|
const examplesDir = resolve(__dirname, '../examples')
|
||||||
|
const serve = sirv(examplesDir, { dev: true })
|
||||||
|
server.middlewares.use((req, _res, next) => {
|
||||||
|
if (req.url === '/' || req.url === '/index.html') req.url = '/examples/'
|
||||||
|
next()
|
||||||
|
})
|
||||||
|
server.middlewares.use('/examples', serve)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'move-html-files',
|
||||||
|
closeBundle() {
|
||||||
|
if (command !== 'build') return
|
||||||
|
|
||||||
|
const outDir = resolve(__dirname, '../paskia/frontend-build')
|
||||||
|
const moves = [
|
||||||
|
{ from: 'auth.html', to: 'auth/index.html' },
|
||||||
|
{ from: 'admin.html', to: 'admin/index.html' },
|
||||||
|
{ from: 'restricted.html', to: 'restricted/index.html' },
|
||||||
|
{ from: 'reset.html', to: 'reset/index.html' },
|
||||||
|
{ from: 'forward.html', to: 'forward/index.html' }
|
||||||
|
]
|
||||||
|
|
||||||
|
for (const { from, to } of moves) {
|
||||||
|
const fromPath = resolve(outDir, from)
|
||||||
|
const toPath = resolve(outDir, to)
|
||||||
|
if (existsSync(fromPath)) {
|
||||||
|
mkdirSync(resolve(outDir, to.split('/')[0]), { recursive: true })
|
||||||
|
renameSync(fromPath, toPath)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
],
|
],
|
||||||
resolve: {
|
resolve: {
|
||||||
alias: {
|
alias: { '@': fileURLToPath(new URL('./src', import.meta.url)) }
|
||||||
'@': fileURLToPath(new URL('./src', import.meta.url))
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
// Use absolute paths at dev, deploy under /auth/
|
base: '/',
|
||||||
base: command === 'build' ? '/auth/' : '/',
|
|
||||||
server: {
|
server: {
|
||||||
port: 4403,
|
port: 4403,
|
||||||
|
allowedHosts: true,
|
||||||
|
fs: {
|
||||||
|
allow: ['..']
|
||||||
|
},
|
||||||
proxy: {
|
proxy: {
|
||||||
'/auth/': {
|
// Only proxy these two specific backend API paths
|
||||||
|
'/auth/api': {
|
||||||
|
target: 'http://localhost:4402'
|
||||||
|
},
|
||||||
|
'/auth/ws': {
|
||||||
target: 'http://localhost:4402',
|
target: 'http://localhost:4402',
|
||||||
ws: true,
|
ws: true
|
||||||
changeOrigin: false,
|
},
|
||||||
// We proxy API + WS under /auth/, but want Vite to serve the SPA entrypoints
|
// Passphrase links: /auth/word1.word2.word3.word4.word5
|
||||||
// and static assets so that HMR works. Bypass tells http-proxy to skip
|
'^/auth/[a-z]+\\.[a-z]+\\.[a-z]+\\.[a-z]+\\.[a-z]+$': {
|
||||||
// proxying when we return a (possibly rewritten) local path.
|
target: 'http://localhost:4402'
|
||||||
bypass(req) {
|
},
|
||||||
const rawUrl = req.url || ''
|
// Passphrase links: /word1.word2.word3.word4.word5
|
||||||
// Strip query/hash to match path-only for SPA entrypoints with query params (e.g. ?reset=token)
|
'^/[a-z]+\\.[a-z]+\\.[a-z]+\\.[a-z]+\\.[a-z]+$': {
|
||||||
const url = rawUrl.split('?')[0].split('#')[0]
|
target: 'http://localhost:4402'
|
||||||
// Bypass only root SPA entrypoints + static assets so Vite serves them for HMR.
|
|
||||||
// Admin API endpoints (e.g., /auth/admin/orgs) must still hit backend.
|
|
||||||
if (url === '/auth/' || url === '/auth') return '/'
|
|
||||||
if (url === '/auth/admin' || url === '/auth/admin/') return '/admin/'
|
|
||||||
if (url.startsWith('/auth/assets/')) return url.replace(/^\/auth/, '')
|
|
||||||
if (/^\/auth\/([a-z]+\.){4}[a-z]+\/?$/.test(url)) return '/reset/index.html'
|
|
||||||
if (/^\/([a-z]+\.){4}[a-z]+\/?$/.test(url)) return '/reset/index.html'
|
|
||||||
if (url === '/auth/restricted' || url === '/auth/restricted/') return '/restricted/index.html'
|
|
||||||
if (url === '/restricted' || url === '/restricted/') return '/restricted/index.html'
|
|
||||||
// Everything else (including /auth/admin/* APIs) should proxy.
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
build: {
|
build: {
|
||||||
outDir: '../passkey/frontend-build',
|
outDir: '../paskia/frontend-build',
|
||||||
emptyOutDir: true,
|
emptyOutDir: true,
|
||||||
assetsDir: 'assets',
|
|
||||||
rollupOptions: {
|
rollupOptions: {
|
||||||
input: {
|
input: {
|
||||||
index: resolve(__dirname, 'index.html'),
|
auth: resolve(__dirname, 'auth/index.html'),
|
||||||
admin: resolve(__dirname, 'admin/index.html'),
|
admin: resolve(__dirname, 'auth/admin/index.html'),
|
||||||
reset: resolve(__dirname, 'reset/index.html'),
|
restricted: resolve(__dirname, 'auth/restricted/index.html'),
|
||||||
restricted: resolve(__dirname, 'restricted/index.html')
|
reset: resolve(__dirname, 'int/reset/index.html'),
|
||||||
|
forward: resolve(__dirname, 'int/forward/index.html'),
|
||||||
},
|
},
|
||||||
output: {}
|
output: {
|
||||||
|
entryFileNames: (chunkInfo) => {
|
||||||
|
return 'auth/assets/[name]-[hash].js'
|
||||||
|
},
|
||||||
|
chunkFileNames: (chunkInfo) => {
|
||||||
|
return 'auth/assets/[name]-[hash].js'
|
||||||
|
},
|
||||||
|
assetFileNames: (assetInfo) => {
|
||||||
|
return 'auth/assets/[name]-[hash][extname]'
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}))
|
}))
|
||||||
|
|||||||
@@ -0,0 +1,3 @@
|
|||||||
|
from paskia.sansio import Passkey
|
||||||
|
|
||||||
|
__all__ = ["Passkey"]
|
||||||
@@ -14,7 +14,7 @@ from importlib.resources import files
|
|||||||
__ALL__ = ["AAGUID", "filter"]
|
__ALL__ = ["AAGUID", "filter"]
|
||||||
|
|
||||||
# Path to the AAGUID JSON file
|
# Path to the AAGUID JSON file
|
||||||
AAGUID_FILE = files("passkey") / "aaguid" / "combined_aaguid.json"
|
AAGUID_FILE = files("paskia") / "aaguid" / "combined_aaguid.json"
|
||||||
AAGUID: dict[str, dict] = json.loads(AAGUID_FILE.read_text(encoding="utf-8"))
|
AAGUID: dict[str, dict] = json.loads(AAGUID_FILE.read_text(encoding="utf-8"))
|
||||||
|
|
||||||
|
|
||||||
@@ -0,0 +1,112 @@
|
|||||||
|
"""
|
||||||
|
Core session management for WebAuthn authentication.
|
||||||
|
|
||||||
|
This module provides generic session management functionality that is
|
||||||
|
independent of any web framework:
|
||||||
|
- Session creation and validation
|
||||||
|
- Token handling and refresh
|
||||||
|
- Credential management
|
||||||
|
"""
|
||||||
|
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
|
||||||
|
def expires() -> datetime:
|
||||||
|
return datetime.now(timezone.utc) + EXPIRES
|
||||||
|
|
||||||
|
|
||||||
|
def reset_expires() -> datetime:
|
||||||
|
from .config import RESET_LIFETIME
|
||||||
|
|
||||||
|
return datetime.now(timezone.utc) + RESET_LIFETIME
|
||||||
|
|
||||||
|
|
||||||
|
def session_expiry(session: Session) -> datetime:
|
||||||
|
"""Calculate the expiration timestamp for a session (UTC aware)."""
|
||||||
|
# After migration all renewed timestamps are timezone-aware UTC
|
||||||
|
return session.renewed + EXPIRES
|
||||||
|
|
||||||
|
|
||||||
|
async def create_session(
|
||||||
|
user_uuid: UUID,
|
||||||
|
credential_uuid: UUID,
|
||||||
|
*,
|
||||||
|
host: str,
|
||||||
|
ip: str,
|
||||||
|
user_agent: str,
|
||||||
|
) -> str:
|
||||||
|
"""Create a new session and return a session token."""
|
||||||
|
normalized_host = hostutil.normalize_host(host)
|
||||||
|
if not normalized_host:
|
||||||
|
raise ValueError("Host required for session creation")
|
||||||
|
hostname = normalized_host.split(":")[0] # Domain names only, IPs aren't supported
|
||||||
|
rp_id = passkey.instance.rp_id
|
||||||
|
if not (hostname == rp_id or hostname.endswith(f".{rp_id}")):
|
||||||
|
raise ValueError(f"Host must be the same as or a subdomain of {rp_id}")
|
||||||
|
token = create_token()
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
await db.instance.create_session(
|
||||||
|
user_uuid=user_uuid,
|
||||||
|
credential_uuid=credential_uuid,
|
||||||
|
key=session_key(token),
|
||||||
|
host=normalized_host,
|
||||||
|
ip=ip,
|
||||||
|
user_agent=user_agent,
|
||||||
|
renewed=now,
|
||||||
|
)
|
||||||
|
return token
|
||||||
|
|
||||||
|
|
||||||
|
async def get_reset(token: str) -> ResetToken:
|
||||||
|
"""Validate a credential reset token. Returns None if the token is not well formed (i.e. it is another type of token)."""
|
||||||
|
record = await db.instance.get_reset_token(reset_key(token))
|
||||||
|
if record and record.expiry >= datetime.now(timezone.utc):
|
||||||
|
return record
|
||||||
|
raise ValueError("This authentication link is no longer valid.")
|
||||||
|
|
||||||
|
|
||||||
|
async def get_session(token: str, host: str | None = None) -> Session:
|
||||||
|
"""Validate a session token and return session data if valid."""
|
||||||
|
host = hostutil.normalize_host(host)
|
||||||
|
if not host:
|
||||||
|
raise ValueError("Invalid host")
|
||||||
|
session = await db.instance.get_session(session_key(token))
|
||||||
|
if session and session_expiry(session) >= datetime.now(timezone.utc):
|
||||||
|
if session.host is None:
|
||||||
|
# First time binding: store exact host:port (or IPv6 form) now.
|
||||||
|
await db.instance.set_session_host(session.key, host)
|
||||||
|
session.host = host
|
||||||
|
elif session.host != host:
|
||||||
|
raise ValueError("Session host mismatch")
|
||||||
|
return session
|
||||||
|
raise ValueError("Your session has expired. Please sign in again!")
|
||||||
|
|
||||||
|
|
||||||
|
async def refresh_session_token(token: str, *, ip: str, user_agent: str):
|
||||||
|
"""Refresh a session extending its expiry."""
|
||||||
|
session_record = await db.instance.get_session(session_key(token))
|
||||||
|
if not session_record:
|
||||||
|
raise ValueError("Session not found or expired")
|
||||||
|
updated = await db.instance.update_session(
|
||||||
|
session_key(token),
|
||||||
|
ip=ip,
|
||||||
|
user_agent=user_agent,
|
||||||
|
renewed=datetime.now(timezone.utc),
|
||||||
|
)
|
||||||
|
if not updated:
|
||||||
|
raise ValueError("Session not found or expired")
|
||||||
|
|
||||||
|
|
||||||
|
async def delete_credential(credential_uuid: UUID, auth: str, host: str | None = None):
|
||||||
|
"""Delete a specific credential for the current user."""
|
||||||
|
s = await get_session(auth, host=host)
|
||||||
|
await db.instance.delete_credential(credential_uuid, s.user_uuid)
|
||||||
@@ -8,13 +8,13 @@ generating a reset link for initial admin setup.
|
|||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
import logging
|
import logging
|
||||||
from datetime import datetime
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
import uuid7
|
import uuid7
|
||||||
|
|
||||||
from . import authsession, globals
|
from paskia import authsession, globals
|
||||||
from .db import Org, Permission, Role, User
|
from paskia.db import Org, Permission, Role, User
|
||||||
from .util import hostutil, passphrase, tokens
|
from paskia.util import hostutil, passphrase, tokens
|
||||||
|
|
||||||
|
|
||||||
def _init_logger() -> logging.Logger:
|
def _init_logger() -> logging.Logger:
|
||||||
@@ -41,27 +41,22 @@ ADMIN_RESET_MESSAGE = """\
|
|||||||
async def _create_and_log_admin_reset_link(user_uuid, message, session_type) -> str:
|
async def _create_and_log_admin_reset_link(user_uuid, message, session_type) -> str:
|
||||||
"""Create an admin reset link and log it with the provided message."""
|
"""Create an admin reset link and log it with the provided message."""
|
||||||
token = passphrase.generate()
|
token = passphrase.generate()
|
||||||
await globals.db.instance.create_session(
|
expiry = authsession.reset_expires()
|
||||||
|
await globals.db.instance.create_reset_token(
|
||||||
user_uuid=user_uuid,
|
user_uuid=user_uuid,
|
||||||
key=tokens.reset_key(token),
|
key=tokens.reset_key(token),
|
||||||
expires=authsession.expires(),
|
expiry=expiry,
|
||||||
info={"type": session_type},
|
token_type=session_type,
|
||||||
)
|
)
|
||||||
reset_link = hostutil.reset_link_url(token)
|
reset_link = hostutil.reset_link_url(token)
|
||||||
logger.info(ADMIN_RESET_MESSAGE, message, reset_link)
|
logger.info(ADMIN_RESET_MESSAGE, message, reset_link)
|
||||||
return reset_link
|
return reset_link
|
||||||
|
|
||||||
|
|
||||||
async def bootstrap_system(
|
async def bootstrap_system() -> dict:
|
||||||
user_name: str | None = None, org_name: str | None = None
|
|
||||||
) -> dict:
|
|
||||||
"""
|
"""
|
||||||
Bootstrap the entire system with default data.
|
Bootstrap the entire system with default data.
|
||||||
|
|
||||||
Args:
|
|
||||||
user_name: Display name for the admin user (default: "Admin")
|
|
||||||
org_name: Display name for the organization (default: "Organization")
|
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
dict: Contains information about created entities and reset link
|
dict: Contains information about created entities and reset link
|
||||||
"""
|
"""
|
||||||
@@ -69,7 +64,7 @@ async def bootstrap_system(
|
|||||||
perm0 = Permission(id="auth:admin", display_name="Master Admin")
|
perm0 = Permission(id="auth:admin", display_name="Master Admin")
|
||||||
await globals.db.instance.create_permission(perm0)
|
await globals.db.instance.create_permission(perm0)
|
||||||
|
|
||||||
org = Org(uuid7.create(), org_name or "Organization")
|
org = Org(uuid7.create(), "Organization")
|
||||||
await globals.db.instance.create_organization(org)
|
await globals.db.instance.create_organization(org)
|
||||||
|
|
||||||
# After creation, org.permissions now includes the auto-created org admin permission
|
# After creation, org.permissions now includes the auto-created org admin permission
|
||||||
@@ -88,9 +83,9 @@ async def bootstrap_system(
|
|||||||
|
|
||||||
user = User(
|
user = User(
|
||||||
uuid=uuid7.create(),
|
uuid=uuid7.create(),
|
||||||
display_name=user_name or "Admin",
|
display_name="Admin",
|
||||||
role_uuid=role.uuid,
|
role_uuid=role.uuid,
|
||||||
created_at=datetime.now(),
|
created_at=datetime.now(timezone.utc),
|
||||||
visits=0,
|
visits=0,
|
||||||
)
|
)
|
||||||
await globals.db.instance.create_user(user)
|
await globals.db.instance.create_user(user)
|
||||||
@@ -158,16 +153,10 @@ async def check_admin_credentials() -> bool:
|
|||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
async def bootstrap_if_needed(
|
async def bootstrap_if_needed() -> bool:
|
||||||
default_admin: str | None = None, default_org: str | None = None
|
|
||||||
) -> bool:
|
|
||||||
"""
|
"""
|
||||||
Check if system needs bootstrapping and perform it if necessary.
|
Check if system needs bootstrapping and perform it if necessary.
|
||||||
|
|
||||||
Args:
|
|
||||||
default_admin: Display name for the admin user
|
|
||||||
default_org: Display name for the organization
|
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
bool: True if bootstrapping was performed, False if system was already set up
|
bool: True if bootstrapping was performed, False if system was already set up
|
||||||
"""
|
"""
|
||||||
@@ -184,35 +173,17 @@ async def bootstrap_if_needed(
|
|||||||
|
|
||||||
# No admin permission found, need to bootstrap
|
# No admin permission found, need to bootstrap
|
||||||
# Bootstrap creates the admin user AND the reset link, so no need to check credentials after
|
# Bootstrap creates the admin user AND the reset link, so no need to check credentials after
|
||||||
await bootstrap_system(default_admin, default_org)
|
await bootstrap_system()
|
||||||
return True
|
return True
|
||||||
|
|
||||||
|
|
||||||
# CLI interface
|
# CLI interface
|
||||||
async def main():
|
async def main():
|
||||||
"""Main CLI entry point for bootstrapping."""
|
"""Main CLI entry point for bootstrapping."""
|
||||||
import argparse
|
|
||||||
|
|
||||||
# Configure logging for CLI usage
|
# Configure logging for CLI usage
|
||||||
logging.basicConfig(level=logging.INFO, format="%(message)s", force=True)
|
logging.basicConfig(level=logging.INFO, format="%(message)s", force=True)
|
||||||
|
|
||||||
parser = argparse.ArgumentParser(
|
await globals.init()
|
||||||
description="Bootstrap passkey authentication system"
|
|
||||||
)
|
|
||||||
parser.add_argument(
|
|
||||||
"--user-name",
|
|
||||||
default=None,
|
|
||||||
help="Name for the admin user (default: Admin)",
|
|
||||||
)
|
|
||||||
parser.add_argument(
|
|
||||||
"--org-name",
|
|
||||||
default=None,
|
|
||||||
help="Name for the organization (default: Organization)",
|
|
||||||
)
|
|
||||||
|
|
||||||
args = parser.parse_args()
|
|
||||||
|
|
||||||
await globals.init(default_admin=args.user_name, default_org=args.org_name)
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
from dataclasses import dataclass
|
||||||
|
from datetime import timedelta
|
||||||
|
|
||||||
|
# Shared configuration constants for session management.
|
||||||
|
SESSION_LIFETIME = timedelta(hours=24)
|
||||||
|
|
||||||
|
# Lifetime for reset links created by admins
|
||||||
|
RESET_LIFETIME = timedelta(days=14)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class PaskiaConfig:
|
||||||
|
"""Runtime configuration for the Paskia authentication server."""
|
||||||
|
|
||||||
|
rp_id: str
|
||||||
|
rp_name: str | None
|
||||||
|
origins: list[str] | None
|
||||||
|
auth_host: str | None
|
||||||
|
site_url: str # Base URL without trailing path (e.g. https://example.com)
|
||||||
|
site_path: str # Path to auth UI: "/" if auth_host, else "/auth/"
|
||||||
|
# Listen address (one of host:port or uds)
|
||||||
|
host: str | None = None
|
||||||
|
port: int | None = None
|
||||||
|
uds: str | None = None
|
||||||
|
devmode: bool = False
|
||||||
@@ -63,9 +63,27 @@ class Credential:
|
|||||||
class Session:
|
class Session:
|
||||||
key: bytes
|
key: bytes
|
||||||
user_uuid: UUID
|
user_uuid: UUID
|
||||||
expires: datetime
|
credential_uuid: UUID
|
||||||
info: dict
|
host: str
|
||||||
credential_uuid: UUID | None = None
|
ip: str
|
||||||
|
user_agent: str
|
||||||
|
renewed: datetime
|
||||||
|
|
||||||
|
def metadata(self) -> dict:
|
||||||
|
"""Return session metadata for backwards compatibility."""
|
||||||
|
return {
|
||||||
|
"ip": self.ip,
|
||||||
|
"user_agent": self.user_agent,
|
||||||
|
"renewed": self.renewed.isoformat(),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class ResetToken:
|
||||||
|
key: bytes
|
||||||
|
user_uuid: UUID
|
||||||
|
expiry: datetime
|
||||||
|
token_type: str
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
@@ -74,6 +92,7 @@ class SessionContext:
|
|||||||
user: User
|
user: User
|
||||||
org: Org
|
org: Org
|
||||||
role: Role
|
role: Role
|
||||||
|
credential: Credential | None = None
|
||||||
permissions: list[Permission] | None = None
|
permissions: list[Permission] | None = None
|
||||||
|
|
||||||
|
|
||||||
@@ -146,9 +165,11 @@ class DatabaseInterface(ABC):
|
|||||||
self,
|
self,
|
||||||
user_uuid: UUID,
|
user_uuid: UUID,
|
||||||
key: bytes,
|
key: bytes,
|
||||||
expires: datetime,
|
credential_uuid: UUID,
|
||||||
info: dict,
|
host: str,
|
||||||
credential_uuid: UUID | None = None,
|
ip: str,
|
||||||
|
user_agent: str,
|
||||||
|
renewed: datetime,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Create a new session."""
|
"""Create a new session."""
|
||||||
|
|
||||||
@@ -162,14 +183,50 @@ class DatabaseInterface(ABC):
|
|||||||
|
|
||||||
@abstractmethod
|
@abstractmethod
|
||||||
async def update_session(
|
async def update_session(
|
||||||
self, key: bytes, expires: datetime, info: dict
|
self,
|
||||||
|
key: bytes,
|
||||||
|
*,
|
||||||
|
ip: str,
|
||||||
|
user_agent: str,
|
||||||
|
renewed: datetime,
|
||||||
) -> Session | None:
|
) -> Session | None:
|
||||||
"""Update session expiry and info."""
|
"""Update session metadata and touch renewed timestamp."""
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
async def set_session_host(self, key: bytes, host: str) -> None:
|
||||||
|
"""Bind a session to a specific host if not already set."""
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
async def list_sessions_for_user(self, user_uuid: UUID) -> list[Session]:
|
||||||
|
"""Return all sessions for a user (including other hosts)."""
|
||||||
|
|
||||||
@abstractmethod
|
@abstractmethod
|
||||||
async def cleanup(self) -> None:
|
async def cleanup(self) -> None:
|
||||||
"""Called periodically to clean up expired records."""
|
"""Called periodically to clean up expired records."""
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
async def delete_sessions_for_user(self, user_uuid: UUID) -> None:
|
||||||
|
"""Delete all sessions belonging to the provided user."""
|
||||||
|
|
||||||
|
# Reset token operations
|
||||||
|
@abstractmethod
|
||||||
|
async def create_reset_token(
|
||||||
|
self,
|
||||||
|
user_uuid: UUID,
|
||||||
|
key: bytes,
|
||||||
|
expiry: datetime,
|
||||||
|
token_type: str,
|
||||||
|
) -> None:
|
||||||
|
"""Create a reset token for a user."""
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
async def get_reset_token(self, key: bytes) -> ResetToken | None:
|
||||||
|
"""Retrieve a reset token by key."""
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
async def delete_reset_token(self, key: bytes) -> None:
|
||||||
|
"""Delete a reset token by key."""
|
||||||
|
|
||||||
# Organization operations
|
# Organization operations
|
||||||
@abstractmethod
|
@abstractmethod
|
||||||
async def create_organization(self, org: Org) -> None:
|
async def create_organization(self, org: Org) -> None:
|
||||||
@@ -315,36 +372,41 @@ class DatabaseInterface(ABC):
|
|||||||
"""Create a new user and their first credential in a transaction."""
|
"""Create a new user and their first credential in a transaction."""
|
||||||
|
|
||||||
@abstractmethod
|
@abstractmethod
|
||||||
async def get_session_context(self, session_key: bytes) -> SessionContext | None:
|
async def get_session_context(
|
||||||
|
self, session_key: bytes, host: str | None = None
|
||||||
|
) -> SessionContext | None:
|
||||||
"""Get complete session context including user, organization, role, and permissions."""
|
"""Get complete session context including user, organization, role, and permissions."""
|
||||||
|
|
||||||
# Combined atomic operations
|
# Combined atomic operations
|
||||||
@abstractmethod
|
@abstractmethod
|
||||||
async def create_credential_session(
|
async def create_credential_session(
|
||||||
self,
|
self,
|
||||||
user_uuid: UUID,
|
user_uuid: UUID,
|
||||||
credential: Credential,
|
credential: Credential,
|
||||||
reset_key: bytes | None,
|
reset_key: bytes | None,
|
||||||
session_key: bytes,
|
session_key: bytes,
|
||||||
session_expires: datetime,
|
*,
|
||||||
session_info: dict,
|
display_name: str | None = None,
|
||||||
display_name: str | None = None,
|
host: str | None = None,
|
||||||
) -> None:
|
ip: str | None = None,
|
||||||
"""Atomically add a credential and create a session.
|
user_agent: str | None = None,
|
||||||
|
) -> None:
|
||||||
|
"""Atomically add a credential and create a session.
|
||||||
|
|
||||||
Steps (single transaction):
|
Steps (single transaction):
|
||||||
1. Insert credential
|
1. Insert credential
|
||||||
2. Optionally delete old session (e.g. reset token) if provided
|
2. Optionally delete old reset token if provided
|
||||||
3. Optionally update user's display name
|
3. Optionally update user's display name
|
||||||
4. Insert new session referencing the credential
|
4. Insert new session referencing the credential
|
||||||
5. Update user's last_seen and increment visits (treat as a login)
|
5. Update user's last_seen and increment visits (treat as a login)
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"User",
|
"User",
|
||||||
"Credential",
|
"Credential",
|
||||||
"Session",
|
"Session",
|
||||||
|
"ResetToken",
|
||||||
"SessionContext",
|
"SessionContext",
|
||||||
"Org",
|
"Org",
|
||||||
"Role",
|
"Role",
|
||||||
@@ -5,8 +5,9 @@ This module provides an async database layer using SQLAlchemy async mode
|
|||||||
for managing users and credentials in a WebAuthn authentication system.
|
for managing users and credentials in a WebAuthn authentication system.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
from contextlib import asynccontextmanager
|
from contextlib import asynccontextmanager
|
||||||
from datetime import datetime
|
from datetime import datetime, timezone
|
||||||
from uuid import UUID
|
from uuid import UUID
|
||||||
|
|
||||||
from sqlalchemy import (
|
from sqlalchemy import (
|
||||||
@@ -19,29 +20,41 @@ from sqlalchemy import (
|
|||||||
event,
|
event,
|
||||||
insert,
|
insert,
|
||||||
select,
|
select,
|
||||||
|
text,
|
||||||
update,
|
update,
|
||||||
)
|
)
|
||||||
from sqlalchemy.dialects.sqlite import BLOB, JSON
|
from sqlalchemy.dialects.sqlite import BLOB
|
||||||
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
|
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
|
||||||
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column
|
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column
|
||||||
|
|
||||||
from ..globals import db
|
from paskia.config import SESSION_LIFETIME
|
||||||
from . import (
|
from paskia.db import (
|
||||||
Credential,
|
Credential,
|
||||||
DatabaseInterface,
|
DatabaseInterface,
|
||||||
Org,
|
Org,
|
||||||
Permission,
|
Permission,
|
||||||
|
ResetToken,
|
||||||
Role,
|
Role,
|
||||||
Session,
|
Session,
|
||||||
SessionContext,
|
SessionContext,
|
||||||
User,
|
User,
|
||||||
)
|
)
|
||||||
|
from paskia.globals import db
|
||||||
|
|
||||||
DB_PATH = "sqlite+aiosqlite:///passkey-auth.sqlite"
|
DB_PATH_DEFAULT = "sqlite+aiosqlite:///paskia.sqlite"
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_dt(value: datetime | None) -> datetime | None:
|
||||||
|
if value is None:
|
||||||
|
return None
|
||||||
|
if value.tzinfo is None:
|
||||||
|
return value.replace(tzinfo=timezone.utc)
|
||||||
|
return value.astimezone(timezone.utc)
|
||||||
|
|
||||||
|
|
||||||
async def init(*args, **kwargs):
|
async def init(*args, **kwargs):
|
||||||
db.instance = DB()
|
db_path = os.environ.get("PASKIA_DB", DB_PATH_DEFAULT)
|
||||||
|
db.instance = DB(db_path)
|
||||||
await db.instance.init_db()
|
await db.instance.init_db()
|
||||||
|
|
||||||
|
|
||||||
@@ -98,8 +111,12 @@ class UserModel(Base):
|
|||||||
role_uuid: Mapped[bytes] = mapped_column(
|
role_uuid: Mapped[bytes] = mapped_column(
|
||||||
LargeBinary(16), ForeignKey("roles.uuid", ondelete="CASCADE"), nullable=False
|
LargeBinary(16), ForeignKey("roles.uuid", ondelete="CASCADE"), nullable=False
|
||||||
)
|
)
|
||||||
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.now)
|
created_at: Mapped[datetime] = mapped_column(
|
||||||
last_seen: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
|
DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)
|
||||||
|
)
|
||||||
|
last_seen: Mapped[datetime | None] = mapped_column(
|
||||||
|
DateTime(timezone=True), nullable=True
|
||||||
|
)
|
||||||
visits: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
visits: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
||||||
|
|
||||||
def as_dataclass(self) -> User:
|
def as_dataclass(self) -> User:
|
||||||
@@ -107,8 +124,8 @@ class UserModel(Base):
|
|||||||
uuid=UUID(bytes=self.uuid),
|
uuid=UUID(bytes=self.uuid),
|
||||||
display_name=self.display_name,
|
display_name=self.display_name,
|
||||||
role_uuid=UUID(bytes=self.role_uuid),
|
role_uuid=UUID(bytes=self.role_uuid),
|
||||||
created_at=self.created_at,
|
created_at=_normalize_dt(self.created_at) or self.created_at,
|
||||||
last_seen=self.last_seen,
|
last_seen=_normalize_dt(self.last_seen) or self.last_seen,
|
||||||
visits=self.visits,
|
visits=self.visits,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -118,7 +135,7 @@ class UserModel(Base):
|
|||||||
uuid=user.uuid.bytes,
|
uuid=user.uuid.bytes,
|
||||||
display_name=user.display_name,
|
display_name=user.display_name,
|
||||||
role_uuid=user.role_uuid.bytes,
|
role_uuid=user.role_uuid.bytes,
|
||||||
created_at=user.created_at or datetime.now(),
|
created_at=user.created_at or datetime.now(timezone.utc),
|
||||||
last_seen=user.last_seen,
|
last_seen=user.last_seen,
|
||||||
visits=user.visits,
|
visits=user.visits,
|
||||||
)
|
)
|
||||||
@@ -137,9 +154,29 @@ class CredentialModel(Base):
|
|||||||
aaguid: Mapped[bytes] = mapped_column(LargeBinary(16), nullable=False)
|
aaguid: Mapped[bytes] = mapped_column(LargeBinary(16), nullable=False)
|
||||||
public_key: Mapped[bytes] = mapped_column(BLOB, nullable=False)
|
public_key: Mapped[bytes] = mapped_column(BLOB, nullable=False)
|
||||||
sign_count: Mapped[int] = mapped_column(Integer, nullable=False)
|
sign_count: Mapped[int] = mapped_column(Integer, nullable=False)
|
||||||
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.now)
|
created_at: Mapped[datetime] = mapped_column(
|
||||||
last_used: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
|
DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)
|
||||||
last_verified: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
|
)
|
||||||
|
# Columns declared timezone-aware going forward; legacy rows may still be naive in storage
|
||||||
|
last_used: Mapped[datetime | None] = mapped_column(
|
||||||
|
DateTime(timezone=True), nullable=True
|
||||||
|
)
|
||||||
|
last_verified: Mapped[datetime | None] = mapped_column(
|
||||||
|
DateTime(timezone=True), nullable=True
|
||||||
|
)
|
||||||
|
|
||||||
|
def as_dataclass(self): # type: ignore[override]
|
||||||
|
return Credential(
|
||||||
|
uuid=UUID(bytes=self.uuid),
|
||||||
|
credential_id=self.credential_id,
|
||||||
|
user_uuid=UUID(bytes=self.user_uuid),
|
||||||
|
aaguid=UUID(bytes=self.aaguid),
|
||||||
|
public_key=self.public_key,
|
||||||
|
sign_count=self.sign_count,
|
||||||
|
created_at=_normalize_dt(self.created_at) or self.created_at,
|
||||||
|
last_used=_normalize_dt(self.last_used) or self.last_used,
|
||||||
|
last_verified=_normalize_dt(self.last_verified) or self.last_verified,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class SessionModel(Base):
|
class SessionModel(Base):
|
||||||
@@ -147,23 +184,31 @@ class SessionModel(Base):
|
|||||||
|
|
||||||
key: Mapped[bytes] = mapped_column(LargeBinary(16), primary_key=True)
|
key: Mapped[bytes] = mapped_column(LargeBinary(16), primary_key=True)
|
||||||
user_uuid: Mapped[bytes] = mapped_column(
|
user_uuid: Mapped[bytes] = mapped_column(
|
||||||
LargeBinary(16), ForeignKey("users.uuid", ondelete="CASCADE")
|
LargeBinary(16), ForeignKey("users.uuid", ondelete="CASCADE"), nullable=False
|
||||||
)
|
)
|
||||||
credential_uuid: Mapped[bytes | None] = mapped_column(
|
credential_uuid: Mapped[bytes] = mapped_column(
|
||||||
LargeBinary(16), ForeignKey("credentials.uuid", ondelete="CASCADE")
|
LargeBinary(16),
|
||||||
|
ForeignKey("credentials.uuid", ondelete="CASCADE"),
|
||||||
|
nullable=False,
|
||||||
|
)
|
||||||
|
host: Mapped[str] = mapped_column(String, nullable=False)
|
||||||
|
ip: Mapped[str] = mapped_column(String(64), nullable=False)
|
||||||
|
user_agent: Mapped[str] = mapped_column(String(512), nullable=False)
|
||||||
|
renewed: Mapped[datetime] = mapped_column(
|
||||||
|
DateTime(timezone=True),
|
||||||
|
default=lambda: datetime.now(timezone.utc),
|
||||||
|
nullable=False,
|
||||||
)
|
)
|
||||||
expires: Mapped[datetime] = mapped_column(DateTime, nullable=False)
|
|
||||||
info: Mapped[dict] = mapped_column(JSON, default=dict)
|
|
||||||
|
|
||||||
def as_dataclass(self):
|
def as_dataclass(self):
|
||||||
return Session(
|
return Session(
|
||||||
key=self.key,
|
key=self.key,
|
||||||
user_uuid=UUID(bytes=self.user_uuid),
|
user_uuid=UUID(bytes=self.user_uuid),
|
||||||
credential_uuid=(
|
credential_uuid=UUID(bytes=self.credential_uuid),
|
||||||
UUID(bytes=self.credential_uuid) if self.credential_uuid else None
|
host=self.host,
|
||||||
),
|
ip=self.ip,
|
||||||
expires=self.expires,
|
user_agent=self.user_agent,
|
||||||
info=self.info,
|
renewed=_normalize_dt(self.renewed) or self.renewed,
|
||||||
)
|
)
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
@@ -171,9 +216,30 @@ class SessionModel(Base):
|
|||||||
return SessionModel(
|
return SessionModel(
|
||||||
key=session.key,
|
key=session.key,
|
||||||
user_uuid=session.user_uuid.bytes,
|
user_uuid=session.user_uuid.bytes,
|
||||||
credential_uuid=session.credential_uuid and session.credential_uuid.bytes,
|
credential_uuid=session.credential_uuid.bytes,
|
||||||
expires=session.expires,
|
host=session.host,
|
||||||
info=session.info,
|
ip=session.ip,
|
||||||
|
user_agent=session.user_agent,
|
||||||
|
renewed=session.renewed,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class ResetTokenModel(Base):
|
||||||
|
__tablename__ = "reset_tokens"
|
||||||
|
|
||||||
|
key: Mapped[bytes] = mapped_column(LargeBinary(16), primary_key=True)
|
||||||
|
user_uuid: Mapped[bytes] = mapped_column(
|
||||||
|
LargeBinary(16), ForeignKey("users.uuid", ondelete="CASCADE"), nullable=False
|
||||||
|
)
|
||||||
|
token_type: Mapped[str] = mapped_column(String, nullable=False)
|
||||||
|
expiry: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
|
||||||
|
|
||||||
|
def as_dataclass(self) -> ResetToken:
|
||||||
|
return ResetToken(
|
||||||
|
key=self.key,
|
||||||
|
user_uuid=UUID(bytes=self.user_uuid),
|
||||||
|
token_type=self.token_type,
|
||||||
|
expiry=_normalize_dt(self.expiry) or self.expiry,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -225,7 +291,7 @@ class RolePermission(Base):
|
|||||||
class DB(DatabaseInterface):
|
class DB(DatabaseInterface):
|
||||||
"""Database class that handles its own connections."""
|
"""Database class that handles its own connections."""
|
||||||
|
|
||||||
def __init__(self, db_path: str = DB_PATH):
|
def __init__(self, db_path: str = DB_PATH_DEFAULT):
|
||||||
"""Initialize with database path."""
|
"""Initialize with database path."""
|
||||||
self.engine = create_async_engine(db_path, echo=False)
|
self.engine = create_async_engine(db_path, echo=False)
|
||||||
# Ensure SQLite foreign key enforcement is ON for every new connection
|
# Ensure SQLite foreign key enforcement is ON for every new connection
|
||||||
@@ -257,6 +323,58 @@ class DB(DatabaseInterface):
|
|||||||
"""Initialize database tables."""
|
"""Initialize database tables."""
|
||||||
async with self.engine.begin() as conn:
|
async with self.engine.begin() as conn:
|
||||||
await conn.run_sync(Base.metadata.create_all)
|
await conn.run_sync(Base.metadata.create_all)
|
||||||
|
result = await conn.execute(text("PRAGMA table_info('sessions')"))
|
||||||
|
columns = {row[1] for row in result}
|
||||||
|
expected = {
|
||||||
|
"key",
|
||||||
|
"user_uuid",
|
||||||
|
"credential_uuid",
|
||||||
|
"host",
|
||||||
|
"ip",
|
||||||
|
"user_agent",
|
||||||
|
"renewed",
|
||||||
|
}
|
||||||
|
needs_recreate = False
|
||||||
|
if columns and columns != expected:
|
||||||
|
await conn.execute(text("DROP TABLE sessions"))
|
||||||
|
needs_recreate = True
|
||||||
|
result = await conn.execute(text("PRAGMA table_info('reset_tokens')"))
|
||||||
|
if not list(result):
|
||||||
|
needs_recreate = True
|
||||||
|
if needs_recreate:
|
||||||
|
await conn.run_sync(Base.metadata.create_all)
|
||||||
|
# Run one-time migration to add UTC tzinfo to any naive datetimes
|
||||||
|
await self._migrate_naive_datetimes()
|
||||||
|
|
||||||
|
async def _migrate_naive_datetimes(self) -> None:
|
||||||
|
"""Attach UTC tzinfo to any legacy naive datetime rows.
|
||||||
|
|
||||||
|
SQLite stores datetimes as text; older rows may have been inserted naive.
|
||||||
|
We treat naive timestamps as already UTC and rewrite them in ISO8601 with Z.
|
||||||
|
"""
|
||||||
|
# Helper SQL fragment for detecting naive (no timezone offset) for ISO strings
|
||||||
|
# We only update rows whose textual representation lacks a 'Z' or '+' sign.
|
||||||
|
async with self.session() as session:
|
||||||
|
# Users
|
||||||
|
for model, fields in [
|
||||||
|
(UserModel, ["created_at", "last_seen"]),
|
||||||
|
(CredentialModel, ["created_at", "last_used", "last_verified"]),
|
||||||
|
(SessionModel, ["renewed"]),
|
||||||
|
(ResetTokenModel, ["expiry"]),
|
||||||
|
]:
|
||||||
|
stmt = select(model)
|
||||||
|
result = await session.execute(stmt)
|
||||||
|
rows = result.scalars().all()
|
||||||
|
dirty = False
|
||||||
|
for row in rows:
|
||||||
|
for fname in fields:
|
||||||
|
value = getattr(row, fname, None)
|
||||||
|
if isinstance(value, datetime) and value.tzinfo is None:
|
||||||
|
setattr(row, fname, value.replace(tzinfo=timezone.utc))
|
||||||
|
dirty = True
|
||||||
|
if dirty:
|
||||||
|
# SQLAlchemy autoflush/commit in context manager will persist
|
||||||
|
pass
|
||||||
|
|
||||||
async def get_user_by_uuid(self, user_uuid: UUID) -> User:
|
async def get_user_by_uuid(self, user_uuid: UUID) -> User:
|
||||||
async with self.session() as session:
|
async with self.session() as session:
|
||||||
@@ -323,7 +441,7 @@ class DB(DatabaseInterface):
|
|||||||
credential_model = result.scalar_one_or_none()
|
credential_model = result.scalar_one_or_none()
|
||||||
|
|
||||||
if not credential_model:
|
if not credential_model:
|
||||||
raise ValueError("Credential not registered")
|
raise ValueError("Credential not found")
|
||||||
return Credential(
|
return Credential(
|
||||||
uuid=UUID(bytes=credential_model.uuid),
|
uuid=UUID(bytes=credential_model.uuid),
|
||||||
credential_id=credential_model.credential_id,
|
credential_id=credential_model.credential_id,
|
||||||
@@ -409,9 +527,11 @@ class DB(DatabaseInterface):
|
|||||||
credential: Credential,
|
credential: Credential,
|
||||||
reset_key: bytes | None,
|
reset_key: bytes | None,
|
||||||
session_key: bytes,
|
session_key: bytes,
|
||||||
session_expires: datetime,
|
*,
|
||||||
session_info: dict,
|
|
||||||
display_name: str | None = None,
|
display_name: str | None = None,
|
||||||
|
host: str | None = None,
|
||||||
|
ip: str | None = None,
|
||||||
|
user_agent: str | None = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Atomic credential + (optional old session delete) + (optional rename) + new session."""
|
"""Atomic credential + (optional old session delete) + (optional rename) + new session."""
|
||||||
async with self.session() as session:
|
async with self.session() as session:
|
||||||
@@ -434,10 +554,10 @@ class DB(DatabaseInterface):
|
|||||||
last_verified=credential.last_verified,
|
last_verified=credential.last_verified,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
# Delete old session if provided
|
# Delete old reset token if provided
|
||||||
if reset_key:
|
if reset_key:
|
||||||
await session.execute(
|
await session.execute(
|
||||||
delete(SessionModel).where(SessionModel.key == reset_key)
|
delete(ResetTokenModel).where(ResetTokenModel.key == reset_key)
|
||||||
)
|
)
|
||||||
# Optional rename
|
# Optional rename
|
||||||
if display_name:
|
if display_name:
|
||||||
@@ -452,8 +572,9 @@ class DB(DatabaseInterface):
|
|||||||
key=session_key,
|
key=session_key,
|
||||||
user_uuid=user_uuid.bytes,
|
user_uuid=user_uuid.bytes,
|
||||||
credential_uuid=credential.uuid.bytes,
|
credential_uuid=credential.uuid.bytes,
|
||||||
expires=session_expires,
|
host=host,
|
||||||
info=session_info,
|
ip=ip,
|
||||||
|
user_agent=user_agent,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
# Login side-effects: update user analytics (last_seen + visits increment)
|
# Login side-effects: update user analytics (last_seen + visits increment)
|
||||||
@@ -476,17 +597,21 @@ class DB(DatabaseInterface):
|
|||||||
self,
|
self,
|
||||||
user_uuid: UUID,
|
user_uuid: UUID,
|
||||||
key: bytes,
|
key: bytes,
|
||||||
expires: datetime,
|
credential_uuid: UUID,
|
||||||
info: dict,
|
host: str,
|
||||||
credential_uuid: UUID | None = None,
|
ip: str,
|
||||||
|
user_agent: str,
|
||||||
|
renewed: datetime,
|
||||||
) -> None:
|
) -> None:
|
||||||
async with self.session() as session:
|
async with self.session() as session:
|
||||||
session_model = SessionModel(
|
session_model = SessionModel(
|
||||||
key=key,
|
key=key,
|
||||||
user_uuid=user_uuid.bytes,
|
user_uuid=user_uuid.bytes,
|
||||||
credential_uuid=credential_uuid.bytes if credential_uuid else None,
|
credential_uuid=credential_uuid.bytes,
|
||||||
expires=expires,
|
host=host,
|
||||||
info=info,
|
ip=ip,
|
||||||
|
user_agent=user_agent,
|
||||||
|
renewed=renewed,
|
||||||
)
|
)
|
||||||
session.add(session_model)
|
session.add(session_model)
|
||||||
|
|
||||||
@@ -497,29 +622,88 @@ class DB(DatabaseInterface):
|
|||||||
session_model = result.scalar_one_or_none()
|
session_model = result.scalar_one_or_none()
|
||||||
|
|
||||||
if session_model:
|
if session_model:
|
||||||
return Session(
|
return session_model.as_dataclass()
|
||||||
key=session_model.key,
|
|
||||||
user_uuid=UUID(bytes=session_model.user_uuid),
|
|
||||||
credential_uuid=UUID(bytes=session_model.credential_uuid)
|
|
||||||
if session_model.credential_uuid
|
|
||||||
else None,
|
|
||||||
expires=session_model.expires,
|
|
||||||
info=session_model.info or {},
|
|
||||||
)
|
|
||||||
return None
|
return None
|
||||||
|
|
||||||
async def delete_session(self, key: bytes) -> None:
|
async def delete_session(self, key: bytes) -> None:
|
||||||
async with self.session() as session:
|
async with self.session() as session:
|
||||||
await session.execute(delete(SessionModel).where(SessionModel.key == key))
|
await session.execute(delete(SessionModel).where(SessionModel.key == key))
|
||||||
|
|
||||||
async def update_session(self, key: bytes, expires: datetime, info: dict) -> None:
|
async def delete_sessions_for_user(self, user_uuid: UUID) -> None:
|
||||||
async with self.session() as session:
|
async with self.session() as session:
|
||||||
await session.execute(
|
await session.execute(
|
||||||
update(SessionModel)
|
delete(SessionModel).where(SessionModel.user_uuid == user_uuid.bytes)
|
||||||
.where(SessionModel.key == key)
|
|
||||||
.values(expires=expires, info=info)
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
async def create_reset_token(
|
||||||
|
self,
|
||||||
|
user_uuid: UUID,
|
||||||
|
key: bytes,
|
||||||
|
expiry: datetime,
|
||||||
|
token_type: str,
|
||||||
|
) -> None:
|
||||||
|
async with self.session() as session:
|
||||||
|
model = ResetTokenModel(
|
||||||
|
key=key,
|
||||||
|
user_uuid=user_uuid.bytes,
|
||||||
|
token_type=token_type,
|
||||||
|
expiry=expiry,
|
||||||
|
)
|
||||||
|
session.add(model)
|
||||||
|
|
||||||
|
async def get_reset_token(self, key: bytes) -> ResetToken | None:
|
||||||
|
async with self.session() as session:
|
||||||
|
stmt = select(ResetTokenModel).where(ResetTokenModel.key == key)
|
||||||
|
result = await session.execute(stmt)
|
||||||
|
model = result.scalar_one_or_none()
|
||||||
|
return model.as_dataclass() if model else None
|
||||||
|
|
||||||
|
async def delete_reset_token(self, key: bytes) -> None:
|
||||||
|
async with self.session() as session:
|
||||||
|
await session.execute(
|
||||||
|
delete(ResetTokenModel).where(ResetTokenModel.key == key)
|
||||||
|
)
|
||||||
|
|
||||||
|
async def update_session(
|
||||||
|
self,
|
||||||
|
key: bytes,
|
||||||
|
*,
|
||||||
|
ip: str,
|
||||||
|
user_agent: str,
|
||||||
|
renewed: datetime,
|
||||||
|
) -> Session | None:
|
||||||
|
async with self.session() as session:
|
||||||
|
model = await session.get(SessionModel, key)
|
||||||
|
if not model:
|
||||||
|
return None
|
||||||
|
model.ip = ip
|
||||||
|
model.user_agent = user_agent
|
||||||
|
model.renewed = renewed
|
||||||
|
await session.flush()
|
||||||
|
return model.as_dataclass()
|
||||||
|
|
||||||
|
async def set_session_host(self, key: bytes, host: str) -> None:
|
||||||
|
async with self.session() as session:
|
||||||
|
model = await session.get(SessionModel, key)
|
||||||
|
if model and model.host is None:
|
||||||
|
model.host = host
|
||||||
|
await session.flush()
|
||||||
|
|
||||||
|
async def list_sessions_for_user(self, user_uuid: UUID) -> list[Session]:
|
||||||
|
async with self.session() as session:
|
||||||
|
stmt = (
|
||||||
|
select(SessionModel)
|
||||||
|
.where(SessionModel.user_uuid == user_uuid.bytes)
|
||||||
|
.order_by(SessionModel.renewed.desc())
|
||||||
|
)
|
||||||
|
result = await session.execute(stmt)
|
||||||
|
session_models = [
|
||||||
|
model
|
||||||
|
for model in result.scalars().all()
|
||||||
|
if model.key.startswith(b"sess")
|
||||||
|
]
|
||||||
|
return [model.as_dataclass() for model in session_models]
|
||||||
|
|
||||||
# Organization operations
|
# Organization operations
|
||||||
async def create_organization(self, org: Org) -> None:
|
async def create_organization(self, org: Org) -> None:
|
||||||
async with self.session() as session:
|
async with self.session() as session:
|
||||||
@@ -1115,29 +1299,41 @@ class DB(DatabaseInterface):
|
|||||||
|
|
||||||
async def cleanup(self) -> None:
|
async def cleanup(self) -> None:
|
||||||
async with self.session() as session:
|
async with self.session() as session:
|
||||||
current_time = datetime.now()
|
current_time = datetime.now(timezone.utc)
|
||||||
stmt = delete(SessionModel).where(SessionModel.expires < current_time)
|
session_threshold = current_time - SESSION_LIFETIME
|
||||||
await session.execute(stmt)
|
await session.execute(
|
||||||
|
delete(SessionModel).where(SessionModel.renewed < session_threshold)
|
||||||
|
)
|
||||||
|
await session.execute(
|
||||||
|
delete(ResetTokenModel).where(ResetTokenModel.expiry < current_time)
|
||||||
|
)
|
||||||
|
|
||||||
async def get_session_context(self, session_key: bytes) -> SessionContext | None:
|
async def get_session_context(
|
||||||
|
self, session_key: bytes, host: str | None = None
|
||||||
|
) -> SessionContext | None:
|
||||||
"""Get complete session context including user, organization, role, and permissions.
|
"""Get complete session context including user, organization, role, and permissions.
|
||||||
|
|
||||||
Uses efficient JOINs to retrieve all related data in a single database query.
|
Uses efficient JOINs to retrieve all related data in a single database query.
|
||||||
"""
|
"""
|
||||||
async with self.session() as session:
|
async with self.session() as session:
|
||||||
# Build a query that joins sessions, users, roles, organizations, and role_permissions
|
# Build a query that joins sessions, users, roles, organizations, credentials and role_permissions
|
||||||
stmt = (
|
stmt = (
|
||||||
select(
|
select(
|
||||||
SessionModel,
|
SessionModel,
|
||||||
UserModel,
|
UserModel,
|
||||||
RoleModel,
|
RoleModel,
|
||||||
OrgModel,
|
OrgModel,
|
||||||
|
CredentialModel,
|
||||||
PermissionModel,
|
PermissionModel,
|
||||||
)
|
)
|
||||||
.select_from(SessionModel)
|
.select_from(SessionModel)
|
||||||
.join(UserModel, SessionModel.user_uuid == UserModel.uuid)
|
.join(UserModel, SessionModel.user_uuid == UserModel.uuid)
|
||||||
.join(RoleModel, UserModel.role_uuid == RoleModel.uuid)
|
.join(RoleModel, UserModel.role_uuid == RoleModel.uuid)
|
||||||
.join(OrgModel, RoleModel.org_uuid == OrgModel.uuid)
|
.join(OrgModel, RoleModel.org_uuid == OrgModel.uuid)
|
||||||
|
.outerjoin(
|
||||||
|
CredentialModel,
|
||||||
|
SessionModel.credential_uuid == CredentialModel.uuid,
|
||||||
|
)
|
||||||
.outerjoin(RolePermission, RoleModel.uuid == RolePermission.role_uuid)
|
.outerjoin(RolePermission, RoleModel.uuid == RolePermission.role_uuid)
|
||||||
.outerjoin(
|
.outerjoin(
|
||||||
PermissionModel, RolePermission.permission_id == PermissionModel.id
|
PermissionModel, RolePermission.permission_id == PermissionModel.id
|
||||||
@@ -1153,18 +1349,23 @@ class DB(DatabaseInterface):
|
|||||||
|
|
||||||
# Extract the first row to get session and user data
|
# Extract the first row to get session and user data
|
||||||
first_row = rows[0]
|
first_row = rows[0]
|
||||||
session_model, user_model, role_model, org_model, _ = first_row
|
session_model, user_model, role_model, org_model, credential_model, _ = (
|
||||||
|
first_row
|
||||||
|
)
|
||||||
|
|
||||||
# Create the session object
|
# Create the session object
|
||||||
session_obj = Session(
|
if host is not None:
|
||||||
key=session_model.key,
|
if session_model.host is None:
|
||||||
user_uuid=UUID(bytes=session_model.user_uuid),
|
await session.execute(
|
||||||
credential_uuid=UUID(bytes=session_model.credential_uuid)
|
update(SessionModel)
|
||||||
if session_model.credential_uuid
|
.where(SessionModel.key == session_key)
|
||||||
else None,
|
.values(host=host)
|
||||||
expires=session_model.expires,
|
)
|
||||||
info=session_model.info or {},
|
session_model.host = host
|
||||||
)
|
elif session_model.host != host:
|
||||||
|
return None
|
||||||
|
|
||||||
|
session_obj = session_model.as_dataclass()
|
||||||
|
|
||||||
# Create the user object
|
# Create the user object
|
||||||
user_obj = user_model.as_dataclass()
|
user_obj = user_model.as_dataclass()
|
||||||
@@ -1179,11 +1380,16 @@ class DB(DatabaseInterface):
|
|||||||
display_name=role_model.display_name,
|
display_name=role_model.display_name,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Create credential object if available
|
||||||
|
credential_obj = (
|
||||||
|
credential_model.as_dataclass() if credential_model else None
|
||||||
|
)
|
||||||
|
|
||||||
# Collect all unique permissions for the role
|
# Collect all unique permissions for the role
|
||||||
permissions = []
|
permissions = []
|
||||||
seen_permission_ids = set()
|
seen_permission_ids = set()
|
||||||
for row in rows:
|
for row in rows:
|
||||||
_, _, _, _, permission_model = row
|
_, _, _, _, _, permission_model = row
|
||||||
if permission_model and permission_model.id not in seen_permission_ids:
|
if permission_model and permission_model.id not in seen_permission_ids:
|
||||||
permissions.append(
|
permissions.append(
|
||||||
Permission(
|
Permission(
|
||||||
@@ -1213,5 +1419,6 @@ class DB(DatabaseInterface):
|
|||||||
user=user_obj,
|
user=user_obj,
|
||||||
org=organization,
|
org=organization,
|
||||||
role=role,
|
role=role,
|
||||||
|
credential=credential_obj,
|
||||||
permissions=effective_permissions if effective_permissions else None,
|
permissions=effective_permissions if effective_permissions else None,
|
||||||
)
|
)
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
from paskia.fastapi.mainapp import app
|
||||||
|
|
||||||
|
__all__ = ["app"]
|
||||||
@@ -7,11 +7,31 @@ from urllib.parse import urlparse
|
|||||||
|
|
||||||
import uvicorn
|
import uvicorn
|
||||||
|
|
||||||
from passkey.util import frontend
|
from paskia.util.hostutil import normalize_origin
|
||||||
|
|
||||||
DEFAULT_HOST = "localhost"
|
DEFAULT_HOST = "localhost"
|
||||||
DEFAULT_SERVE_PORT = 4401
|
DEFAULT_SERVE_PORT = 4401
|
||||||
DEFAULT_DEV_PORT = 4402
|
|
||||||
|
|
||||||
|
def is_subdomain(sub: str, domain: str) -> bool:
|
||||||
|
"""Check if sub is a subdomain of domain (or equal)."""
|
||||||
|
sub_parts = sub.lower().split(".")
|
||||||
|
domain_parts = domain.lower().split(".")
|
||||||
|
if len(sub_parts) < len(domain_parts):
|
||||||
|
return False
|
||||||
|
return sub_parts[-len(domain_parts) :] == domain_parts
|
||||||
|
|
||||||
|
|
||||||
|
def validate_auth_host(auth_host: str, rp_id: str) -> None:
|
||||||
|
"""Validate that auth_host is a subdomain of rp_id."""
|
||||||
|
parsed = urlparse(auth_host if "://" in auth_host else f"//{auth_host}")
|
||||||
|
host = parsed.hostname or parsed.path
|
||||||
|
if not host:
|
||||||
|
raise SystemExit(f"Invalid auth-host: '{auth_host}'")
|
||||||
|
if not is_subdomain(host, rp_id):
|
||||||
|
raise SystemExit(
|
||||||
|
f"auth-host '{auth_host}' is not a subdomain of rp-id '{rp_id}'"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def parse_endpoint(
|
def parse_endpoint(
|
||||||
@@ -93,7 +113,13 @@ def add_common_options(p: argparse.ArgumentParser) -> None:
|
|||||||
"--rp-id", default="localhost", help="Relying Party ID (default: localhost)"
|
"--rp-id", default="localhost", help="Relying Party ID (default: localhost)"
|
||||||
)
|
)
|
||||||
p.add_argument("--rp-name", help="Relying Party name (default: same as rp-id)")
|
p.add_argument("--rp-name", help="Relying Party name (default: same as rp-id)")
|
||||||
p.add_argument("--origin", help="Origin URL (default: https://<rp-id>)")
|
p.add_argument(
|
||||||
|
"--origin",
|
||||||
|
action="append",
|
||||||
|
dest="origins",
|
||||||
|
metavar="URL",
|
||||||
|
help="Allowed origin URL(s). May be specified multiple times. If any are specified, only those origins are permitted for WebSocket authentication.",
|
||||||
|
)
|
||||||
p.add_argument(
|
p.add_argument(
|
||||||
"--auth-host",
|
"--auth-host",
|
||||||
help=(
|
help=(
|
||||||
@@ -108,7 +134,7 @@ def main():
|
|||||||
logging.basicConfig(level=logging.INFO, format="%(message)s", force=True)
|
logging.basicConfig(level=logging.INFO, format="%(message)s", force=True)
|
||||||
|
|
||||||
parser = argparse.ArgumentParser(
|
parser = argparse.ArgumentParser(
|
||||||
prog="passkey-auth", description="Passkey authentication server"
|
prog="paskia", description="Paskia authentication server"
|
||||||
)
|
)
|
||||||
sub = parser.add_subparsers(dest="command", required=True)
|
sub = parser.add_subparsers(dest="command", required=True)
|
||||||
|
|
||||||
@@ -126,18 +152,6 @@ def main():
|
|||||||
)
|
)
|
||||||
add_common_options(serve)
|
add_common_options(serve)
|
||||||
|
|
||||||
# dev subcommand
|
|
||||||
dev = sub.add_parser("dev", help="Run the server in development (auto-reload)")
|
|
||||||
dev.add_argument(
|
|
||||||
"hostport",
|
|
||||||
nargs="?",
|
|
||||||
help=(
|
|
||||||
"Endpoint (default: localhost:4402). Forms: host[:port] | :port | "
|
|
||||||
"[ipv6][:port] | ipv6 | unix:/path.sock"
|
|
||||||
),
|
|
||||||
)
|
|
||||||
add_common_options(dev)
|
|
||||||
|
|
||||||
# reset subcommand
|
# reset subcommand
|
||||||
reset = sub.add_parser(
|
reset = sub.add_parser(
|
||||||
"reset",
|
"reset",
|
||||||
@@ -155,64 +169,121 @@ def main():
|
|||||||
|
|
||||||
args = parser.parse_args()
|
args = parser.parse_args()
|
||||||
|
|
||||||
if args.command in {"serve", "dev"}:
|
if args.command == "serve":
|
||||||
default_port = DEFAULT_DEV_PORT if args.command == "dev" else DEFAULT_SERVE_PORT
|
host, port, uds, all_ifaces = parse_endpoint(args.hostport, DEFAULT_SERVE_PORT)
|
||||||
host, port, uds, all_ifaces = parse_endpoint(args.hostport, default_port)
|
|
||||||
devmode = args.command == "dev"
|
|
||||||
else:
|
else:
|
||||||
host = port = uds = all_ifaces = None # type: ignore
|
host = port = uds = all_ifaces = None # type: ignore
|
||||||
devmode = False
|
|
||||||
|
|
||||||
# Determine origin (dev mode default override)
|
# Collect and normalize origins, handle auth_host
|
||||||
origin = args.origin
|
origins = [normalize_origin(o) for o in (getattr(args, "origins", None) or [])]
|
||||||
if devmode and not args.origin and not args.rp_id:
|
if args.auth_host:
|
||||||
# Dev mode: Vite runs on another port, override:
|
# Normalize auth_host with scheme
|
||||||
origin = "http://localhost:4403"
|
if "://" not in args.auth_host:
|
||||||
|
args.auth_host = f"https://{args.auth_host}"
|
||||||
|
|
||||||
# Export configuration via environment for lifespan initialization in each process
|
validate_auth_host(args.auth_host, args.rp_id)
|
||||||
os.environ.setdefault("PASSKEY_RP_ID", args.rp_id)
|
|
||||||
if args.rp_name:
|
# If origins are configured, ensure auth_host is included at top
|
||||||
os.environ["PASSKEY_RP_NAME"] = args.rp_name
|
if origins:
|
||||||
if origin:
|
# Insert auth_host at the beginning (Passkey.__init__ will dedupe)
|
||||||
os.environ["PASSKEY_ORIGIN"] = origin
|
origins.insert(0, args.auth_host)
|
||||||
if getattr(args, "auth_host", None):
|
|
||||||
os.environ["PASSKEY_AUTH_HOST"] = args.auth_host
|
# 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:
|
else:
|
||||||
# Preserve pre-set env variable if CLI option omitted
|
site_url = f"https://{args.rp_id}"
|
||||||
args.auth_host = os.environ.get("PASSKEY_AUTH_HOST")
|
site_path = "/auth/"
|
||||||
|
|
||||||
if getattr(args, "auth_host", None):
|
# Build runtime configuration
|
||||||
from passkey.util import hostutil as _hostutil # local import
|
from paskia.config import PaskiaConfig
|
||||||
|
|
||||||
_hostutil.reload_config()
|
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,
|
||||||
|
)
|
||||||
|
|
||||||
# One-time initialization + bootstrap before starting any server processes.
|
# Export configuration via single JSON env variable for worker processes
|
||||||
# Lifespan in worker processes will call globals.init with bootstrap disabled.
|
import json
|
||||||
from passkey import globals as _globals # local import
|
|
||||||
|
config_json = {
|
||||||
|
"rp_id": config.rp_id,
|
||||||
|
"rp_name": config.rp_name,
|
||||||
|
"origins": config.origins,
|
||||||
|
"auth_host": config.auth_host,
|
||||||
|
"site_url": config.site_url,
|
||||||
|
"site_path": config.site_path,
|
||||||
|
}
|
||||||
|
os.environ["PASKIA_CONFIG"] = json.dumps(config_json)
|
||||||
|
|
||||||
|
# Initialize globals (without bootstrap yet)
|
||||||
|
from paskia import globals as _globals # local import
|
||||||
|
|
||||||
asyncio.run(
|
asyncio.run(
|
||||||
_globals.init(
|
_globals.init(
|
||||||
rp_id=args.rp_id,
|
rp_id=config.rp_id,
|
||||||
rp_name=args.rp_name,
|
rp_name=config.rp_name,
|
||||||
origin=origin,
|
origins=config.origins,
|
||||||
default_admin=os.getenv("PASSKEY_DEFAULT_ADMIN") or None,
|
bootstrap=False,
|
||||||
default_org=os.getenv("PASSKEY_DEFAULT_ORG") or None,
|
|
||||||
bootstrap=True,
|
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Print startup configuration
|
||||||
|
from paskia.util import startupbox
|
||||||
|
|
||||||
|
startupbox.print_startup_config(config)
|
||||||
|
|
||||||
|
# Bootstrap after startup box is printed
|
||||||
|
from paskia.bootstrap import bootstrap_if_needed
|
||||||
|
|
||||||
|
asyncio.run(bootstrap_if_needed())
|
||||||
|
|
||||||
# Handle recover-admin command (no server start)
|
# Handle recover-admin command (no server start)
|
||||||
if args.command == "reset":
|
if args.command == "reset":
|
||||||
from passkey.fastapi import reset as reset_cmd # local import
|
from paskia.fastapi import reset as reset_cmd # local import
|
||||||
|
|
||||||
exit_code = reset_cmd.run(getattr(args, "query", None))
|
exit_code = reset_cmd.run(getattr(args, "query", None))
|
||||||
raise SystemExit(exit_code)
|
raise SystemExit(exit_code)
|
||||||
|
|
||||||
if args.command in {"serve", "dev"}:
|
if args.command == "serve":
|
||||||
run_kwargs: dict = {
|
run_kwargs: dict = {
|
||||||
"reload": devmode,
|
|
||||||
"log_level": "info",
|
"log_level": "info",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Dev mode: enable reload when PASKIA_DEVMODE is set
|
||||||
|
devmode = bool(os.environ.get("PASKIA_DEVMODE"))
|
||||||
|
if devmode:
|
||||||
|
# Security: dev mode must run on localhost:4402 to prevent
|
||||||
|
# accidental public exposure of the Vite dev server
|
||||||
|
if host != "localhost" or port != 4402:
|
||||||
|
raise SystemExit(f"Dev mode requires localhost:4402, got {host}:{port}")
|
||||||
|
run_kwargs["reload"] = True
|
||||||
|
run_kwargs["reload_dirs"] = ["paskia"]
|
||||||
|
# Suppress uvicorn startup messages in dev mode
|
||||||
|
run_kwargs["log_level"] = "warning"
|
||||||
|
|
||||||
if uds:
|
if uds:
|
||||||
run_kwargs["uds"] = uds
|
run_kwargs["uds"] = uds
|
||||||
else:
|
else:
|
||||||
@@ -220,20 +291,17 @@ def main():
|
|||||||
run_kwargs["host"] = host
|
run_kwargs["host"] = host
|
||||||
run_kwargs["port"] = port
|
run_kwargs["port"] = port
|
||||||
|
|
||||||
if devmode:
|
|
||||||
if os.environ.get("PASSKEY_BUN_PARENT") != "1":
|
|
||||||
os.environ["PASSKEY_BUN_PARENT"] = "1"
|
|
||||||
frontend.run_dev()
|
|
||||||
|
|
||||||
if all_ifaces and not uds:
|
if all_ifaces and not uds:
|
||||||
|
# Dev mode with all interfaces: use simple single-server approach
|
||||||
if devmode:
|
if devmode:
|
||||||
run_kwargs["host"] = "::"
|
run_kwargs["host"] = "::"
|
||||||
run_kwargs["port"] = port
|
run_kwargs["port"] = port
|
||||||
uvicorn.run("passkey.fastapi:app", **run_kwargs)
|
uvicorn.run("paskia.fastapi:app", **run_kwargs)
|
||||||
else:
|
else:
|
||||||
|
# Production: run separate servers for IPv4 and IPv6
|
||||||
from uvicorn import Config, Server # noqa: E402 local import
|
from uvicorn import Config, Server # noqa: E402 local import
|
||||||
|
|
||||||
from passkey.fastapi import (
|
from paskia.fastapi import (
|
||||||
app as fastapi_app, # noqa: E402 local import
|
app as fastapi_app, # noqa: E402 local import
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -256,7 +324,7 @@ def main():
|
|||||||
|
|
||||||
asyncio.run(serve_both())
|
asyncio.run(serve_both())
|
||||||
else:
|
else:
|
||||||
uvicorn.run("passkey.fastapi:app", **run_kwargs)
|
uvicorn.run("paskia.fastapi:app", **run_kwargs)
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
@@ -1,13 +1,24 @@
|
|||||||
import logging
|
import logging
|
||||||
|
from datetime import timezone
|
||||||
from uuid import UUID, uuid4
|
from uuid import UUID, uuid4
|
||||||
|
|
||||||
from fastapi import Body, Cookie, FastAPI, HTTPException, Request
|
from fastapi import Body, FastAPI, HTTPException, Request, Response
|
||||||
from fastapi.responses import FileResponse, JSONResponse
|
from fastapi.responses import JSONResponse
|
||||||
|
|
||||||
from ..authsession import expires
|
from paskia.authsession import reset_expires
|
||||||
from ..globals import db
|
from paskia.fastapi import authz
|
||||||
from ..util import frontend, hostutil, passphrase, permutil, querysafe, tokens
|
from paskia.fastapi.session import AUTH_COOKIE
|
||||||
from . import authz
|
from paskia.globals import db
|
||||||
|
from paskia.util import (
|
||||||
|
frontend,
|
||||||
|
hostutil,
|
||||||
|
passphrase,
|
||||||
|
permutil,
|
||||||
|
querysafe,
|
||||||
|
tokens,
|
||||||
|
useragent,
|
||||||
|
)
|
||||||
|
from paskia.util.tokens import encode_session_key, session_key
|
||||||
|
|
||||||
app = FastAPI()
|
app = FastAPI()
|
||||||
|
|
||||||
@@ -17,27 +28,37 @@ async def value_error_handler(_request, exc: ValueError): # pragma: no cover -
|
|||||||
return JSONResponse(status_code=400, content={"detail": str(exc)})
|
return JSONResponse(status_code=400, content={"detail": str(exc)})
|
||||||
|
|
||||||
|
|
||||||
|
@app.exception_handler(authz.AuthException)
|
||||||
|
async def auth_exception_handler(_request, exc: authz.AuthException):
|
||||||
|
"""Handle AuthException with auth info for UI."""
|
||||||
|
return JSONResponse(
|
||||||
|
status_code=exc.status_code,
|
||||||
|
content=await authz.auth_error_content(exc),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@app.exception_handler(Exception)
|
@app.exception_handler(Exception)
|
||||||
async def general_exception_handler(_request, exc: Exception):
|
async def general_exception_handler(_request, exc: Exception): # pragma: no cover
|
||||||
logging.exception("Unhandled exception in admin app")
|
logging.exception("Unhandled exception in admin app")
|
||||||
return JSONResponse(status_code=500, content={"detail": "Internal server error"})
|
return JSONResponse(status_code=500, content={"detail": "Internal server error"})
|
||||||
|
|
||||||
|
|
||||||
@app.get("/")
|
@app.get("/")
|
||||||
async def adminapp(auth=Cookie(None)):
|
async def adminapp(request: Request, auth=AUTH_COOKIE):
|
||||||
try:
|
return Response(*await frontend.read("/auth/admin/index.html"))
|
||||||
await authz.verify(auth, ["auth:admin", "auth:org:*"], match=permutil.has_any)
|
|
||||||
return FileResponse(frontend.file("admin/index.html"))
|
|
||||||
except HTTPException as e:
|
|
||||||
return FileResponse(frontend.file("index.html"), status_code=e.status_code)
|
|
||||||
|
|
||||||
|
|
||||||
# -------------------- Organizations --------------------
|
# -------------------- Organizations --------------------
|
||||||
|
|
||||||
|
|
||||||
@app.get("/orgs")
|
@app.get("/orgs")
|
||||||
async def admin_list_orgs(auth=Cookie(None)):
|
async def admin_list_orgs(request: Request, auth=AUTH_COOKIE):
|
||||||
ctx = await authz.verify(auth, ["auth:admin", "auth:org:*"], match=permutil.has_any)
|
ctx = await authz.verify(
|
||||||
|
auth,
|
||||||
|
["auth:admin", "auth:org:*"],
|
||||||
|
match=permutil.has_any,
|
||||||
|
host=request.headers.get("host"),
|
||||||
|
)
|
||||||
orgs = await db.instance.list_organizations()
|
orgs = await db.instance.list_organizations()
|
||||||
if "auth:admin" not in ctx.role.permissions:
|
if "auth:admin" not in ctx.role.permissions:
|
||||||
orgs = [o for o in orgs if f"auth:org:{o.uuid}" in ctx.role.permissions]
|
orgs = [o for o in orgs if f"auth:org:{o.uuid}" in ctx.role.permissions]
|
||||||
@@ -73,8 +94,12 @@ async def admin_list_orgs(auth=Cookie(None)):
|
|||||||
|
|
||||||
|
|
||||||
@app.post("/orgs")
|
@app.post("/orgs")
|
||||||
async def admin_create_org(payload: dict = Body(...), auth=Cookie(None)):
|
async def admin_create_org(
|
||||||
await authz.verify(auth, ["auth:admin"])
|
request: Request, payload: dict = Body(...), auth=AUTH_COOKIE
|
||||||
|
):
|
||||||
|
await authz.verify(
|
||||||
|
auth, ["auth:admin"], host=request.headers.get("host"), match=permutil.has_all
|
||||||
|
)
|
||||||
from ..db import Org as OrgDC # local import to avoid cycles
|
from ..db import Org as OrgDC # local import to avoid cycles
|
||||||
from ..db import Role as RoleDC # local import to avoid cycles
|
from ..db import Role as RoleDC # local import to avoid cycles
|
||||||
|
|
||||||
@@ -99,16 +124,24 @@ async def admin_create_org(payload: dict = Body(...), auth=Cookie(None)):
|
|||||||
|
|
||||||
@app.put("/orgs/{org_uuid}")
|
@app.put("/orgs/{org_uuid}")
|
||||||
async def admin_update_org(
|
async def admin_update_org(
|
||||||
org_uuid: UUID, payload: dict = Body(...), auth=Cookie(None)
|
org_uuid: UUID,
|
||||||
|
request: Request,
|
||||||
|
payload: dict = Body(...),
|
||||||
|
auth=AUTH_COOKIE,
|
||||||
):
|
):
|
||||||
ctx = await authz.verify(
|
ctx = await authz.verify(
|
||||||
auth, ["auth:admin", f"auth:org:{org_uuid}"], match=permutil.has_any
|
auth,
|
||||||
|
["auth:admin", f"auth:org:{org_uuid}"],
|
||||||
|
match=permutil.has_any,
|
||||||
|
host=request.headers.get("host"),
|
||||||
)
|
)
|
||||||
from ..db import Org as OrgDC # local import to avoid cycles
|
from ..db import Org as OrgDC # local import to avoid cycles
|
||||||
|
|
||||||
current = await db.instance.get_organization(str(org_uuid))
|
current = await db.instance.get_organization(str(org_uuid))
|
||||||
display_name = payload.get("display_name") or current.display_name
|
display_name = payload.get("display_name") or current.display_name
|
||||||
permissions = payload.get("permissions") or current.permissions or []
|
permissions = payload.get("permissions")
|
||||||
|
if permissions is None:
|
||||||
|
permissions = current.permissions or []
|
||||||
|
|
||||||
# Sanity check: prevent removing permissions that would break current user's admin access
|
# Sanity check: prevent removing permissions that would break current user's admin access
|
||||||
org_admin_perm = f"auth:org:{org_uuid}"
|
org_admin_perm = f"auth:org:{org_uuid}"
|
||||||
@@ -129,9 +162,13 @@ async def admin_update_org(
|
|||||||
|
|
||||||
|
|
||||||
@app.delete("/orgs/{org_uuid}")
|
@app.delete("/orgs/{org_uuid}")
|
||||||
async def admin_delete_org(org_uuid: UUID, auth=Cookie(None)):
|
async def admin_delete_org(org_uuid: UUID, request: Request, auth=AUTH_COOKIE):
|
||||||
ctx = await authz.verify(
|
ctx = await authz.verify(
|
||||||
auth, ["auth:admin", f"auth:org:{org_uuid}"], match=permutil.has_any
|
auth,
|
||||||
|
["auth:admin", f"auth:org:{org_uuid}"],
|
||||||
|
match=permutil.has_any,
|
||||||
|
host=request.headers.get("host"),
|
||||||
|
max_age="5m",
|
||||||
)
|
)
|
||||||
if ctx.org.uuid == org_uuid:
|
if ctx.org.uuid == org_uuid:
|
||||||
raise ValueError("Cannot delete the organization you belong to")
|
raise ValueError("Cannot delete the organization you belong to")
|
||||||
@@ -156,18 +193,28 @@ async def admin_delete_org(org_uuid: UUID, auth=Cookie(None)):
|
|||||||
|
|
||||||
@app.post("/orgs/{org_uuid}/permission")
|
@app.post("/orgs/{org_uuid}/permission")
|
||||||
async def admin_add_org_permission(
|
async def admin_add_org_permission(
|
||||||
org_uuid: UUID, permission_id: str, auth=Cookie(None)
|
org_uuid: UUID,
|
||||||
|
permission_id: str,
|
||||||
|
request: Request,
|
||||||
|
auth=AUTH_COOKIE,
|
||||||
):
|
):
|
||||||
await authz.verify(auth, ["auth:admin"])
|
await authz.verify(
|
||||||
|
auth, ["auth:admin"], host=request.headers.get("host"), match=permutil.has_all
|
||||||
|
)
|
||||||
await db.instance.add_permission_to_organization(str(org_uuid), permission_id)
|
await db.instance.add_permission_to_organization(str(org_uuid), permission_id)
|
||||||
return {"status": "ok"}
|
return {"status": "ok"}
|
||||||
|
|
||||||
|
|
||||||
@app.delete("/orgs/{org_uuid}/permission")
|
@app.delete("/orgs/{org_uuid}/permission")
|
||||||
async def admin_remove_org_permission(
|
async def admin_remove_org_permission(
|
||||||
org_uuid: UUID, permission_id: str, auth=Cookie(None)
|
org_uuid: UUID,
|
||||||
|
permission_id: str,
|
||||||
|
request: Request,
|
||||||
|
auth=AUTH_COOKIE,
|
||||||
):
|
):
|
||||||
await authz.verify(auth, ["auth:admin"])
|
await authz.verify(
|
||||||
|
auth, ["auth:admin"], host=request.headers.get("host"), match=permutil.has_all
|
||||||
|
)
|
||||||
await db.instance.remove_permission_from_organization(str(org_uuid), permission_id)
|
await db.instance.remove_permission_from_organization(str(org_uuid), permission_id)
|
||||||
return {"status": "ok"}
|
return {"status": "ok"}
|
||||||
|
|
||||||
@@ -177,10 +224,16 @@ async def admin_remove_org_permission(
|
|||||||
|
|
||||||
@app.post("/orgs/{org_uuid}/roles")
|
@app.post("/orgs/{org_uuid}/roles")
|
||||||
async def admin_create_role(
|
async def admin_create_role(
|
||||||
org_uuid: UUID, payload: dict = Body(...), auth=Cookie(None)
|
org_uuid: UUID,
|
||||||
|
request: Request,
|
||||||
|
payload: dict = Body(...),
|
||||||
|
auth=AUTH_COOKIE,
|
||||||
):
|
):
|
||||||
await authz.verify(
|
await authz.verify(
|
||||||
auth, ["auth:admin", f"auth:org:{org_uuid}"], match=permutil.has_any
|
auth,
|
||||||
|
["auth:admin", f"auth:org:{org_uuid}"],
|
||||||
|
match=permutil.has_any,
|
||||||
|
host=request.headers.get("host"),
|
||||||
)
|
)
|
||||||
from ..db import Role as RoleDC
|
from ..db import Role as RoleDC
|
||||||
|
|
||||||
@@ -205,11 +258,18 @@ async def admin_create_role(
|
|||||||
|
|
||||||
@app.put("/orgs/{org_uuid}/roles/{role_uuid}")
|
@app.put("/orgs/{org_uuid}/roles/{role_uuid}")
|
||||||
async def admin_update_role(
|
async def admin_update_role(
|
||||||
org_uuid: UUID, role_uuid: UUID, payload: dict = Body(...), auth=Cookie(None)
|
org_uuid: UUID,
|
||||||
|
role_uuid: UUID,
|
||||||
|
request: Request,
|
||||||
|
payload: dict = Body(...),
|
||||||
|
auth=AUTH_COOKIE,
|
||||||
):
|
):
|
||||||
# Verify caller is global admin or admin of provided org
|
# Verify caller is global admin or admin of provided org
|
||||||
ctx = await authz.verify(
|
ctx = await authz.verify(
|
||||||
auth, ["auth:admin", f"auth:org:{org_uuid}"], match=permutil.has_any
|
auth,
|
||||||
|
["auth:admin", f"auth:org:{org_uuid}"],
|
||||||
|
match=permutil.has_any,
|
||||||
|
host=request.headers.get("host"),
|
||||||
)
|
)
|
||||||
role = await db.instance.get_role(role_uuid)
|
role = await db.instance.get_role(role_uuid)
|
||||||
if role.org_uuid != org_uuid:
|
if role.org_uuid != org_uuid:
|
||||||
@@ -247,9 +307,18 @@ async def admin_update_role(
|
|||||||
|
|
||||||
|
|
||||||
@app.delete("/orgs/{org_uuid}/roles/{role_uuid}")
|
@app.delete("/orgs/{org_uuid}/roles/{role_uuid}")
|
||||||
async def admin_delete_role(org_uuid: UUID, role_uuid: UUID, auth=Cookie(None)):
|
async def admin_delete_role(
|
||||||
|
org_uuid: UUID,
|
||||||
|
role_uuid: UUID,
|
||||||
|
request: Request,
|
||||||
|
auth=AUTH_COOKIE,
|
||||||
|
):
|
||||||
ctx = await authz.verify(
|
ctx = await authz.verify(
|
||||||
auth, ["auth:admin", f"auth:org:{org_uuid}"], match=permutil.has_any
|
auth,
|
||||||
|
["auth:admin", f"auth:org:{org_uuid}"],
|
||||||
|
match=permutil.has_any,
|
||||||
|
host=request.headers.get("host"),
|
||||||
|
max_age="5m",
|
||||||
)
|
)
|
||||||
role = await db.instance.get_role(role_uuid)
|
role = await db.instance.get_role(role_uuid)
|
||||||
if role.org_uuid != org_uuid:
|
if role.org_uuid != org_uuid:
|
||||||
@@ -268,10 +337,16 @@ async def admin_delete_role(org_uuid: UUID, role_uuid: UUID, auth=Cookie(None)):
|
|||||||
|
|
||||||
@app.post("/orgs/{org_uuid}/users")
|
@app.post("/orgs/{org_uuid}/users")
|
||||||
async def admin_create_user(
|
async def admin_create_user(
|
||||||
org_uuid: UUID, payload: dict = Body(...), auth=Cookie(None)
|
org_uuid: UUID,
|
||||||
|
request: Request,
|
||||||
|
payload: dict = Body(...),
|
||||||
|
auth=AUTH_COOKIE,
|
||||||
):
|
):
|
||||||
await authz.verify(
|
await authz.verify(
|
||||||
auth, ["auth:admin", f"auth:org:{org_uuid}"], match=permutil.has_any
|
auth,
|
||||||
|
["auth:admin", f"auth:org:{org_uuid}"],
|
||||||
|
match=permutil.has_any,
|
||||||
|
host=request.headers.get("host"),
|
||||||
)
|
)
|
||||||
display_name = payload.get("display_name")
|
display_name = payload.get("display_name")
|
||||||
role_name = payload.get("role")
|
role_name = payload.get("role")
|
||||||
@@ -297,10 +372,17 @@ async def admin_create_user(
|
|||||||
|
|
||||||
@app.put("/orgs/{org_uuid}/users/{user_uuid}/role")
|
@app.put("/orgs/{org_uuid}/users/{user_uuid}/role")
|
||||||
async def admin_update_user_role(
|
async def admin_update_user_role(
|
||||||
org_uuid: UUID, user_uuid: UUID, payload: dict = Body(...), auth=Cookie(None)
|
org_uuid: UUID,
|
||||||
|
user_uuid: UUID,
|
||||||
|
request: Request,
|
||||||
|
payload: dict = Body(...),
|
||||||
|
auth=AUTH_COOKIE,
|
||||||
):
|
):
|
||||||
ctx = await authz.verify(
|
ctx = await authz.verify(
|
||||||
auth, ["auth:admin", f"auth:org:{org_uuid}"], match=permutil.has_any
|
auth,
|
||||||
|
["auth:admin", f"auth:org:{org_uuid}"],
|
||||||
|
match=permutil.has_any,
|
||||||
|
host=request.headers.get("host"),
|
||||||
)
|
)
|
||||||
new_role = payload.get("role")
|
new_role = payload.get("role")
|
||||||
if not new_role:
|
if not new_role:
|
||||||
@@ -318,7 +400,7 @@ async def admin_update_user_role(
|
|||||||
# Sanity check: prevent admin from removing their own access
|
# Sanity check: prevent admin from removing their own access
|
||||||
if ctx.user.uuid == user_uuid:
|
if ctx.user.uuid == user_uuid:
|
||||||
new_role_obj = next((r for r in roles if r.display_name == new_role), None)
|
new_role_obj = next((r for r in roles if r.display_name == new_role), None)
|
||||||
if new_role_obj:
|
if new_role_obj: # pragma: no branch - always true, role validated above
|
||||||
has_admin_access = (
|
has_admin_access = (
|
||||||
"auth:admin" in new_role_obj.permissions
|
"auth:admin" in new_role_obj.permissions
|
||||||
or f"auth:org:{org_uuid}" in new_role_obj.permissions
|
or f"auth:org:{org_uuid}" in new_role_obj.permissions
|
||||||
@@ -334,7 +416,10 @@ async def admin_update_user_role(
|
|||||||
|
|
||||||
@app.post("/orgs/{org_uuid}/users/{user_uuid}/create-link")
|
@app.post("/orgs/{org_uuid}/users/{user_uuid}/create-link")
|
||||||
async def admin_create_user_registration_link(
|
async def admin_create_user_registration_link(
|
||||||
org_uuid: UUID, user_uuid: UUID, request: Request, auth=Cookie(None)
|
org_uuid: UUID,
|
||||||
|
user_uuid: UUID,
|
||||||
|
request: Request,
|
||||||
|
auth=AUTH_COOKIE,
|
||||||
):
|
):
|
||||||
try:
|
try:
|
||||||
user_org, _role_name = await db.instance.get_user_organization(user_uuid)
|
user_org, _role_name = await db.instance.get_user_organization(user_uuid)
|
||||||
@@ -343,28 +428,50 @@ async def admin_create_user_registration_link(
|
|||||||
if user_org.uuid != org_uuid:
|
if user_org.uuid != org_uuid:
|
||||||
raise HTTPException(status_code=404, detail="User not found in organization")
|
raise HTTPException(status_code=404, detail="User not found in organization")
|
||||||
ctx = await authz.verify(
|
ctx = await authz.verify(
|
||||||
auth, ["auth:admin", f"auth:org:{org_uuid}"], match=permutil.has_any
|
auth,
|
||||||
|
["auth:admin", f"auth:org:{org_uuid}"],
|
||||||
|
match=permutil.has_any,
|
||||||
|
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
|
"auth:admin" not in ctx.role.permissions
|
||||||
and f"auth:org:{org_uuid}" not in ctx.role.permissions
|
and f"auth:org:{org_uuid}" not in ctx.role.permissions
|
||||||
):
|
):
|
||||||
raise HTTPException(status_code=403, detail="Insufficient permissions")
|
raise authz.AuthException(
|
||||||
|
status_code=403, detail="Insufficient permissions", mode="forbidden"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Check if user has existing credentials
|
||||||
|
credentials = await db.instance.get_credentials_by_user_uuid(user_uuid)
|
||||||
|
token_type = "user registration" if not credentials else "account recovery"
|
||||||
|
|
||||||
token = passphrase.generate()
|
token = passphrase.generate()
|
||||||
await db.instance.create_session(
|
expiry = reset_expires()
|
||||||
|
await db.instance.create_reset_token(
|
||||||
user_uuid=user_uuid,
|
user_uuid=user_uuid,
|
||||||
key=tokens.reset_key(token),
|
key=tokens.reset_key(token),
|
||||||
expires=expires(),
|
expiry=expiry,
|
||||||
info={"type": "device addition", "created_by_admin": True},
|
token_type=token_type,
|
||||||
)
|
)
|
||||||
url = hostutil.reset_link_url(
|
url = hostutil.reset_link_url(token)
|
||||||
token, request.url.scheme, request.headers.get("host")
|
return {
|
||||||
)
|
"url": url,
|
||||||
return {"url": url, "expires": expires().isoformat()}
|
"expires": (
|
||||||
|
expiry.astimezone(timezone.utc).isoformat().replace("+00:00", "Z")
|
||||||
|
if expiry.tzinfo
|
||||||
|
else expiry.replace(tzinfo=timezone.utc).isoformat().replace("+00:00", "Z")
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@app.get("/orgs/{org_uuid}/users/{user_uuid}")
|
@app.get("/orgs/{org_uuid}/users/{user_uuid}")
|
||||||
async def admin_get_user_detail(org_uuid: UUID, user_uuid: UUID, auth=Cookie(None)):
|
async def admin_get_user_detail(
|
||||||
|
org_uuid: UUID,
|
||||||
|
user_uuid: UUID,
|
||||||
|
request: Request,
|
||||||
|
auth=AUTH_COOKIE,
|
||||||
|
):
|
||||||
try:
|
try:
|
||||||
user_org, role_name = await db.instance.get_user_organization(user_uuid)
|
user_org, role_name = await db.instance.get_user_organization(user_uuid)
|
||||||
except ValueError:
|
except ValueError:
|
||||||
@@ -372,13 +479,18 @@ async def admin_get_user_detail(org_uuid: UUID, user_uuid: UUID, auth=Cookie(Non
|
|||||||
if user_org.uuid != org_uuid:
|
if user_org.uuid != org_uuid:
|
||||||
raise HTTPException(status_code=404, detail="User not found in organization")
|
raise HTTPException(status_code=404, detail="User not found in organization")
|
||||||
ctx = await authz.verify(
|
ctx = await authz.verify(
|
||||||
auth, ["auth:admin", f"auth:org:{org_uuid}"], match=permutil.has_any
|
auth,
|
||||||
|
["auth:admin", f"auth:org:{org_uuid}"],
|
||||||
|
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
|
"auth:admin" not in ctx.role.permissions
|
||||||
and f"auth:org:{org_uuid}" not in ctx.role.permissions
|
and f"auth:org:{org_uuid}" not in ctx.role.permissions
|
||||||
):
|
):
|
||||||
raise HTTPException(status_code=403, detail="Insufficient permissions")
|
raise authz.AuthException(
|
||||||
|
status_code=403, detail="Insufficient permissions", mode="forbidden"
|
||||||
|
)
|
||||||
user = await db.instance.get_user_by_uuid(user_uuid)
|
user = await db.instance.get_user_by_uuid(user_uuid)
|
||||||
cred_ids = await db.instance.get_credentials_by_user_uuid(user_uuid)
|
cred_ids = await db.instance.get_credentials_by_user_uuid(user_uuid)
|
||||||
creds: list[dict] = []
|
creds: list[dict] = []
|
||||||
@@ -386,7 +498,7 @@ async def admin_get_user_detail(org_uuid: UUID, user_uuid: UUID, auth=Cookie(Non
|
|||||||
for cid in cred_ids:
|
for cid in cred_ids:
|
||||||
try:
|
try:
|
||||||
c = await db.instance.get_credential_by_id(cid)
|
c = await db.instance.get_credential_by_id(cid)
|
||||||
except ValueError:
|
except ValueError: # pragma: no cover - race condition handling
|
||||||
continue
|
continue
|
||||||
aaguid_str = str(c.aaguid)
|
aaguid_str = str(c.aaguid)
|
||||||
aaguids.add(aaguid_str)
|
aaguids.add(aaguid_str)
|
||||||
@@ -394,9 +506,41 @@ async def admin_get_user_detail(org_uuid: UUID, user_uuid: UUID, auth=Cookie(Non
|
|||||||
{
|
{
|
||||||
"credential_uuid": str(c.uuid),
|
"credential_uuid": str(c.uuid),
|
||||||
"aaguid": aaguid_str,
|
"aaguid": aaguid_str,
|
||||||
"created_at": c.created_at.isoformat(),
|
"created_at": (
|
||||||
"last_used": c.last_used.isoformat() if c.last_used else None,
|
c.created_at.astimezone(timezone.utc)
|
||||||
"last_verified": c.last_verified.isoformat()
|
.isoformat()
|
||||||
|
.replace("+00:00", "Z")
|
||||||
|
if c.created_at.tzinfo
|
||||||
|
else c.created_at.replace(tzinfo=timezone.utc)
|
||||||
|
.isoformat()
|
||||||
|
.replace("+00:00", "Z")
|
||||||
|
),
|
||||||
|
"last_used": (
|
||||||
|
c.last_used.astimezone(timezone.utc)
|
||||||
|
.isoformat()
|
||||||
|
.replace("+00:00", "Z")
|
||||||
|
if c.last_used and c.last_used.tzinfo
|
||||||
|
else (
|
||||||
|
c.last_used.replace(tzinfo=timezone.utc)
|
||||||
|
.isoformat()
|
||||||
|
.replace("+00:00", "Z")
|
||||||
|
if c.last_used
|
||||||
|
else None
|
||||||
|
)
|
||||||
|
),
|
||||||
|
"last_verified": (
|
||||||
|
c.last_verified.astimezone(timezone.utc)
|
||||||
|
.isoformat()
|
||||||
|
.replace("+00:00", "Z")
|
||||||
|
if c.last_verified and c.last_verified.tzinfo
|
||||||
|
else (
|
||||||
|
c.last_verified.replace(tzinfo=timezone.utc)
|
||||||
|
.isoformat()
|
||||||
|
.replace("+00:00", "Z")
|
||||||
|
if c.last_verified
|
||||||
|
else None
|
||||||
|
)
|
||||||
|
)
|
||||||
if c.last_verified
|
if c.last_verified
|
||||||
else None,
|
else None,
|
||||||
"sign_count": c.sign_count,
|
"sign_count": c.sign_count,
|
||||||
@@ -405,21 +549,77 @@ async def admin_get_user_detail(org_uuid: UUID, user_uuid: UUID, auth=Cookie(Non
|
|||||||
from .. import aaguid as aaguid_mod
|
from .. import aaguid as aaguid_mod
|
||||||
|
|
||||||
aaguid_info = aaguid_mod.filter(aaguids)
|
aaguid_info = aaguid_mod.filter(aaguids)
|
||||||
|
|
||||||
|
# Get sessions for the user
|
||||||
|
normalized_request_host = hostutil.normalize_host(request.headers.get("host"))
|
||||||
|
session_records = await db.instance.list_sessions_for_user(user_uuid)
|
||||||
|
current_session_key = session_key(auth)
|
||||||
|
sessions_payload: list[dict] = []
|
||||||
|
for entry in session_records:
|
||||||
|
sessions_payload.append(
|
||||||
|
{
|
||||||
|
"id": encode_session_key(entry.key),
|
||||||
|
"host": entry.host,
|
||||||
|
"ip": entry.ip,
|
||||||
|
"user_agent": useragent.compact_user_agent(entry.user_agent),
|
||||||
|
"last_renewed": (
|
||||||
|
entry.renewed.astimezone(timezone.utc)
|
||||||
|
.isoformat()
|
||||||
|
.replace("+00:00", "Z")
|
||||||
|
if entry.renewed.tzinfo
|
||||||
|
else entry.renewed.replace(tzinfo=timezone.utc)
|
||||||
|
.isoformat()
|
||||||
|
.replace("+00:00", "Z")
|
||||||
|
),
|
||||||
|
"is_current": entry.key == current_session_key,
|
||||||
|
"is_current_host": bool(
|
||||||
|
normalized_request_host
|
||||||
|
and entry.host
|
||||||
|
and entry.host == normalized_request_host
|
||||||
|
),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"display_name": user.display_name,
|
"display_name": user.display_name,
|
||||||
"org": {"display_name": user_org.display_name},
|
"org": {"display_name": user_org.display_name},
|
||||||
"role": role_name,
|
"role": role_name,
|
||||||
"visits": user.visits,
|
"visits": user.visits,
|
||||||
"created_at": user.created_at.isoformat() if user.created_at else None,
|
"created_at": (
|
||||||
"last_seen": user.last_seen.isoformat() if user.last_seen else None,
|
user.created_at.astimezone(timezone.utc).isoformat().replace("+00:00", "Z")
|
||||||
|
if user.created_at and user.created_at.tzinfo
|
||||||
|
else (
|
||||||
|
user.created_at.replace(tzinfo=timezone.utc)
|
||||||
|
.isoformat()
|
||||||
|
.replace("+00:00", "Z")
|
||||||
|
if user.created_at
|
||||||
|
else None
|
||||||
|
)
|
||||||
|
),
|
||||||
|
"last_seen": (
|
||||||
|
user.last_seen.astimezone(timezone.utc).isoformat().replace("+00:00", "Z")
|
||||||
|
if user.last_seen and user.last_seen.tzinfo
|
||||||
|
else (
|
||||||
|
user.last_seen.replace(tzinfo=timezone.utc)
|
||||||
|
.isoformat()
|
||||||
|
.replace("+00:00", "Z")
|
||||||
|
if user.last_seen
|
||||||
|
else None
|
||||||
|
)
|
||||||
|
),
|
||||||
"credentials": creds,
|
"credentials": creds,
|
||||||
"aaguid_info": aaguid_info,
|
"aaguid_info": aaguid_info,
|
||||||
|
"sessions": sessions_payload,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@app.put("/orgs/{org_uuid}/users/{user_uuid}/display-name")
|
@app.put("/orgs/{org_uuid}/users/{user_uuid}/display-name")
|
||||||
async def admin_update_user_display_name(
|
async def admin_update_user_display_name(
|
||||||
org_uuid: UUID, user_uuid: UUID, payload: dict = Body(...), auth=Cookie(None)
|
org_uuid: UUID,
|
||||||
|
user_uuid: UUID,
|
||||||
|
request: Request,
|
||||||
|
payload: dict = Body(...),
|
||||||
|
auth=AUTH_COOKIE,
|
||||||
):
|
):
|
||||||
try:
|
try:
|
||||||
user_org, _role_name = await db.instance.get_user_organization(user_uuid)
|
user_org, _role_name = await db.instance.get_user_organization(user_uuid)
|
||||||
@@ -428,13 +628,18 @@ async def admin_update_user_display_name(
|
|||||||
if user_org.uuid != org_uuid:
|
if user_org.uuid != org_uuid:
|
||||||
raise HTTPException(status_code=404, detail="User not found in organization")
|
raise HTTPException(status_code=404, detail="User not found in organization")
|
||||||
ctx = await authz.verify(
|
ctx = await authz.verify(
|
||||||
auth, ["auth:admin", f"auth:org:{org_uuid}"], match=permutil.has_any
|
auth,
|
||||||
|
["auth:admin", f"auth:org:{org_uuid}"],
|
||||||
|
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
|
"auth:admin" not in ctx.role.permissions
|
||||||
and f"auth:org:{org_uuid}" not in ctx.role.permissions
|
and f"auth:org:{org_uuid}" not in ctx.role.permissions
|
||||||
):
|
):
|
||||||
raise HTTPException(status_code=403, detail="Insufficient permissions")
|
raise authz.AuthException(
|
||||||
|
status_code=403, detail="Insufficient permissions", mode="forbidden"
|
||||||
|
)
|
||||||
new_name = (payload.get("display_name") or "").strip()
|
new_name = (payload.get("display_name") or "").strip()
|
||||||
if not new_name:
|
if not new_name:
|
||||||
raise HTTPException(status_code=400, detail="display_name required")
|
raise HTTPException(status_code=400, detail="display_name required")
|
||||||
@@ -446,7 +651,11 @@ async def admin_update_user_display_name(
|
|||||||
|
|
||||||
@app.delete("/orgs/{org_uuid}/users/{user_uuid}/credentials/{credential_uuid}")
|
@app.delete("/orgs/{org_uuid}/users/{user_uuid}/credentials/{credential_uuid}")
|
||||||
async def admin_delete_user_credential(
|
async def admin_delete_user_credential(
|
||||||
org_uuid: UUID, user_uuid: UUID, credential_uuid: UUID, auth=Cookie(None)
|
org_uuid: UUID,
|
||||||
|
user_uuid: UUID,
|
||||||
|
credential_uuid: UUID,
|
||||||
|
request: Request,
|
||||||
|
auth=AUTH_COOKIE,
|
||||||
):
|
):
|
||||||
try:
|
try:
|
||||||
user_org, _role_name = await db.instance.get_user_organization(user_uuid)
|
user_org, _role_name = await db.instance.get_user_organization(user_uuid)
|
||||||
@@ -455,23 +664,80 @@ async def admin_delete_user_credential(
|
|||||||
if user_org.uuid != org_uuid:
|
if user_org.uuid != org_uuid:
|
||||||
raise HTTPException(status_code=404, detail="User not found in organization")
|
raise HTTPException(status_code=404, detail="User not found in organization")
|
||||||
ctx = await authz.verify(
|
ctx = await authz.verify(
|
||||||
auth, ["auth:admin", f"auth:org:{org_uuid}"], match=permutil.has_any
|
auth,
|
||||||
|
["auth:admin", f"auth:org:{org_uuid}"],
|
||||||
|
match=permutil.has_any,
|
||||||
|
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
|
"auth:admin" not in ctx.role.permissions
|
||||||
and f"auth:org:{org_uuid}" not in ctx.role.permissions
|
and f"auth:org:{org_uuid}" not in ctx.role.permissions
|
||||||
):
|
):
|
||||||
raise HTTPException(status_code=403, detail="Insufficient permissions")
|
raise authz.AuthException(
|
||||||
|
status_code=403, detail="Insufficient permissions", mode="forbidden"
|
||||||
|
)
|
||||||
await db.instance.delete_credential(credential_uuid, user_uuid)
|
await db.instance.delete_credential(credential_uuid, user_uuid)
|
||||||
return {"status": "ok"}
|
return {"status": "ok"}
|
||||||
|
|
||||||
|
|
||||||
|
@app.delete("/orgs/{org_uuid}/users/{user_uuid}/sessions/{session_id}")
|
||||||
|
async def admin_delete_user_session(
|
||||||
|
org_uuid: UUID,
|
||||||
|
user_uuid: UUID,
|
||||||
|
session_id: str,
|
||||||
|
request: Request,
|
||||||
|
auth=AUTH_COOKIE,
|
||||||
|
):
|
||||||
|
try:
|
||||||
|
user_org, _role_name = await db.instance.get_user_organization(user_uuid)
|
||||||
|
except ValueError:
|
||||||
|
raise HTTPException(status_code=404, detail="User not found")
|
||||||
|
if user_org.uuid != org_uuid:
|
||||||
|
raise HTTPException(status_code=404, detail="User not found in organization")
|
||||||
|
ctx = await authz.verify(
|
||||||
|
auth,
|
||||||
|
["auth:admin", f"auth:org:{org_uuid}"],
|
||||||
|
match=permutil.has_any,
|
||||||
|
host=request.headers.get("host"),
|
||||||
|
)
|
||||||
|
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
|
||||||
|
):
|
||||||
|
raise authz.AuthException(
|
||||||
|
status_code=403, detail="Insufficient permissions", mode="forbidden"
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
target_key = tokens.decode_session_key(session_id)
|
||||||
|
except ValueError as exc:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=400, detail="Invalid session identifier"
|
||||||
|
) from exc
|
||||||
|
|
||||||
|
target_session = await db.instance.get_session(target_key)
|
||||||
|
if not target_session or target_session.user_uuid != user_uuid:
|
||||||
|
raise HTTPException(status_code=404, detail="Session not found")
|
||||||
|
|
||||||
|
await db.instance.delete_session(target_key)
|
||||||
|
|
||||||
|
# Check if admin terminated their own session
|
||||||
|
current_terminated = target_key == session_key(auth)
|
||||||
|
return {"status": "ok", "current_session_terminated": current_terminated}
|
||||||
|
|
||||||
|
|
||||||
# -------------------- Permissions (global) --------------------
|
# -------------------- Permissions (global) --------------------
|
||||||
|
|
||||||
|
|
||||||
@app.get("/permissions")
|
@app.get("/permissions")
|
||||||
async def admin_list_permissions(auth=Cookie(None)):
|
async def admin_list_permissions(request: Request, auth=AUTH_COOKIE):
|
||||||
ctx = await authz.verify(auth, ["auth:admin", "auth:org:*"], match=permutil.has_any)
|
ctx = await authz.verify(
|
||||||
|
auth,
|
||||||
|
["auth:admin", "auth:org:*"],
|
||||||
|
match=permutil.has_any,
|
||||||
|
host=request.headers.get("host"),
|
||||||
|
)
|
||||||
perms = await db.instance.list_permissions()
|
perms = await db.instance.list_permissions()
|
||||||
|
|
||||||
# Global admins see all permissions
|
# Global admins see all permissions
|
||||||
@@ -485,8 +751,18 @@ async def admin_list_permissions(auth=Cookie(None)):
|
|||||||
|
|
||||||
|
|
||||||
@app.post("/permissions")
|
@app.post("/permissions")
|
||||||
async def admin_create_permission(payload: dict = Body(...), auth=Cookie(None)):
|
async def admin_create_permission(
|
||||||
await authz.verify(auth, ["auth:admin"])
|
request: Request,
|
||||||
|
payload: dict = Body(...),
|
||||||
|
auth=AUTH_COOKIE,
|
||||||
|
):
|
||||||
|
await authz.verify(
|
||||||
|
auth,
|
||||||
|
["auth:admin"],
|
||||||
|
host=request.headers.get("host"),
|
||||||
|
match=permutil.has_all,
|
||||||
|
max_age="5m",
|
||||||
|
)
|
||||||
from ..db import Permission as PermDC
|
from ..db import Permission as PermDC
|
||||||
|
|
||||||
perm_id = payload.get("id")
|
perm_id = payload.get("id")
|
||||||
@@ -500,9 +776,14 @@ async def admin_create_permission(payload: dict = Body(...), auth=Cookie(None)):
|
|||||||
|
|
||||||
@app.put("/permission")
|
@app.put("/permission")
|
||||||
async def admin_update_permission(
|
async def admin_update_permission(
|
||||||
permission_id: str, display_name: str, auth=Cookie(None)
|
permission_id: str,
|
||||||
|
display_name: str,
|
||||||
|
request: Request,
|
||||||
|
auth=AUTH_COOKIE,
|
||||||
):
|
):
|
||||||
await authz.verify(auth, ["auth:admin"])
|
await authz.verify(
|
||||||
|
auth, ["auth:admin"], host=request.headers.get("host"), match=permutil.has_all
|
||||||
|
)
|
||||||
from ..db import Permission as PermDC
|
from ..db import Permission as PermDC
|
||||||
|
|
||||||
if not display_name:
|
if not display_name:
|
||||||
@@ -515,8 +796,14 @@ async def admin_update_permission(
|
|||||||
|
|
||||||
|
|
||||||
@app.post("/permission/rename")
|
@app.post("/permission/rename")
|
||||||
async def admin_rename_permission(payload: dict = Body(...), auth=Cookie(None)):
|
async def admin_rename_permission(
|
||||||
await authz.verify(auth, ["auth:admin"])
|
request: Request,
|
||||||
|
payload: dict = Body(...),
|
||||||
|
auth=AUTH_COOKIE,
|
||||||
|
):
|
||||||
|
await authz.verify(
|
||||||
|
auth, ["auth:admin"], host=request.headers.get("host"), match=permutil.has_all
|
||||||
|
)
|
||||||
old_id = payload.get("old_id")
|
old_id = payload.get("old_id")
|
||||||
new_id = payload.get("new_id")
|
new_id = payload.get("new_id")
|
||||||
display_name = payload.get("display_name")
|
display_name = payload.get("display_name")
|
||||||
@@ -533,15 +820,25 @@ async def admin_rename_permission(payload: dict = Body(...), auth=Cookie(None)):
|
|||||||
perm = await db.instance.get_permission(old_id)
|
perm = await db.instance.get_permission(old_id)
|
||||||
display_name = perm.display_name
|
display_name = perm.display_name
|
||||||
rename_fn = getattr(db.instance, "rename_permission", None)
|
rename_fn = getattr(db.instance, "rename_permission", None)
|
||||||
if not rename_fn:
|
if not rename_fn: # pragma: no cover - all current backends support rename
|
||||||
raise ValueError("Permission renaming not supported by this backend")
|
raise ValueError("Permission renaming not supported by this backend")
|
||||||
await rename_fn(old_id, new_id, display_name)
|
await rename_fn(old_id, new_id, display_name)
|
||||||
return {"status": "ok"}
|
return {"status": "ok"}
|
||||||
|
|
||||||
|
|
||||||
@app.delete("/permission")
|
@app.delete("/permission")
|
||||||
async def admin_delete_permission(permission_id: str, auth=Cookie(None)):
|
async def admin_delete_permission(
|
||||||
await authz.verify(auth, ["auth:admin"])
|
permission_id: str,
|
||||||
|
request: Request,
|
||||||
|
auth=AUTH_COOKIE,
|
||||||
|
):
|
||||||
|
await authz.verify(
|
||||||
|
auth,
|
||||||
|
["auth:admin"],
|
||||||
|
host=request.headers.get("host"),
|
||||||
|
match=permutil.has_all,
|
||||||
|
max_age="5m",
|
||||||
|
)
|
||||||
querysafe.assert_safe(permission_id, field="permission_id")
|
querysafe.assert_safe(permission_id, field="permission_id")
|
||||||
|
|
||||||
# Sanity check: prevent deleting critical permissions
|
# Sanity check: prevent deleting critical permissions
|
||||||
@@ -0,0 +1,308 @@
|
|||||||
|
import logging
|
||||||
|
from contextlib import suppress
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
|
||||||
|
from fastapi import (
|
||||||
|
Depends,
|
||||||
|
FastAPI,
|
||||||
|
HTTPException,
|
||||||
|
Query,
|
||||||
|
Request,
|
||||||
|
Response,
|
||||||
|
)
|
||||||
|
from fastapi.responses import JSONResponse
|
||||||
|
from fastapi.security import HTTPBearer
|
||||||
|
|
||||||
|
from paskia.authsession import (
|
||||||
|
EXPIRES,
|
||||||
|
get_reset,
|
||||||
|
get_session,
|
||||||
|
refresh_session_token,
|
||||||
|
session_expiry,
|
||||||
|
)
|
||||||
|
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)
|
||||||
|
|
||||||
|
app = FastAPI()
|
||||||
|
|
||||||
|
app.mount("/user", user.app)
|
||||||
|
|
||||||
|
|
||||||
|
@app.exception_handler(HTTPException)
|
||||||
|
async def http_exception_handler(_request: Request, exc: HTTPException):
|
||||||
|
"""Ensure auth cookie is cleared on 401 responses (JSON responses only)."""
|
||||||
|
if exc.status_code == 401:
|
||||||
|
resp = JSONResponse(status_code=exc.status_code, content={"detail": exc.detail})
|
||||||
|
session.clear_session_cookie(resp)
|
||||||
|
return resp
|
||||||
|
return JSONResponse(status_code=exc.status_code, content={"detail": exc.detail})
|
||||||
|
|
||||||
|
|
||||||
|
# Refresh only if at least this much of the session lifetime has been *consumed*.
|
||||||
|
# Consumption is derived from (now + EXPIRES) - current_expires.
|
||||||
|
# This guarantees a minimum spacing between DB writes even with frequent /validate calls.
|
||||||
|
_REFRESH_INTERVAL = timedelta(minutes=5)
|
||||||
|
|
||||||
|
|
||||||
|
@app.exception_handler(ValueError)
|
||||||
|
async def value_error_handler(_request: Request, exc: ValueError):
|
||||||
|
return JSONResponse(status_code=400, content={"detail": str(exc)})
|
||||||
|
|
||||||
|
|
||||||
|
@app.exception_handler(authz.AuthException)
|
||||||
|
async def auth_exception_handler(_request: Request, exc: authz.AuthException):
|
||||||
|
"""Handle AuthException with auth info for UI."""
|
||||||
|
return JSONResponse(
|
||||||
|
status_code=exc.status_code,
|
||||||
|
content=await authz.auth_error_content(exc),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@app.exception_handler(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"})
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/validate")
|
||||||
|
async def validate_token(
|
||||||
|
request: Request,
|
||||||
|
response: Response,
|
||||||
|
perm: list[str] = Query([]),
|
||||||
|
auth=AUTH_COOKIE,
|
||||||
|
):
|
||||||
|
"""Validate the current session and extend its expiry.
|
||||||
|
|
||||||
|
Always refreshes the session (sliding expiration) and re-sets the cookie with a
|
||||||
|
renewed max-age. This keeps active users logged in without needing a separate
|
||||||
|
refresh endpoint.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
ctx = await authz.verify(auth, perm, host=request.headers.get("host"))
|
||||||
|
except HTTPException:
|
||||||
|
# Global handler will clear cookie if 401
|
||||||
|
raise
|
||||||
|
renewed = False
|
||||||
|
if auth:
|
||||||
|
current_expiry = session_expiry(ctx.session)
|
||||||
|
consumed = EXPIRES - (current_expiry - datetime.now(timezone.utc))
|
||||||
|
if not timedelta(0) < consumed < _REFRESH_INTERVAL:
|
||||||
|
try:
|
||||||
|
await refresh_session_token(
|
||||||
|
auth,
|
||||||
|
ip=request.client.host if request.client else "",
|
||||||
|
user_agent=request.headers.get("user-agent") or "",
|
||||||
|
)
|
||||||
|
session.set_session_cookie(response, auth)
|
||||||
|
renewed = True
|
||||||
|
except ValueError:
|
||||||
|
# Session disappeared, e.g. due to concurrent logout; global handler will clear
|
||||||
|
raise authz.AuthException(
|
||||||
|
status_code=401, detail="Session expired", mode="login"
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
"valid": True,
|
||||||
|
"user_uuid": str(ctx.session.user_uuid),
|
||||||
|
"renewed": renewed,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/forward")
|
||||||
|
async def forward_authentication(
|
||||||
|
request: Request,
|
||||||
|
response: Response,
|
||||||
|
perm: list[str] = Query([]),
|
||||||
|
max_age: str | None = Query(None),
|
||||||
|
auth=AUTH_COOKIE,
|
||||||
|
):
|
||||||
|
"""Forward auth validation for Caddy/Nginx.
|
||||||
|
|
||||||
|
Query Params:
|
||||||
|
- perm: repeated permission IDs the authenticated user must possess (ALL required).
|
||||||
|
- max_age: maximum age of authentication (e.g., "5m", "1h", "30s"). If the session
|
||||||
|
is older than this, user must re-authenticate.
|
||||||
|
|
||||||
|
Success: 204 No Content with Remote-* headers describing the authenticated user.
|
||||||
|
Failure (unauthenticated / unauthorized): 4xx response.
|
||||||
|
- If Accept header contains "text/html": HTML page for authentication
|
||||||
|
with data attributes for mode and other metadata.
|
||||||
|
- Otherwise: JSON response with error details and an `iframe` field
|
||||||
|
pointing to /auth/restricted/?mode=... for iframe-based authentication.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
ctx = await authz.verify(
|
||||||
|
auth, perm, host=request.headers.get("host"), max_age=max_age
|
||||||
|
)
|
||||||
|
role_permissions = set(ctx.role.permissions or [])
|
||||||
|
if ctx.permissions:
|
||||||
|
role_permissions.update(permission.id for permission in ctx.permissions)
|
||||||
|
|
||||||
|
remote_headers: dict[str, str] = {
|
||||||
|
"Remote-User": str(ctx.user.uuid),
|
||||||
|
"Remote-Name": ctx.user.display_name,
|
||||||
|
"Remote-Groups": ",".join(sorted(role_permissions)),
|
||||||
|
"Remote-Org": str(ctx.org.uuid),
|
||||||
|
"Remote-Org-Name": ctx.org.display_name,
|
||||||
|
"Remote-Role": str(ctx.role.uuid),
|
||||||
|
"Remote-Role-Name": ctx.role.display_name,
|
||||||
|
"Remote-Session-Expires": (
|
||||||
|
session_expiry(ctx.session)
|
||||||
|
.astimezone(timezone.utc)
|
||||||
|
.isoformat()
|
||||||
|
.replace("+00:00", "Z")
|
||||||
|
if session_expiry(ctx.session).tzinfo
|
||||||
|
else session_expiry(ctx.session)
|
||||||
|
.replace(tzinfo=timezone.utc)
|
||||||
|
.isoformat()
|
||||||
|
.replace("+00:00", "Z")
|
||||||
|
),
|
||||||
|
"Remote-Credential": str(ctx.session.credential_uuid),
|
||||||
|
}
|
||||||
|
return Response(status_code=204, headers=remote_headers)
|
||||||
|
except authz.AuthException as e:
|
||||||
|
# Clear cookie only if session is invalid (not for reauth)
|
||||||
|
if e.clear_session:
|
||||||
|
session.clear_session_cookie(response)
|
||||||
|
|
||||||
|
# Check Accept header to decide response format
|
||||||
|
accept = request.headers.get("accept", "")
|
||||||
|
wants_html = "text/html" in accept
|
||||||
|
|
||||||
|
if wants_html:
|
||||||
|
# Browser request - return full-page HTML with metadata
|
||||||
|
data_attrs = {"mode": e.mode, **e.metadata}
|
||||||
|
html = (await frontend.read("/int/forward/index.html"))[0]
|
||||||
|
html = htmlutil.patch_html_data_attrs(html, **data_attrs)
|
||||||
|
return Response(
|
||||||
|
html, status_code=e.status_code, media_type="text/html; charset=UTF-8"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
# API request - return JSON with iframe srcdoc HTML
|
||||||
|
return JSONResponse(
|
||||||
|
status_code=e.status_code,
|
||||||
|
content=await authz.auth_error_content(e),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/settings")
|
||||||
|
async def get_settings():
|
||||||
|
pk = global_passkey.instance
|
||||||
|
base_path = hostutil.ui_base_path()
|
||||||
|
return {
|
||||||
|
"rp_id": pk.rp_id,
|
||||||
|
"rp_name": pk.rp_name,
|
||||||
|
"ui_base_path": base_path,
|
||||||
|
"auth_host": hostutil.dedicated_auth_host(),
|
||||||
|
"auth_site_url": hostutil.auth_site_url(),
|
||||||
|
"session_cookie": AUTH_COOKIE_NAME,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/token-info")
|
||||||
|
async def api_token_info(token: str):
|
||||||
|
"""Get information about a reset token.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
- type: "reset"
|
||||||
|
- user_name: display name of the user
|
||||||
|
- token_type: type of reset token
|
||||||
|
"""
|
||||||
|
if not passphrase.is_well_formed(token):
|
||||||
|
raise HTTPException(status_code=404, detail="Invalid token")
|
||||||
|
|
||||||
|
# Check if this is a reset token
|
||||||
|
try:
|
||||||
|
reset_token = await get_reset(token)
|
||||||
|
user = await db.instance.get_user_by_uuid(reset_token.user_uuid)
|
||||||
|
return {
|
||||||
|
"type": "reset",
|
||||||
|
"user_name": user.display_name,
|
||||||
|
"token_type": reset_token.token_type,
|
||||||
|
}
|
||||||
|
except (ValueError, Exception):
|
||||||
|
raise HTTPException(status_code=404, detail="Token not found or expired")
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/user-info")
|
||||||
|
async def api_user_info(
|
||||||
|
request: Request,
|
||||||
|
response: Response,
|
||||||
|
reset: str | None = None,
|
||||||
|
auth=AUTH_COOKIE,
|
||||||
|
):
|
||||||
|
"""Get user information including credentials, sessions, and permissions.
|
||||||
|
|
||||||
|
Can be called with either:
|
||||||
|
- A session cookie (auth) for authenticated users
|
||||||
|
- A reset token for users in password reset flow
|
||||||
|
"""
|
||||||
|
authenticated = False
|
||||||
|
session_record = None
|
||||||
|
reset_token = None
|
||||||
|
try:
|
||||||
|
if reset:
|
||||||
|
if not passphrase.is_well_formed(reset):
|
||||||
|
raise ValueError("Invalid reset token")
|
||||||
|
reset_token = await get_reset(reset)
|
||||||
|
target_user_uuid = reset_token.user_uuid
|
||||||
|
else:
|
||||||
|
if auth is None:
|
||||||
|
raise authz.AuthException(
|
||||||
|
status_code=401,
|
||||||
|
detail="Authentication required",
|
||||||
|
mode="login",
|
||||||
|
)
|
||||||
|
session_record = await get_session(auth, host=request.headers.get("host"))
|
||||||
|
authenticated = True
|
||||||
|
target_user_uuid = session_record.user_uuid
|
||||||
|
except ValueError as e:
|
||||||
|
raise HTTPException(401, str(e))
|
||||||
|
|
||||||
|
# Return minimal response for reset tokens
|
||||||
|
if not authenticated and reset_token:
|
||||||
|
return await userinfo.format_reset_user_info(target_user_uuid, reset_token)
|
||||||
|
|
||||||
|
# Return full user info for authenticated users
|
||||||
|
assert auth is not None
|
||||||
|
assert session_record is not None
|
||||||
|
|
||||||
|
return await userinfo.format_user_info(
|
||||||
|
user_uuid=target_user_uuid,
|
||||||
|
auth=auth,
|
||||||
|
session_record=session_record,
|
||||||
|
request_host=request.headers.get("host"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/logout")
|
||||||
|
async def api_logout(request: Request, response: Response, auth=AUTH_COOKIE):
|
||||||
|
if not auth:
|
||||||
|
return {"message": "Already logged out"}
|
||||||
|
try:
|
||||||
|
await get_session(auth, host=request.headers.get("host"))
|
||||||
|
except ValueError:
|
||||||
|
return {"message": "Already logged out"}
|
||||||
|
with suppress(Exception):
|
||||||
|
await db.instance.delete_session(session_key(auth))
|
||||||
|
session.clear_session_cookie(response)
|
||||||
|
return {"message": "Logged out successfully"}
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/set-session")
|
||||||
|
async def api_set_session(
|
||||||
|
request: Request, response: Response, auth=Depends(bearer_auth)
|
||||||
|
):
|
||||||
|
user = await get_session(auth.credentials, host=request.headers.get("host"))
|
||||||
|
session.set_session_cookie(response, auth.credentials)
|
||||||
|
return {
|
||||||
|
"message": "Session cookie set successfully",
|
||||||
|
"user_uuid": str(user.user_uuid),
|
||||||
|
}
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
"""Middleware for handling auth host redirects."""
|
||||||
|
|
||||||
|
from fastapi import Request, Response
|
||||||
|
from fastapi.responses import RedirectResponse
|
||||||
|
|
||||||
|
from paskia.util import hostutil, passphrase
|
||||||
|
|
||||||
|
|
||||||
|
def is_ui_path(path: str) -> bool:
|
||||||
|
"""Check if the path is a UI endpoint."""
|
||||||
|
ui_paths = {
|
||||||
|
"/",
|
||||||
|
"/admin",
|
||||||
|
"/admin/",
|
||||||
|
"/auth",
|
||||||
|
"/auth/",
|
||||||
|
"/auth/admin",
|
||||||
|
"/auth/admin/",
|
||||||
|
}
|
||||||
|
if path in ui_paths:
|
||||||
|
return True
|
||||||
|
# Treat reset token pages as UI (dynamic). Accept single-segment tokens.
|
||||||
|
if path.startswith("/auth/"):
|
||||||
|
token = path[6:]
|
||||||
|
if token and "/" not in token and passphrase.is_well_formed(token):
|
||||||
|
return True
|
||||||
|
else:
|
||||||
|
token = path[1:]
|
||||||
|
if token and "/" not in token and passphrase.is_well_formed(token):
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def is_restricted_path(path: str) -> bool:
|
||||||
|
"""Check if the path is restricted (API/admin endpoints)."""
|
||||||
|
return path.startswith(("/auth/api/admin/", "/auth/api/user/", "/auth/ws/"))
|
||||||
|
|
||||||
|
|
||||||
|
def should_redirect_to_auth_host(path: str) -> bool:
|
||||||
|
"""Determine if the request should be redirected to the auth host."""
|
||||||
|
if path in {"/", "/auth", "/auth/"}:
|
||||||
|
return False
|
||||||
|
return is_ui_path(path) or is_restricted_path(path)
|
||||||
|
|
||||||
|
|
||||||
|
def redirect_to_auth_host(request: Request, cfg: str, path: str) -> Response:
|
||||||
|
"""Create a redirect response to the auth host."""
|
||||||
|
if is_restricted_path(path):
|
||||||
|
return Response(status_code=404)
|
||||||
|
new_path = (
|
||||||
|
path[5:] or "/" if is_ui_path(path) and path.startswith("/auth") else path
|
||||||
|
)
|
||||||
|
return RedirectResponse(f"{request.url.scheme}://{cfg}{new_path}", 307)
|
||||||
|
|
||||||
|
|
||||||
|
def should_redirect_auth_path_to_root(path: str) -> bool:
|
||||||
|
"""Check if /auth/ UI path should be redirected to root on auth host."""
|
||||||
|
if not path.startswith("/auth/"):
|
||||||
|
return False
|
||||||
|
ui_paths = {"/auth", "/auth/", "/auth/admin", "/auth/admin/"}
|
||||||
|
if path in ui_paths:
|
||||||
|
return True
|
||||||
|
# Check for reset token
|
||||||
|
token = path[6:]
|
||||||
|
return bool(token and "/" not in token and passphrase.is_well_formed(token))
|
||||||
|
|
||||||
|
|
||||||
|
def redirect_to_root_on_auth_host(request: Request, cur: str, path: str) -> Response:
|
||||||
|
"""Create a redirect response to root path on the same host."""
|
||||||
|
new_path = path[5:] or "/"
|
||||||
|
return RedirectResponse(f"{request.url.scheme}://{cur}{new_path}", 307)
|
||||||
|
|
||||||
|
|
||||||
|
async def redirect_middleware(request: Request, call_next):
|
||||||
|
"""Middleware to handle auth host redirects."""
|
||||||
|
cfg = hostutil.dedicated_auth_host()
|
||||||
|
if not cfg:
|
||||||
|
return await call_next(request)
|
||||||
|
|
||||||
|
cur = hostutil.normalize_host(request.headers.get("host"))
|
||||||
|
if not cur:
|
||||||
|
return await call_next(request)
|
||||||
|
|
||||||
|
cfg_normalized = hostutil.normalize_host(cfg)
|
||||||
|
on_auth_host = cur == cfg_normalized
|
||||||
|
|
||||||
|
path = request.url.path or "/"
|
||||||
|
|
||||||
|
if not on_auth_host:
|
||||||
|
if not should_redirect_to_auth_host(path):
|
||||||
|
return await call_next(request)
|
||||||
|
return redirect_to_auth_host(request, cfg, path)
|
||||||
|
else:
|
||||||
|
# On auth host: force UI endpoints at root
|
||||||
|
if should_redirect_auth_path_to_root(path):
|
||||||
|
return redirect_to_root_on_auth_host(request, cur, path)
|
||||||
|
return await call_next(request)
|
||||||
@@ -0,0 +1,110 @@
|
|||||||
|
import logging
|
||||||
|
|
||||||
|
from fastapi import HTTPException
|
||||||
|
|
||||||
|
from paskia.util import permutil, sessionutil
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class AuthException(HTTPException):
|
||||||
|
"""Exception raised during authentication/authorization with metadata for the UI.
|
||||||
|
|
||||||
|
Attributes:
|
||||||
|
status_code: HTTP status code (401 for auth, 403 for authz)
|
||||||
|
detail: Error message
|
||||||
|
mode: UI mode ('login' or 'reauth')
|
||||||
|
clear_session: Whether to clear the session cookie (True for invalid sessions)
|
||||||
|
metadata: Additional data to pass to the frontend
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
status_code: int,
|
||||||
|
detail: str,
|
||||||
|
mode: str,
|
||||||
|
clear_session: bool = False,
|
||||||
|
**metadata,
|
||||||
|
):
|
||||||
|
super().__init__(status_code=status_code, detail=detail)
|
||||||
|
self.mode = mode
|
||||||
|
self.clear_session = clear_session
|
||||||
|
self.metadata = metadata
|
||||||
|
|
||||||
|
|
||||||
|
async def auth_error_content(exc: AuthException) -> dict:
|
||||||
|
"""Generate JSON response content for an AuthException.
|
||||||
|
|
||||||
|
Returns a dict with detail, mode, and iframe URL for src embedding.
|
||||||
|
"""
|
||||||
|
# Build hash fragment from mode and metadata
|
||||||
|
params = {"mode": exc.mode, **exc.metadata}
|
||||||
|
fragment = "&".join(f"{k}={v}" for k, v in params.items() if v is not None)
|
||||||
|
iframe_url = f"/auth/restricted/#{fragment}"
|
||||||
|
return {
|
||||||
|
"detail": exc.detail,
|
||||||
|
"auth": {
|
||||||
|
"mode": exc.mode,
|
||||||
|
"iframe": iframe_url,
|
||||||
|
**exc.metadata,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async def verify(
|
||||||
|
auth: str | None,
|
||||||
|
perm: list[str],
|
||||||
|
match=permutil.has_all,
|
||||||
|
host: str | None = None,
|
||||||
|
max_age: str | None = None,
|
||||||
|
):
|
||||||
|
"""Validate session token and optional list of required permissions.
|
||||||
|
|
||||||
|
Returns the session context.
|
||||||
|
|
||||||
|
Raises AuthException on failure with metadata for UI rendering.
|
||||||
|
"""
|
||||||
|
if not auth:
|
||||||
|
raise AuthException(
|
||||||
|
status_code=401,
|
||||||
|
detail="Authentication required",
|
||||||
|
mode="login",
|
||||||
|
)
|
||||||
|
|
||||||
|
ctx = await permutil.session_context(auth, host)
|
||||||
|
if not ctx:
|
||||||
|
raise AuthException(
|
||||||
|
status_code=401,
|
||||||
|
detail="Your session has expired. Please sign in again.",
|
||||||
|
mode="login",
|
||||||
|
clear_session=True,
|
||||||
|
)
|
||||||
|
# Check max_age requirement if specified
|
||||||
|
if max_age:
|
||||||
|
try:
|
||||||
|
if not sessionutil.check_session_age(ctx, max_age):
|
||||||
|
raise AuthException(
|
||||||
|
status_code=401,
|
||||||
|
detail="Additional authentication required",
|
||||||
|
mode="reauth",
|
||||||
|
)
|
||||||
|
except ValueError as e:
|
||||||
|
# Invalid max_age format - log but don't fail the request
|
||||||
|
logger.warning(f"Invalid max_age format '{max_age}': {e}")
|
||||||
|
|
||||||
|
if not match(ctx, perm):
|
||||||
|
# Determine which permissions are missing for clearer diagnostics
|
||||||
|
missing = sorted(set(perm) - set(ctx.role.permissions))
|
||||||
|
logger.warning(
|
||||||
|
"Permission denied: user=%s role=%s missing=%s required=%s granted=%s", # noqa: E501
|
||||||
|
getattr(ctx.user, "uuid", "?"),
|
||||||
|
getattr(ctx.role, "display_name", "?"),
|
||||||
|
missing,
|
||||||
|
perm,
|
||||||
|
ctx.role.permissions,
|
||||||
|
)
|
||||||
|
raise AuthException(
|
||||||
|
status_code=403, mode="forbidden", detail="Permission required"
|
||||||
|
)
|
||||||
|
|
||||||
|
return ctx
|
||||||
@@ -0,0 +1,130 @@
|
|||||||
|
import logging
|
||||||
|
import os
|
||||||
|
from contextlib import asynccontextmanager
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from fastapi import FastAPI, HTTPException, Request, Response
|
||||||
|
from fastapi.responses import FileResponse, RedirectResponse
|
||||||
|
from fastapi.staticfiles import StaticFiles
|
||||||
|
|
||||||
|
from paskia.fastapi import admin, api, auth_host, ws
|
||||||
|
from paskia.fastapi.session import AUTH_COOKIE
|
||||||
|
from paskia.util import frontend, hostutil, passphrase
|
||||||
|
|
||||||
|
# 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.
|
||||||
|
|
||||||
|
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.
|
||||||
|
"""
|
||||||
|
import json
|
||||||
|
|
||||||
|
from paskia import globals
|
||||||
|
|
||||||
|
config = json.loads(os.environ["PASKIA_CONFIG"])
|
||||||
|
|
||||||
|
try:
|
||||||
|
# CLI (__main__) performs bootstrap once; here we skip to avoid duplicate work
|
||||||
|
await globals.init(
|
||||||
|
rp_id=config["rp_id"],
|
||||||
|
rp_name=config["rp_name"],
|
||||||
|
origins=config["origins"],
|
||||||
|
bootstrap=False,
|
||||||
|
)
|
||||||
|
except ValueError as e:
|
||||||
|
logging.error(f"⚠️ {e}")
|
||||||
|
# Re-raise to fail fast
|
||||||
|
raise
|
||||||
|
|
||||||
|
# Restore info level logging after startup (suppressed during uvicorn init in dev mode)
|
||||||
|
if frontend.is_dev_mode():
|
||||||
|
logging.getLogger("uvicorn").setLevel(logging.INFO)
|
||||||
|
logging.getLogger("uvicorn.access").setLevel(logging.INFO)
|
||||||
|
|
||||||
|
yield
|
||||||
|
|
||||||
|
|
||||||
|
app = FastAPI(lifespan=lifespan)
|
||||||
|
|
||||||
|
# Apply redirections to auth-host if configured (deny access to restricted endpoints, remove /auth/)
|
||||||
|
app.middleware("http")(auth_host.redirect_middleware)
|
||||||
|
|
||||||
|
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():
|
||||||
|
"""Serve the restricted/authentication UI for iframe embedding."""
|
||||||
|
return Response(*await frontend.read("/auth/restricted/index.html"))
|
||||||
|
|
||||||
|
|
||||||
|
# Navigable URLs are defined here. We support both / and /auth/ as the base path
|
||||||
|
# / is used on a dedicated auth site, /auth/ on app domains with auth
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/")
|
||||||
|
@app.get("/auth/")
|
||||||
|
async def frontapp(request: Request, response: Response, auth=AUTH_COOKIE):
|
||||||
|
"""Serve the user profile app.
|
||||||
|
|
||||||
|
The frontend handles mode detection (host mode vs full profile) based on settings.
|
||||||
|
Access control is handled via APIs.
|
||||||
|
"""
|
||||||
|
return Response(*await frontend.read("/auth/index.html"))
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/admin", include_in_schema=False)
|
||||||
|
@app.get("/auth/admin", include_in_schema=False)
|
||||||
|
async def admin_root_redirect():
|
||||||
|
return RedirectResponse(f"{hostutil.ui_base_path()}admin/", status_code=307)
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/admin/", include_in_schema=False)
|
||||||
|
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("/{token}")
|
||||||
|
@app.get("/auth/{token}")
|
||||||
|
async def token_link(token: str):
|
||||||
|
"""Serve the reset app for reset tokens (password reset / device addition).
|
||||||
|
|
||||||
|
The frontend will validate the token via /auth/api/token-info.
|
||||||
|
"""
|
||||||
|
if not passphrase.is_well_formed(token):
|
||||||
|
raise HTTPException(status_code=404)
|
||||||
|
|
||||||
|
return Response(*await frontend.read("/int/reset/index.html"))
|
||||||
@@ -0,0 +1,504 @@
|
|||||||
|
"""
|
||||||
|
Remote authentication WebSocket endpoints.
|
||||||
|
|
||||||
|
This module handles cross-device authentication where one device (requesting)
|
||||||
|
wants to log in and another device (authenticating) provides the passkey.
|
||||||
|
|
||||||
|
Endpoints:
|
||||||
|
- /request: Called by the device wanting to be authenticated
|
||||||
|
- /pair: Called by the authenticating device to complete the request
|
||||||
|
"""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
|
import base64url
|
||||||
|
from fastapi import FastAPI, WebSocket, WebSocketDisconnect
|
||||||
|
|
||||||
|
from paskia import remoteauth
|
||||||
|
from paskia.authsession import create_session
|
||||||
|
from paskia.fastapi.session import infodict
|
||||||
|
from paskia.fastapi.wsutil import validate_origin, websocket_error_handler
|
||||||
|
from paskia.globals import db, passkey
|
||||||
|
from paskia.util import passphrase, pow
|
||||||
|
|
||||||
|
# Create a FastAPI subapp for remote auth WebSocket endpoints
|
||||||
|
app = FastAPI()
|
||||||
|
|
||||||
|
|
||||||
|
@app.websocket("/request")
|
||||||
|
@websocket_error_handler
|
||||||
|
async def websocket_remote_auth_request(ws: WebSocket):
|
||||||
|
"""Request authentication from another device.
|
||||||
|
|
||||||
|
This endpoint is called by the device that wants to be authenticated.
|
||||||
|
It creates a remote auth request and waits for another device to authenticate.
|
||||||
|
|
||||||
|
Flow:
|
||||||
|
1. Client connects
|
||||||
|
2. Server sends HARD PoW challenge, client solves and responds
|
||||||
|
3. Server creates a 3-word pairing code and sends it with expiry
|
||||||
|
4. Server waits for another device to authenticate via /remote-auth/pair
|
||||||
|
5. When auth completes, server sends session_token to this client
|
||||||
|
6. Client can then use the session token to set a cookie
|
||||||
|
7. Connection times out after 5 minutes with explicit timeout message
|
||||||
|
"""
|
||||||
|
origin = validate_origin(ws)
|
||||||
|
host = origin.split("://", 1)[1]
|
||||||
|
|
||||||
|
if remoteauth.instance is None:
|
||||||
|
raise ValueError("Remote authentication is not available")
|
||||||
|
|
||||||
|
# Track this WebSocket connection for load-based PoW difficulty
|
||||||
|
remoteauth.instance.increment_connections()
|
||||||
|
try:
|
||||||
|
# Send PoW challenge immediately with dynamic difficulty based on load
|
||||||
|
challenge = pow.generate_challenge()
|
||||||
|
work = remoteauth.instance.get_pow_difficulty()
|
||||||
|
|
||||||
|
await ws.send_json(
|
||||||
|
{
|
||||||
|
"pow": {
|
||||||
|
"challenge": base64url.enc(challenge),
|
||||||
|
"work": work,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
# Receive client response with PoW solution and action
|
||||||
|
response = await ws.receive_json()
|
||||||
|
|
||||||
|
# Verify PoW (required for this endpoint - SECURITY)
|
||||||
|
solution_b64 = response.get("pow")
|
||||||
|
if not solution_b64:
|
||||||
|
raise ValueError("PoW solution required")
|
||||||
|
|
||||||
|
try:
|
||||||
|
solution = base64url.dec(solution_b64)
|
||||||
|
except Exception:
|
||||||
|
raise ValueError("Invalid PoW solution encoding")
|
||||||
|
|
||||||
|
pow.verify_pow(challenge, solution, work)
|
||||||
|
|
||||||
|
# Extract action from the same message
|
||||||
|
action = response.get("action", "login")
|
||||||
|
if action not in ("login", "register"):
|
||||||
|
action = "login"
|
||||||
|
|
||||||
|
metadata = infodict(ws, "remote-auth-request")
|
||||||
|
|
||||||
|
# Create the remote auth request
|
||||||
|
pairing_code, expiry = await remoteauth.instance.create_request(
|
||||||
|
host=host,
|
||||||
|
ip=metadata.get("ip") or "",
|
||||||
|
user_agent=metadata.get("user_agent") or "",
|
||||||
|
action=action,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Send the pairing code to the client
|
||||||
|
await ws.send_json(
|
||||||
|
{
|
||||||
|
"pairing_code": pairing_code,
|
||||||
|
"expires": expiry.isoformat().replace("+00:00", "Z"),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
# Set up async notification for completion
|
||||||
|
result_event = asyncio.Event()
|
||||||
|
result_data: dict = {}
|
||||||
|
|
||||||
|
def on_complete(
|
||||||
|
session_token: str | None,
|
||||||
|
user_uuid: UUID | None,
|
||||||
|
credential_uuid: UUID | None,
|
||||||
|
reset_token: str | None,
|
||||||
|
):
|
||||||
|
# Check if this was an explicit denial (UUID(int=0) is the signal)
|
||||||
|
was_denied = user_uuid is not None and user_uuid == UUID(int=0)
|
||||||
|
result_data["session_token"] = session_token
|
||||||
|
result_data["user_uuid"] = user_uuid
|
||||||
|
result_data["credential_uuid"] = credential_uuid
|
||||||
|
result_data["reset_token"] = reset_token
|
||||||
|
result_data["was_denied"] = was_denied
|
||||||
|
result_event.set()
|
||||||
|
|
||||||
|
await remoteauth.instance.set_notify_callback(pairing_code, on_complete)
|
||||||
|
|
||||||
|
# Set up async notification for action lock
|
||||||
|
locked_event = asyncio.Event()
|
||||||
|
locked_data: dict = {}
|
||||||
|
|
||||||
|
def on_action_locked(action: str):
|
||||||
|
locked_data["action"] = action
|
||||||
|
locked_event.set()
|
||||||
|
|
||||||
|
await remoteauth.instance.set_action_locked_callback(
|
||||||
|
pairing_code, on_action_locked
|
||||||
|
)
|
||||||
|
|
||||||
|
# 5 minute timeout for the entire remote auth flow
|
||||||
|
timeout_seconds = 5 * 60
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Wait for either:
|
||||||
|
# 1. Authentication to complete (result_event set)
|
||||||
|
# 2. Action locked (locked_event set)
|
||||||
|
# 3. Client to disconnect
|
||||||
|
# 4. Client to send a cancel or update_action message
|
||||||
|
# 5. Timeout after 5 minutes
|
||||||
|
|
||||||
|
async with asyncio.timeout(timeout_seconds):
|
||||||
|
while True:
|
||||||
|
# Use asyncio.wait to handle events and websocket
|
||||||
|
receive_task = asyncio.create_task(ws.receive_json())
|
||||||
|
result_wait_task = asyncio.create_task(result_event.wait())
|
||||||
|
locked_wait_task = asyncio.create_task(locked_event.wait())
|
||||||
|
|
||||||
|
tasks = [receive_task, result_wait_task]
|
||||||
|
# Only wait for locked event if not already locked
|
||||||
|
if not locked_event.is_set():
|
||||||
|
tasks.append(locked_wait_task)
|
||||||
|
|
||||||
|
done, pending = await asyncio.wait(
|
||||||
|
tasks,
|
||||||
|
return_when=asyncio.FIRST_COMPLETED,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Cancel pending tasks
|
||||||
|
for task in pending:
|
||||||
|
task.cancel()
|
||||||
|
try:
|
||||||
|
await task
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
if result_wait_task in done:
|
||||||
|
# Authentication completed (or expired/cancelled/denied)
|
||||||
|
was_denied = result_data.get("was_denied", False)
|
||||||
|
if result_data.get("session_token") or result_data.get(
|
||||||
|
"reset_token"
|
||||||
|
):
|
||||||
|
response = {
|
||||||
|
"status": "authenticated",
|
||||||
|
"user_uuid": str(result_data["user_uuid"]),
|
||||||
|
}
|
||||||
|
if result_data.get("session_token"):
|
||||||
|
response["session_token"] = result_data["session_token"]
|
||||||
|
if result_data.get("reset_token"):
|
||||||
|
response["reset_token"] = result_data["reset_token"]
|
||||||
|
await ws.send_json(response)
|
||||||
|
else:
|
||||||
|
# Check if it was explicitly denied
|
||||||
|
if was_denied:
|
||||||
|
await ws.send_json(
|
||||||
|
{
|
||||||
|
"status": "denied",
|
||||||
|
"detail": "Access denied",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
await ws.send_json(
|
||||||
|
{
|
||||||
|
"status": "expired",
|
||||||
|
"detail": "Remote authentication request expired or was cancelled",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
if locked_wait_task in done:
|
||||||
|
# Action was locked by the authenticating device
|
||||||
|
await ws.send_json(
|
||||||
|
{
|
||||||
|
"status": "locked",
|
||||||
|
"action": locked_data.get("action", "login"),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
# Continue waiting for result
|
||||||
|
|
||||||
|
if receive_task in done:
|
||||||
|
# Client sent a message
|
||||||
|
msg = receive_task.result()
|
||||||
|
if msg.get("action") == "cancel":
|
||||||
|
await remoteauth.instance.cancel_request(pairing_code)
|
||||||
|
await ws.send_json({"status": "cancelled"})
|
||||||
|
return
|
||||||
|
elif msg.get("action") == "update_action":
|
||||||
|
# Update the action (login/register) if not locked
|
||||||
|
new_action = "register" if msg.get("register") else "login"
|
||||||
|
await remoteauth.instance.update_action(
|
||||||
|
pairing_code, new_action
|
||||||
|
)
|
||||||
|
# Ignore other messages
|
||||||
|
|
||||||
|
except TimeoutError:
|
||||||
|
# 5 minute timeout reached
|
||||||
|
await remoteauth.instance.cancel_request(pairing_code)
|
||||||
|
await ws.send_json(
|
||||||
|
{
|
||||||
|
"status": "timeout",
|
||||||
|
"detail": "Remote authentication request timed out after 5 minutes",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
except WebSocketDisconnect:
|
||||||
|
# Client disconnected, cancel the request and mark as denied
|
||||||
|
await remoteauth.instance.cancel_request(pairing_code, denied=True)
|
||||||
|
except Exception:
|
||||||
|
await remoteauth.instance.cancel_request(pairing_code)
|
||||||
|
raise
|
||||||
|
finally:
|
||||||
|
# Decrement connection count
|
||||||
|
remoteauth.instance.decrement_connections()
|
||||||
|
|
||||||
|
|
||||||
|
@app.websocket("/pair")
|
||||||
|
@websocket_error_handler
|
||||||
|
async def websocket_remote_auth_pair(ws: WebSocket):
|
||||||
|
"""Complete a remote authentication request using a 3-word pairing code.
|
||||||
|
|
||||||
|
This endpoint is called from the user's profile on the authenticating device.
|
||||||
|
The user enters the pairing code displayed on the requesting device.
|
||||||
|
|
||||||
|
Protocol:
|
||||||
|
1. Server sends PoW challenge immediately on connect
|
||||||
|
2. Client sends {code: "word.word.word", pow: "<base64>"} for 3-word pairing code
|
||||||
|
3. Server validates PoW and code:
|
||||||
|
- If invalid code/PoW: {status: 4xx, detail: "...", pow: {challenge, work}}
|
||||||
|
- If valid: {status: "found", host: "...", user_agent_pretty: "...", pow: {challenge, work}}
|
||||||
|
4. Client can then send {authenticate: true} to start WebAuthn
|
||||||
|
5. Server sends {optionsJSON: ...}
|
||||||
|
6. Client sends WebAuthn response
|
||||||
|
7. Server sends {status: "success", message: "..."}
|
||||||
|
"""
|
||||||
|
from paskia.util import useragent
|
||||||
|
|
||||||
|
origin = validate_origin(ws)
|
||||||
|
|
||||||
|
if remoteauth.instance is None:
|
||||||
|
raise ValueError("Remote authentication is not available")
|
||||||
|
|
||||||
|
# Generate initial PoW challenge (always NORMAL for authenticated users)
|
||||||
|
challenge = pow.generate_challenge()
|
||||||
|
work = pow.NORMAL
|
||||||
|
|
||||||
|
await ws.send_json(
|
||||||
|
{
|
||||||
|
"pow": {
|
||||||
|
"challenge": base64url.enc(challenge),
|
||||||
|
"work": work,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
request = None
|
||||||
|
webauthn_challenge = None
|
||||||
|
explicitly_denied = False
|
||||||
|
|
||||||
|
try:
|
||||||
|
while True:
|
||||||
|
msg = await ws.receive_json()
|
||||||
|
|
||||||
|
# Handle deny request first (no PoW needed - already validated during lookup)
|
||||||
|
if msg.get("deny") and request is not None:
|
||||||
|
# Cancel the request and mark it as denied
|
||||||
|
explicitly_denied = True
|
||||||
|
await remoteauth.instance.cancel_request(request.key, denied=True)
|
||||||
|
await ws.send_json(
|
||||||
|
{
|
||||||
|
"status": "denied",
|
||||||
|
"message": "Request denied",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
break
|
||||||
|
|
||||||
|
# Handle authenticate request (no PoW needed - already validated during lookup)
|
||||||
|
if msg.get("authenticate") and request is not None:
|
||||||
|
# Generate authentication options
|
||||||
|
options, webauthn_challenge = passkey.instance.auth_generate_options(
|
||||||
|
credential_ids=None
|
||||||
|
)
|
||||||
|
await ws.send_json({"optionsJSON": options})
|
||||||
|
|
||||||
|
# Wait for WebAuthn response
|
||||||
|
credential = passkey.instance.auth_parse(await ws.receive_json())
|
||||||
|
|
||||||
|
# Fetch and verify credential
|
||||||
|
try:
|
||||||
|
stored_cred = await db.instance.get_credential_by_id(
|
||||||
|
credential.raw_id
|
||||||
|
)
|
||||||
|
except ValueError:
|
||||||
|
raise ValueError(
|
||||||
|
f"This passkey is no longer registered with {passkey.instance.rp_name}"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Verify the credential
|
||||||
|
passkey.instance.auth_verify(
|
||||||
|
credential, webauthn_challenge, stored_cred, origin
|
||||||
|
)
|
||||||
|
|
||||||
|
# Update credential last_used
|
||||||
|
await db.instance.login(stored_cred.user_uuid, stored_cred)
|
||||||
|
|
||||||
|
# Create a session for the REQUESTING device
|
||||||
|
assert stored_cred.uuid is not None
|
||||||
|
|
||||||
|
session_token = None
|
||||||
|
reset_token = None
|
||||||
|
|
||||||
|
if request.action == "register":
|
||||||
|
# For registration, create a reset token for device addition
|
||||||
|
from paskia.authsession import expires
|
||||||
|
from paskia.util import tokens
|
||||||
|
|
||||||
|
token_str = passphrase.generate()
|
||||||
|
expiry = expires()
|
||||||
|
await db.instance.create_reset_token(
|
||||||
|
user_uuid=stored_cred.user_uuid,
|
||||||
|
key=tokens.reset_key(token_str),
|
||||||
|
expiry=expiry,
|
||||||
|
token_type="device addition",
|
||||||
|
)
|
||||||
|
reset_token = token_str
|
||||||
|
# Also create a session so the device is logged in?
|
||||||
|
# User requested: "We can make the flow always create a new session, but make additional tokens for other possibilities."
|
||||||
|
session_token = await create_session(
|
||||||
|
user_uuid=stored_cred.user_uuid,
|
||||||
|
credential_uuid=stored_cred.uuid,
|
||||||
|
host=request.host,
|
||||||
|
ip=request.ip,
|
||||||
|
user_agent=request.user_agent,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
# Default login action
|
||||||
|
session_token = await create_session(
|
||||||
|
user_uuid=stored_cred.user_uuid,
|
||||||
|
credential_uuid=stored_cred.uuid,
|
||||||
|
host=request.host,
|
||||||
|
ip=request.ip,
|
||||||
|
user_agent=request.user_agent,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Complete the remote auth request (notifies the waiting device)
|
||||||
|
completed = await remoteauth.instance.complete_request(
|
||||||
|
token=request.key,
|
||||||
|
session_token=session_token,
|
||||||
|
user_uuid=stored_cred.user_uuid,
|
||||||
|
credential_uuid=stored_cred.uuid,
|
||||||
|
reset_token=reset_token,
|
||||||
|
)
|
||||||
|
|
||||||
|
if not completed:
|
||||||
|
raise ValueError("Failed to complete remote authentication")
|
||||||
|
|
||||||
|
msg = "Authentication successful."
|
||||||
|
if request.action == "register":
|
||||||
|
msg += " The other device can now register a passkey."
|
||||||
|
else:
|
||||||
|
msg += " The other device is now logged in."
|
||||||
|
|
||||||
|
await ws.send_json(
|
||||||
|
{
|
||||||
|
"status": "success",
|
||||||
|
"message": msg,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
break
|
||||||
|
|
||||||
|
# Handle code lookup request - requires PoW validation
|
||||||
|
code = msg.get("code", "")
|
||||||
|
|
||||||
|
# Validate PoW for pairing codes
|
||||||
|
solution_b64 = msg.get("pow")
|
||||||
|
if not solution_b64:
|
||||||
|
raise ValueError("PoW solution required")
|
||||||
|
|
||||||
|
try:
|
||||||
|
solution = base64url.dec(solution_b64)
|
||||||
|
except Exception:
|
||||||
|
raise ValueError("Invalid PoW solution encoding")
|
||||||
|
|
||||||
|
try:
|
||||||
|
pow.verify_pow(challenge, solution, work)
|
||||||
|
except ValueError as e:
|
||||||
|
# Invalid PoW - send new challenge
|
||||||
|
challenge = pow.generate_challenge()
|
||||||
|
await ws.send_json(
|
||||||
|
{
|
||||||
|
"status": 400,
|
||||||
|
"detail": str(e),
|
||||||
|
"pow": {
|
||||||
|
"challenge": base64url.enc(challenge),
|
||||||
|
"work": work,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
|
||||||
|
if not code:
|
||||||
|
raise ValueError("Pairing code required")
|
||||||
|
|
||||||
|
# Look up the remote auth request by pairing code
|
||||||
|
request = await remoteauth.instance.get_request(code)
|
||||||
|
|
||||||
|
# Generate new challenge for next request (always NORMAL for authenticated users)
|
||||||
|
challenge = pow.generate_challenge()
|
||||||
|
|
||||||
|
if request is None:
|
||||||
|
await ws.send_json(
|
||||||
|
{
|
||||||
|
"status": 404,
|
||||||
|
"detail": "Code not found",
|
||||||
|
"pow": {
|
||||||
|
"challenge": base64url.enc(challenge),
|
||||||
|
"work": work,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
request = None # Reset for next attempt
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Valid code found - lock the action so it can't be changed anymore
|
||||||
|
# This also notifies the requesting device
|
||||||
|
locked_action = await remoteauth.instance.lock_action(request.key)
|
||||||
|
if locked_action is None:
|
||||||
|
# Already locked by another device
|
||||||
|
await ws.send_json(
|
||||||
|
{
|
||||||
|
"status": 409,
|
||||||
|
"detail": "This request is already being processed in another window",
|
||||||
|
"pow": {
|
||||||
|
"challenge": base64url.enc(challenge),
|
||||||
|
"work": work,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
request = None # Reset for next attempt
|
||||||
|
continue
|
||||||
|
|
||||||
|
request.action = locked_action # Update local copy with locked value
|
||||||
|
|
||||||
|
# Send device info to the authenticating device
|
||||||
|
await ws.send_json(
|
||||||
|
{
|
||||||
|
"status": "found",
|
||||||
|
"host": request.host,
|
||||||
|
"user_agent_pretty": useragent.compact_user_agent(
|
||||||
|
request.user_agent
|
||||||
|
),
|
||||||
|
"client_ip": request.ip,
|
||||||
|
"action": request.action,
|
||||||
|
"pow": {
|
||||||
|
"challenge": base64url.enc(challenge),
|
||||||
|
"work": work,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
# If websocket disconnects without explicit denial, unlock the request
|
||||||
|
if request and not explicitly_denied:
|
||||||
|
# Unlock the request so the code can be used again
|
||||||
|
async with remoteauth.instance._lock:
|
||||||
|
req = remoteauth.instance._requests.get(request.key)
|
||||||
|
if req and req.locked:
|
||||||
|
req.locked = False
|
||||||
|
raise
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
"""CLI support for creating user credential reset links.
|
"""CLI support for creating user credential reset links.
|
||||||
|
|
||||||
Usage (via main CLI):
|
Usage (via main CLI):
|
||||||
passkey-auth reset [query]
|
paskia reset [query]
|
||||||
|
|
||||||
If query is omitted, the master admin (first Administration role user in
|
If query is omitted, the master admin (first Administration role user in
|
||||||
an organization granting auth:admin) is targeted. Otherwise query is
|
an organization granting auth:admin) is targeted. Otherwise query is
|
||||||
@@ -15,10 +15,10 @@ from __future__ import annotations
|
|||||||
import asyncio
|
import asyncio
|
||||||
from uuid import UUID
|
from uuid import UUID
|
||||||
|
|
||||||
from passkey import authsession as _authsession
|
from paskia import authsession as _authsession
|
||||||
from passkey import globals as _g
|
from paskia import globals as _g
|
||||||
from passkey.util import hostutil, passphrase
|
from paskia.util import hostutil, passphrase
|
||||||
from passkey.util import tokens as _tokens
|
from paskia.util import tokens as _tokens
|
||||||
|
|
||||||
|
|
||||||
async def _resolve_targets(query: str | None):
|
async def _resolve_targets(query: str | None):
|
||||||
@@ -63,11 +63,12 @@ async def _resolve_targets(query: str | None):
|
|||||||
|
|
||||||
async def _create_reset(user, role_name: str):
|
async def _create_reset(user, role_name: str):
|
||||||
token = passphrase.generate()
|
token = passphrase.generate()
|
||||||
await _g.db.instance.create_session(
|
expiry = _authsession.reset_expires()
|
||||||
|
await _g.db.instance.create_reset_token(
|
||||||
user_uuid=user.uuid,
|
user_uuid=user.uuid,
|
||||||
key=_tokens.reset_key(token),
|
key=_tokens.reset_key(token),
|
||||||
expires=_authsession.expires(),
|
expiry=expiry,
|
||||||
info={"type": "manual reset", "role": role_name},
|
token_type="manual reset",
|
||||||
)
|
)
|
||||||
return hostutil.reset_link_url(token), token
|
return hostutil.reset_link_url(token), token
|
||||||
|
|
||||||
@@ -8,26 +8,45 @@ This module provides FastAPI-specific session management functionality:
|
|||||||
Generic session management functions have been moved to authsession.py
|
Generic session management functions have been moved to authsession.py
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from fastapi import Request, Response, WebSocket
|
from fastapi import Cookie, Request, Response, WebSocket
|
||||||
|
|
||||||
from ..authsession import EXPIRES
|
from paskia.authsession import EXPIRES
|
||||||
|
|
||||||
|
AUTH_COOKIE_NAME = "__Host-paskia"
|
||||||
|
AUTH_COOKIE = Cookie(None, alias=AUTH_COOKIE_NAME)
|
||||||
|
|
||||||
|
|
||||||
def infodict(request: Request | WebSocket, type: str) -> dict:
|
def infodict(request: Request | WebSocket, type: str) -> dict:
|
||||||
"""Extract client information from request."""
|
"""Extract client information from request."""
|
||||||
return {
|
return {
|
||||||
"ip": request.client.host if request.client else "",
|
"ip": request.client.host if request.client else None,
|
||||||
"user_agent": request.headers.get("user-agent", "")[:500],
|
"user_agent": request.headers.get("user-agent", "")[:500] or None,
|
||||||
"type": type,
|
"session_type": type,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def set_session_cookie(response: Response, token: str) -> None:
|
def set_session_cookie(response: Response, token: str) -> None:
|
||||||
"""Set the session token as an HTTP-only cookie."""
|
"""Set the session token as an HTTP-only cookie."""
|
||||||
response.set_cookie(
|
response.set_cookie(
|
||||||
key="auth",
|
key=AUTH_COOKIE_NAME,
|
||||||
value=token,
|
value=token,
|
||||||
max_age=int(EXPIRES.total_seconds()),
|
max_age=int(EXPIRES.total_seconds()),
|
||||||
httponly=True,
|
httponly=True,
|
||||||
secure=True,
|
secure=True,
|
||||||
|
path="/",
|
||||||
|
samesite="lax",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def clear_session_cookie(response: Response) -> None:
|
||||||
|
# FastAPI's delete_cookie does not set the secure attribute
|
||||||
|
response.set_cookie(
|
||||||
|
key=AUTH_COOKIE_NAME,
|
||||||
|
value="",
|
||||||
|
max_age=0,
|
||||||
|
expires=0,
|
||||||
|
httponly=True,
|
||||||
|
secure=True,
|
||||||
|
path="/",
|
||||||
|
samesite="lax",
|
||||||
)
|
)
|
||||||
@@ -0,0 +1,162 @@
|
|||||||
|
from datetime import timezone
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
|
from fastapi import (
|
||||||
|
Body,
|
||||||
|
FastAPI,
|
||||||
|
HTTPException,
|
||||||
|
Request,
|
||||||
|
Response,
|
||||||
|
)
|
||||||
|
from fastapi.responses import JSONResponse
|
||||||
|
|
||||||
|
from paskia.authsession import (
|
||||||
|
delete_credential,
|
||||||
|
expires,
|
||||||
|
get_session,
|
||||||
|
)
|
||||||
|
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()
|
||||||
|
|
||||||
|
|
||||||
|
@app.exception_handler(authz.AuthException)
|
||||||
|
async def auth_exception_handler(_request, exc: authz.AuthException):
|
||||||
|
"""Handle AuthException with auth info for UI."""
|
||||||
|
return JSONResponse(
|
||||||
|
status_code=exc.status_code,
|
||||||
|
content=await authz.auth_error_content(exc),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@app.put("/display-name")
|
||||||
|
async def user_update_display_name(
|
||||||
|
request: Request,
|
||||||
|
response: Response,
|
||||||
|
payload: dict = Body(...),
|
||||||
|
auth=AUTH_COOKIE,
|
||||||
|
):
|
||||||
|
if not auth:
|
||||||
|
raise authz.AuthException(
|
||||||
|
status_code=401, detail="Authentication Required", mode="login"
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
s = await get_session(auth, host=request.headers.get("host"))
|
||||||
|
except ValueError as e:
|
||||||
|
raise authz.AuthException(
|
||||||
|
status_code=401, detail="Session expired", mode="login"
|
||||||
|
) from e
|
||||||
|
new_name = (payload.get("display_name") or "").strip()
|
||||||
|
if not new_name:
|
||||||
|
raise HTTPException(status_code=400, detail="display_name required")
|
||||||
|
if len(new_name) > 64:
|
||||||
|
raise HTTPException(status_code=400, detail="display_name too long")
|
||||||
|
await db.instance.update_user_display_name(s.user_uuid, new_name)
|
||||||
|
return {"status": "ok"}
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/logout-all")
|
||||||
|
async def api_logout_all(request: Request, response: Response, auth=AUTH_COOKIE):
|
||||||
|
if not auth:
|
||||||
|
return {"message": "Already logged out"}
|
||||||
|
try:
|
||||||
|
s = await get_session(auth, host=request.headers.get("host"))
|
||||||
|
except ValueError:
|
||||||
|
raise authz.AuthException(
|
||||||
|
status_code=401, detail="Session expired", mode="login"
|
||||||
|
)
|
||||||
|
await db.instance.delete_sessions_for_user(s.user_uuid)
|
||||||
|
session.clear_session_cookie(response)
|
||||||
|
return {"message": "Logged out from all hosts"}
|
||||||
|
|
||||||
|
|
||||||
|
@app.delete("/session/{session_id}")
|
||||||
|
async def api_delete_session(
|
||||||
|
request: Request,
|
||||||
|
response: Response,
|
||||||
|
session_id: str,
|
||||||
|
auth=AUTH_COOKIE,
|
||||||
|
):
|
||||||
|
if not auth:
|
||||||
|
raise authz.AuthException(
|
||||||
|
status_code=401, detail="Authentication Required", mode="login"
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
current_session = await get_session(auth, host=request.headers.get("host"))
|
||||||
|
except ValueError as exc:
|
||||||
|
raise authz.AuthException(
|
||||||
|
status_code=401, detail="Session expired", mode="login"
|
||||||
|
) from exc
|
||||||
|
|
||||||
|
try:
|
||||||
|
target_key = decode_session_key(session_id)
|
||||||
|
except ValueError as exc:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=400, detail="Invalid session identifier"
|
||||||
|
) from exc
|
||||||
|
|
||||||
|
target_session = await db.instance.get_session(target_key)
|
||||||
|
if not target_session or target_session.user_uuid != current_session.user_uuid:
|
||||||
|
raise HTTPException(status_code=404, detail="Session not found")
|
||||||
|
|
||||||
|
await db.instance.delete_session(target_key)
|
||||||
|
current_terminated = target_key == session_key(auth)
|
||||||
|
if current_terminated:
|
||||||
|
session.clear_session_cookie(response) # explicit because 200
|
||||||
|
return {"status": "ok", "current_session_terminated": current_terminated}
|
||||||
|
|
||||||
|
|
||||||
|
@app.delete("/credential/{uuid}")
|
||||||
|
async def api_delete_credential(
|
||||||
|
request: Request,
|
||||||
|
response: Response,
|
||||||
|
uuid: UUID,
|
||||||
|
auth: str = AUTH_COOKIE,
|
||||||
|
):
|
||||||
|
# Require recent authentication for sensitive operation
|
||||||
|
await authz.verify(auth, [], host=request.headers.get("host"), max_age="5m")
|
||||||
|
try:
|
||||||
|
await delete_credential(uuid, auth, host=request.headers.get("host"))
|
||||||
|
except ValueError as e:
|
||||||
|
raise authz.AuthException(
|
||||||
|
status_code=401, detail="Session expired", mode="login"
|
||||||
|
) from e
|
||||||
|
return {"message": "Credential deleted successfully"}
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/create-link")
|
||||||
|
async def api_create_link(
|
||||||
|
request: Request,
|
||||||
|
response: Response,
|
||||||
|
auth=AUTH_COOKIE,
|
||||||
|
):
|
||||||
|
# Require recent authentication for sensitive operation
|
||||||
|
await authz.verify(auth, [], host=request.headers.get("host"), max_age="5m")
|
||||||
|
try:
|
||||||
|
s = await get_session(auth, host=request.headers.get("host"))
|
||||||
|
except ValueError as e:
|
||||||
|
raise authz.AuthException(
|
||||||
|
status_code=401, detail="Session expired", mode="login"
|
||||||
|
) from e
|
||||||
|
token = passphrase.generate()
|
||||||
|
expiry = expires()
|
||||||
|
await db.instance.create_reset_token(
|
||||||
|
user_uuid=s.user_uuid,
|
||||||
|
key=tokens.reset_key(token),
|
||||||
|
expiry=expiry,
|
||||||
|
token_type="device addition",
|
||||||
|
)
|
||||||
|
url = hostutil.reset_link_url(token)
|
||||||
|
return {
|
||||||
|
"message": "Registration link generated successfully",
|
||||||
|
"url": url,
|
||||||
|
"expires": (
|
||||||
|
expiry.astimezone(timezone.utc).isoformat().replace("+00:00", "Z")
|
||||||
|
if expiry.tzinfo
|
||||||
|
else expiry.replace(tzinfo=timezone.utc).isoformat().replace("+00:00", "Z")
|
||||||
|
),
|
||||||
|
}
|
||||||
@@ -1,34 +1,14 @@
|
|||||||
import logging
|
|
||||||
from functools import wraps
|
|
||||||
from uuid import UUID
|
from uuid import UUID
|
||||||
|
|
||||||
from fastapi import Cookie, FastAPI, WebSocket, WebSocketDisconnect
|
from fastapi import FastAPI, WebSocket
|
||||||
from webauthn.helpers.exceptions import InvalidAuthenticationResponse
|
|
||||||
|
|
||||||
from ..authsession import create_session, expires, get_reset, get_session
|
|
||||||
from ..globals import db, passkey
|
|
||||||
from ..util import passphrase
|
|
||||||
from ..util.tokens import create_token, session_key
|
|
||||||
from .session import infodict
|
|
||||||
|
|
||||||
|
|
||||||
# WebSocket error handling decorator
|
|
||||||
def websocket_error_handler(func):
|
|
||||||
@wraps(func)
|
|
||||||
async def wrapper(ws: WebSocket, *args, **kwargs):
|
|
||||||
try:
|
|
||||||
await ws.accept()
|
|
||||||
return await func(ws, *args, **kwargs)
|
|
||||||
except WebSocketDisconnect:
|
|
||||||
pass
|
|
||||||
except (ValueError, InvalidAuthenticationResponse) as e:
|
|
||||||
await ws.send_json({"detail": str(e)})
|
|
||||||
except Exception:
|
|
||||||
logging.exception("Internal Server Error")
|
|
||||||
await ws.send_json({"detail": "Internal Server Error"})
|
|
||||||
|
|
||||||
return wrapper
|
|
||||||
|
|
||||||
|
from paskia.authsession import create_session, get_reset, get_session
|
||||||
|
from paskia.fastapi import authz
|
||||||
|
from paskia.fastapi.session import AUTH_COOKIE, infodict
|
||||||
|
from paskia.fastapi.wsutil import validate_origin, websocket_error_handler
|
||||||
|
from paskia.globals import db, passkey
|
||||||
|
from paskia.util import passphrase
|
||||||
|
from paskia.util.tokens import create_token, session_key
|
||||||
|
|
||||||
# Create a FastAPI subapp for WebSocket endpoints
|
# Create a FastAPI subapp for WebSocket endpoints
|
||||||
app = FastAPI()
|
app = FastAPI()
|
||||||
@@ -38,17 +18,16 @@ async def register_chat(
|
|||||||
ws: WebSocket,
|
ws: WebSocket,
|
||||||
user_uuid: UUID,
|
user_uuid: UUID,
|
||||||
user_name: str,
|
user_name: str,
|
||||||
|
origin: str,
|
||||||
credential_ids: list[bytes] | None = None,
|
credential_ids: list[bytes] | None = None,
|
||||||
origin: str | None = None,
|
|
||||||
):
|
):
|
||||||
"""Generate registration options and send them to the client."""
|
"""Generate registration options and send them to the client."""
|
||||||
options, challenge = passkey.instance.reg_generate_options(
|
options, challenge = passkey.instance.reg_generate_options(
|
||||||
user_id=user_uuid,
|
user_id=user_uuid,
|
||||||
user_name=user_name,
|
user_name=user_name,
|
||||||
credential_ids=credential_ids,
|
credential_ids=credential_ids,
|
||||||
origin=origin,
|
|
||||||
)
|
)
|
||||||
await ws.send_json(options)
|
await ws.send_json({"optionsJSON": options})
|
||||||
response = await ws.receive_json()
|
response = await ws.receive_json()
|
||||||
return passkey.instance.reg_verify(response, challenge, user_uuid, origin=origin)
|
return passkey.instance.reg_verify(response, challenge, user_uuid, origin=origin)
|
||||||
|
|
||||||
@@ -56,24 +35,31 @@ async def register_chat(
|
|||||||
@app.websocket("/register")
|
@app.websocket("/register")
|
||||||
@websocket_error_handler
|
@websocket_error_handler
|
||||||
async def websocket_register_add(
|
async def websocket_register_add(
|
||||||
ws: WebSocket, reset: str | None = None, name: str | None = None, auth=Cookie(None)
|
ws: WebSocket,
|
||||||
|
reset: str | None = None,
|
||||||
|
name: str | None = None,
|
||||||
|
auth=AUTH_COOKIE,
|
||||||
):
|
):
|
||||||
"""Register a new credential for an existing user.
|
"""Register a new credential for an existing user.
|
||||||
|
|
||||||
Supports either:
|
Supports either:
|
||||||
- Normal session via auth cookie
|
- Normal session via auth cookie (requires recent authentication)
|
||||||
- Reset token supplied as ?reset=... (auth cookie ignored)
|
- Reset token supplied as ?reset=... (auth cookie ignored)
|
||||||
"""
|
"""
|
||||||
origin = ws.headers["origin"]
|
origin = validate_origin(ws)
|
||||||
|
host = origin.split("://", 1)[1]
|
||||||
if reset is not None:
|
if reset is not None:
|
||||||
if not passphrase.is_well_formed(reset):
|
if not passphrase.is_well_formed(reset):
|
||||||
raise ValueError("Invalid reset token")
|
raise ValueError(
|
||||||
|
f"The reset link for {passkey.instance.rp_name} is invalid or has expired"
|
||||||
|
)
|
||||||
s = await get_reset(reset)
|
s = await get_reset(reset)
|
||||||
|
user_uuid = s.user_uuid
|
||||||
else:
|
else:
|
||||||
if not auth:
|
# Require recent authentication for adding a new passkey
|
||||||
raise ValueError("Authentication Required")
|
ctx = await authz.verify(auth, perm=[], host=host, max_age="5m")
|
||||||
s = await get_session(auth)
|
user_uuid = ctx.session.user_uuid
|
||||||
user_uuid = s.user_uuid
|
s = ctx.session
|
||||||
|
|
||||||
# Get user information and determine effective user_name for this registration
|
# Get user information and determine effective user_name for this registration
|
||||||
user = await db.instance.get_user_by_uuid(user_uuid)
|
user = await db.instance.get_user_by_uuid(user_uuid)
|
||||||
@@ -85,18 +71,20 @@ async def websocket_register_add(
|
|||||||
challenge_ids = await db.instance.get_credentials_by_user_uuid(user_uuid)
|
challenge_ids = await db.instance.get_credentials_by_user_uuid(user_uuid)
|
||||||
|
|
||||||
# WebAuthn registration
|
# WebAuthn registration
|
||||||
credential = await register_chat(ws, user_uuid, user_name, challenge_ids, origin)
|
credential = await register_chat(ws, user_uuid, user_name, origin, challenge_ids)
|
||||||
|
|
||||||
# Create a new session and store everything in database
|
# Create a new session and store everything in database
|
||||||
token = create_token()
|
token = create_token()
|
||||||
|
metadata = infodict(ws, "authenticated")
|
||||||
await db.instance.create_credential_session( # type: ignore[attr-defined]
|
await db.instance.create_credential_session( # type: ignore[attr-defined]
|
||||||
user_uuid=user_uuid,
|
user_uuid=user_uuid,
|
||||||
credential=credential,
|
credential=credential,
|
||||||
reset_key=(s.key if reset is not None else None),
|
reset_key=(s.key if reset is not None else None),
|
||||||
session_key=session_key(token),
|
session_key=session_key(token),
|
||||||
session_expires=expires(),
|
|
||||||
session_info=infodict(ws, "authenticated"),
|
|
||||||
display_name=user_name,
|
display_name=user_name,
|
||||||
|
host=host,
|
||||||
|
ip=metadata.get("ip"),
|
||||||
|
user_agent=metadata.get("user_agent"),
|
||||||
)
|
)
|
||||||
auth = token
|
auth = token
|
||||||
|
|
||||||
@@ -113,25 +101,55 @@ async def websocket_register_add(
|
|||||||
|
|
||||||
@app.websocket("/authenticate")
|
@app.websocket("/authenticate")
|
||||||
@websocket_error_handler
|
@websocket_error_handler
|
||||||
async def websocket_authenticate(ws: WebSocket):
|
async def websocket_authenticate(ws: WebSocket, auth=AUTH_COOKIE):
|
||||||
origin = ws.headers["origin"]
|
origin = validate_origin(ws)
|
||||||
options, challenge = passkey.instance.auth_generate_options()
|
host = origin.split("://", 1)[1]
|
||||||
await ws.send_json(options)
|
|
||||||
|
# If there's an existing session, restrict to that user's credentials (reauth)
|
||||||
|
session_user_uuid = None
|
||||||
|
credential_ids = None
|
||||||
|
if auth:
|
||||||
|
try:
|
||||||
|
session = await get_session(auth, host=host)
|
||||||
|
session_user_uuid = session.user_uuid
|
||||||
|
credential_ids = await db.instance.get_credentials_by_user_uuid(
|
||||||
|
session_user_uuid
|
||||||
|
)
|
||||||
|
except ValueError:
|
||||||
|
pass # Invalid/expired session - allow normal authentication
|
||||||
|
|
||||||
|
options, challenge = passkey.instance.auth_generate_options(
|
||||||
|
credential_ids=credential_ids
|
||||||
|
)
|
||||||
|
await ws.send_json({"optionsJSON": options})
|
||||||
# Wait for the client to use his authenticator to authenticate
|
# Wait for the client to use his authenticator to authenticate
|
||||||
credential = passkey.instance.auth_parse(await ws.receive_json())
|
credential = passkey.instance.auth_parse(await ws.receive_json())
|
||||||
# Fetch from the database by credential ID
|
# Fetch from the database by credential ID
|
||||||
stored_cred = await db.instance.get_credential_by_id(credential.raw_id)
|
try:
|
||||||
|
stored_cred = await db.instance.get_credential_by_id(credential.raw_id)
|
||||||
|
except ValueError:
|
||||||
|
raise ValueError(
|
||||||
|
f"This passkey is no longer registered with {passkey.instance.rp_name}"
|
||||||
|
)
|
||||||
|
|
||||||
|
# If reauth mode, verify the credential belongs to the session's user
|
||||||
|
if session_user_uuid and stored_cred.user_uuid != session_user_uuid:
|
||||||
|
raise ValueError("This passkey belongs to a different account")
|
||||||
|
|
||||||
# Verify the credential matches the stored data
|
# Verify the credential matches the stored data
|
||||||
passkey.instance.auth_verify(credential, challenge, stored_cred, origin=origin)
|
passkey.instance.auth_verify(credential, challenge, stored_cred, origin)
|
||||||
# Update both credential and user's last_seen timestamp
|
# Update both credential and user's last_seen timestamp
|
||||||
await db.instance.login(stored_cred.user_uuid, stored_cred)
|
await db.instance.login(stored_cred.user_uuid, stored_cred)
|
||||||
|
|
||||||
# Create a session token for the authenticated user
|
# Create a session token for the authenticated user
|
||||||
assert stored_cred.uuid is not None
|
assert stored_cred.uuid is not None
|
||||||
|
metadata = infodict(ws, "auth")
|
||||||
token = await create_session(
|
token = await create_session(
|
||||||
user_uuid=stored_cred.user_uuid,
|
user_uuid=stored_cred.user_uuid,
|
||||||
info=infodict(ws, "auth"),
|
|
||||||
credential_uuid=stored_cred.uuid,
|
credential_uuid=stored_cred.uuid,
|
||||||
|
host=host,
|
||||||
|
ip=metadata.get("ip") or "",
|
||||||
|
user_agent=metadata.get("user_agent") or "",
|
||||||
)
|
)
|
||||||
|
|
||||||
await ws.send_json(
|
await ws.send_json(
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
"""
|
||||||
|
Shared WebSocket utilities for FastAPI endpoints.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from functools import wraps
|
||||||
|
|
||||||
|
import base64url
|
||||||
|
from fastapi import WebSocket, WebSocketDisconnect
|
||||||
|
from webauthn.helpers.exceptions import InvalidAuthenticationResponse
|
||||||
|
|
||||||
|
from paskia.fastapi import authz
|
||||||
|
from paskia.globals import passkey
|
||||||
|
from paskia.util import pow
|
||||||
|
|
||||||
|
|
||||||
|
def websocket_error_handler(func):
|
||||||
|
"""Decorator for WebSocket endpoints that handles common errors."""
|
||||||
|
|
||||||
|
@wraps(func)
|
||||||
|
async def wrapper(ws: WebSocket, *args, **kwargs):
|
||||||
|
try:
|
||||||
|
await ws.accept()
|
||||||
|
return await func(ws, *args, **kwargs)
|
||||||
|
except WebSocketDisconnect:
|
||||||
|
pass
|
||||||
|
except authz.AuthException as e:
|
||||||
|
await ws.send_json(
|
||||||
|
{
|
||||||
|
"status": e.status_code,
|
||||||
|
**(await authz.auth_error_content(e)),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
except (ValueError, InvalidAuthenticationResponse) as e:
|
||||||
|
await ws.send_json({"status": 401, "detail": str(e)})
|
||||||
|
except Exception:
|
||||||
|
logging.exception("Internal Server Error")
|
||||||
|
await ws.send_json({"status": 500, "detail": "Internal Server Error"})
|
||||||
|
|
||||||
|
return wrapper
|
||||||
|
|
||||||
|
|
||||||
|
async def require_pow(ws: WebSocket, work: int | None = None) -> None:
|
||||||
|
"""Send a PoW challenge and verify the client's solution.
|
||||||
|
|
||||||
|
Sends: {"pow": {"challenge": "<base64>", "work": 10}}
|
||||||
|
Expects: {"pow": "<base64-solution>"}
|
||||||
|
|
||||||
|
Args:
|
||||||
|
ws: WebSocket connection
|
||||||
|
work: PoW difficulty level (default: pow.DEFAULT_WORK)
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ValueError: If the PoW solution is invalid
|
||||||
|
"""
|
||||||
|
challenge = pow.generate_challenge()
|
||||||
|
if work is None:
|
||||||
|
work = pow.DEFAULT_WORK
|
||||||
|
|
||||||
|
await ws.send_json(
|
||||||
|
{
|
||||||
|
"pow": {
|
||||||
|
"challenge": base64url.enc(challenge),
|
||||||
|
"work": work,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
response = await ws.receive_json()
|
||||||
|
solution_b64 = response.get("pow")
|
||||||
|
if not solution_b64:
|
||||||
|
raise ValueError("PoW solution required")
|
||||||
|
|
||||||
|
try:
|
||||||
|
solution = base64url.dec(solution_b64)
|
||||||
|
except Exception:
|
||||||
|
raise ValueError("Invalid PoW solution encoding")
|
||||||
|
|
||||||
|
pow.verify_pow(challenge, solution, work)
|
||||||
|
|
||||||
|
|
||||||
|
def validate_origin(ws: WebSocket) -> str:
|
||||||
|
"""Extract and validate origin from WebSocket request headers.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ValueError: If origin header is missing or not in allowed list
|
||||||
|
"""
|
||||||
|
origin = ws.headers.get("origin")
|
||||||
|
if not origin:
|
||||||
|
raise ValueError("Origin header is required for WebSocket connections")
|
||||||
|
return passkey.instance.validate_origin(origin)
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
from typing import Generic, TypeVar
|
from typing import Generic, TypeVar
|
||||||
|
|
||||||
from .db import DatabaseInterface
|
from paskia.db import DatabaseInterface
|
||||||
from .sansio import Passkey
|
from paskia.sansio import Passkey
|
||||||
|
|
||||||
T = TypeVar("T")
|
T = TypeVar("T")
|
||||||
|
|
||||||
@@ -29,9 +29,7 @@ class Manager(Generic[T]):
|
|||||||
async def init(
|
async def init(
|
||||||
rp_id: str = "localhost",
|
rp_id: str = "localhost",
|
||||||
rp_name: str | None = None,
|
rp_name: str | None = None,
|
||||||
origin: str | None = None,
|
origins: list[str] | None = None,
|
||||||
default_admin: str | None = None,
|
|
||||||
default_org: str | None = None,
|
|
||||||
*,
|
*,
|
||||||
bootstrap: bool = True,
|
bootstrap: bool = True,
|
||||||
) -> None:
|
) -> None:
|
||||||
@@ -45,7 +43,7 @@ async def init(
|
|||||||
passkey.instance = Passkey(
|
passkey.instance = Passkey(
|
||||||
rp_id=rp_id,
|
rp_id=rp_id,
|
||||||
rp_name=rp_name or rp_id,
|
rp_name=rp_name or rp_id,
|
||||||
origin=origin,
|
origins=origins,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Test if we have a database already initialized, otherwise use SQL
|
# Test if we have a database already initialized, otherwise use SQL
|
||||||
@@ -60,7 +58,7 @@ async def init(
|
|||||||
# Bootstrap system if needed
|
# Bootstrap system if needed
|
||||||
from .bootstrap import bootstrap_if_needed
|
from .bootstrap import bootstrap_if_needed
|
||||||
|
|
||||||
await bootstrap_if_needed(default_admin, default_org)
|
await bootstrap_if_needed()
|
||||||
|
|
||||||
|
|
||||||
# Global instances
|
# Global instances
|
||||||
@@ -0,0 +1,359 @@
|
|||||||
|
"""
|
||||||
|
Cross-device (remote) authentication support.
|
||||||
|
|
||||||
|
This module manages the flow for authenticating from another device:
|
||||||
|
1. Device A (requesting) creates a remote auth request and displays QR/link
|
||||||
|
2. Device B (authenticating) opens the link and authenticates with passkey
|
||||||
|
3. Device A receives the session via WebSocket notification
|
||||||
|
|
||||||
|
Alternative flow (initiated from profile/authenticating device):
|
||||||
|
1. Device A (requesting) creates request and displays short pairing code
|
||||||
|
2. Device B (authenticating) enters the pairing code in their profile
|
||||||
|
3. Device B authenticates, Device A receives the session
|
||||||
|
|
||||||
|
The requests are stored in-memory with short expiration (5 minutes).
|
||||||
|
The link uses the same /{token} endpoint as reset tokens, but the server
|
||||||
|
distinguishes between them by checking if the token exists in remoteauth first.
|
||||||
|
The first 3 words of the token serve as the pairing code for manual entry.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import logging
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
from typing import Callable
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
|
from paskia.util import passphrase
|
||||||
|
|
||||||
|
# Remote auth requests expire after this duration
|
||||||
|
REMOTE_AUTH_LIFETIME = timedelta(minutes=5)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class RemoteAuthRequest:
|
||||||
|
"""A pending remote authentication request."""
|
||||||
|
|
||||||
|
key: str # The 3-word passphrase code
|
||||||
|
created_at: datetime
|
||||||
|
host: str # The host where the session should be created
|
||||||
|
ip: str # IP of the requesting device
|
||||||
|
user_agent: str # User agent of the requesting device
|
||||||
|
action: str = "login" # "login" or "register"
|
||||||
|
locked: bool = False # True once the authenticating device has entered the code
|
||||||
|
# Callback to notify the requesting device when auth completes
|
||||||
|
# Takes (session_token, user_uuid, credential_uuid, reset_token) or (None, None, None, None) on cancel/expire
|
||||||
|
notify: (
|
||||||
|
Callable[[str | None, UUID | None, UUID | None, str | None], None] | None
|
||||||
|
) = None
|
||||||
|
# Callback to notify the requesting device when action is locked
|
||||||
|
# Takes (action) to confirm what action was locked
|
||||||
|
action_locked_notify: Callable[[str], None] | None = None
|
||||||
|
# Set when authentication completes
|
||||||
|
completed: bool = False
|
||||||
|
denied: bool = False # True if explicitly denied by the authenticating device
|
||||||
|
session_token: str | None = None
|
||||||
|
user_uuid: UUID | None = None
|
||||||
|
credential_uuid: UUID | None = None
|
||||||
|
reset_token: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class RemoteAuthManager:
|
||||||
|
"""Manages pending remote authentication requests."""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self._requests: dict[str, RemoteAuthRequest] = {} # keyed by 3-word code
|
||||||
|
self._cleanup_task: asyncio.Task | None = None
|
||||||
|
self._lock = asyncio.Lock()
|
||||||
|
|
||||||
|
async def start(self):
|
||||||
|
"""Start the cleanup background task."""
|
||||||
|
if self._cleanup_task is None:
|
||||||
|
self._cleanup_task = asyncio.create_task(self._cleanup_loop())
|
||||||
|
|
||||||
|
async def stop(self):
|
||||||
|
"""Stop the cleanup background task."""
|
||||||
|
if self._cleanup_task:
|
||||||
|
self._cleanup_task.cancel()
|
||||||
|
try:
|
||||||
|
await self._cleanup_task
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
pass
|
||||||
|
self._cleanup_task = None
|
||||||
|
|
||||||
|
async def _cleanup_loop(self):
|
||||||
|
"""Periodically clean up expired requests."""
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
await asyncio.sleep(60) # Check every minute
|
||||||
|
await self._cleanup_expired()
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
break
|
||||||
|
except Exception:
|
||||||
|
logging.exception("Error in remote auth cleanup loop")
|
||||||
|
|
||||||
|
async def _cleanup_expired(self):
|
||||||
|
"""Remove expired requests and notify waiting clients."""
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
expired_keys = []
|
||||||
|
async with self._lock:
|
||||||
|
for key, req in self._requests.items():
|
||||||
|
if now > req.created_at + REMOTE_AUTH_LIFETIME:
|
||||||
|
expired_keys.append(key)
|
||||||
|
for key in expired_keys:
|
||||||
|
req = self._requests.pop(key)
|
||||||
|
if req.notify and not req.completed:
|
||||||
|
try:
|
||||||
|
req.notify(None, None, None, None)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
async def create_request(
|
||||||
|
self,
|
||||||
|
host: str,
|
||||||
|
ip: str,
|
||||||
|
user_agent: str,
|
||||||
|
action: str = "login",
|
||||||
|
) -> tuple[str, datetime]:
|
||||||
|
"""Create a new remote auth request.
|
||||||
|
|
||||||
|
The code is a 3-word passphrase.
|
||||||
|
We ensure uniqueness across concurrent requests.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
(code, expiry) - The 3-word passphrase code and expiration time
|
||||||
|
"""
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
expiry = now + REMOTE_AUTH_LIFETIME
|
||||||
|
|
||||||
|
async with self._lock:
|
||||||
|
# Generate unique 3-word code
|
||||||
|
max_attempts = 100
|
||||||
|
for _ in range(max_attempts):
|
||||||
|
code = passphrase.generate(n=passphrase.N_WORDS_SHORT)
|
||||||
|
if code not in self._requests:
|
||||||
|
break
|
||||||
|
else:
|
||||||
|
# Extremely unlikely but handle gracefully
|
||||||
|
raise ValueError("Unable to generate unique code")
|
||||||
|
|
||||||
|
request = RemoteAuthRequest(
|
||||||
|
key=code,
|
||||||
|
created_at=now,
|
||||||
|
host=host,
|
||||||
|
ip=ip,
|
||||||
|
user_agent=user_agent,
|
||||||
|
action=action,
|
||||||
|
)
|
||||||
|
|
||||||
|
self._requests[code] = request
|
||||||
|
|
||||||
|
return code, expiry
|
||||||
|
|
||||||
|
async def get_request(self, code: str) -> RemoteAuthRequest | None:
|
||||||
|
"""Get a pending request by code, if valid and not expired."""
|
||||||
|
# Normalize: lowercase, dot-separated words
|
||||||
|
normalized = code.lower().strip().replace(" ", ".")
|
||||||
|
if not passphrase.is_well_formed(normalized, n=passphrase.N_WORDS_SHORT):
|
||||||
|
return None
|
||||||
|
async with self._lock:
|
||||||
|
req = self._requests.get(normalized)
|
||||||
|
if req is None:
|
||||||
|
return None
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
if now > req.created_at + REMOTE_AUTH_LIFETIME:
|
||||||
|
# Expired
|
||||||
|
del self._requests[normalized]
|
||||||
|
return None
|
||||||
|
return req
|
||||||
|
|
||||||
|
async def set_notify_callback(
|
||||||
|
self,
|
||||||
|
token: str,
|
||||||
|
callback: Callable[[str | None, UUID | None, UUID | None, str | None], None],
|
||||||
|
) -> bool:
|
||||||
|
"""Set the notification callback for a request.
|
||||||
|
|
||||||
|
Returns True if the request exists and callback was set.
|
||||||
|
"""
|
||||||
|
async with self._lock:
|
||||||
|
req = self._requests.get(token)
|
||||||
|
if req is None:
|
||||||
|
return False
|
||||||
|
req.notify = callback
|
||||||
|
return True
|
||||||
|
|
||||||
|
async def set_action_locked_callback(
|
||||||
|
self,
|
||||||
|
token: str,
|
||||||
|
callback: Callable[[str], None],
|
||||||
|
) -> bool:
|
||||||
|
"""Set the callback for when the action is locked.
|
||||||
|
|
||||||
|
Returns True if the request exists and callback was set.
|
||||||
|
"""
|
||||||
|
async with self._lock:
|
||||||
|
req = self._requests.get(token)
|
||||||
|
if req is None:
|
||||||
|
return False
|
||||||
|
req.action_locked_notify = callback
|
||||||
|
return True
|
||||||
|
|
||||||
|
async def update_action(
|
||||||
|
self,
|
||||||
|
token: str,
|
||||||
|
action: str,
|
||||||
|
) -> bool:
|
||||||
|
"""Update the action for a request (only if not locked).
|
||||||
|
|
||||||
|
Returns True if the request exists and was updated.
|
||||||
|
"""
|
||||||
|
if action not in ("login", "register"):
|
||||||
|
return False
|
||||||
|
async with self._lock:
|
||||||
|
req = self._requests.get(token)
|
||||||
|
if req is None or req.locked:
|
||||||
|
return False
|
||||||
|
req.action = action
|
||||||
|
return True
|
||||||
|
|
||||||
|
async def lock_action(
|
||||||
|
self,
|
||||||
|
token: str,
|
||||||
|
) -> str | None:
|
||||||
|
"""Lock the action for a request (called when authenticating device enters code).
|
||||||
|
|
||||||
|
Returns the locked action, or None if request doesn't exist or is already locked.
|
||||||
|
Notifies the requesting device via action_locked_notify callback.
|
||||||
|
"""
|
||||||
|
async with self._lock:
|
||||||
|
req = self._requests.get(token)
|
||||||
|
if req is None:
|
||||||
|
return None
|
||||||
|
if req.locked:
|
||||||
|
# Already locked by another authenticating device
|
||||||
|
return None
|
||||||
|
req.locked = True
|
||||||
|
action = req.action
|
||||||
|
if req.action_locked_notify:
|
||||||
|
try:
|
||||||
|
req.action_locked_notify(action)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return action
|
||||||
|
|
||||||
|
async def complete_request(
|
||||||
|
self,
|
||||||
|
token: str,
|
||||||
|
session_token: str | None,
|
||||||
|
user_uuid: UUID,
|
||||||
|
credential_uuid: UUID,
|
||||||
|
reset_token: str | None = None,
|
||||||
|
) -> bool:
|
||||||
|
"""Mark a request as completed with the authentication result.
|
||||||
|
|
||||||
|
The request is removed after notifying the waiting client.
|
||||||
|
Returns True if the request existed and was completed.
|
||||||
|
"""
|
||||||
|
async with self._lock:
|
||||||
|
req = self._requests.pop(token, None)
|
||||||
|
if req is None:
|
||||||
|
return False
|
||||||
|
if req.notify:
|
||||||
|
try:
|
||||||
|
req.notify(session_token, user_uuid, credential_uuid, reset_token)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return True
|
||||||
|
|
||||||
|
async def cancel_request(
|
||||||
|
self, token: str, *, denied: bool = False
|
||||||
|
) -> RemoteAuthRequest | None:
|
||||||
|
"""Cancel and remove a request.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
token: The request token
|
||||||
|
denied: If True, marks this as an explicit denial (not just timeout/disconnect)
|
||||||
|
|
||||||
|
Returns the removed request if it existed, None otherwise.
|
||||||
|
"""
|
||||||
|
async with self._lock:
|
||||||
|
req = self._requests.pop(token, None)
|
||||||
|
if req is None:
|
||||||
|
return None
|
||||||
|
if denied:
|
||||||
|
req.denied = True
|
||||||
|
if req.notify and not req.completed:
|
||||||
|
try:
|
||||||
|
# Pass denied status through a special UUID value (all zeros means denied)
|
||||||
|
if denied:
|
||||||
|
req.notify(None, UUID(int=0), None, None)
|
||||||
|
else:
|
||||||
|
req.notify(None, None, None, None)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return req
|
||||||
|
|
||||||
|
def get_connection_count(self) -> int:
|
||||||
|
"""Get the current count of open WebSocket connections.
|
||||||
|
|
||||||
|
This is used to determine PoW difficulty based on load.
|
||||||
|
"""
|
||||||
|
# Count is maintained externally by the WebSocket endpoints
|
||||||
|
return getattr(self, "_ws_count", 0)
|
||||||
|
|
||||||
|
def increment_connections(self) -> None:
|
||||||
|
"""Increment the WebSocket connection counter."""
|
||||||
|
self._ws_count = getattr(self, "_ws_count", 0) + 1
|
||||||
|
|
||||||
|
def decrement_connections(self) -> None:
|
||||||
|
"""Decrement the WebSocket connection counter."""
|
||||||
|
self._ws_count = max(0, getattr(self, "_ws_count", 0) - 1)
|
||||||
|
|
||||||
|
def get_pow_difficulty(self) -> int:
|
||||||
|
"""Get PoW difficulty based on current WebSocket connection count.
|
||||||
|
|
||||||
|
Uses NORMAL difficulty with low load (< 10 connections),
|
||||||
|
HARD difficulty with high load (>= 10 connections).
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
PoW work units (pow.NORMAL or pow.HARD)
|
||||||
|
"""
|
||||||
|
from paskia.util import pow
|
||||||
|
|
||||||
|
count = self.get_connection_count()
|
||||||
|
return pow.HARD if count >= 10 else pow.NORMAL
|
||||||
|
|
||||||
|
async def consume_request(self, token: str) -> RemoteAuthRequest | None:
|
||||||
|
"""Get and remove a request (for use by the authenticating device)."""
|
||||||
|
if not passphrase.is_well_formed(token, n=passphrase.N_WORDS_SHORT):
|
||||||
|
return None
|
||||||
|
async with self._lock:
|
||||||
|
req = self._requests.get(token)
|
||||||
|
if req is None:
|
||||||
|
return None
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
if now > req.created_at + REMOTE_AUTH_LIFETIME:
|
||||||
|
del self._requests[token]
|
||||||
|
return None
|
||||||
|
# Don't remove yet - wait until completion
|
||||||
|
return req
|
||||||
|
|
||||||
|
|
||||||
|
# Global instance
|
||||||
|
instance: RemoteAuthManager | None = None
|
||||||
|
|
||||||
|
|
||||||
|
async def init():
|
||||||
|
"""Initialize the global remote auth manager."""
|
||||||
|
global instance
|
||||||
|
instance = RemoteAuthManager()
|
||||||
|
await instance.start()
|
||||||
|
|
||||||
|
|
||||||
|
async def shutdown():
|
||||||
|
"""Shutdown the global remote auth manager."""
|
||||||
|
global instance
|
||||||
|
if instance:
|
||||||
|
await instance.stop()
|
||||||
|
instance = None
|
||||||
@@ -8,7 +8,7 @@ This module provides a unified interface for WebAuthn operations including:
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
import json
|
import json
|
||||||
from datetime import datetime
|
from datetime import datetime, timezone
|
||||||
from urllib.parse import urlparse
|
from urllib.parse import urlparse
|
||||||
from uuid import UUID
|
from uuid import UUID
|
||||||
|
|
||||||
@@ -37,7 +37,7 @@ from webauthn.helpers.structs import (
|
|||||||
UserVerificationRequirement,
|
UserVerificationRequirement,
|
||||||
)
|
)
|
||||||
|
|
||||||
from .db import Credential
|
from paskia.db import Credential
|
||||||
|
|
||||||
|
|
||||||
class Passkey:
|
class Passkey:
|
||||||
@@ -47,7 +47,7 @@ class Passkey:
|
|||||||
self,
|
self,
|
||||||
rp_id: str,
|
rp_id: str,
|
||||||
rp_name: str | None = None,
|
rp_name: str | None = None,
|
||||||
origin: str | None = None,
|
origins: list[str] | None = None,
|
||||||
supported_pub_key_algs: list[COSEAlgorithmIdentifier] | None = None,
|
supported_pub_key_algs: list[COSEAlgorithmIdentifier] | None = None,
|
||||||
):
|
):
|
||||||
"""
|
"""
|
||||||
@@ -56,40 +56,58 @@ class Passkey:
|
|||||||
Args:
|
Args:
|
||||||
rp_id: Your security domain (e.g. "example.com")
|
rp_id: Your security domain (e.g. "example.com")
|
||||||
rp_name: The relying party display name (e.g. "Example App"). May be shown in authenticators.
|
rp_name: The relying party display name (e.g. "Example App"). May be shown in authenticators.
|
||||||
origin: The origin URL of the application (e.g. "https://app.example.com").
|
origins: List of allowed origin URLs (e.g. ["https://app.example.com", "https://auth.example.com"]).
|
||||||
If no scheme is provided, "https://" will be prepended.
|
Each must be a subdomain or same as rp_id. If not provided, any subdomain of rp_id is allowed.
|
||||||
Must be a subdomain or same as rp_id, with port and scheme but no path included.
|
|
||||||
supported_pub_key_algs: List of supported COSE algorithms (default is EDDSA, ECDSA_SHA_256, RSASSA_PKCS1_v1_5_SHA_256).
|
supported_pub_key_algs: List of supported COSE algorithms (default is EDDSA, ECDSA_SHA_256, RSASSA_PKCS1_v1_5_SHA_256).
|
||||||
|
|
||||||
Raises:
|
Raises:
|
||||||
ValueError: If the origin domain doesn't match or isn't a subdomain of rp_id.
|
ValueError: If any origin domain doesn't match or isn't a subdomain of rp_id.
|
||||||
"""
|
"""
|
||||||
self.rp_id = rp_id
|
self.rp_id = rp_id
|
||||||
self.rp_name = rp_name or rp_id
|
self.rp_name = rp_name or rp_id
|
||||||
self.origin = self._normalize_and_validate_origin(origin, rp_id)
|
self.allowed_origins: set[str] | None = None
|
||||||
|
if origins:
|
||||||
|
# Validate and deduplicate origins into a set for O(1) lookups
|
||||||
|
for o in origins:
|
||||||
|
self._validate_origin(o, rp_id)
|
||||||
|
self.allowed_origins = set(origins)
|
||||||
self.supported_pub_key_algs = supported_pub_key_algs or [
|
self.supported_pub_key_algs = supported_pub_key_algs or [
|
||||||
COSEAlgorithmIdentifier.EDDSA,
|
COSEAlgorithmIdentifier.EDDSA,
|
||||||
COSEAlgorithmIdentifier.ECDSA_SHA_256,
|
COSEAlgorithmIdentifier.ECDSA_SHA_256,
|
||||||
COSEAlgorithmIdentifier.RSASSA_PKCS1_v1_5_SHA_256,
|
COSEAlgorithmIdentifier.RSASSA_PKCS1_v1_5_SHA_256,
|
||||||
]
|
]
|
||||||
|
|
||||||
def _normalize_and_validate_origin(self, origin: str | None, rp_id: str) -> str:
|
def _validate_origin(self, origin: str, rp_id: str) -> None:
|
||||||
if origin is None:
|
"""Validate an origin URL against the rp_id."""
|
||||||
origin = f"https://{rp_id}"
|
|
||||||
elif "://" not in origin:
|
|
||||||
origin = f"https://{origin}"
|
|
||||||
|
|
||||||
hostname = urlparse(origin).hostname
|
hostname = urlparse(origin).hostname
|
||||||
if not hostname:
|
if not hostname:
|
||||||
raise ValueError(f"Invalid origin URL: no hostname found in '{origin}'")
|
raise ValueError(f"Invalid origin URL: no hostname found in '{origin}'")
|
||||||
|
|
||||||
if hostname == rp_id or hostname.endswith(f".{rp_id}"):
|
if hostname == rp_id or hostname.endswith(f".{rp_id}"):
|
||||||
return origin
|
return
|
||||||
|
|
||||||
raise ValueError(
|
raise ValueError(
|
||||||
f"Origin domain '{hostname}' must be the same as or a subdomain of rp_id '{rp_id}'"
|
f"Origin domain '{hostname}' must be the same as or a subdomain of rp_id '{rp_id}'"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def validate_origin(self, origin: str) -> str:
|
||||||
|
"""Validate that origin is allowed and return it.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
origin: The origin URL to validate (from WebSocket request header)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
The validated origin URL
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ValueError: If origin is not in the allowed list (when origins are configured)
|
||||||
|
or if origin is not a valid subdomain of rp_id
|
||||||
|
"""
|
||||||
|
self._validate_origin(origin, self.rp_id)
|
||||||
|
if self.allowed_origins is not None and origin not in self.allowed_origins:
|
||||||
|
raise ValueError(f"Origin '{origin}' is not in the allowed origins list")
|
||||||
|
return origin
|
||||||
|
|
||||||
### Registration Methods ###
|
### Registration Methods ###
|
||||||
|
|
||||||
def reg_generate_options(
|
def reg_generate_options(
|
||||||
@@ -137,14 +155,16 @@ class Passkey:
|
|||||||
response_json: dict | str,
|
response_json: dict | str,
|
||||||
expected_challenge: bytes,
|
expected_challenge: bytes,
|
||||||
user_uuid: UUID,
|
user_uuid: UUID,
|
||||||
origin: str | None = None,
|
origin: str,
|
||||||
) -> Credential:
|
) -> Credential:
|
||||||
"""
|
"""
|
||||||
Verify registration response.
|
Verify registration response.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
credential: The credential response from the client
|
response_json: The credential response from the client
|
||||||
expected_challenge: The expected challenge bytes
|
expected_challenge: The expected challenge bytes
|
||||||
|
user_uuid: The user's UUID
|
||||||
|
origin: The origin URL (required, must be pre-validated)
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Registration verification result
|
Registration verification result
|
||||||
@@ -153,7 +173,7 @@ class Passkey:
|
|||||||
registration = verify_registration_response(
|
registration = verify_registration_response(
|
||||||
credential=credential,
|
credential=credential,
|
||||||
expected_challenge=expected_challenge,
|
expected_challenge=expected_challenge,
|
||||||
expected_origin=origin or self.origin,
|
expected_origin=origin,
|
||||||
expected_rp_id=self.rp_id,
|
expected_rp_id=self.rp_id,
|
||||||
)
|
)
|
||||||
return Credential(
|
return Credential(
|
||||||
@@ -163,7 +183,7 @@ class Passkey:
|
|||||||
aaguid=UUID(registration.aaguid),
|
aaguid=UUID(registration.aaguid),
|
||||||
public_key=registration.credential_public_key,
|
public_key=registration.credential_public_key,
|
||||||
sign_count=registration.sign_count,
|
sign_count=registration.sign_count,
|
||||||
created_at=datetime.now(),
|
created_at=datetime.now(timezone.utc),
|
||||||
)
|
)
|
||||||
|
|
||||||
### Authentication Methods ###
|
### Authentication Methods ###
|
||||||
@@ -184,7 +204,7 @@ class Passkey:
|
|||||||
authopts: Additional arguments to generate_authentication_options.
|
authopts: Additional arguments to generate_authentication_options.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Tuple of (JSON to be sent to client, challenge bytes to store)
|
Tuple of (JSON dict to be sent to client, challenge bytes to store)
|
||||||
"""
|
"""
|
||||||
options = generate_authentication_options(
|
options = generate_authentication_options(
|
||||||
rp_id=self.rp_id,
|
rp_id=self.rp_id,
|
||||||
@@ -206,7 +226,7 @@ class Passkey:
|
|||||||
credential: AuthenticationCredential,
|
credential: AuthenticationCredential,
|
||||||
expected_challenge: bytes,
|
expected_challenge: bytes,
|
||||||
stored_cred: Credential,
|
stored_cred: Credential,
|
||||||
origin: str | None = None,
|
origin: str,
|
||||||
) -> VerifiedAuthentication:
|
) -> VerifiedAuthentication:
|
||||||
"""
|
"""
|
||||||
Verify authentication response against locally stored credential data.
|
Verify authentication response against locally stored credential data.
|
||||||
@@ -215,19 +235,19 @@ class Passkey:
|
|||||||
credential: The authentication credential response from the client
|
credential: The authentication credential response from the client
|
||||||
expected_challenge: The earlier generated challenge bytes
|
expected_challenge: The earlier generated challenge bytes
|
||||||
stored_cred: The server stored credential record (modified by this function)
|
stored_cred: The server stored credential record (modified by this function)
|
||||||
|
origin: The origin URL (required, must be pre-validated)
|
||||||
"""
|
"""
|
||||||
expected_origin = origin or self.origin
|
|
||||||
# Verify the authentication response
|
# Verify the authentication response
|
||||||
verification = verify_authentication_response(
|
verification = verify_authentication_response(
|
||||||
credential=credential,
|
credential=credential,
|
||||||
expected_challenge=expected_challenge,
|
expected_challenge=expected_challenge,
|
||||||
expected_origin=expected_origin,
|
expected_origin=origin,
|
||||||
expected_rp_id=self.rp_id,
|
expected_rp_id=self.rp_id,
|
||||||
credential_public_key=stored_cred.public_key,
|
credential_public_key=stored_cred.public_key,
|
||||||
credential_current_sign_count=stored_cred.sign_count,
|
credential_current_sign_count=stored_cred.sign_count,
|
||||||
)
|
)
|
||||||
stored_cred.sign_count = verification.new_sign_count
|
stored_cred.sign_count = verification.new_sign_count
|
||||||
now = datetime.now()
|
now = datetime.now(timezone.utc)
|
||||||
stored_cred.last_used = now
|
stored_cred.last_used = now
|
||||||
if verification.user_verified:
|
if verification.user_verified:
|
||||||
stored_cred.last_verified = now
|
stored_cred.last_verified = now
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
import asyncio
|
||||||
|
import mimetypes
|
||||||
|
import os
|
||||||
|
from importlib import resources
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
__all__ = ["path", "file", "read", "is_dev_mode"]
|
||||||
|
|
||||||
|
|
||||||
|
def _get_dev_server() -> str | None:
|
||||||
|
"""Get the dev server URL from environment, or None if not in dev mode."""
|
||||||
|
return os.environ.get("PASKIA_DEVMODE") or None
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_static_dir() -> Path:
|
||||||
|
# Try packaged path via importlib.resources (works for wheel/installed).
|
||||||
|
try: # pragma: no cover - trivial path resolution
|
||||||
|
pkg_dir = resources.files("paskia") / "frontend-build"
|
||||||
|
fs_path = Path(str(pkg_dir))
|
||||||
|
if fs_path.is_dir():
|
||||||
|
return fs_path
|
||||||
|
except Exception: # pragma: no cover - defensive
|
||||||
|
pass
|
||||||
|
# Fallback for editable/development before build.
|
||||||
|
return Path(__file__).parent.parent / "frontend-build"
|
||||||
|
|
||||||
|
|
||||||
|
path: Path = _resolve_static_dir()
|
||||||
|
|
||||||
|
|
||||||
|
def file(*parts: str) -> Path:
|
||||||
|
"""Return a child path under the static root."""
|
||||||
|
return path.joinpath(*parts)
|
||||||
|
|
||||||
|
|
||||||
|
def is_dev_mode() -> bool:
|
||||||
|
"""Check if we're running in dev mode (Vite frontend server)."""
|
||||||
|
return bool(_get_dev_server())
|
||||||
|
|
||||||
|
|
||||||
|
async def read(filepath: str) -> tuple[bytes, int, dict[str, str]]:
|
||||||
|
"""Read file content and return response tuple.
|
||||||
|
|
||||||
|
In dev mode, fetches from the Vite dev server.
|
||||||
|
In production, reads from the static build directory.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
filepath: Path relative to frontend root, e.g. "/auth/index.html"
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Tuple of (content, status_code, headers) suitable for
|
||||||
|
FastAPI Response(*args) or Sanic raw response.
|
||||||
|
"""
|
||||||
|
if is_dev_mode():
|
||||||
|
dev_server = _get_dev_server()
|
||||||
|
async with httpx.AsyncClient() as client:
|
||||||
|
resp = await client.get(f"{dev_server}{filepath}")
|
||||||
|
resp.raise_for_status()
|
||||||
|
mime = resp.headers.get("content-type", "application/octet-stream")
|
||||||
|
# Strip charset suffix if present
|
||||||
|
mime = mime.split(";")[0].strip()
|
||||||
|
return resp.content, resp.status_code, {"content-type": mime}
|
||||||
|
else:
|
||||||
|
# Production: read from static build
|
||||||
|
file_path = path / filepath.lstrip("/")
|
||||||
|
content = await _read_file_async(file_path)
|
||||||
|
mime, _ = mimetypes.guess_type(str(file_path))
|
||||||
|
return content, 200, {"content-type": mime or "application/octet-stream"}
|
||||||
|
|
||||||
|
|
||||||
|
async def _read_file_async(file_path: Path) -> bytes:
|
||||||
|
"""Read file asynchronously using asyncio.to_thread."""
|
||||||
|
return await asyncio.to_thread(file_path.read_bytes)
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
"""Utilities for determining the auth UI host and base URLs."""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
from functools import lru_cache
|
||||||
|
from urllib.parse import urlsplit
|
||||||
|
|
||||||
|
|
||||||
|
@lru_cache(maxsize=1)
|
||||||
|
def _load_config() -> dict:
|
||||||
|
"""Load PASKIA_CONFIG JSON."""
|
||||||
|
config_json = os.getenv("PASKIA_CONFIG")
|
||||||
|
if not config_json:
|
||||||
|
return {}
|
||||||
|
return json.loads(config_json)
|
||||||
|
|
||||||
|
|
||||||
|
def is_root_mode() -> bool:
|
||||||
|
return _load_config().get("auth_host") is not None
|
||||||
|
|
||||||
|
|
||||||
|
def dedicated_auth_host() -> str | None:
|
||||||
|
"""Return configured auth_host netloc, or None."""
|
||||||
|
auth_host = _load_config().get("auth_host")
|
||||||
|
if not auth_host:
|
||||||
|
return None
|
||||||
|
from urllib.parse import urlparse
|
||||||
|
|
||||||
|
parsed = urlparse(auth_host if "://" in auth_host else f"//{auth_host}")
|
||||||
|
return parsed.netloc or parsed.path or None
|
||||||
|
|
||||||
|
|
||||||
|
def ui_base_path() -> str:
|
||||||
|
return "/" if is_root_mode() else "/auth/"
|
||||||
|
|
||||||
|
|
||||||
|
def auth_site_url() -> str:
|
||||||
|
"""Return the base URL for the auth site UI (computed at startup)."""
|
||||||
|
cfg = _load_config()
|
||||||
|
return cfg.get("site_url", "https://localhost") + cfg.get("site_path", "/auth/")
|
||||||
|
|
||||||
|
|
||||||
|
def reset_link_url(token: str) -> str:
|
||||||
|
"""Generate a reset link URL for the given token."""
|
||||||
|
return f"{auth_site_url()}{token}"
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_origin(origin: str) -> str:
|
||||||
|
"""Normalize an origin URL by adding https:// if no scheme is present."""
|
||||||
|
if "://" not in origin:
|
||||||
|
return f"https://{origin}"
|
||||||
|
return origin
|
||||||
|
|
||||||
|
|
||||||
|
def reload_config() -> None:
|
||||||
|
_load_config.cache_clear()
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_host(raw_host: str | None) -> str | None:
|
||||||
|
"""Normalize a Host header preserving port (exact match required)."""
|
||||||
|
if not raw_host:
|
||||||
|
return None
|
||||||
|
candidate = raw_host.strip()
|
||||||
|
if not candidate:
|
||||||
|
return None
|
||||||
|
# urlsplit to parse (add // for scheme-less); prefer netloc to retain port.
|
||||||
|
parsed = urlsplit(candidate if "//" in candidate else f"//{candidate}")
|
||||||
|
netloc = parsed.netloc or parsed.path or ""
|
||||||
|
# Strip IPv6 brackets around host part but retain port suffix.
|
||||||
|
if netloc.startswith("["):
|
||||||
|
# format: [ipv6]:port or [ipv6]
|
||||||
|
if "]" in netloc:
|
||||||
|
host_part, _, rest = netloc.partition("]")
|
||||||
|
port_part = rest.lstrip(":")
|
||||||
|
netloc = host_part.strip("[]") + (f":{port_part}" if port_part else "")
|
||||||
|
return netloc.lower() or None
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
"""Utility functions for HTML manipulation."""
|
||||||
|
|
||||||
|
import re
|
||||||
|
|
||||||
|
|
||||||
|
def patch_html_data_attrs(html: bytes, **data_attrs: str) -> bytes:
|
||||||
|
"""Patch HTML by adding data attributes to the <html> tag.
|
||||||
|
|
||||||
|
If an <html> tag exists, adds data attributes to it.
|
||||||
|
If no <html> tag exists, prepends one with the data attributes.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
html: The HTML content as bytes
|
||||||
|
**data_attrs: Key-value pairs for data attributes (e.g., mode='reauth')
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Modified HTML as bytes
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
>>> patch_html_data_attrs(b'<html><body>test</body></html>', mode='reauth')
|
||||||
|
b'<html data-mode="reauth"><body>test</body></html>'
|
||||||
|
|
||||||
|
>>> patch_html_data_attrs(b'<body>test</body>', mode='reauth')
|
||||||
|
b'<html data-mode="reauth"><body>test</body>'
|
||||||
|
"""
|
||||||
|
if not data_attrs:
|
||||||
|
return html
|
||||||
|
|
||||||
|
html_str = html.decode("utf-8")
|
||||||
|
|
||||||
|
# Build the data attributes string
|
||||||
|
attrs_str = " ".join(f'data-{key}="{value}"' for key, value in data_attrs.items())
|
||||||
|
|
||||||
|
# Check if there's an <html> tag (case-insensitive, may have existing attributes)
|
||||||
|
html_tag_pattern = re.compile(r"<html([^>]*)>", re.IGNORECASE)
|
||||||
|
match = html_tag_pattern.search(html_str)
|
||||||
|
|
||||||
|
if match:
|
||||||
|
# Insert data attributes into existing <html> tag
|
||||||
|
existing_attrs = match.group(1)
|
||||||
|
new_tag = f"<html{existing_attrs} {attrs_str}>"
|
||||||
|
html_str = html_tag_pattern.sub(new_tag, html_str, count=1)
|
||||||
|
else:
|
||||||
|
# Prepend <html> tag with data attributes
|
||||||
|
html_str = f"<html {attrs_str}>" + html_str
|
||||||
|
|
||||||
|
return html_str.encode("utf-8")
|
||||||
@@ -1,8 +1,9 @@
|
|||||||
import secrets
|
import secrets
|
||||||
|
|
||||||
from .wordlist import words
|
from paskia.util.wordlist import words
|
||||||
|
|
||||||
N_WORDS = 5
|
N_WORDS = 5
|
||||||
|
N_WORDS_SHORT = 3
|
||||||
|
|
||||||
wset = set(words)
|
wset = set(words)
|
||||||
|
|
||||||
@@ -3,8 +3,9 @@
|
|||||||
from collections.abc import Sequence
|
from collections.abc import Sequence
|
||||||
from fnmatch import fnmatchcase
|
from fnmatch import fnmatchcase
|
||||||
|
|
||||||
from ..globals import db
|
from paskia.globals import db
|
||||||
from .tokens import session_key
|
from paskia.util.hostutil import normalize_host
|
||||||
|
from paskia.util.tokens import session_key
|
||||||
|
|
||||||
__all__ = ["has_any", "has_all", "session_context"]
|
__all__ = ["has_any", "has_all", "session_context"]
|
||||||
|
|
||||||
@@ -24,5 +25,8 @@ def has_all(ctx, patterns: Sequence[str]) -> bool:
|
|||||||
return all(_match(ctx.role.permissions, patterns)) if ctx else False
|
return all(_match(ctx.role.permissions, patterns)) if ctx else False
|
||||||
|
|
||||||
|
|
||||||
async def session_context(auth: str | None):
|
async def session_context(auth: str | None, host: str | None = None):
|
||||||
return await db.instance.get_session_context(session_key(auth)) if auth else None
|
if not auth:
|
||||||
|
return None
|
||||||
|
normalized_host = normalize_host(host) if host else None
|
||||||
|
return await db.instance.get_session_context(session_key(auth), normalized_host)
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
"""
|
||||||
|
Proof of Work utility using PBKDF2-SHA512.
|
||||||
|
|
||||||
|
The PoW requires finding nonces where PBKDF2(challenge, nonce) produces
|
||||||
|
output with a zero first byte. Each work unit requires finding one such nonce.
|
||||||
|
All valid nonces are concatenated into a solution for server verification.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
import secrets
|
||||||
|
|
||||||
|
EASY = 2 # Around 0.25s
|
||||||
|
NORMAL = 8 # Around 1s
|
||||||
|
HARD = 32 # Around 4s
|
||||||
|
|
||||||
|
|
||||||
|
def generate_challenge() -> bytes:
|
||||||
|
"""Generate a random 8-byte challenge."""
|
||||||
|
return secrets.token_bytes(8)
|
||||||
|
|
||||||
|
|
||||||
|
def verify_pow(challenge: bytes, solution: bytes, work: int = NORMAL) -> None:
|
||||||
|
"""Verify a Proof of Work solution.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
challenge: 8-byte server-provided challenge
|
||||||
|
solution: Concatenated 8-byte nonces (8 * work bytes)
|
||||||
|
work: Number of work units expected
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ValueError: If the solution is invalid
|
||||||
|
"""
|
||||||
|
if len(challenge) != 8:
|
||||||
|
raise ValueError("Invalid challenge length")
|
||||||
|
|
||||||
|
if len(solution) != 8 * work:
|
||||||
|
raise ValueError("Invalid solution length")
|
||||||
|
|
||||||
|
# Verify each work unit - check that PBKDF2 output starts with 0x00
|
||||||
|
for i in range(work):
|
||||||
|
nonce = solution[i * 8 : (i + 1) * 8]
|
||||||
|
# Require first byte of PBKDF2-SHA512 to be zero
|
||||||
|
result = hashlib.pbkdf2_hmac("sha512", challenge, nonce, 128, 2)
|
||||||
|
if result[0] or result[1] & 0x07:
|
||||||
|
raise ValueError("Invalid PoW solution")
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
"""Utility functions for session validation and checking."""
|
||||||
|
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
from paskia.db import SessionContext
|
||||||
|
from paskia.util.timeutil import parse_duration
|
||||||
|
|
||||||
|
|
||||||
|
def check_session_age(ctx: SessionContext, max_age: str | None) -> bool:
|
||||||
|
"""Check if a session satisfies the max_age requirement.
|
||||||
|
|
||||||
|
Uses the credential's last_used timestamp to determine authentication age,
|
||||||
|
since session renewal can happen without re-authentication.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
ctx: The session context containing session and credential info
|
||||||
|
max_age: Maximum age string (e.g., "5m", "1h", "30s") or None
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if authentication is recent enough or max_age is None, False if too old
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ValueError: If max_age format is invalid
|
||||||
|
"""
|
||||||
|
if not max_age:
|
||||||
|
return True
|
||||||
|
|
||||||
|
max_age_delta = parse_duration(max_age)
|
||||||
|
|
||||||
|
# Use credential's last_used time if available, fall back to session renewed
|
||||||
|
if ctx.credential and ctx.credential.last_used:
|
||||||
|
auth_time = ctx.credential.last_used
|
||||||
|
else:
|
||||||
|
auth_time = ctx.session.renewed
|
||||||
|
|
||||||
|
time_since_auth = datetime.now(timezone.utc) - auth_time
|
||||||
|
return time_since_auth <= max_age_delta
|
||||||
@@ -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))
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user