Compare commits

...
79 Commits
Author SHA1 Message Date
LeoVasanko ea1ddbbe6f Make dev mode run without static files, only serving assets in production. 2025-12-04 10:15:26 +00:00
LeoVasanko b091744665 Cleanup old hostapp files (finished, working). 2025-12-04 10:06:54 +00:00
LeoVasanko 2cf8799c75 Missing new component. 2025-12-04 10:03:33 +00:00
LeoVasanko a72349077c Integrate host app to main app (WIP). 2025-12-04 10:00:47 +00:00
LeoVasanko e102b8383b Admin app simplification by using API auth properly. Implemented promise to keep request blocked by permission check while the user authenticates, fixing concurrent requests. 2025-12-04 09:19:40 +00:00
LeoVasanko 5aa8d021e6 Brought examples directly to front page. 2025-12-04 08:19:32 +00:00
LeoVasanko 3d5b0aa4bf Fix view switching of restricted app. 2025-12-04 07:46:36 +00:00
LeoVasanko 29df169a67 Make restricted app use simple fetch that doesn't do API authentication (recursively). 2025-12-04 06:20:14 +00:00
LeoVasanko 97dc459bfb Fixed and simplified examples. 2025-12-04 06:08:52 +00:00
LeoVasanko 4d4b290cc8 Revert earlier change to iframe srcdoc, using src instead, because srcdoc was not compatible with all passkey implementations (BitWarden). 2025-12-04 06:01:47 +00:00
LeoVasanko 0e1b9f529b Log authentication options on the client. 2025-12-04 05:07:44 +00:00
LeoVasanko 0c3e0d3fa5 Improved dialog layout with separate mobile portrait mode. 2025-12-04 04:06:32 +00:00
LeoVasanko 1782547b9e Fix infinitely nested login iframes when the restricted app notices it needs login. 2025-12-04 03:56:17 +00:00
LeoVasanko 9976e05696 Various fixes and cleanup, regressions from prior commits. 2025-12-04 03:40:59 +00:00
LeoVasanko 6124fa6c01 Fix syntax error in reset app created by earlier commit. 2025-12-04 02:31:13 +00:00
LeoVasanko a6591a1fbb Better static files handling on backend, when in dev mode: fetch from vite. 2025-12-04 02:30:02 +00:00
LeoVasanko b9b1c995f9 Update forward API to return in JSON iframe srcdoc with options injected. (currently broken in dev mode). 2025-12-04 01:58:18 +00:00
LeoVasanko 4482a601f3 Fix fetch timeout rolling while in authentication flow. Now each fetch gets a fresh timeout. 2025-12-04 01:35:44 +00:00
LeoVasanko aa4b1bfd42 Viewing linked passkeys/sessions (by clicking either one of them). 2025-12-04 01:21:52 +00:00
LeoVasanko 2ecf8433a1 Consistently use apiJson for fetches, with timeout and proper error handling (less code duplication). 2025-12-04 01:00:24 +00:00
LeoVasanko db892365dc Improved auth profile UX, consistent transparent-blur dialog background everywhere. 2025-12-04 00:29:42 +00:00
LeoVasanko 8d02c0f615 Formatting, tidy up, transparent auth dialog background. 2025-12-03 23:31:35 +00:00
LeoVasanko 469d606ce5 Improved apiFetch and jsonFetch functions. 2025-12-03 23:26:38 +00:00
LeoVasanko 547a6cd923 Make auth/admin apps API calls use apiFetch, a new function that asks for permission by iframe if needed. Implement max-age checks for API authz.verify as well along with a custom exception type that carries metadata. 2025-12-03 23:17:02 +00:00
LeoVasanko deabee3b5c Reload backend only on changes on the backend or frontend-build within, not outside that in the repo. 2025-12-03 22:58:48 +00:00
LeoVasanko fd1aa11409 Add E2E tests to register and verify passkey. 2025-12-03 02:52:39 +00:00
LeoVasanko ca1ea9d90b Always use timezone aware UTC time. 2025-12-03 01:36:15 +00:00
LeoVasanko 2dac0be77a Improved session list IP handling. Hovering sessions shows Same IP on matching sessions. 2025-12-03 01:32:05 +00:00
LeoVasanko f63c62d9ff Implement session termination in admin API, for completeness. 2025-12-03 01:20:52 +00:00
LeoVasanko 768a4391cf Improved profile view layout. 2025-12-03 01:03:25 +00:00
LeoVasanko f64876e73b Improved profile view layout. 2025-12-03 00:52:52 +00:00
LeoVasanko b6a3cdd3a4 Fix examples folder serving broken a couple of commits ago. 2025-12-03 00:06:32 +00:00
LeoVasanko fd9a5afc1c Implement metadata for RestrictedForward, set by /auth/api/forward endpoint when returning the app. Use this to implement support for time-based reauth requirement. 2025-12-02 23:39:31 +00:00
LeoVasanko 8714fe9319 Vite proxy config simplified. Renaming /auth/restricted to have a trailing slash for better Vite compatibility. 2025-12-02 22:41:12 +00:00
LeoVasanko adbab88c86 Major refactor of frontend source tree such that paths better match where they are served. 2025-12-02 22:09:07 +00:00
LeoVasanko 5d9d2b794d Refactor restricted app paths and naming. 2025-12-02 19:10:13 +00:00
LeoVasanko eedbd4aaa4 Moved the restricted-api iframe src to /auth/api/restricted and removed the endpoint of the other restricted app. 2025-12-02 18:34:59 +00:00
LeoVasanko 15916047fa Remove backend access control, now that the profile and admin apps handle that via API. 2025-12-02 18:25:58 +00:00
LeoVasanko 643d9bafab Fix the back buttons (navigate back if you can but close if it was a new window). 2025-12-02 18:02:02 +00:00
LeoVasanko 2699aaa472 Implement Forbidden view for API calls, cleanup and better UX. 2025-12-02 17:36:37 +00:00
LeoVasanko 5422845192 Better error messages from backend, avoid bad toasts, cleanup of session validation. 2025-12-02 16:37:27 +00:00
LeoVasanko c1ccb048f0 Update admin app authentication in API mode too, reusing components between it and the main app. 2025-12-02 15:42:55 +00:00
LeoVasanko 3030122807 Implemented auth app authentication in API mode (if loading the app itself wasn't blocked). Removed unnecessary toasts when entering restricted pages. 2025-12-02 15:25:31 +00:00
LeoVasanko d4f8e97469 Refactor lengthy user info formatting to its own utility module that doesn't depend on FastAPI. 2025-12-02 14:30:31 +00:00
LeoVasanko a62e8ddf1e Implement restricted-api for JS-driven auth calls, examples added (WIP!). Layout and styling simplified. 2025-12-02 03:10:16 +00:00
LeoVasanko 2dca6b1eec Updated frontend running dev mode using deno/npm/bun as well. Additional dev mode Caddyfile to go https://localhost/. 2025-12-01 20:07:26 +00:00
LeoVasanko 4f50974222 Updated build-frontend script, now uses deno, npm, bun in this order. 2025-12-01 19:25:08 +00:00
LeoVasanko c218ddad61 Centralise all cookie handling to session.py. 2025-10-05 06:48:24 +00:00
LeoVasanko 7247f7c584 Refactor /api/user/* to its own module. 2025-10-05 06:41:14 +00:00
LeoVasanko af2834b4c0 Reset dialog UX improved. 2025-10-05 06:25:40 +00:00
LeoVasanko ef66baff20 Harmonise ProfileView and HostApp. 2025-10-05 06:14:17 +00:00
LeoVasanko 08d4607d65 Tuning the host app. 2025-10-05 06:03:28 +00:00
LeoVasanko 1ca9e3ef58 Don't redirect non-auth-host /auth/ to auth site but show basic info on current host, and allow logging out. Adds a new host app for this purpose. 2025-10-05 05:55:08 +00:00
LeoVasanko 575d3cb1fb Deny creating sessions for hosts other than rp-id subdomains. 2025-10-05 05:26:03 +00:00
LeoVasanko a4ac19f54c WebSockets must use origin for finding the host calling them. 2025-10-05 05:16:51 +00:00
LeoVasanko 11887d15b2 Correction on restricted path checking (auth-host). 2025-10-05 04:59:05 +00:00
LeoVasanko cefb9c3d92 Refactor auth-host redirection middleware to its own module.
Implement redirection to remove /auth/ from UI URLs when on auth-host.
2025-10-05 04:49:23 +00:00
LeoVasanko 5b9a3fc27f Add validation of the CLI specified --auth-host (needs to be within rp-id). 2025-10-05 04:35:55 +00:00
LeoVasanko 19a6c32cf2 Fix deletion of session cookie on host logout. 2025-10-05 04:26:36 +00:00
LeoVasanko eaa16abe2a Better UX for profile view logout buttons. 2025-10-05 04:22:16 +00:00
LeoVasanko 01bc39a0e8 A major refactoring for more consistent and stricter flows.
- Force using the dedicated authentication site configured via auth-host
- Stricter host validation
- Using the restricted app consistently for all access control (instead of the old loginview).
2025-10-05 03:55:11 +00:00
LeoVasanko fa513940c7 Refactor user editing endpoints (only auth site) under api/user/ while leaving host-based endpoints at api root. 2025-10-04 20:59:51 +00:00
LeoVasanko f24aaa295d More consistent shared styling between credential and session cards. 2025-10-04 20:32:27 +00:00
LeoVasanko 0af7aad28c Add host-based authentication, UTC timestamps, session management, and secure cookies; fix styling issues; refactor to remove module; update database schema for sessions and reset tokens. 2025-10-04 06:31:54 +00:00
LeoVasanko 24692fcfec Use git tag versioning for the Python project. 2025-10-03 04:07:11 +00:00
LeoVasanko 43850c218f Fix reset link logic to include /auth when no configured auth-host. 2025-10-03 03:57:20 +00:00
LeoVasanko 2f1578c4bc Refactor user-profile, restricted access and reset token registration as separate apps so the frontend does not need to guess which context it is running in.
Support user-navigable URLs at / as well as /auth/, allowing for a dedicated authentication site with pretty URLs.
2025-10-03 03:42:01 +00:00
LeoVasanko b4871c671f Create registration links on the same host (subdomain) that is being used by the one who creates it. 2025-10-03 00:22:02 +00:00
LeoVasanko 095768e07c Version 0.2.0 2025-10-01 05:04:53 +00:00
LeoVasanko 2f77753354 Make the login/reset/forbidden dialogs look better. 2025-10-01 05:03:51 +00:00
LeoVasanko ea871635e0 Admin app: guard rails extended, consistent styling, also share styling with main app. 2025-10-01 04:38:14 +00:00
LeoVasanko c3e4c18d5c Remove duplicate message from permission denied page. 2025-10-01 00:56:41 +00:00
LeoVasanko a7c23b31e7 Admin app divided to separate components. 2025-10-01 00:54:18 +00:00
LeoVasanko 3f45024396 Massive style redesign, WIP. 2025-09-30 09:02:49 +00:00
LeoVasanko 2d2e4e899d Simplified Caddy snippets (removed auth/all). 2025-09-29 08:00:19 +00:00
LeoVasanko 48f718191f Version 0.1.2 2025-09-29 07:46:49 +00:00
LeoVasanko e130bc5c0a Clear sessionStorage on logout. 2025-09-29 07:45:37 +00:00
LeoVasanko 2df444b80f chore: bump version to 0.1.1 2025-09-28 08:51:46 +00:00
LeoVasanko 5ad3ccb5ae Implement breadcrumb navigation. 2025-09-28 08:47:45 +00:00
90 changed files with 7744 additions and 2384 deletions
+1
View File
@@ -6,3 +6,4 @@ dist/
passkey-auth.sqlite passkey-auth.sqlite
/passkey/frontend-build /passkey/frontend-build
/test_*.py /test_*.py
passkey/_version.py
+99 -22
View File
@@ -1,28 +1,105 @@
# PassKey Auth API Documentation # PassKey Auth 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 PassKey Auth
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. Multihost (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 (nonauth) hosts show a lightweight account summary at `/` or `/auth/`, while other UI routes still redirect to the auth host.
- Restricted endpoints on nonauth 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 nonauth hosts |
| WebSocket (register/auth) | `/auth/ws/*` | `/auth/ws/*` | 404 on nonauth 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 (multihost) | 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 nonauth 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 nonauth hosts when auth host configured |
| `/auth/ws/authenticate` | Authenticate user & issue session | 404 on nonauth hosts when auth host configured |
## Redirection & Status Codes
| Scenario | Response |
|----------|----------|
| UI path on nonauth host (auth host configured) | 307 redirect to auth host; `/auth` prefix stripped |
| Reset token UI path on nonauth host | 307 redirect (token preserved) |
| Restricted API on nonauth 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 -13
View File
@@ -1,4 +1,5 @@
localhost { localhost {
# Setup the authentication site at /auth/
import auth/setup import auth/setup
# Only users with myapp:reports and auth admin permissions # Only users with myapp:reports and auth admin permissions
handle_path /reports { handle_path /reports {
@@ -22,16 +23,3 @@ localhost {
reverse_proxy :3000 reverse_proxy :3000
} }
} }
example.com {
# Public endpoints in handle blocks before auth
@public path /favicon.ico /.well-known/*
handle @public {
root * /var/www/
file_server
}
# The rest of the site protected, /auth/ reserved for auth service
import auth/all perm=auth:admin {
reverse_proxy :3000
}
}
+10
View File
@@ -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
}
}
-6
View File
@@ -1,6 +0,0 @@
# Enable auth site at /auth (setup) and require authentication on all paths
import setup
handle {
import require {args[0]}
{block}
}
+4 -2
View File
@@ -1,5 +1,7 @@
# Permission to use within your endpoints that need authentication/authorization, that # Permission to use within your endpoints that need authentication/authorization
# is different depending on the route (otherwise use auth/all). # Argument is mandatory and provides a query string to /auth/api/forward
# "" means just authentication
# perm=yourservice:login to require specific permission
forward_auth {$AUTH_UPSTREAM:localhost:4401} { forward_auth {$AUTH_UPSTREAM:localhost:4401} {
uri /auth/api/forward?{args[0]} uri /auth/api/forward?{args[0]}
header_up Connection keep-alive # Much higher performance header_up Connection keep-alive # Much higher performance
+13
View File
@@ -0,0 +1,13 @@
# Dependencies
node_modules/
# Test artifacts
test-data/
test-results/
playwright-report/
# Playwright
.playwright/
# Bun
bun.lockb
+157
View File
@@ -0,0 +1,157 @@
# PasskeyAuth E2E Tests
End-to-end tests for PasskeyAuth 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 PasskeyAuth server with a test database
2. Run all E2E tests against it
3. Clean up the server when done
### 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:4401` |
| `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
```
+20
View File
@@ -0,0 +1,20 @@
{
"name": "passkey-auth-e2e",
"version": "1.0.0",
"private": true,
"description": "E2E tests for PasskeyAuth 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",
"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"
}
}
+50
View File
@@ -0,0 +1,50 @@
import { defineConfig, devices } from '@playwright/test'
/**
* Playwright configuration for PasskeyAuth 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 passkey-auth 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',
],
},
},
},
],
})
+319
View File
@@ -0,0 +1,319 @@
import { type Page } from '@playwright/test'
import { existsSync, readFileSync } from 'fs'
import { join, dirname } from 'path'
import { fileURLToPath } from 'url'
const __dirname = dirname(fileURLToPath(import.meta.url))
/**
* 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 {
const stateFile = join(__dirname, '..', '..', 'test-data', 'test-state.json')
if (existsSync(stateFile)) {
try {
const state = JSON.parse(readFileSync(stateFile, 'utf-8'))
return state.resetToken
} catch {
return undefined
}
}
return undefined
}
/**
* 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
// Use the native WebAuthn API with the virtual authenticator
try {
// Convert base64url challenge to ArrayBuffer
const challenge = Uint8Array.from(atob(data.challenge.replace(/-/g, '+').replace(/_/g, '/')), c => c.charCodeAt(0))
// Build the credential creation options
const publicKeyCredentialCreationOptions: CredentialCreationOptions = {
publicKey: {
challenge: challenge,
rp: {
name: data.rp.name,
id: data.rp.id,
},
user: {
id: Uint8Array.from(atob(data.user.id.replace(/-/g, '+').replace(/_/g, '/')), c => c.charCodeAt(0)),
name: data.user.name,
displayName: data.user.displayName,
},
pubKeyCredParams: data.pubKeyCredParams,
authenticatorSelection: data.authenticatorSelection,
timeout: data.timeout,
attestation: data.attestation,
excludeCredentials: data.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
try {
// Convert base64url challenge to ArrayBuffer
const challenge = Uint8Array.from(atob(data.challenge.replace(/-/g, '+').replace(/_/g, '/')), c => c.charCodeAt(0))
// Build the credential request options
const publicKeyCredentialRequestOptions: CredentialRequestOptions = {
publicKey: {
challenge: challenge,
rpId: data.rpId,
timeout: data.timeout,
userVerification: data.userVerification,
allowCredentials: data.allowCredentials?.map((cred: any) => ({
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 response = await page.request.post(`${baseUrl}/auth/api/validate`, {
headers: {
'Cookie': `__Host-auth=${sessionToken}`,
},
})
return await response.json()
}
/**
* Get user info via the API.
*/
export async function getUserInfo(
page: Page,
baseUrl: string,
sessionToken: string
): Promise<any> {
const response = await page.request.post(`${baseUrl}/auth/api/user-info`, {
headers: {
'Cookie': `__Host-auth=${sessionToken}`,
},
})
return await response.json()
}
/**
* Logout via the API.
*/
export async function logout(
page: Page,
baseUrl: string,
sessionToken: string
): Promise<void> {
await page.request.post(`${baseUrl}/auth/api/logout`, {
headers: {
'Cookie': `__Host-auth=${sessionToken}`,
},
})
}
/**
* 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 response = await page.request.post(`${baseUrl}/auth/api/user/create-link`, {
headers: {
'Cookie': `__Host-auth=${sessionToken}`,
},
})
const data = await response.json()
// Extract token from URL (last path segment)
const url = new URL(data.url)
const token = url.pathname.split('/').pop() || ''
return { url: data.url, token }
}
+93
View File
@@ -0,0 +1,93 @@
import { test as base, expect, type CDPSession, type Page } from '@playwright/test'
/**
* 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.
*/
export const test = base.extend<{
virtualAuthenticator: VirtualAuthenticator
}>({
virtualAuthenticator: async ({ page }, use) => {
// Create virtual authenticator before test
const authenticator = await createVirtualAuthenticator(page)
// Run the test
await use(authenticator)
// Cleanup after test
await removeVirtualAuthenticator(authenticator)
},
})
export { expect }
+113
View File
@@ -0,0 +1,113 @@
import { spawn } from 'child_process'
import { join, dirname } from 'path'
import { existsSync, mkdirSync, rmSync, writeFileSync } from 'fs'
import { fileURLToPath } from 'url'
const __dirname = dirname(fileURLToPath(import.meta.url))
const testDataDir = join(__dirname, '..', 'test-data')
const stateFile = join(testDataDir, 'test-state.json')
const dbPath = join(testDataDir, 'test.sqlite')
interface TestState {
resetToken?: string
serverPid?: number
}
/**
* Global setup for E2E tests.
*
* This creates a fresh test database and starts the server,
* capturing 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
if (!existsSync(testDataDir)) {
mkdirSync(testDataDir, { recursive: true })
}
// Remove old database for clean state
if (existsSync(dbPath)) {
console.log(' Removing old test database...')
rmSync(dbPath)
}
// Remove any wal/shm files too
for (const ext of ['-wal', '-shm']) {
const file = dbPath + ext
if (existsSync(file)) rmSync(file)
}
console.log(' Starting server with fresh database...')
const state: TestState = {}
// Start the server using Node's spawn
const serverProcess = spawn('uv', [
'run', 'passkey-auth', 'serve', ':4401',
'--rp-id', 'localhost',
'--origin', 'http://localhost:4401'
], {
cwd: testDataDir, // Run from test-data so DB is created there
env: {
...process.env,
},
stdio: ['ignore', 'pipe', 'pipe'],
})
state.serverPid = serverProcess.pid
// Capture output to find reset token
const resetTokenPromise = new Promise<string>((resolve, reject) => {
const timeout = setTimeout(() => {
reject(new Error('Timed out waiting for server bootstrap (30s)'))
}, 30000)
let output = ''
const handleData = (data: Buffer) => {
const text = data.toString()
output += text
process.stdout.write(text) // Echo to console
// Look for the reset token URL in the output
// Format: http://localhost:4401/auth/{token} where token is word.word.word.word.word (dot separated)
const match = output.match(/http:\/\/localhost:\d+\/auth\/([a-z]+(?:\.[a-z]+)+)/)
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
}
// Save state for tests
writeFileSync(stateFile, JSON.stringify(state, null, 2))
console.log(' ✅ E2E test environment ready\n')
}
+63
View File
@@ -0,0 +1,63 @@
import { join, dirname } from 'path'
import { existsSync, rmSync, readFileSync } 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')
interface TestState {
resetToken?: string
serverPid?: number
}
/**
* Global teardown for E2E tests.
*
* This cleans up the test server and optionally removes the test database.
*/
export default async function globalTeardown() {
console.log('\n🧹 Cleaning up E2E test environment...\n')
// Read state file to get server PID
if (existsSync(stateFile)) {
try {
const state: TestState = JSON.parse(readFileSync(stateFile, 'utf-8'))
if (state.serverPid) {
console.log(` Stopping server (PID: ${state.serverPid})...`)
try {
process.kill(state.serverPid, 'SIGTERM')
// Wait a moment for graceful shutdown
await new Promise(r => setTimeout(r, 500))
} 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)
}
}
console.log(' ✅ Cleanup complete\n')
}
+205
View File
@@ -0,0 +1,205 @@
import { test, expect } from './fixtures/virtual-authenticator'
import {
registerPasskey,
authenticatePasskey,
validateSession,
getUserInfo,
logout,
getBootstrapResetToken,
createDeviceLink,
} from './fixtures/passkey-helpers'
/**
* E2E tests for PasskeyAuth using Chrome's Virtual Authenticator.
*
* These tests exercise the complete WebAuthn flow:
* 1. Registration via WebSocket using bootstrap reset token
* 2. Authentication via WebSocket
* 3. Session validation
* 4. User info retrieval
* 5. Logout
*
* The virtual authenticator simulates a hardware passkey device,
* allowing fully automated testing without physical hardware.
*/
test.describe('Passkey Authentication E2E', () => {
const baseUrl = process.env.BASE_URL || 'http://localhost:4401'
test.describe.configure({ mode: 'serial' })
// Shared state across tests in this describe block
let sessionToken: string
let userUuid: string
let credentialUuid: string
let resetToken: string | undefined
test.beforeAll(() => {
// Get the bootstrap reset token from global setup
resetToken = getBootstrapResetToken()
if (!resetToken) {
console.warn('⚠️ No reset token found - registration test may fail')
} else {
console.log(`📝 Using reset token: ${resetToken}`)
}
})
test('should load the auth page', async ({ page }) => {
// Navigate to auth page to establish origin for WebAuthn
await page.goto('/auth/')
await expect(page).toHaveTitle(/.*/)
// Page should load - 401 errors are expected since user is not logged in
await page.waitForTimeout(500)
// Just verify the page loaded without JS errors (network 401s are OK)
console.log('✓ Auth page loaded successfully')
})
test('should register admin passkey via WebSocket using reset token', async ({ page, virtualAuthenticator }) => {
test.skip(!resetToken, 'No reset token available from bootstrap')
// Must visit the page first to establish origin
await page.goto('/auth/')
// Perform registration via WebSocket with virtual authenticator
// Using the bootstrap reset token for the admin user
const result = await registerPasskey(page, baseUrl, {
resetToken: resetToken,
displayName: 'Admin User',
})
// Verify registration result
expect(result.session_token).toBeDefined()
expect(result.session_token).toHaveLength(16)
expect(result.user_uuid).toBeDefined()
expect(result.credential_uuid).toBeDefined()
expect(result.message).toContain('successfully')
// Store for subsequent tests
sessionToken = result.session_token
userUuid = result.user_uuid
credentialUuid = result.credential_uuid
console.log(`✓ Registered user: ${userUuid}`)
console.log(`✓ Credential: ${credentialUuid}`)
console.log(`✓ Session token: ${sessionToken.substring(0, 4)}...`)
})
test('should validate the session token', async ({ page }) => {
// Skip if registration didn't run
test.skip(!sessionToken, 'Requires successful registration')
const validation = await validateSession(page, baseUrl, sessionToken)
expect(validation.valid).toBe(true)
expect(validation.user_uuid).toBe(userUuid)
console.log(`✓ Session validated for user: ${validation.user_uuid}`)
})
test('should retrieve user info', async ({ page }) => {
test.skip(!sessionToken, 'Requires successful registration')
const userInfo = await getUserInfo(page, baseUrl, sessionToken)
expect(userInfo.user.user_uuid).toBe(userUuid)
expect(userInfo.user.user_name).toBe('Admin User')
expect(userInfo.credentials).toBeDefined()
expect(userInfo.credentials.length).toBeGreaterThanOrEqual(1)
console.log(`✓ User info retrieved: ${userInfo.user.user_name}`)
console.log(`✓ Credentials count: ${userInfo.credentials.length}`)
})
test('should authenticate with existing passkey', async ({ page, virtualAuthenticator }) => {
test.skip(!sessionToken, 'Requires successful registration')
// Navigate to page (required for WebAuthn origin)
await page.goto('/auth/')
// The virtual authenticator in this context is new and doesn't have credentials.
// Create a device link using the current session, then register a new credential.
const deviceLink = await createDeviceLink(page, baseUrl, sessionToken)
console.log(`✓ Created device link with token: ${deviceLink.token}`)
// Register a new credential using the device link
const regResult = await registerPasskey(page, baseUrl, {
resetToken: deviceLink.token,
displayName: 'Admin User (test device)'
})
console.log(`✓ Added test credential: ${regResult.credential_uuid}`)
// Now logout and authenticate with the fresh credential
await logout(page, baseUrl, regResult.session_token)
console.log('✓ Logged out')
// Authenticate with the virtual authenticator (now has a valid credential)
const result = await authenticatePasskey(page, baseUrl)
expect(result.session_token).toBeDefined()
expect(result.session_token).toHaveLength(16)
expect(result.user_uuid).toBe(userUuid)
// Update session token for subsequent tests
sessionToken = result.session_token
console.log(`✓ Authenticated as user: ${result.user_uuid}`)
console.log(`✓ New session token: ${sessionToken.substring(0, 4)}...`)
})
test('should validate new session after authentication', async ({ page }) => {
test.skip(!sessionToken, 'Requires successful authentication')
const validation = await validateSession(page, baseUrl, sessionToken)
expect(validation.valid).toBe(true)
expect(validation.user_uuid).toBe(userUuid)
console.log(`✓ New session validated`)
})
test('should logout successfully', async ({ page }) => {
test.skip(!sessionToken, 'Requires valid session')
await logout(page, baseUrl, sessionToken)
// Session should no longer be valid
const response = await page.request.post(`${baseUrl}/auth/api/validate`, {
headers: {
'Cookie': `__Host-auth=${sessionToken}`,
},
failOnStatusCode: false,
})
expect(response.status()).toBe(401)
console.log(`✓ Logout successful, session invalidated`)
})
})
test.describe('Session Management', () => {
const baseUrl = process.env.BASE_URL || 'http://localhost:4401'
test('should reject invalid session token', async ({ page }) => {
const response = await page.request.post(`${baseUrl}/auth/api/validate`, {
headers: {
'Cookie': '__Host-auth=invalid_token_123',
},
failOnStatusCode: false,
})
// Server may return 400 (bad format) or 401 (unauthorized)
expect([400, 401]).toContain(response.status())
console.log(`✓ Invalid token correctly rejected`)
})
test('should reject missing session token', async ({ page }) => {
const response = await page.request.post(`${baseUrl}/auth/api/validate`, {
failOnStatusCode: false,
})
expect(response.status()).toBe(401)
console.log(`✓ Missing token correctly rejected`)
})
})
+15
View File
@@ -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"]
}
+166
View File
@@ -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>PassKey Auth - 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>🔐 PassKey Auth</h1>
<p class="subtitle">Development server demonstration page.</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, Nxinx):</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>
+203
View File
@@ -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>
+561
View File
@@ -0,0 +1,561 @@
<script setup>
import { ref, onMounted, onUnmounted, computed, watch } from 'vue'
import Breadcrumbs from '@/components/Breadcrumbs.vue'
import CredentialList from '@/components/CredentialList.vue'
import UserBasicInfo from '@/components/UserBasicInfo.vue'
import RegistrationLinkModal from '@/components/RegistrationLinkModal.vue'
import StatusMessage from '@/components/StatusMessage.vue'
import LoadingView from '@/components/LoadingView.vue'
import AuthRequiredMessage from '@/components/AccessDenied.vue'
import AdminOverview from '@/admin/AdminOverview.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 { getSettings, adminUiPath, makeUiHref } from '@/utils/settings'
import { apiJson } from '@/utils/api'
const info = ref(null)
const loading = ref(true)
const loadingMessage = ref('Loading...')
const authenticated = ref(false)
const showBackMessage = ref(false)
const error = ref(null)
const orgs = ref([])
const permissions = ref([])
const currentOrgId = ref(null) // UUID of selected org for detail view
const currentUserId = ref(null) // UUID for user detail view
const userDetail = ref(null) // cached user detail object
const userLink = ref(null) // latest generated registration link
const userLinkExpires = ref(null)
const authStore = useAuthStore()
const addingOrgForPermission = ref(null)
const PERMISSION_ID_PATTERN = '^[A-Za-z0-9:._~-]+$'
const editingPermId = ref(null)
const renameIdValue = ref('')
const editingPermDisplay = ref(null)
const renameDisplayValue = ref('')
const dialog = ref({ type: null, data: null, busy: false, error: '' })
const safeIdRegex = /[^A-Za-z0-9:._~-]/g
function sanitizeRenameId() { if (renameIdValue.value) renameIdValue.value = renameIdValue.value.replace(safeIdRegex, '') }
function handleGlobalClick(e) {
if (!addingOrgForPermission.value) return
const menu = e.target.closest('.org-add-menu')
const trigger = e.target.closest('.add-org-btn')
if (!menu && !trigger) {
addingOrgForPermission.value = null
}
}
onMounted(async () => {
document.addEventListener('click', handleGlobalClick)
window.addEventListener('hashchange', parseHash)
const settings = await getSettings()
if (settings?.rp_name) document.title = settings.rp_name + ' Admin'
await load()
})
onUnmounted(() => {
document.removeEventListener('click', handleGlobalClick)
window.removeEventListener('hashchange', parseHash)
})
// Build a summary: for each permission id -> { orgs: Set(org_display_name), userCount }
const permissionSummary = computed(() => {
const summary = {}
for (const o of orgs.value) {
const orgBase = { uuid: o.uuid, display_name: o.display_name }
const orgPerms = new Set(o.permissions || [])
// Org-level permissions (direct) - only count if org can grant them
for (const pid of o.permissions || []) {
if (!summary[pid]) summary[pid] = { orgs: [], orgSet: new Set(), userCount: 0 }
if (!summary[pid].orgSet.has(o.uuid)) {
summary[pid].orgs.push(orgBase)
summary[pid].orgSet.add(o.uuid)
}
}
// Role-based permissions (inheritance) - only count if org can grant them
for (const r of o.roles) {
for (const pid of r.permissions) {
// Only count if the org can grant this permission
if (!orgPerms.has(pid)) continue
if (!summary[pid]) summary[pid] = { orgs: [], orgSet: new Set(), userCount: 0 }
if (!summary[pid].orgSet.has(o.uuid)) {
summary[pid].orgs.push(orgBase)
summary[pid].orgSet.add(o.uuid)
}
summary[pid].userCount += r.users.length
}
}
}
const display = {}
for (const [pid, v] of Object.entries(summary)) {
display[pid] = { orgs: v.orgs.sort((a,b)=>a.display_name.localeCompare(b.display_name)), userCount: v.userCount }
}
return display
})
function renamePermissionDisplay(p) { openDialog('perm-display', { permission: p, id: p.id, display_name: p.display_name }) }
async function refreshPermissionsContext() {
// Reload both lists so All Permissions table shows new associations promptly.
await Promise.all([loadPermissions(), loadOrgs()])
}
async function attachPermissionToOrg(pid, orgUuid) {
if (!orgUuid) return
try {
const params = new URLSearchParams({ permission_id: pid })
await apiJson(`/auth/api/admin/orgs/${orgUuid}/permission?${params.toString()}`, { method: 'POST' })
await loadOrgs()
} catch (e) {
authStore.showMessage(e.message || 'Failed to add permission to org')
}
}
async function detachPermissionFromOrg(pid, orgUuid) {
openDialog('confirm', { message: 'Remove permission from this org?', action: async () => {
try {
const params = new URLSearchParams({ permission_id: pid })
await apiJson(`/auth/api/admin/orgs/${orgUuid}/permission?${params.toString()}`, { method: 'DELETE' })
await loadOrgs()
} catch (e) {
authStore.showMessage(e.message || 'Failed to remove permission from org')
}
} })
}
function parseHash() {
const h = window.location.hash || ''
currentOrgId.value = null
currentUserId.value = null
if (h.startsWith('#org/')) {
currentOrgId.value = h.slice(5)
} else if (h.startsWith('#user/')) {
currentUserId.value = h.slice(6)
}
}
async function loadOrgs() {
const data = await apiJson('/auth/api/admin/orgs')
orgs.value = data.map(o => {
const roles = o.roles.map(r => ({ ...r, org_uuid: o.uuid, users: [] }))
const roleMap = Object.fromEntries(roles.map(r => [r.display_name, r]))
for (const u of o.users || []) {
if (roleMap[u.role]) roleMap[u.role].users.push(u)
}
return { ...o, roles }
})
}
async function loadPermissions() {
permissions.value = await apiJson('/auth/api/admin/permissions')
}
async function loadUserInfo() {
info.value = await apiJson('/auth/api/user-info', { method: 'POST' })
authenticated.value = true
}
async function load() {
loading.value = true
loadingMessage.value = 'Loading...'
error.value = null
try {
// Load admin data first - apiJson will handle 401/403 with iframe authentication
await Promise.all([loadOrgs(), loadPermissions()])
// If we get here, user has admin access - now fetch user info for display
await loadUserInfo()
if (!info.value.is_global_admin && info.value.is_org_admin && orgs.value.length === 1) {
if (!window.location.hash || window.location.hash === '#overview') {
currentOrgId.value = orgs.value[0].uuid
window.location.hash = `#org/${currentOrgId.value}`
authStore.showMessage(`Navigating to ${orgs.value[0].display_name} Administration`, 'info', 3000)
} else {
parseHash()
}
} else parseHash()
} catch (e) {
if (e.name === 'AuthCancelledError') {
showBackMessage.value = true
} else {
error.value = e.message
}
} finally {
loading.value = false
}
}
// Org actions
function createOrg() { openDialog('org-create', {}) }
function updateOrg(org) { openDialog('org-update', { org, name: org.display_name }) }
function editUserName(user) { openDialog('user-update-name', { user, name: user.display_name }) }
function deleteOrg(org) {
if (!info.value?.is_global_admin) { authStore.showMessage('Global admin only'); return }
openDialog('confirm', { message: `Delete organization ${org.display_name}?`, action: async () => {
await apiJson(`/auth/api/admin/orgs/${org.uuid}`, { method: 'DELETE' })
await Promise.all([loadOrgs(), loadPermissions()])
} })
}
function createUserInRole(org, role) { openDialog('user-create', { org, role }) }
async function moveUserToRole(org, user, targetRoleDisplayName) {
if (user.role === targetRoleDisplayName) return
try {
await apiJson(`/auth/api/admin/orgs/${org.uuid}/users/${user.uuid}/role`, {
method: 'PUT',
body: { role: targetRoleDisplayName }
})
await loadOrgs()
} catch (e) {
authStore.showMessage(e.message || 'Failed to update user role')
}
}
function onUserDragStart(e, user, org_uuid) {
e.dataTransfer.effectAllowed = 'move'
e.dataTransfer.setData('text/plain', JSON.stringify({ user_uuid: user.uuid, org_uuid }))
}
function onRoleDragOver(e) {
e.preventDefault()
e.dataTransfer.dropEffect = 'move'
}
function onRoleDrop(e, org, role) {
e.preventDefault()
try {
const data = JSON.parse(e.dataTransfer.getData('text/plain'))
if (data.org_uuid !== org.uuid) return // only within same org
const user = org.roles.flatMap(r => r.users).find(u => u.uuid === data.user_uuid)
if (user) moveUserToRole(org, user, role.display_name)
} catch (_) { /* ignore */ }
}
// (legacy function retained but unused in UI)
async function addOrgPermission() { /* obsolete */ }
async function removeOrgPermission() { /* obsolete */ }
// Role actions
function createRole(org) { openDialog('role-create', { org }) }
function updateRole(role) { openDialog('role-update', { role, name: role.display_name }) }
function deleteRole(role) {
openDialog('confirm', { message: `Delete role ${role.display_name}?`, action: async () => {
await apiJson(`/auth/api/admin/orgs/${role.org_uuid}/roles/${role.uuid}`, { method: 'DELETE' })
await loadOrgs()
} })
}
async function toggleRolePermission(role, pid, checked) {
// Calculate new permissions array
const newPermissions = checked
? [...role.permissions, pid]
: role.permissions.filter(p => p !== pid)
// Optimistic update
const prevPermissions = [...role.permissions]
role.permissions = newPermissions
try {
await apiJson(`/auth/api/admin/orgs/${role.org_uuid}/roles/${role.uuid}`, {
method: 'PUT',
body: { display_name: role.display_name, permissions: newPermissions }
})
await loadOrgs()
} catch (e) {
authStore.showMessage(e.message || 'Failed to update role permission')
role.permissions = prevPermissions // revert
}
}
// Permission actions
function updatePermission(p) { openDialog('perm-display', { permission: p }) }
function deletePermission(p) {
openDialog('confirm', { message: `Delete permission ${p.id}?`, action: async () => {
const params = new URLSearchParams({ permission_id: p.id })
await apiJson(`/auth/api/admin/permission?${params.toString()}`, { method: 'DELETE' })
await loadPermissions()
} })
}
function reloadPage() {
window.location.reload()
}
const selectedOrg = computed(() => orgs.value.find(o => o.uuid === currentOrgId.value) || null)
function openOrg(o) {
window.location.hash = `#org/${o.uuid}`
}
function goOverview() {
window.location.hash = '#overview'
}
function openUser(u) {
window.location.hash = `#user/${u.uuid}`
}
const selectedUser = computed(() => {
if (!currentUserId.value) return null
for (const o of orgs.value) {
for (const r of o.roles) {
const u = r.users.find(x => x.uuid === currentUserId.value)
if (u) return { ...u, org_uuid: o.uuid, role_display_name: r.display_name }
}
}
return null
})
const pageHeading = computed(() => {
if (selectedUser.value) return 'Admin: User'
if (selectedOrg.value) return 'Admin: Org'
return ((authStore.settings?.rp_name) || 'Master') + ' Admin'
})
// Breadcrumb entries for admin app.
const breadcrumbEntries = computed(() => {
const entries = [
{ label: 'Auth', href: makeUiHref() },
{ label: 'Admin', href: adminUiPath() }
]
// Determine organization for user view if selectedOrg not explicitly chosen.
let orgForUser = null
if (selectedUser.value) {
orgForUser = orgs.value.find(o => o.uuid === selectedUser.value.org_uuid) || null
}
const orgToShow = selectedOrg.value || orgForUser
if (orgToShow) {
entries.push({ label: orgToShow.display_name, href: `#org/${orgToShow.uuid}` })
}
if (selectedUser.value) {
entries.push({ label: selectedUser.value.display_name || 'User', href: `#user/${selectedUser.value.uuid}` })
}
return entries
})
watch(selectedUser, async (u) => {
if (!u) { userDetail.value = null; return }
try {
userDetail.value = await apiJson(`/auth/api/admin/orgs/${u.org_uuid}/users/${u.uuid}`)
} catch (e) {
userDetail.value = { error: e.message }
}
})
const showRegModal = ref(false)
function generateUserRegistrationLink(u) {
showRegModal.value = true
}
function onLinkCopied() {
authStore.showMessage('Link copied to clipboard!')
}
function copy(text) {
if (!text) return
navigator.clipboard.writeText(text)
.catch(()=>{})
}
function permissionDisplayName(id) {
return permissions.value.find(p => p.id === id)?.display_name || id
}
async function toggleOrgPermission(org, permId, checked) {
// Build next permission list
const has = org.permissions.includes(permId)
if (checked && has) return
if (!checked && !has) return
const next = checked ? [...org.permissions, permId] : org.permissions.filter(p => p !== permId)
// Optimistic update
const prev = [...org.permissions]
org.permissions = next
try {
const params = new URLSearchParams({ permission_id: permId })
await apiJson(`/auth/api/admin/orgs/${org.uuid}/permission?${params.toString()}`, { method: checked ? 'POST' : 'DELETE' })
await loadOrgs()
} catch (e) {
authStore.showMessage(e.message || 'Failed to update organization permission')
org.permissions = prev // revert
}
}
function openDialog(type, data) { dialog.value = { type, data, busy: false, error: '' } }
function closeDialog() { dialog.value = { type: null, data: null, busy: false, error: '' } }
async function refreshUserDetail() {
await loadOrgs()
if (selectedUser.value) {
try {
userDetail.value = await apiJson(`/auth/api/admin/orgs/${selectedUser.value.org_uuid}/users/${selectedUser.value.uuid}`)
} catch (e) { authStore.showMessage(e.message || 'Failed to reload user', 'error') }
}
}
async function onUserNameSaved() {
await refreshUserDetail()
authStore.showMessage('User renamed', 'success', 1500)
}
async function submitDialog() {
if (!dialog.value.type || dialog.value.busy) return
dialog.value.busy = true; dialog.value.error = ''
try {
const t = dialog.value.type
if (t === 'org-create') {
const name = dialog.value.data.name?.trim(); if (!name) throw new Error('Name required')
await apiJson('/auth/api/admin/orgs', { method: 'POST', body: { display_name: name, permissions: [] } })
await Promise.all([loadOrgs(), loadPermissions()])
} else if (t === 'org-update') {
const { org } = dialog.value.data; const name = dialog.value.data.name?.trim(); if (!name) throw new Error('Name required')
await apiJson(`/auth/api/admin/orgs/${org.uuid}`, { method: 'PUT', body: { display_name: name, permissions: org.permissions } })
await loadOrgs()
} else if (t === 'role-create') {
const { org } = dialog.value.data; const name = dialog.value.data.name?.trim(); if (!name) throw new Error('Name required')
await apiJson(`/auth/api/admin/orgs/${org.uuid}/roles`, { method: 'POST', body: { display_name: name, permissions: [] } })
await loadOrgs()
} else if (t === 'role-update') {
const { role } = dialog.value.data; const name = dialog.value.data.name?.trim(); if (!name) throw new Error('Name required')
await apiJson(`/auth/api/admin/orgs/${role.org_uuid}/roles/${role.uuid}`, { method: 'PUT', body: { display_name: name, permissions: role.permissions } })
await loadOrgs()
} 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')
await apiJson(`/auth/api/admin/orgs/${org.uuid}/users`, { method: 'POST', body: { display_name: name, role: role.display_name } })
await loadOrgs()
} 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')
await apiJson(`/auth/api/admin/orgs/${user.org_uuid}/users/${user.uuid}/display-name`, { method: 'PUT', body: { display_name: name } })
await onUserNameSaved()
} else if (t === 'perm-display') {
const { permission } = dialog.value.data
const newId = dialog.value.data.id?.trim()
const newDisplay = dialog.value.data.display_name?.trim()
if (!newDisplay) throw new Error('Display name required')
if (!newId) throw new Error('ID required')
if (newId !== permission.id) {
// ID changed, use rename endpoint
await apiJson('/auth/api/admin/permission/rename', { method: 'POST', body: { old_id: permission.id, new_id: newId, display_name: newDisplay } })
} else if (newDisplay !== permission.display_name) {
// Only display name changed
const params = new URLSearchParams({ permission_id: permission.id, display_name: newDisplay })
await apiJson(`/auth/api/admin/permission?${params.toString()}`, { method: 'PUT' })
}
await loadPermissions()
} else if (t === 'perm-create') {
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')
await apiJson('/auth/api/admin/permissions', { method: 'POST', body: { id, display_name } })
await loadPermissions(); dialog.value.data.display_name = ''; dialog.value.data.id = ''
} else if (t === 'confirm') {
const action = dialog.value.data.action; if (action) await action()
}
closeDialog()
} catch (e) {
dialog.value.error = e.message || 'Error'
} finally { dialog.value.busy = false }
}
</script>
<template>
<div class="app-shell admin-shell">
<StatusMessage />
<main class="app-main">
<LoadingView v-if="loading" :message="loadingMessage" />
<AuthRequiredMessage
v-else-if="showBackMessage"
@reload="reloadPage"
/>
<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">
<div class="section-body admin-section-body">
<div v-if="error" class="surface surface--tight error">{{ error }}</div>
<div v-else class="admin-panels">
<AdminOverview
v-if="!selectedUser && !selectedOrg && (info.is_global_admin || info.is_org_admin)"
:info="info"
:orgs="orgs"
:permissions="permissions"
:permission-summary="permissionSummary"
@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
v-else-if="selectedUser"
:selected-user="selectedUser"
:user-detail="userDetail"
:selected-org="selectedOrg"
:loading="loading"
:show-reg-modal="showRegModal"
@generate-user-registration-link="generateUserRegistrationLink"
@go-overview="goOverview"
@open-org="openOrg"
@on-user-name-saved="onUserNameSaved"
@refresh-user-detail="refreshUserDetail"
@edit-user-name="editUserName"
@close-reg-modal="showRegModal = false"
/>
<AdminOrgDetail
v-else-if="selectedOrg"
:selected-org="selectedOrg"
:permissions="permissions"
@update-org="updateOrg"
@create-role="createRole"
@update-role="updateRole"
@delete-role="deleteRole"
@create-user-in-role="createUserInRole"
@open-user="openUser"
@toggle-role-permission="toggleRolePermission"
@on-role-drag-over="onRoleDragOver"
@on-role-drop="onRoleDrop"
@on-user-drag-start="onUserDragStart"
/>
</div>
</div>
</section>
</section>
</main>
<AdminDialogs
:dialog="dialog"
:permission-id-pattern="PERMISSION_ID_PATTERN"
@submit-dialog="submitDialog"
@close-dialog="closeDialog"
/>
</div>
</template>
<style scoped>
.view-admin { padding-bottom: var(--space-3xl); }
.view-header { display: flex; flex-direction: column; gap: var(--space-sm); }
.admin-section { margin-top: var(--space-xl); }
.admin-section-body { display: flex; flex-direction: column; gap: var(--space-xl); }
.admin-panels { display: flex; flex-direction: column; gap: var(--space-xl); }
</style>
@@ -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'
@@ -3,10 +3,10 @@
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Authentication</title> <title>Auth Profile</title>
</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,53 @@
<template>
<RestrictedAuth
:mode="authMode"
@authenticated="handleAuthenticated"
@back="handleBack"
/>
</template>
<script setup>
import { computed, onMounted } from 'vue'
import RestrictedAuth from '@/components/RestrictedAuth.vue'
// 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(() => {
postToParent({
type: 'auth-ready'
})
window.addEventListener('keydown', (event) => {
if (event.key === 'Escape') {
handleBack()
}
})
})
</script>
+4
View File
@@ -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>
+5
View File
@@ -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>
+12
View File
@@ -0,0 +1,12 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Access Restricted</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/int/forward/main.js"></script>
</body>
</html>
+5
View File
@@ -0,0 +1,5 @@
import { createApp } from 'vue'
import App from './RestrictedForward.vue'
import '@/assets/style.css'
createApp(App).mount('#app')
+224
View File
@@ -0,0 +1,224 @@
<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="surface surface--tight" style="max-width: 560px; margin: 0 auto; width: 100%;">
<header class="view-header" style="text-align: center;">
<h1>🔑 Registration</h1>
<p class="view-lede">
{{ subtitleMessage }}
</p>
</header>
<section class="section-block" v-if="initializing">
<div class="section-body center">
<p>Loading reset details</p>
</div>
</section>
<section class="section-block" v-else-if="!canRegister">
<div class="section-body center">
<p>{{ errorMessage }}</p>
<div class="button-row center" style="justify-content: center;">
<button class="btn-secondary" @click="goHome">Return to sign-in</button>
</div>
</div>
</section>
<section class="section-block" v-else>
<div class="section-body">
<label class="name-edit">
<span>👤 Name</span>
<input
type="text"
v-model="displayName"
:disabled="loading"
maxlength="64"
@keyup.enter="registerPasskey"
/>
</label>
<button
class="btn-primary"
:disabled="loading"
@click="registerPasskey"
>
{{ loading ? 'Registering…' : 'Register Passkey' }}
</button>
</div>
</section>
</div>
</main>
</div>
</template>
<script setup>
import { computed, onMounted, reactive, ref } from 'vue'
import passkey from '@/utils/passkey'
import { getSettings, uiBasePath } from '@/utils/settings'
import { apiJson, ApiError, getUserFriendlyErrorMessage } from '@/utils/api'
const status = reactive({
show: false,
message: '',
type: 'info'
})
const initializing = ref(true)
const loading = ref(false)
const token = ref('')
const settings = ref(null)
const userInfo = ref(null)
const displayName = ref('')
const errorMessage = ref('')
let statusTimer = null
const sessionDescriptor = computed(() => userInfo.value?.session_type || 'your enrollment')
const subtitleMessage = computed(() => {
if (initializing.value) return 'Preparing your secure enrollment…'
if (!canRegister.value) return 'This reset link is no longer valid.'
return `Finish up ${sessionDescriptor.value}. You may edit the name below if needed, and it will be saved to your passkey.`
})
const basePath = computed(() => uiBasePath())
const canRegister = computed(() => !!(token.value && userInfo.value))
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) document.title = `${data.rp_name} · Passkey Setup`
} catch (error) {
console.warn('Unable to load settings', error)
}
}
async function fetchUserInfo() {
if (!token.value) return
try {
userInfo.value = await apiJson(`/auth/api/user-info?reset=${encodeURIComponent(token.value)}`, {
method: 'POST'
})
displayName.value = userInfo.value?.user?.user_name || ''
} catch (error) {
console.error('Failed to load user info', error)
const message = error instanceof ApiError
? (error.data?.detail || 'Reset link is invalid or expired.')
: getUserFriendlyErrorMessage(error)
errorMessage.value = message
showMessage(message, 'error', 0)
}
}
async function registerPasskey() {
if (!canRegister.value || loading.value) return
loading.value = true
showMessage('Starting passkey registration…', 'info')
let result
try {
const nameValue = displayName.value.trim() || null
result = await passkey.register(token.value, nameValue)
} catch (error) {
loading.value = false
const message = error?.message || 'Passkey registration cancelled'
const cancelled = message === 'Passkey registration cancelled'
showMessage(cancelled ? message : `Registration failed: ${message}`, cancelled ? 'info' : 'error', 4000)
return
}
try {
await setSessionCookie(result)
} catch (error) {
loading.value = false
const message = error?.message || 'Failed to establish session'
showMessage(message, 'error', 4000)
return
}
showMessage('Passkey registered successfully!', 'success', 800)
setTimeout(() => { loading.value = false; goHome() }, 800)
}
async function setSessionCookie(result) {
if (!result?.session_token) {
throw new Error('Registration response missing session_token')
}
return await apiJson('/auth/api/set-session', {
method: 'POST',
headers: {
Authorization: `Bearer ${result.session_token}`
}
})
}
function goHome() {
const target = uiBasePath.value || '/auth/'
if (window.location.pathname !== target) {
history.replaceState(null, '', target)
}
window.location.reload()
}
function extractTokenFromPath() {
const segments = window.location.pathname.split('/').filter(Boolean)
if (!segments.length) return ''
const candidate = segments[segments.length - 1]
const prefix = segments.slice(0, -1)
if (prefix.length > 1) return ''
if (prefix.length === 1 && prefix[0] !== 'auth') return ''
if (!candidate.includes('.')) return ''
return candidate
}
onMounted(async () => {
token.value = extractTokenFromPath()
await fetchSettings()
if (!token.value) {
const message = 'Reset link is missing or malformed.'
errorMessage.value = message
showMessage(message, 'error', 0)
initializing.value = false
return
}
await fetchUserInfo()
initializing.value = false
})
</script>
<style scoped>
.center {
text-align: center;
}
.button-row.center {
display: flex;
justify-content: center;
}
.section-body {
gap: 1.25rem;
}
.name-edit span {
color: var(--color-text-muted);
font-size: 0.9rem;
}
</style>
+12
View File
@@ -0,0 +1,12 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Complete Passkey Setup</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/int/reset/main.js"></script>
</body>
</html>
+5
View File
@@ -0,0 +1,5 @@
import { createApp } from 'vue'
import ResetApp from './ResetApp.vue'
import '@/assets/style.css'
createApp(ResetApp).mount('#app')
+1
View File
@@ -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": {
-59
View File
@@ -1,59 +0,0 @@
<template>
<div>
<StatusMessage />
<LoginView v-if="store.currentView === 'login'" />
<ProfileView v-if="store.currentView === 'profile'" />
<DeviceLinkView v-if="store.currentView === 'device-link'" />
<ResetView v-if="store.currentView === 'reset'" />
<PermissionDeniedView v-if="store.currentView === 'permission-denied'" />
</div>
</template>
<script setup>
import { onMounted } 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'
import ResetView from '@/components/ResetView.vue'
import PermissionDeniedView from '@/components/PermissionDeniedView.vue'
const store = useAuthStore()
onMounted(async () => {
// Detect restricted mode:
// We only allow full functionality on the exact /auth/ (or /auth) path.
// Any other path (including /, /foo, /auth/admin, etc.) is treated as restricted
// so the app will only show login or permission denied views.
const path = location.pathname
if (!(path === '/auth/' || path === '/auth')) {
store.setRestrictedMode(true)
}
// 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)
}
// Capture reset token from query parameter and then remove it
const params = new URLSearchParams(location.search)
const reset = params.get('reset')
if (reset) {
store.resetToken = reset
// Remove query param to avoid lingering in history / clipboard
const targetPath = '/auth/'
const currentPath = location.pathname.endsWith('/') ? location.pathname : location.pathname + '/'
history.replaceState(null, '', currentPath.startsWith('/auth') ? '/auth/' : targetPath)
}
try {
await store.loadUserInfo()
} catch (error) {
console.log('Failed to load user info:', error)
store.currentView = 'login'
}
store.selectView()
})
</script>
-877
View File
@@ -1,877 +0,0 @@
<script setup>
import { ref, onMounted, onBeforeUnmount, computed, watch } from 'vue'
import CredentialList from '@/components/CredentialList.vue'
import UserBasicInfo from '@/components/UserBasicInfo.vue'
import RegistrationLinkModal from '@/components/RegistrationLinkModal.vue'
import StatusMessage from '@/components/StatusMessage.vue'
import { useAuthStore } from '@/stores/auth'
const info = ref(null)
const loading = ref(true)
const error = ref(null)
const orgs = ref([])
const permissions = ref([])
const currentOrgId = ref(null) // UUID of selected org for detail view
const currentUserId = ref(null) // UUID for user detail view
const userDetail = ref(null) // cached user detail object
const userLink = ref(null) // latest generated registration link
const userLinkExpires = ref(null)
const authStore = useAuthStore()
const addingOrgForPermission = ref(null)
const PERMISSION_ID_PATTERN = '^[A-Za-z0-9:._~-]+$'
const showCreatePermission = ref(false)
const newPermId = ref('')
const newPermName = ref('')
const editingPermId = ref(null)
const renameIdValue = ref('')
const dialog = ref({ type: null, data: null, busy: false, error: '' })
const safeIdRegex = /[^A-Za-z0-9:._~-]/g
function sanitizeNewId() { if (newPermId.value) newPermId.value = newPermId.value.replace(safeIdRegex, '') }
function sanitizeRenameId() { if (renameIdValue.value) renameIdValue.value = renameIdValue.value.replace(safeIdRegex, '') }
function handleGlobalClick(e) {
if (!addingOrgForPermission.value) return
const menu = e.target.closest('.org-add-menu')
const trigger = e.target.closest('.add-org-btn')
if (!menu && !trigger) {
addingOrgForPermission.value = null
}
}
onMounted(() => {
document.addEventListener('click', handleGlobalClick)
})
onBeforeUnmount(() => {
document.removeEventListener('click', handleGlobalClick)
})
// Build a summary: for each permission id -> { orgs: Set(org_display_name), userCount }
const permissionSummary = computed(() => {
const summary = {}
for (const o of orgs.value) {
const orgBase = { uuid: o.uuid, display_name: o.display_name }
// Org-level permissions (direct)
for (const pid of o.permissions || []) {
if (!summary[pid]) summary[pid] = { orgs: [], orgSet: new Set(), userCount: 0 }
if (!summary[pid].orgSet.has(o.uuid)) {
summary[pid].orgs.push(orgBase)
summary[pid].orgSet.add(o.uuid)
}
}
// Role-based permissions (inheritance)
for (const r of o.roles) {
for (const pid of r.permissions) {
if (!summary[pid]) summary[pid] = { orgs: [], orgSet: new Set(), userCount: 0 }
if (!summary[pid].orgSet.has(o.uuid)) {
summary[pid].orgs.push(orgBase)
summary[pid].orgSet.add(o.uuid)
}
summary[pid].userCount += r.users.length
}
}
}
const display = {}
for (const [pid, v] of Object.entries(summary)) {
display[pid] = { orgs: v.orgs.sort((a,b)=>a.display_name.localeCompare(b.display_name)), userCount: v.userCount }
}
return display
})
function availableOrgsForPermission(pid) {
return orgs.value.filter(o => !o.permissions.includes(pid))
}
function renamePermissionDisplay(p) { openDialog('perm-display', { permission: p }) }
function startRenamePermissionId(p) { editingPermId.value = p.id; renameIdValue.value = p.id }
function cancelRenameId() { editingPermId.value = null; renameIdValue.value = '' }
async function submitRenamePermissionId(p) {
const newId = renameIdValue.value.trim()
if (!newId || newId === p.id) { cancelRenameId(); return }
try {
const body = { old_id: p.id, new_id: newId, display_name: p.display_name }
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})`)
await refreshPermissionsContext(); cancelRenameId()
} catch (e) { authStore.showMessage(e?.message || 'Rename failed') }
}
async function refreshPermissionsContext() {
// Reload both lists so All Permissions table shows new associations promptly.
await Promise.all([loadPermissions(), loadOrgs()])
}
async function attachPermissionToOrg(pid, orgUuid) {
if (!orgUuid) return
try {
const params = new URLSearchParams({ permission_id: pid })
const res = await fetch(`/auth/admin/orgs/${orgUuid}/permission?${params.toString()}`, { method: 'POST' })
const data = await res.json()
if (data.detail) throw new Error(data.detail)
await loadOrgs()
} catch (e) {
authStore.showMessage(e.message || 'Failed to add permission to org')
}
}
async function detachPermissionFromOrg(pid, orgUuid) {
openDialog('confirm', { message: 'Remove permission from this org?', action: async () => {
try {
const params = new URLSearchParams({ permission_id: pid })
const res = await fetch(`/auth/admin/orgs/${orgUuid}/permission?${params.toString()}`, { method: 'DELETE' })
const data = await res.json()
if (data.detail) throw new Error(data.detail)
await loadOrgs()
} catch (e) {
authStore.showMessage(e.message || 'Failed to remove permission from org')
}
} })
}
function parseHash() {
const h = window.location.hash || ''
currentOrgId.value = null
currentUserId.value = null
if (h.startsWith('#org/')) {
currentOrgId.value = h.slice(5)
} else if (h.startsWith('#user/')) {
currentUserId.value = h.slice(6)
}
}
async function loadOrgs() {
const res = await fetch('/auth/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 => {
const roles = o.roles.map(r => ({ ...r, org_uuid: o.uuid, users: [] }))
const roleMap = Object.fromEntries(roles.map(r => [r.display_name, r]))
for (const u of o.users || []) {
if (roleMap[u.role]) roleMap[u.role].users.push(u)
}
return { ...o, roles }
})
}
async function loadPermissions() {
const res = await fetch('/auth/admin/permissions')
const data = await res.json()
if (data.detail) throw new Error(data.detail)
permissions.value = data
}
async function load() {
loading.value = true
error.value = null
try {
const res = await fetch('/auth/api/user-info', { method: 'POST' })
const data = await res.json()
if (data.detail) throw new Error(data.detail)
info.value = data
if (data.authenticated && (data.is_global_admin || data.is_org_admin)) {
await Promise.all([loadOrgs(), loadPermissions()])
}
// 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') {
currentOrgId.value = orgs.value[0].uuid
window.location.hash = `#org/${currentOrgId.value}`
} else {
parseHash()
}
} else parseHash()
} catch (e) {
error.value = e.message
} finally {
loading.value = false
}
}
// Org actions
function createOrg() { openDialog('org-create', {}) }
function updateOrg(org) { openDialog('org-update', { org }) }
function deleteOrg(org) {
if (!info.value?.is_global_admin) { authStore.showMessage('Global admin only'); return }
openDialog('confirm', { message: `Delete organization ${org.display_name}?`, action: async () => {
const res = await fetch(`/auth/admin/orgs/${org.uuid}`, { method: 'DELETE' })
const data = await res.json(); if (data.detail) throw new Error(data.detail)
await loadOrgs()
} })
}
function createUserInRole(org, role) { openDialog('user-create', { org, role }) }
async function moveUserToRole(org, user, targetRoleDisplayName) {
if (user.role === targetRoleDisplayName) return
const res = await fetch(`/auth/admin/orgs/${org.uuid}/users/${user.uuid}/role`, {
method: 'PUT',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ role: targetRoleDisplayName })
})
const data = await res.json()
if (data.detail) { authStore.showMessage(data.detail); return }
await loadOrgs()
}
function onUserDragStart(e, user, org_uuid) {
e.dataTransfer.effectAllowed = 'move'
e.dataTransfer.setData('text/plain', JSON.stringify({ user_uuid: user.uuid, org_uuid }))
}
function onRoleDragOver(e) {
e.preventDefault()
e.dataTransfer.dropEffect = 'move'
}
function onRoleDrop(e, org, role) {
e.preventDefault()
try {
const data = JSON.parse(e.dataTransfer.getData('text/plain'))
if (data.org_uuid !== org.uuid) return // only within same org
const user = org.roles.flatMap(r => r.users).find(u => u.uuid === data.user_uuid)
if (user) moveUserToRole(org, user, role.display_name)
} catch (_) { /* ignore */ }
}
// (legacy function retained but unused in UI)
async function addOrgPermission() { /* obsolete */ }
async function removeOrgPermission() { /* obsolete */ }
// Role actions
function createRole(org) { openDialog('role-create', { org }) }
function updateRole(role) { openDialog('role-update', { role }) }
function deleteRole(role) {
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' })
const data = await res.json(); if (data.detail) throw new Error(data.detail)
await loadOrgs()
} })
}
// Permission actions
async function submitCreatePermission() {
const id = newPermId.value.trim()
const name = newPermName.value.trim()
if (!id || !name) return
const res = await fetch('/auth/admin/permissions', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ id, display_name: name }) })
const data = await res.json(); if (data.detail) { authStore.showMessage(data.detail); return }
await loadPermissions(); newPermId.value=''; newPermName.value=''; showCreatePermission.value=false
}
function cancelCreatePermission() { newPermId.value=''; newPermName.value=''; showCreatePermission.value=false }
function updatePermission(p) { openDialog('perm-display', { permission: p }) }
function deletePermission(p) {
openDialog('confirm', { message: `Delete permission ${p.id}?`, action: async () => {
const params = new URLSearchParams({ permission_id: p.id })
const res = await fetch(`/auth/admin/permission?${params.toString()}`, { method: 'DELETE' })
const data = await res.json(); if (data.detail) throw new Error(data.detail)
await loadPermissions()
} })
}
onMounted(async () => {
window.addEventListener('hashchange', parseHash)
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)
function openOrg(o) {
window.location.hash = `#org/${o.uuid}`
}
function goOverview() {
window.location.hash = '#overview'
}
function openUser(u) {
window.location.hash = `#user/${u.uuid}`
}
const selectedUser = computed(() => {
if (!currentUserId.value) return null
for (const o of orgs.value) {
for (const r of o.roles) {
const u = r.users.find(x => x.uuid === currentUserId.value)
if (u) return { ...u, org_uuid: o.uuid, role_display_name: r.display_name }
}
}
return null
})
const pageHeading = computed(() => {
if (selectedUser.value) return 'Organization Admin'
if (selectedOrg.value) return 'Organization Admin'
return (authStore.settings?.rp_name || 'Passkey') + ' Admin'
})
watch(selectedUser, async (u) => {
if (!u) { userDetail.value = null; return }
try {
const res = await fetch(`/auth/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) {
userDetail.value = { error: e.message }
}
})
const showRegModal = ref(false)
function generateUserRegistrationLink(u) {
showRegModal.value = true
}
function onLinkCopied() {
authStore.showMessage('Link copied to clipboard!')
}
function copy(text) {
if (!text) return
navigator.clipboard.writeText(text)
.catch(()=>{})
}
function permissionDisplayName(id) {
return permissions.value.find(p => p.id === id)?.display_name || id
}
async function toggleRolePermission(role, permId, checked) {
// Build next permission list
const has = role.permissions.includes(permId)
if (checked && has) return
if (!checked && !has) return
const next = checked ? [...role.permissions, permId] : role.permissions.filter(p => p !== permId)
// Optimistic update
const prev = [...role.permissions]
role.permissions = next
try {
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: role.display_name, permissions: next })
})
const data = await res.json()
if (data.detail) throw new Error(data.detail)
} catch (e) {
authStore.showMessage(e.message || 'Failed to update role permission')
role.permissions = prev // revert
}
}
function openDialog(type, data) { dialog.value = { type, data, busy: false, error: '' } }
function closeDialog() { dialog.value = { type: null, data: null, busy: false, error: '' } }
async function onUserNameSaved() {
await loadOrgs()
if (selectedUser.value) {
try {
const r = await fetch(`/auth/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') }
}
authStore.showMessage('User renamed', 'success', 1500)
}
async function submitDialog() {
if (!dialog.value.type || dialog.value.busy) return
dialog.value.busy = true; dialog.value.error = ''
try {
const t = dialog.value.type
if (t === 'org-create') {
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: [] }) })
const d = await res.json(); if (d.detail) throw new Error(d.detail); await Promise.all([loadOrgs(), loadPermissions()])
} 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 res = await fetch(`/auth/admin/orgs/${org.uuid}`, { method: 'PUT', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ display_name: name, permissions: org.permissions }) })
const d = await res.json(); if (d.detail) throw new Error(d.detail); await loadOrgs()
} 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 res = await fetch(`/auth/admin/orgs/${org.uuid}/roles`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ display_name: name, permissions: [] }) })
const d = await res.json(); if (d.detail) throw new Error(d.detail); await loadOrgs()
} 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 permsCsv = dialog.value.data.perms || ''
const perms = permsCsv.split(',').map(s=>s.trim()).filter(Boolean)
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: perms }) })
const d = await res.json(); if (d.detail) throw new Error(d.detail); await loadOrgs()
} 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 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 }) })
const d = await res.json(); if (d.detail) throw new Error(d.detail); await loadOrgs()
} else if (t === 'perm-display') {
const { permission } = dialog.value.data; const display = dialog.value.data.display_name?.trim(); if (!display) throw new Error('Display name required')
const params = new URLSearchParams({ permission_id: permission.id, display_name: display })
const res = await fetch(`/auth/admin/permission?${params.toString()}`, { method: 'PUT' })
const d = await res.json(); if (d.detail) throw new Error(d.detail); await loadPermissions()
} else if (t === 'confirm') {
const action = dialog.value.data.action; if (action) await action()
}
closeDialog()
} catch (e) {
dialog.value.error = e.message || 'Error'
} finally { dialog.value.busy = false }
}
</script>
<template>
<div class="container">
<h1>
{{ pageHeading }}
<a href="/auth/" class="back-link" title="Back to User App">User</a>
<a
v-if="info?.is_global_admin && (selectedOrg || selectedUser)"
@click.prevent="goOverview"
href="#overview"
class="nav-link"
title="Back to overview"
>Overview</a>
</h1>
<div v-if="loading">Loading</div>
<div v-else-if="error" class="error">{{ error }}</div>
<div v-else>
<div v-if="!info?.authenticated">
<p>You must be authenticated.</p>
</div>
<div v-else-if="!(info?.is_global_admin || info?.is_org_admin)">
<p>Insufficient permissions.</p>
</div>
<div v-else>
<!-- Removed user-specific info (current org, effective permissions, admin flags) -->
<!-- Overview Page -->
<div v-if="!selectedUser && !selectedOrg && (info.is_global_admin || info.is_org_admin)" class="card">
<h2>Organizations</h2>
<div class="actions">
<button @click="createOrg" v-if="info.is_global_admin">+ Create Org</button>
</div>
<table class="org-table">
<thead>
<tr>
<th>Name</th>
<th>Roles</th>
<th>Members</th>
<th v-if="info.is_global_admin">Actions</th>
</tr>
</thead>
<tbody>
<tr v-for="o in orgs" :key="o.uuid">
<td><a href="#org/{{o.uuid}}" @click.prevent="openOrg(o)">{{ o.display_name }}</a></td>
<td>{{ o.roles.length }}</td>
<td>{{ o.roles.reduce((acc,r)=>acc + r.users.length,0) }}</td>
<td v-if="info.is_global_admin">
<button @click="updateOrg(o)" class="icon-btn" aria-label="Rename organization" title="Rename organization"></button>
<button @click="deleteOrg(o)" class="icon-btn delete-icon" aria-label="Delete organization" title="Delete organization"></button>
</td>
</tr>
</tbody>
</table>
</div>
<!-- User Detail Page -->
<div v-if="selectedUser" class="card user-detail">
<UserBasicInfo
v-if="userDetail && !userDetail.error"
:name="userDetail.display_name || selectedUser.display_name"
:visits="userDetail.visits"
:created-at="userDetail.created_at"
:last-seen="userDetail.last_seen"
:loading="loading"
:org-display-name="userDetail.org.display_name"
:role-name="userDetail.role"
:update-endpoint="`/auth/admin/orgs/${selectedUser.org_uuid}/users/${selectedUser.uuid}/display-name`"
@saved="onUserNameSaved"
/>
<div v-else-if="userDetail?.error" class="error small">{{ userDetail.error }}</div>
<template v-if="userDetail && !userDetail.error">
<h3 class="cred-title">Registered Passkeys</h3>
<CredentialList :credentials="userDetail.credentials" :aaguid-info="userDetail.aaguid_info" />
</template>
<div class="actions">
<button @click="generateUserRegistrationLink(selectedUser)">Generate Registration Token</button>
<button @click="goOverview" v-if="info.is_global_admin" class="icon-btn" title="Overview">🏠</button>
<button @click="openOrg(selectedOrg)" v-if="selectedOrg" class="icon-btn" title="Back to Org"></button>
</div>
<p class="matrix-hint muted">Use the token dialog to register a new credential for the member.</p>
<RegistrationLinkModal
v-if="showRegModal"
:endpoint="`/auth/admin/orgs/${selectedUser.org_uuid}/users/${selectedUser.uuid}/create-link`"
:auto-copy="false"
@close="showRegModal = false"
@copied="onLinkCopied"
/>
</div>
<!-- Organization Detail Page -->
<div v-else-if="selectedOrg" class="card">
<h2 class="org-title" :title="selectedOrg.uuid">
<span class="org-name">{{ selectedOrg.display_name }}</span>
<button @click="updateOrg(selectedOrg)" class="icon-btn" aria-label="Rename organization" title="Rename organization"></button>
</h2>
<div class="org-actions"></div>
<div class="matrix-wrapper">
<div class="matrix-scroll">
<div
class="perm-matrix-grid"
:style="{ gridTemplateColumns: 'minmax(180px, 1fr) ' + selectedOrg.roles.map(()=> '2.2rem').join(' ') + ' 2.2rem' }"
>
<!-- Headers -->
<div class="grid-head perm-head">Permission</div>
<div
v-for="r in selectedOrg.roles"
:key="'head-' + r.uuid"
class="grid-head role-head"
:title="r.display_name"
>
<span>{{ r.display_name }}</span>
</div>
<div class="grid-head role-head add-role-head" title="Add role" @click="createRole(selectedOrg)" role="button"></div>
<!-- Data Rows -->
<template v-for="pid in selectedOrg.permissions" :key="pid">
<div class="perm-name" :title="pid">{{ permissionDisplayName(pid) }}</div>
<div
v-for="r in selectedOrg.roles"
:key="r.uuid + '-' + pid"
class="matrix-cell"
>
<input
type="checkbox"
:checked="r.permissions.includes(pid)"
@change="e => toggleRolePermission(r, pid, e.target.checked)"
/>
</div>
<div class="matrix-cell add-role-cell" />
</template>
</div>
</div>
<p class="matrix-hint muted">Toggle which permissions each role grants.</p>
</div>
<div class="roles-grid">
<div
v-for="r in selectedOrg.roles"
:key="r.uuid"
class="role-column"
@dragover="onRoleDragOver"
@drop="e => onRoleDrop(e, selectedOrg, r)"
>
<div class="role-header">
<strong class="role-name" :title="r.uuid">
<span>{{ r.display_name }}</span>
<button @click="updateRole(r)" class="icon-btn" aria-label="Edit role" title="Edit role"></button>
</strong>
<div class="role-actions">
<button @click="createUserInRole(selectedOrg, r)" class="plus-btn" aria-label="Add user" title="Add user"></button>
</div>
</div>
<template v-if="r.users.length > 0">
<ul class="user-list">
<li
v-for="u in r.users"
:key="u.uuid"
class="user-chip"
draggable="true"
@dragstart="e => onUserDragStart(e, u, selectedOrg.uuid)"
@click="openUser(u)"
:title="u.uuid"
>
<span class="name">{{ u.display_name }}</span>
<span class="meta">{{ u.last_seen ? new Date(u.last_seen).toLocaleDateString() : '—' }}</span>
</li>
</ul>
</template>
<div v-else class="empty-role">
<p class="empty-text muted">No members</p>
<button @click="deleteRole(r)" class="icon-btn delete-icon" aria-label="Delete empty role" title="Delete role"></button>
</div>
</div>
</div>
</div>
<div v-if="!selectedUser && !selectedOrg && (info.is_global_admin || info.is_org_admin)" class="card">
<h2>All Permissions</h2>
<div class="actions">
<button v-if="!showCreatePermission" @click="showCreatePermission = true">+ Create Permission</button>
<form v-else class="inline-form" @submit.prevent="submitCreatePermission">
<input v-model="newPermId" @input="sanitizeNewId" required :pattern="PERMISSION_ID_PATTERN" placeholder="permission id" title="Allowed: A-Za-z0-9:._~-" />
<input v-model="newPermName" required placeholder="display name" />
<button type="submit">Save</button>
<button type="button" @click="cancelCreatePermission">Cancel</button>
</form>
</div>
<div class="permission-grid">
<div class="perm-grid-head">Permission</div>
<div class="perm-grid-head">Orgs</div>
<div class="perm-grid-head center">Members</div>
<div class="perm-grid-head center">Actions</div>
<template v-for="p in [...permissions].sort((a,b)=> a.id.localeCompare(b.id))" :key="p.id">
<div class="perm-cell perm-name" :title="p.id">
<div class="perm-title-line">{{ p.display_name }}</div>
<div class="perm-id-line muted">{{ p.id }}</div>
</div>
<div class="perm-cell perm-orgs" :title="permissionSummary[p.id]?.orgs?.map(o=>o.display_name).join(', ') || ''">
<template v-if="permissionSummary[p.id]">
<span class="org-pill" v-for="o in permissionSummary[p.id].orgs" :key="o.uuid">
{{ o.display_name }}
<button class="pill-x" @click.stop="detachPermissionFromOrg(p.id, o.uuid)" aria-label="Remove">×</button>
</span>
</template>
<span class="org-add-wrapper">
<button
v-if="availableOrgsForPermission(p.id).length && addingOrgForPermission !== p.id"
class="add-org-btn"
@click.stop="addingOrgForPermission = p.id"
aria-label="Add organization"
title="Add organization"
></button>
<div
v-if="addingOrgForPermission === p.id"
class="org-add-menu"
tabindex="0"
@keydown.escape.stop.prevent="addingOrgForPermission = null"
>
<div class="org-add-list">
<button
v-for="o in availableOrgsForPermission(p.id)"
:key="o.uuid"
class="org-add-item"
@click.stop="attachPermissionToOrg(p.id, o.uuid); addingOrgForPermission = null"
>{{ o.display_name }}</button>
</div>
<div class="org-add-footer">
<button class="org-add-cancel" @click.stop="addingOrgForPermission = null" aria-label="Cancel">Cancel</button>
</div>
</div>
</span>
</div>
<div class="perm-cell perm-users center">{{ permissionSummary[p.id]?.userCount || 0 }}</div>
<div class="perm-cell perm-actions center">
<div class="perm-actions-inner" :class="{ editing: editingPermId === p.id }">
<div class="actions-view">
<button @click="renamePermissionDisplay(p)" class="icon-btn" aria-label="Change display name" title="Change display name"></button>
<button @click="startRenamePermissionId(p)" class="icon-btn" aria-label="Change id" title="Change id">🆔</button>
<button @click="deletePermission(p)" class="icon-btn delete-icon" aria-label="Delete permission" title="Delete permission"></button>
</div>
<form class="inline-id-form overlay" @submit.prevent="submitRenamePermissionId(p)">
<input v-model="renameIdValue" @input="sanitizeRenameId" required :pattern="PERMISSION_ID_PATTERN" class="id-input" title="Allowed: A-Za-z0-9:._~-" />
<button type="submit" class="icon-btn" aria-label="Save"></button>
<button type="button" class="icon-btn" @click="cancelRenameId" aria-label="Cancel"></button>
</form>
</div>
</div>
</template>
</div>
</div>
</div>
</div>
</div>
<StatusMessage />
<div v-if="dialog.type" class="modal-overlay" @keydown.esc.prevent.stop="closeDialog" tabindex="-1">
<div class="modal" role="dialog" aria-modal="true">
<h3 class="modal-title">
<template v-if="dialog.type==='org-create'">Create Organization</template>
<template v-else-if="dialog.type==='org-update'">Rename Organization</template>
<template v-else-if="dialog.type==='role-create'">Create Role</template>
<template v-else-if="dialog.type==='role-update'">Edit Role</template>
<template v-else-if="dialog.type==='user-create'">Add User To Role</template>
<template v-else-if="dialog.type==='perm-display'">Edit Permission Display</template>
<template v-else-if="dialog.type==='confirm'">Confirm</template>
</h3>
<form @submit.prevent="submitDialog" class="modal-form">
<template v-if="dialog.type==='org-create' || dialog.type==='org-update'">
<label>Name
<input v-model="dialog.data.name" :placeholder="dialog.type==='org-update'? dialog.data.org.display_name : 'Organization name'" required />
</label>
</template>
<template v-else-if="dialog.type==='role-create'">
<label>Role Name
<input v-model="dialog.data.name" placeholder="Role name" required />
</label>
</template>
<template v-else-if="dialog.type==='role-update'">
<label>Role Name
<input v-model="dialog.data.name" :placeholder="dialog.data.role.display_name" required />
</label>
<label>Permissions (comma separated)
<textarea v-model="dialog.data.perms" rows="2" placeholder="perm:a, perm:b"></textarea>
</label>
</template>
<template v-else-if="dialog.type==='user-create'">
<p class="small muted">Role: {{ dialog.data.role.display_name }}</p>
<label>Display Name
<input v-model="dialog.data.name" placeholder="User display name" required />
</label>
</template>
<template v-else-if="dialog.type==='perm-display'">
<p class="small muted">ID: {{ dialog.data.permission.id }}</p>
<label>Display Name
<input v-model="dialog.data.display_name" :placeholder="dialog.data.permission.display_name" required />
</label>
</template>
<template v-else-if="dialog.type==='confirm'">
<p>{{ dialog.data.message }}</p>
</template>
<div v-if="dialog.error" class="error small">{{ dialog.error }}</div>
<div class="modal-actions">
<button type="submit" :disabled="dialog.busy">{{ dialog.type==='confirm' ? 'OK' : 'Save' }}</button>
<button type="button" @click="closeDialog" :disabled="dialog.busy">Cancel</button>
</div>
</form>
</div>
</div>
</template>
<style scoped>
.container { max-width: 960px; margin: 2rem auto; padding: 0 1rem; }
.subtitle { color: #888 }
.card { margin: 1rem 0; padding: 1rem; border: 1px solid #eee; border-radius: 8px; }
.error { color: #a00 }
.actions { margin-bottom: .5rem }
.org { border-top: 1px dashed #eee; padding: .5rem 0 }
.org-header { display: flex; gap: .5rem; align-items: baseline }
.user-item { display: flex; gap: .5rem; margin: .15rem 0 }
.users-table { width: 100%; border-collapse: collapse; margin-top: .25rem; }
.users-table th, .users-table td { padding: .25rem .4rem; text-align: left; border-bottom: 1px solid #eee; font-weight: normal; }
.users-table th { font-size: .75rem; text-transform: uppercase; letter-spacing: .05em; color: #555; }
.users-table tbody tr:hover { background: #fafafa; }
.org-actions, .role-actions, .perm-actions { display: flex; gap: .5rem; margin: .25rem 0 }
.muted { color: #666 }
.small { font-size: .9em }
.pill-list { display: flex; flex-wrap: wrap; gap: .25rem }
.pill { background: #f3f3f3; border: 1px solid #e2e2e2; border-radius: 999px; padding: .1rem .5rem; display: inline-flex; align-items: center; gap: .25rem }
.pill-x { background: transparent; border: none; color: #900; cursor: pointer }
button { padding: .25rem .5rem; border-radius: 6px; border: 1px solid #ddd; background: #fff; cursor: pointer }
button:hover { background: #f7f7f7 }
/* Avoid global button 100% width from frontend main styles */
button, .perm-actions button, .org-actions button, .role-actions button { width: auto; }
.roles-grid { display: flex; flex-wrap: wrap; gap: 1rem; align-items: stretch; padding: .5rem 0; }
.role-column { background: #fafafa; border: 1px solid #eee; border-radius: 8px; padding: .5rem; min-width: 200px; flex: 1 1 240px; display: flex; flex-direction: column; max-width: 300px; }
.role-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: .25rem }
.user-list { list-style: none; padding: 0; margin: 0; display: flex; flex-direction: column; gap: .25rem; flex: 1 1 auto; }
.user-chip { background: #fff; border: 1px solid #ddd; border-radius: 6px; padding: .25rem .4rem; display: flex; justify-content: space-between; gap: .5rem; cursor: grab; }
.user-chip:active { cursor: grabbing }
.user-chip .name { font-weight: 500 }
.user-chip .meta { font-size: .65rem; color: #666 }
.role-column.drag-over { outline: 2px dashed #66a; }
.org-table { width: 100%; border-collapse: collapse; }
.org-table th, .org-table td { padding: .4rem .5rem; border-bottom: 1px solid #eee; text-align: left; }
.org-table th { font-size: .75rem; text-transform: uppercase; letter-spacing: .05em; color: #555; }
.org-table a { text-decoration: none; color: #0366d6; }
.org-table a:hover { text-decoration: underline; }
.nav-link { font-size: .6em; margin-left: .5rem; background: #eee; padding: .25em .6em; border-radius: 999px; border: 1px solid #ccc; text-decoration: none; }
.nav-link:hover { background: #ddd; }
.back-link { font-size: .5em; margin-left: .75rem; text-decoration: none; background: #eee; padding: .25em .6em; border-radius: 999px; border: 1px solid #ccc; vertical-align: middle; line-height: 1.2; }
.back-link:hover { background: #ddd; }
.matrix-wrapper { margin: 1rem 0; text-align: left; }
.matrix-scroll { overflow-x: auto; text-align: left; }
.perm-matrix-grid { display: inline-grid; gap: 0; align-items: stretch; margin-right: 4rem; }
.perm-matrix-grid > * { background: #fff; border: none; padding: .35rem .4rem; font-size: .75rem; }
.perm-matrix-grid .grid-head { background: transparent; border: none; font-size: .65rem; letter-spacing: .05em; font-weight: 600; text-transform: uppercase; display: flex; justify-content: center; align-items: flex-end; padding-bottom: .25rem; }
.perm-matrix-grid .perm-head { justify-content: flex-start; align-items: flex-end; }
.perm-matrix-grid .role-head span { writing-mode: vertical-rl; transform: rotate(180deg); font-size: .6rem; line-height: 1; }
.perm-matrix-grid .perm-name { font-weight: 500; white-space: nowrap; text-align: left; }
.perm-matrix-grid .matrix-cell { display: flex; justify-content: center; align-items: center; }
.perm-matrix-grid .matrix-cell input { cursor: pointer; }
.matrix-hint { font-size: .7rem; margin-top: .25rem; }
/* Add role column styles */
.add-role-head { cursor: pointer; color: #2a6; font-size: 1rem; display:flex; justify-content:center; align-items:flex-end; }
.add-role-head:hover { color:#1c4; }
/* Removed add-role placeholder styles */
/* Inline organization title with icon */
.org-title { display: flex; align-items: center; gap: .4rem; }
.org-title .org-name { flex: 0 1 auto; }
/* Plus button for adding users */
.plus-btn { background: none; border: none; font-size: 1.15rem; line-height: 1; padding: 0 .1rem; cursor: pointer; opacity: .6; }
.plus-btn:hover, .plus-btn:focus { opacity: 1; outline: none; }
.plus-btn:focus-visible { outline: 2px solid #555; outline-offset: 2px; }
.empty-role { display: flex; flex-direction: column; gap: .4rem; align-items: flex-start; padding: .35rem .25rem; /* removed flex grow & width for natural size */ }
.empty-role .empty-text { font-size: .7rem; margin: 0; }
.delete-icon { color: #c00; }
.delete-icon:hover, .delete-icon:focus { color: #ff0000; }
.user-detail .user-link-box { margin-top: .75rem; font-size: .7rem; background: #fff; border: 1px dashed #ccc; padding: .5rem; border-radius: 6px; cursor: pointer; word-break: break-all; }
.user-detail .user-link-box:hover { background: #f9f9f9; }
.user-detail .user-link-box .expires { font-size: .6rem; margin-top: .25rem; color: #555; }
/* Minimal icon button for rename/edit actions */
.icon-btn { background: none; border: none; padding: 0 .15rem; margin-left: .15rem; cursor: pointer; font-size: .8rem; line-height: 1; opacity: .55; vertical-align: middle; }
.icon-btn:hover, .icon-btn:focus { opacity: .95; outline: none; }
.icon-btn:focus-visible { outline: 2px solid #555; outline-offset: 2px; }
.icon-btn:active { transform: translateY(1px); }
.org-title { display: flex; align-items: baseline; gap: .25rem; }
.role-name { display: inline-flex; align-items: center; gap: .15rem; font-weight: 600; }
.perm-name-line { display: flex; align-items: center; gap: .15rem; }
.user-meta { margin-top: .25rem; }
.cred-title { margin-top: .75rem; font-size: .85rem; }
.cred-list { list-style: none; padding: 0; margin: .25rem 0 .5rem; display: flex; flex-direction: column; gap: .35rem; }
.cred-item { background: #fff; border: 1px solid #eee; border-radius: 6px; padding: .35rem .5rem; font-size: .65rem; }
.cred-line { display: flex; flex-direction: column; gap: .15rem; }
.cred-line .dates { color: #555; font-size: .6rem; }
/* Permission grid */
.permission-grid { display: grid; grid-template-columns: minmax(220px,2fr) minmax(160px,3fr) 70px 90px; gap: 2px; margin-top: .5rem; }
.permission-grid .perm-grid-head { font-size: .6rem; text-transform: uppercase; letter-spacing: .05em; font-weight: 600; padding: .35rem .4rem; background: #f3f3f3; border: 1px solid #e1e1e1; }
.permission-grid .perm-cell { background: #fff; border: 1px solid #eee; padding: .35rem .4rem; font-size: .7rem; display: flex; align-items: center; gap: .4rem; }
.permission-grid .perm-name { flex-direction: row; flex-wrap: wrap; }
.permission-grid .perm-name { flex-direction: column; align-items: flex-start; gap:2px; }
.permission-grid .perm-title-line { font-weight:600; line-height:1.1; }
.permission-grid .perm-id-line { font-size:.55rem; line-height:1.1; word-break:break-all; }
.permission-grid .center { justify-content: center; }
.permission-grid .perm-actions { gap: .25rem; }
.permission-grid .perm-actions .icon-btn { font-size: .9rem; }
/* Inline edit overlay to avoid layout shift */
.perm-actions-inner { position: relative; display:flex; width:100%; justify-content:center; }
.perm-actions-inner .inline-id-form.overlay { position:absolute; inset:0; display:none; align-items:center; justify-content:center; gap:.25rem; background:rgba(255,255,255,.9); backdrop-filter:blur(2px); padding:0 .15rem; }
.perm-actions-inner.editing .inline-id-form.overlay { display:inline-flex; }
.perm-actions-inner.editing .actions-view { visibility:hidden; }
/* Inline forms */
.inline-form, .inline-id-form { display:inline-flex; gap:.25rem; align-items:center; }
.inline-form input, .inline-id-form input { padding:.25rem .4rem; font-size:.6rem; border:1px solid #ccc; border-radius:4px; }
.inline-form button, .inline-id-form button { font-size:.6rem; padding:.3rem .5rem; }
.inline-id-form .id-input { width:120px; }
/* Modal */
.modal-overlay { position:fixed; inset:0; background:rgba(0,0,0,.4); display:flex; justify-content:center; align-items:flex-start; padding-top:8vh; z-index:200; }
.modal { background:#fff; border-radius:10px; padding:1rem 1.1rem; width: min(420px, 90%); box-shadow:0 10px 30px rgba(0,0,0,.25); animation:pop .18s ease; }
@keyframes pop { from { transform:translateY(10px); opacity:0 } to { transform:translateY(0); opacity:1 } }
.modal-title { margin:0 0 .65rem; font-size:1rem; }
.modal-form { display:flex; flex-direction:column; gap:.65rem; }
.modal-form label { display:flex; flex-direction:column; font-size:.65rem; gap:.25rem; font-weight:600; }
.modal-form input, .modal-form textarea { border:1px solid #ccc; border-radius:6px; padding:.45rem .55rem; font-size:.7rem; font-weight:400; font-family:inherit; }
.modal-form textarea { resize:vertical; }
.modal-actions { display:flex; gap:.5rem; justify-content:flex-end; margin-top:.25rem; }
.modal-actions button { font-size:.65rem; }
/* Org pill editing */
.perm-orgs { flex-wrap: wrap; gap: .25rem; }
.perm-orgs .org-pill { background:#eef4ff; border:1px solid #d0dcf0; padding:2px 6px; border-radius:999px; font-size:.55rem; display:inline-flex; align-items:center; gap:4px; }
.perm-orgs .org-pill .pill-x { background:none; border:none; cursor:pointer; font-size:.7rem; line-height:1; padding:0; margin:0; color:#555; }
.perm-orgs .org-pill .pill-x:hover { color:#c00; }
.add-org-btn { background:none; border:none; cursor:pointer; font-size:.7rem; padding:0 2px; line-height:1; opacity:.55; display:inline; }
.add-org-btn:hover, .add-org-btn:focus { opacity:1; }
.add-org-btn:focus-visible { outline:2px solid #555; outline-offset:2px; }
.org-add-wrapper { position:relative; display:inline-block; }
.org-add-menu { position:absolute; top:100%; left:0; z-index:20; margin-top:4px; min-width:160px; background:#fff; border:1px solid #e2e6ea; border-radius:6px; padding:.3rem .35rem; box-shadow:0 4px 10px rgba(0,0,0,.08); display:flex; flex-direction:column; gap:.25rem; font-size:.6rem; }
.org-add-menu:before { content:""; position:absolute; top:-5px; left:10px; width:8px; height:8px; background:#fff; border-left:1px solid #e2e6ea; border-top:1px solid #e2e6ea; transform:rotate(45deg); }
.org-add-list { display:flex; flex-direction:column; gap:0; max-height:180px; overflow-y:auto; scrollbar-width:thin; }
.org-add-item { background:transparent; border:none; padding:.25rem .4rem; font-size:.6rem; border-radius:4px; cursor:pointer; line-height:1.1; text-align:left; width:100%; color:#222; }
.org-add-item:hover, .org-add-item:focus { background:#f2f5f9; }
.org-add-item:active { background:#e6ebf0; }
.org-add-footer { margin-top:.25rem; display:flex; justify-content:flex-end; }
.org-add-cancel { background:transparent; border:none; font-size:.55rem; padding:.15rem .35rem; cursor:pointer; color:#666; border-radius:4px; }
.org-add-cancel:hover, .org-add-cancel:focus { background:#f2f5f9; color:#222; }
.org-add-cancel:active { background:#e6ebf0; }
</style>
+128
View File
@@ -0,0 +1,128 @@
<script setup>
import { ref, watch, nextTick } from 'vue'
import Modal from '@/components/Modal.vue'
import NameEditForm from '@/components/NameEditForm.vue'
const props = defineProps({
dialog: Object,
PERMISSION_ID_PATTERN: String
})
const emit = defineEmits(['submitDialog', 'closeDialog'])
const nameInput = ref(null)
const displayNameInput = ref(null)
const NAME_EDIT_TYPES = new Set(['org-update', 'role-update', 'user-update-name'])
watch(() => props.dialog.type, (newType) => {
if (newType === 'org-create') {
nextTick(() => {
nameInput.value?.focus()
})
} else if (newType === 'perm-display' || newType === 'perm-create') {
nextTick(() => {
displayNameInput.value?.focus()
if (newType === 'perm-display') {
displayNameInput.value?.select()
}
})
}
})
</script>
<template>
<Modal v-if="dialog.type" @close="$emit('closeDialog')">
<h3 class="modal-title">
<template v-if="dialog.type==='org-create'">Create Organization</template>
<template v-else-if="dialog.type==='org-update'">Rename Organization</template>
<template v-else-if="dialog.type==='role-create'">Create Role</template>
<template v-else-if="dialog.type==='role-update'">Edit Role</template>
<template v-else-if="dialog.type==='user-create'">Add User To Role</template>
<template v-else-if="dialog.type==='user-update-name'">Edit User Name</template>
<template v-else-if="dialog.type==='perm-create' || dialog.type==='perm-display'">{{ dialog.type === 'perm-create' ? 'Create Permission' : 'Edit Permission Display' }}</template>
<template v-else-if="dialog.type==='confirm'">Confirm</template>
</h3>
<form @submit.prevent="$emit('submitDialog')" class="modal-form">
<template v-if="dialog.type==='org-create'">
<label>Name
<input ref="nameInput" v-model="dialog.data.name" required />
</label>
</template>
<template v-else-if="dialog.type==='org-update'">
<NameEditForm
label="Organization Name"
v-model="dialog.data.name"
:busy="dialog.busy"
:error="dialog.error"
@cancel="$emit('closeDialog')"
/>
</template>
<template v-else-if="dialog.type==='role-create'">
<label>Role Name
<input v-model="dialog.data.name" placeholder="Role name" required />
</label>
</template>
<template v-else-if="dialog.type==='role-update'">
<NameEditForm
label="Role Name"
v-model="dialog.data.name"
:busy="dialog.busy"
:error="dialog.error"
@cancel="$emit('closeDialog')"
/>
</template>
<template v-else-if="dialog.type==='user-create'">
<p class="small muted">Role: {{ dialog.data.role.display_name }}</p>
<label>Display Name
<input v-model="dialog.data.name" placeholder="User display name" required />
</label>
</template>
<template v-else-if="dialog.type==='user-update-name'">
<NameEditForm
label="Display Name"
v-model="dialog.data.name"
:busy="dialog.busy"
:error="dialog.error"
@cancel="$emit('closeDialog')"
/>
</template>
<template v-else-if="dialog.type==='perm-create' || dialog.type==='perm-display'">
<label>Display Name
<input ref="displayNameInput" v-model="dialog.data.display_name" required />
</label>
<label>Permission ID
<input v-model="dialog.data.id" :placeholder="dialog.type === 'perm-create' ? 'yourapp:login' : dialog.data.permission.id" required :pattern="PERMISSION_ID_PATTERN" title="Allowed: A-Za-z0-9:._~-" />
</label>
<p class="small muted">The permission ID is used for permission checks in the application. Changing it may break deployed applications that reference this permission.</p>
</template>
<template v-else-if="dialog.type==='confirm'">
<p>{{ dialog.data.message }}</p>
</template>
<div v-if="dialog.error && !NAME_EDIT_TYPES.has(dialog.type)" class="error small">{{ dialog.error }}</div>
<div v-if="!NAME_EDIT_TYPES.has(dialog.type)" class="modal-actions">
<button
type="button"
class="btn-secondary"
@click="$emit('closeDialog')"
:disabled="dialog.busy"
>
Cancel
</button>
<button
type="submit"
class="btn-primary"
:disabled="dialog.busy"
>
{{ dialog.type==='confirm' ? 'OK' : 'Save' }}
</button>
</div>
</form>
</Modal>
</template>
<style scoped>
.error { color: var(--color-danger-text); }
.small { font-size: 0.9rem; }
.muted { color: var(--color-text-muted); }
</style>
+157
View File
@@ -0,0 +1,157 @@
<script setup>
import { computed } from 'vue'
const props = defineProps({
selectedOrg: Object,
permissions: Array
})
const emit = defineEmits(['updateOrg', 'createRole', 'updateRole', 'deleteRole', 'createUserInRole', 'openUser', 'toggleRolePermission', 'onRoleDragOver', 'onRoleDrop', 'onUserDragStart'])
const sortedRoles = computed(() => {
return [...props.selectedOrg.roles].sort((a, b) => {
const nameA = a.display_name.toLowerCase()
const nameB = b.display_name.toLowerCase()
if (nameA !== nameB) {
return nameA.localeCompare(nameB)
}
return a.uuid.localeCompare(b.uuid)
})
})
function permissionDisplayName(id) {
return props.permissions.find(p => p.id === id)?.display_name || id
}
function toggleRolePermission(role, pid, checked) {
emit('toggleRolePermission', role, pid, checked)
}
</script>
<template>
<h2 class="org-title" :title="selectedOrg.uuid">
<span class="org-name">{{ selectedOrg.display_name }}</span>
<button @click="$emit('updateOrg', selectedOrg)" class="icon-btn" aria-label="Rename organization" title="Rename organization"></button>
</h2>
<div class="matrix-wrapper">
<div class="matrix-scroll">
<div
class="perm-matrix-grid"
:style="{ gridTemplateColumns: 'minmax(180px, 1fr) ' + sortedRoles.map(()=> '2.2rem').join(' ') + ' 2.2rem' }"
>
<div class="grid-head perm-head">Permission</div>
<div
v-for="r in sortedRoles"
:key="'head-' + r.uuid"
class="grid-head role-head"
:title="r.display_name"
>
<span>{{ r.display_name }}</span>
</div>
<div class="grid-head role-head add-role-head" title="Add role" @click="$emit('createRole', selectedOrg)" role="button"></div>
<template v-for="pid in selectedOrg.permissions" :key="pid">
<div class="perm-name" :title="pid">{{ permissionDisplayName(pid) }}</div>
<div
v-for="r in sortedRoles"
:key="r.uuid + '-' + pid"
class="matrix-cell"
>
<input
type="checkbox"
:checked="r.permissions.includes(pid)"
@change="e => toggleRolePermission(r, pid, e.target.checked)"
/>
</div>
<div class="matrix-cell add-role-cell" />
</template>
</div>
</div>
<p class="matrix-hint muted">Toggle which permissions each role grants.</p>
</div>
<div class="roles-grid">
<div
v-for="r in sortedRoles"
:key="r.uuid"
class="role-column"
@dragover="$emit('onRoleDragOver', $event)"
@drop="e => $emit('onRoleDrop', e, selectedOrg, r)"
>
<div class="role-header">
<strong class="role-name" :title="r.uuid">
<span>{{ r.display_name }}</span>
<button @click="$emit('updateRole', r)" class="icon-btn" aria-label="Edit role" title="Edit role"></button>
</strong>
<div class="role-actions">
<button @click="$emit('createUserInRole', selectedOrg, r)" class="plus-btn" aria-label="Add user" title="Add user"></button>
</div>
</div>
<template v-if="r.users.length > 0">
<ul class="user-list">
<li
v-for="u in r.users.slice().sort((a, b) => {
const nameA = a.display_name.toLowerCase()
const nameB = b.display_name.toLowerCase()
if (nameA !== nameB) {
return nameA.localeCompare(nameB)
}
return a.uuid.localeCompare(b.uuid)
})"
:key="u.uuid"
class="user-chip"
draggable="true"
@dragstart="e => $emit('onUserDragStart', e, u, selectedOrg.uuid)"
@click="$emit('openUser', u)"
:title="u.uuid"
>
<span class="name">{{ u.display_name }}</span>
<span class="meta">{{ u.last_seen ? new Date(u.last_seen).toLocaleDateString() : '—' }}</span>
</li>
</ul>
</template>
<div v-else class="empty-role">
<p class="empty-text muted">No members</p>
<button @click="$emit('deleteRole', r)" class="icon-btn delete-icon" aria-label="Delete empty role" title="Delete role"></button>
</div>
</div>
</div>
</template>
<style scoped>
.card.surface { padding: var(--space-lg); }
.org-title { display: flex; align-items: center; gap: var(--space-sm); margin-bottom: var(--space-lg); }
.org-name { font-size: 1.5rem; font-weight: 600; color: var(--color-heading); }
.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); }
.matrix-wrapper { margin: var(--space-md) 0; padding: var(--space-lg); }
.matrix-scroll { overflow-x: auto; }
.matrix-hint { font-size: 0.8rem; color: var(--color-text-muted); }
.perm-matrix-grid { display: inline-grid; gap: 0.25rem; align-items: stretch; }
.perm-matrix-grid > * { padding: 0.35rem 0.45rem; font-size: 0.75rem; }
.perm-matrix-grid .grid-head { color: var(--color-text-muted); text-transform: uppercase; font-weight: 600; letter-spacing: 0.05em; }
.perm-matrix-grid .perm-head { display: flex; align-items: flex-end; justify-content: flex-start; padding: 0.35rem 0.45rem; font-size: 0.75rem; }
.perm-matrix-grid .role-head { display: flex; align-items: flex-end; justify-content: center; }
.perm-matrix-grid .role-head span { writing-mode: vertical-rl; transform: rotate(180deg); font-size: 0.65rem; }
.perm-matrix-grid .add-role-head { cursor: pointer; }
.perm-name { font-weight: 600; color: var(--color-heading); padding: 0.35rem 0.45rem; font-size: 0.75rem; }
.roles-grid { display: flex; gap: var(--space-lg); margin-top: var(--space-lg); }
.role-column { flex: 1; min-width: 200px; border: 1px solid var(--color-border); border-radius: var(--radius-md); padding: var(--space-md); }
.role-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: var(--space-md); }
.role-name { display: flex; align-items: center; gap: var(--space-xs); font-size: 1.1rem; color: var(--color-heading); }
.role-actions { display: flex; gap: var(--space-xs); }
.plus-btn { background: var(--color-accent-soft); color: var(--color-accent); border: none; border-radius: var(--radius-sm); padding: 0.25rem 0.45rem; font-size: 1.1rem; cursor: pointer; }
.plus-btn:hover { background: rgba(37, 99, 235, 0.18); }
.user-list { list-style: none; padding: 0; margin: 0; display: flex; flex-direction: column; gap: var(--space-xs); }
.user-chip { background: var(--color-surface); border: 1px solid var(--color-border); border-radius: var(--radius-md); padding: 0.45rem 0.6rem; display: flex; justify-content: space-between; gap: var(--space-sm); cursor: grab; }
.user-chip .meta { font-size: 0.7rem; color: var(--color-text-muted); }
.empty-role { border: 1px dashed var(--color-border-strong); border-radius: var(--radius-md); padding: var(--space-sm); display: flex; flex-direction: column; gap: var(--space-xs); align-items: flex-start; }
.empty-text { margin: 0; }
.delete-icon { color: var(--color-danger); }
.delete-icon:hover { background: var(--color-danger-bg); color: var(--color-danger-text); }
.muted { color: var(--color-text-muted); }
@media (max-width: 720px) {
.roles-grid { flex-direction: column; }
}
</style>
+165
View File
@@ -0,0 +1,165 @@
<script setup>
import { computed } from 'vue'
const props = defineProps({
info: Object,
orgs: Array,
permissions: Array,
permissionSummary: Object
})
const emit = defineEmits(['createOrg', 'openOrg', 'updateOrg', 'deleteOrg', 'toggleOrgPermission', 'openDialog', 'deletePermission', 'renamePermissionDisplay'])
const sortedOrgs = computed(() => [...props.orgs].sort((a,b)=> {
const nameCompare = a.display_name.localeCompare(b.display_name)
return nameCompare !== 0 ? nameCompare : a.uuid.localeCompare(b.uuid)
}))
const sortedPermissions = computed(() => [...props.permissions].sort((a,b)=> a.id.localeCompare(b.id)))
function permissionDisplayName(id) {
return props.permissions.find(p => p.id === id)?.display_name || id
}
function getRoleNames(org) {
return org.roles
.slice()
.sort((a, b) => a.display_name.localeCompare(b.display_name))
.map(r => r.display_name)
.join(', ')
}
</script>
<template>
<div class="permissions-section">
<h2>{{ info.is_global_admin ? 'Organizations' : 'Your Organizations' }}</h2>
<div class="actions">
<button v-if="info.is_global_admin" @click="$emit('createOrg')">+ Create Org</button>
</div>
<table class="org-table">
<thead>
<tr>
<th>Name</th>
<th>Roles</th>
<th>Members</th>
<th v-if="info.is_global_admin">Actions</th>
</tr>
</thead>
<tbody>
<tr v-for="o in sortedOrgs" :key="o.uuid">
<td>
<a href="#org/{{o.uuid}}" @click.prevent="$emit('openOrg', o)">{{ o.display_name }}</a>
<button v-if="info.is_global_admin || info.is_org_admin" @click="$emit('updateOrg', o)" class="icon-btn edit-org-btn" aria-label="Rename organization" title="Rename organization"></button>
</td>
<td class="role-names">{{ getRoleNames(o) }}</td>
<td class="center">{{ o.roles.reduce((acc,r)=>acc + r.users.length,0) }}</td>
<td v-if="info.is_global_admin" class="center">
<button @click="$emit('deleteOrg', o)" class="icon-btn delete-icon" aria-label="Delete organization" title="Delete organization"></button>
</td>
</tr>
</tbody>
</table>
</div>
<div v-if="info.is_global_admin" class="permissions-section">
<h2>Permissions</h2>
<div class="matrix-wrapper">
<div class="matrix-scroll">
<div
class="perm-matrix-grid"
:style="{ gridTemplateColumns: 'minmax(180px, 1fr) ' + sortedOrgs.map(()=> '2.2rem').join(' ') }"
>
<div class="grid-head perm-head">Permission</div>
<div
v-for="o in sortedOrgs"
:key="'head-' + o.uuid"
class="grid-head org-head"
:title="o.display_name"
>
<span>{{ o.display_name }}</span>
</div>
<template v-for="p in sortedPermissions" :key="p.id">
<div class="perm-name" :title="p.id">
<span class="display-text">{{ p.display_name }}</span>
</div>
<div
v-for="o in sortedOrgs"
:key="o.uuid + '-' + p.id"
class="matrix-cell"
>
<input
type="checkbox"
:checked="o.permissions.includes(p.id)"
@change="e => $emit('toggleOrgPermission', o, p.id, e.target.checked)"
/>
</div>
</template>
</div>
</div>
<p class="matrix-hint muted">Toggle which permissions each organization can grant to its members.</p>
</div>
<div class="actions">
<button v-if="info.is_global_admin" @click="$emit('openDialog', 'perm-create', { display_name: '', id: '' })">+ Create Permission</button>
</div>
<table class="org-table">
<thead>
<tr>
<th scope="col">Permission</th>
<th scope="col" class="center">Members</th>
<th scope="col" class="center">Actions</th>
</tr>
</thead>
<tbody>
<tr v-for="p in sortedPermissions" :key="p.id">
<td class="perm-name-cell">
<div class="perm-title">
<span class="display-text">{{ p.display_name }}</span>
<button @click="$emit('renamePermissionDisplay', p)" class="icon-btn edit-display-btn" aria-label="Edit display name" title="Edit display name"></button>
</div>
<div class="perm-id-info">
<span class="id-text">{{ p.id }}</span>
</div>
</td>
<td class="perm-members center">{{ permissionSummary[p.id]?.userCount || 0 }}</td>
<td class="perm-actions center">
<button @click="$emit('deletePermission', p)" class="icon-btn delete-icon" aria-label="Delete permission" title="Delete permission"></button>
</td>
</tr>
</tbody>
</table>
</div>
</template>
<style scoped>
.permissions-section { margin-bottom: var(--space-xl); }
.permissions-section h2 { margin-bottom: var(--space-md); }
.actions { display: flex; flex-wrap: wrap; gap: var(--space-sm); align-items: center; }
.actions button { width: auto; }
.org-table a { text-decoration: none; color: var(--color-link); }
.org-table a:hover { text-decoration: underline; }
.org-table .center { width: 6rem; min-width: 6rem; }
.org-table .role-names { max-width: 200px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.perm-name-cell { display: flex; flex-direction: column; gap: 0.3rem; }
.perm-title { font-weight: 600; color: var(--color-heading); }
.perm-id-info { font-size: 0.8rem; color: var(--color-text-muted); }
.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); }
.delete-icon { color: var(--color-danger); }
.delete-icon:hover { background: var(--color-danger-bg); color: var(--color-danger-text); }
.matrix-wrapper { margin: var(--space-md) 0; padding: var(--space-lg); }
.matrix-scroll { overflow-x: auto; }
.matrix-hint { font-size: 0.8rem; color: var(--color-text-muted); }
.perm-matrix-grid { display: inline-grid; gap: 0.25rem; align-items: stretch; }
.perm-matrix-grid > * { padding: 0.35rem 0.45rem; font-size: 0.75rem; }
.perm-matrix-grid .grid-head { color: var(--color-text-muted); text-transform: uppercase; font-weight: 600; letter-spacing: 0.05em; }
.perm-matrix-grid .perm-head { display: flex; align-items: flex-end; justify-content: flex-start; padding: 0.35rem 0.45rem; font-size: 0.75rem; }
.perm-matrix-grid .org-head { display: flex; align-items: flex-end; justify-content: center; }
.perm-matrix-grid .org-head span { writing-mode: vertical-rl; transform: rotate(180deg); font-size: 0.65rem; }
.perm-name { font-weight: 600; color: var(--color-heading); padding: 0.35rem 0.45rem; font-size: 0.75rem; }
.display-text { margin-right: var(--space-xs); }
.edit-display-btn { padding: 0.1rem 0.2rem; font-size: 0.8rem; }
.edit-org-btn { padding: 0.1rem 0.2rem; font-size: 0.8rem; margin-left: var(--space-xs); }
.perm-actions { text-align: center; }
.center { text-align: center; }
.muted { color: var(--color-text-muted); }
</style>
+155
View File
@@ -0,0 +1,155 @@
<script setup>
import { ref } from 'vue'
import UserBasicInfo from '@/components/UserBasicInfo.vue'
import CredentialList from '@/components/CredentialList.vue'
import RegistrationLinkModal from '@/components/RegistrationLinkModal.vue'
import SessionList from '@/components/SessionList.vue'
import { useAuthStore } from '@/stores/auth'
import { apiJson } from '@/utils/api'
const props = defineProps({
selectedUser: Object,
userDetail: Object,
selectedOrg: Object,
loading: Boolean,
showRegModal: Boolean
})
const emit = defineEmits(['generateUserRegistrationLink', 'goOverview', 'openOrg', 'onUserNameSaved', 'closeRegModal', 'editUserName', 'refreshUserDetail'])
const authStore = useAuthStore()
const terminatingSessions = ref({})
const hoveredCredentialUuid = ref(null)
const hoveredSession = ref(null)
function onLinkCopied() {
authStore.showMessage('Link copied to clipboard!')
}
function handleEditName() {
emit('editUserName', props.selectedUser)
}
async function handleDelete(credential) {
try {
const data = await apiJson(`/auth/api/admin/orgs/${props.selectedUser.org_uuid}/users/${props.selectedUser.uuid}/credentials/${credential.credential_uuid}`, { method: 'DELETE' })
if (data.status === 'ok') {
emit('onUserNameSaved') // Reuse to refresh user detail
} else {
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
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>
<template>
<div class="user-detail">
<UserBasicInfo
v-if="userDetail && !userDetail.error"
:name="userDetail.display_name || selectedUser.display_name"
:visits="userDetail.visits"
:created-at="userDetail.created_at"
:last-seen="userDetail.last_seen"
:loading="loading"
:org-display-name="userDetail.org.display_name"
:role-name="userDetail.role"
:update-endpoint="`/auth/api/admin/orgs/${selectedUser.org_uuid}/users/${selectedUser.uuid}/display-name`"
@saved="$emit('onUserNameSaved')"
@edit-name="handleEditName"
/>
<div v-else-if="userDetail?.error" class="error small">{{ userDetail.error }}</div>
<template v-if="userDetail && !userDetail.error">
<div class="registration-actions">
<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>
<div class="actions ancillary-actions">
<button v-if="selectedOrg" @click="$emit('openOrg', selectedOrg)" class="icon-btn" title="Back to Org"></button>
</div>
<RegistrationLinkModal
v-if="showRegModal"
:endpoint="`/auth/api/admin/orgs/${selectedUser.org_uuid}/users/${selectedUser.uuid}/create-link`"
:auto-copy="false"
:user-name="userDetail?.display_name || selectedUser.display_name"
@close="$emit('closeRegModal')"
@copied="onLinkCopied"
/>
</div>
</template>
<style scoped>
.user-detail { display: flex; flex-direction: column; gap: var(--space-lg); }
.actions { display: flex; flex-wrap: wrap; gap: var(--space-sm); align-items: center; }
.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:hover { color: var(--color-heading); background: var(--color-surface-muted); }
.matrix-hint { font-size: 0.8rem; color: var(--color-text-muted); }
.error { color: var(--color-danger-text); }
.small { font-size: 0.9rem; }
.muted { color: var(--color-text-muted); }
</style>
File diff suppressed because it is too large Load Diff
+44
View File
@@ -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>
+38
View File
@@ -0,0 +1,38 @@
<script setup>
import { computed } from 'vue'
// Props:
// entries: Array<{ label:string, href:string }>
// showHome: include leading home icon (defaults true)
// homeHref: home link target (default '/')
const props = defineProps({
entries: { type: Array, default: () => [] },
showHome: { type: Boolean, default: true },
homeHref: { type: String, default: '/' }
})
const crumbs = computed(() => {
const base = props.showHome ? [{ label: '🏠', href: props.homeHref }] : []
return [...base, ...props.entries]
})
</script>
<template>
<nav class="breadcrumbs" aria-label="Breadcrumb" v-if="crumbs.length">
<ol>
<li v-for="(c, idx) in crumbs" :key="idx">
<a :href="c.href">{{ c.label }}</a>
<span v-if="idx < crumbs.length - 1" class="sep"> </span>
</li>
</ol>
</nav>
</template>
<style scoped>
.breadcrumbs { margin: .25rem 0 .5rem; line-height:1.2; color: var(--color-text-muted); }
.breadcrumbs ol { list-style: none; padding: 0; margin: 0; display: flex; flex-wrap: wrap; align-items: center; gap: .25rem; }
.breadcrumbs li { display: inline-flex; align-items: center; gap: .25rem; font-size: .9rem; }
.breadcrumbs a { text-decoration: none; color: var(--color-link); padding: 0 .25rem; border-radius:4px; transition: color 0.2s ease, background 0.2s ease; }
.breadcrumbs a:hover, .breadcrumbs a:focus-visible { text-decoration: underline; color: var(--color-link-hover); outline: none; }
.breadcrumbs .sep { color: var(--color-text-muted); margin: 0; }
</style>
+45 -32
View File
@@ -2,14 +2,21 @@
<div class="credential-list"> <div class="credential-list">
<div v-if="loading"><p>Loading credentials...</p></div> <div v-if="loading"><p>Loading credentials...</p></div>
<div v-else-if="!credentials?.length"><p>No passkeys found.</p></div> <div v-else-if="!credentials?.length"><p>No passkeys found.</p></div>
<div v-else> <template v-else>
<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>
</div> </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,18 +95,3 @@ const getCredentialAuthIcon = (credential) => {
return info[iconKey] || null return info[iconKey] || null
} }
</script> </script>
<style scoped>
.credential-list { display: flex; flex-direction: column; gap: .75rem; margin-top: .5rem; }
.credential-item { border: 1px solid #ddd; border-radius: 8px; padding: .5rem .75rem; background: #fff; }
.credential-header { display: flex; align-items: center; gap: 1rem; }
.credential-icon { width: 40px; height: 40px; display: flex; align-items: center; justify-content: center; }
.auth-icon { border-radius: 6px; }
.credential-info { flex: 1 1 auto; }
.credential-info h4 { margin: 0; font-size: .9rem; }
.credential-dates { display: grid; grid-auto-flow: column; gap: .4rem; font-size: .65rem; align-items: center; }
.date-label { font-weight: 600; }
.credential-actions { margin-left: auto; }
.btn-delete-credential { background: none; border: none; cursor: pointer; font-size: .9rem; }
.btn-delete-credential:disabled { opacity: .3; cursor: not-allowed; }
</style>
+52 -52
View File
@@ -1,67 +1,67 @@
<template> <template>
<div class="container"> <section class="view-root view-root--narrow view-device-link">
<div class="view active"> <header class="view-header">
<h1>📱 Add Another Device</h1> <h1>📱 Add Another Device</h1>
<div class="device-link-section"> <p class="view-lede">Generate a one-time link to set up passkeys on a new device.</p>
<div class="qr-container"> </header>
<a :href="url" id="deviceLinkText" @click="copyLink"> <RegistrationLinkModal
<canvas id="qrCode" class="qr-code"></canvas> inline
<p v-if="url"> :endpoint="'/auth/api/user/create-link'"
{{ url.replace(/^[^:]+:\/\//, '') }} :user-name="userName"
</p> :auto-copy="false"
<p v-else> :prefix-copy-with-user-name="!!userName"
<em>Generating link...</em> show-close-in-inline
</p> @copied="onCopied"
</a> />
<p> <div class="button-row" style="margin-top:1rem;">
<strong>Scan and visit the URL on another device.</strong><br> <button @click="authStore.currentView = 'profile'" class="btn-secondary">Back to Profile</button>
<small> Expires in 24 hours and can only be used once.</small>
</p>
</div>
</div>
<button @click="authStore.currentView = 'profile'" class="btn-secondary">
Back to Profile
</button>
</div> </div>
</div> </section>
</template> </template>
<script setup> <script setup>
import { ref, onMounted } 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 onCopied = () => {
const copyLink = async (event) => { authStore.showMessage('Link copied to clipboard!', 'success', 2500)
event.preventDefault() authStore.currentView = 'profile'
if (url.value) {
await navigator.clipboard.writeText(url.value)
authStore.showMessage('Link copied to clipboard!')
authStore.currentView = 'profile'
}
} }
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
// Generate QR code
const qrCodeElement = document.getElementById('qrCode')
if (qrCodeElement) {
QRCode.toCanvas(qrCodeElement, url.value, {scale: 8 }, error => {
if (error) console.error('Failed to generate QR code:', error)
})
}
} catch (error) {
authStore.showMessage(`Failed to create device link: ${error.message}`, 'error')
authStore.currentView = 'profile'
}
}) })
</script> </script>
<style scoped>
.view-lede {
margin: 0;
color: var(--color-text-muted);
}
.qr-link {
text-decoration: none;
color: var(--color-text);
}
.button-row {
justify-content: flex-start;
}
@media (max-width: 720px) {
.button-row {
flex-direction: column;
}
.button-row button {
width: 100%;
}
}
</style>
+121
View File
@@ -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>
+45
View File
@@ -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>
-41
View File
@@ -1,41 +0,0 @@
<template>
<div class="container">
<div class="view active">
<h1>🔐 {{ (authStore.settings?.rp_name || 'Passkey') + ' Login' }}</h1>
<form @submit.prevent="handleLogin">
<button
type="submit"
class="btn-primary"
:disabled="authStore.isLoading"
>
{{ authStore.isLoading ? 'Authenticating...' : 'Login with Your Device' }}
</button>
</form>
</div>
</div>
</template>
<script setup>
import { useAuthStore } from '@/stores/auth'
const authStore = useAuthStore()
const handleLogin = async () => {
try {
console.log('Login button clicked')
authStore.showMessage('Starting authentication...', 'info')
await authStore.authenticate()
authStore.showMessage('Authentication successful!', 'success', 2000)
if (authStore.restrictedMode) {
// Restricted mode: reload so the app re-mounts and selectView() applies (will become permission denied)
location.reload()
} else if (location.pathname === '/auth/') {
authStore.currentView = 'profile'
} else {
location.reload()
}
} catch (error) {
authStore.showMessage(error.message, 'error')
}
}
</script>
+94
View File
@@ -0,0 +1,94 @@
<template>
<div class="modal-overlay" @keydown.esc="$emit('close')" tabindex="-1">
<div class="modal" role="dialog" aria-modal="true">
<slot />
</div>
</div>
</template>
<script setup>
defineEmits(['close'])
</script>
<style scoped>
.modal-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: transparent;
backdrop-filter: blur(.1rem) brightness(0.7);
-webkit-backdrop-filter: blur(.1rem) brightness(0.7);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
}
.modal {
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: var(--radius-lg);
box-shadow: var(--shadow-xl);
padding: calc(var(--space-lg) - var(--space-xs));
max-width: 500px;
width: min(500px, 90vw);
max-height: 90vh;
overflow-y: auto;
}
.modal :deep(.modal-title),
.modal :deep(h3) {
margin: 0 0 var(--space-md);
font-size: 1.25rem;
font-weight: 600;
color: var(--color-heading);
}
.modal :deep(form) {
display: flex;
flex-direction: column;
gap: var(--space-md);
}
.modal :deep(.modal-form) {
display: flex;
flex-direction: column;
gap: var(--space-md);
}
.modal :deep(.modal-form label) {
display: flex;
flex-direction: column;
gap: var(--space-xs);
font-weight: 500;
}
.modal :deep(.modal-form input),
.modal :deep(.modal-form textarea) {
padding: var(--space-md);
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
background: var(--color-bg);
color: var(--color-text);
font-size: 1rem;
line-height: 1.4;
min-height: 2.5rem;
}
.modal :deep(.modal-form input:focus),
.modal :deep(.modal-form textarea:focus) {
outline: none;
border-color: var(--color-accent);
box-shadow: 0 0 0 2px #c7d2fe;
}
.modal :deep(.modal-actions) {
display: flex;
justify-content: flex-end;
gap: var(--space-sm);
margin-top: var(--space-md);
margin-bottom: var(--space-xs);
}
</style>
+94
View File
@@ -0,0 +1,94 @@
<template>
<div class="name-edit-form">
<label :for="resolvedInputId">{{ label }}
<input
:id="resolvedInputId"
ref="inputRef"
:type="inputType"
:placeholder="placeholder"
v-model="localValue"
:disabled="busy"
required
/>
</label>
<div v-if="error" class="error small">{{ error }}</div>
<div class="modal-actions">
<button
type="button"
class="btn-secondary"
@click="handleCancel"
:disabled="busy"
>
{{ cancelText }}
</button>
<button
type="submit"
class="btn-primary"
:disabled="busy"
>
{{ submitText }}
</button>
</div>
</div>
</template>
<script setup>
import { computed, nextTick, onMounted, ref } from 'vue'
const props = defineProps({
modelValue: { type: String, default: '' },
label: { type: String, default: 'Name' },
placeholder: { type: String, default: '' },
submitText: { type: String, default: 'Save' },
cancelText: { type: String, default: 'Cancel' },
busy: { type: Boolean, default: false },
error: { type: String, default: '' },
autoFocus: { type: Boolean, default: true },
autoSelect: { type: Boolean, default: true },
inputId: { type: String, default: null },
inputType: { type: String, default: 'text' }
})
const emit = defineEmits(['update:modelValue', 'cancel'])
const inputRef = ref(null)
const generatedId = `name-edit-${Math.random().toString(36).slice(2, 10)}`
const localValue = computed({
get: () => props.modelValue,
set: (val) => emit('update:modelValue', val)
})
const resolvedInputId = computed(() => props.inputId || generatedId)
onMounted(() => {
if (!props.autoFocus) return
nextTick(() => {
if (props.autoSelect) {
inputRef.value?.select()
} else {
inputRef.value?.focus()
}
})
})
function handleCancel() {
emit('cancel')
}
</script>
<style scoped>
.name-edit-form {
display: flex;
flex-direction: column;
gap: var(--space-md);
}
.error {
color: var(--color-danger-text);
}
.small {
font-size: 0.9rem;
}
</style>
@@ -1,43 +0,0 @@
<template>
<div class="container">
<div class="view active">
<h1>🚫 Forbidden</h1>
<div v-if="authStore.userInfo?.authenticated" class="user-header">
<span class="user-emoji" aria-hidden="true">{{ userEmoji }}</span>
<span class="user-name">{{ displayName }}</span>
</div>
<p>You lack the permissions required for this page.</p>
<div class="actions">
<button class="btn-secondary" @click="back">Back</button>
<button class="btn-primary" @click="goAuth">Account</button>
<button class="btn-danger" @click="logout">Logout</button>
</div>
</div>
</div>
</template>
<script setup>
import { useAuthStore } from '@/stores/auth'
const authStore = useAuthStore()
const userEmoji = '👤' // Placeholder / could be extended later if backend provides one
const displayName = authStore.userInfo?.user?.user_name || 'User'
function goAuth() {
location.href = '/auth/'
}
function back() {
if (history.length > 1) history.back()
else authStore.currentView = 'login'
}
async function logout() {
await authStore.logout()
}
</script>
<style scoped>
.user-header { display:flex; align-items:center; gap:.5rem; font-size:1.1rem; margin-bottom:.75rem; }
.user-emoji { font-size:1.5rem; line-height:1; }
.user-name { font-weight:600; }
.actions { margin-top:1.5rem; display:flex; gap:.5rem; flex-wrap:nowrap; }
.hint { font-size:.9rem; opacity:.85; }
</style>
+153 -118
View File
@@ -1,7 +1,12 @@
<template> <template>
<div class="container"> <section class="view-root" data-view="profile">
<div class="view active"> <header class="view-header">
<h1>👋 Welcome! <a v-if="isAdmin" href="/auth/admin/" class="admin-link" title="Admin Console">Admin</a></h1> <h1>👋 Welcome!</h1>
<Breadcrumbs :entries="breadcrumbEntries" />
<p class="view-lede">Manage your account details and passkeys.</p>
</header>
<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"
@@ -9,160 +14,190 @@
: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" :loading="authStore.isLoading"
update-endpoint="/auth/api/user/display-name" update-endpoint="/auth/api/user/display-name"
@saved="authStore.loadUserInfo()" @saved="authStore.loadUserInfo()"
@edit-name="openNameDialog"
/> />
</section>
<h2>Your Passkeys</h2> <section class="section-block">
<div class="credential-list"> <div class="section-header">
<div v-if="authStore.isLoading"> <h2>Your Passkeys</h2>
<p>Loading credentials...</p> <p class="section-description">Keep at least one trusted passkey so you can always sign in.</p>
</div> </div>
<div v-else-if="authStore.userInfo?.credentials?.length === 0"> <div class="section-body">
<p>No passkeys found.</p> <CredentialList
</div> :credentials="authStore.userInfo?.credentials || []"
<div v-else> :aaguid-info="authStore.userInfo?.aaguid_info || {}"
<div :loading="authStore.isLoading"
v-for="credential in authStore.userInfo?.credentials || []" :hovered-credential-uuid="hoveredCredentialUuid"
:key="credential.credential_uuid" :hovered-session-credential-uuid="hoveredSession?.credential_uuid"
:class="['credential-item', { 'current-session': credential.is_current_session }]" allow-delete
> @delete="handleDelete"
<div class="credential-header"> @credential-hover="hoveredCredentialUuid = $event"
<div class="credential-icon"> />
<img <div class="button-row">
v-if="getCredentialAuthIcon(credential)" <button @click="addNewCredential" class="btn-primary">Add New Passkey</button>
:src="getCredentialAuthIcon(credential)" <button @click="showRegLink = true" class="btn-secondary">Add Another Device</button>
:alt="getCredentialAuthName(credential)"
class="auth-icon"
width="32"
height="32"
>
<span v-else class="auth-emoji">🔑</span>
</div>
<div class="credential-info">
<h4>{{ getCredentialAuthName(credential) }}</h4>
</div>
<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>
</div>
<div class="credential-actions">
<button
@click="deleteCredential(credential.credential_uuid)"
class="btn-delete-credential"
:disabled="credential.is_current_session"
:title="credential.is_current_session ? 'Cannot delete current session credential' : ''"
>
🗑
</button>
</div>
</div>
</div>
</div> </div>
</div> </div>
</section>
<div class="button-group" style="display: flex; gap: 10px;"> <SessionList
<button @click="addNewCredential" class="btn-primary"> :sessions="sessions"
Add New Passkey :terminating-sessions="terminatingSessions"
</button> :hovered-credential-uuid="hoveredCredentialUuid"
<button @click="authStore.currentView = 'device-link'" class="btn-primary"> @terminate="terminateSession"
Add Another Device @session-hover="hoveredSession = $event"
section-description="Review where you're signed in and end any sessions you no longer recognize."
/>
<Modal v-if="showNameDialog" @close="showNameDialog = false">
<h3>Edit Display Name</h3>
<form @submit.prevent="saveName" class="modal-form">
<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>
<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> </div>
<button @click="logout" class="btn-danger" style="width: 100%;"> <p class="logout-note" v-if="!hasMultipleSessions"><strong>Logout</strong> from {{ currentSessionHost }}.</p>
Logout <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>
</button> </section>
</div> <RegistrationLinkModal
</div> v-if="showRegLink"
:endpoint="'/auth/api/user/create-link'"
:auto-copy="false"
:prefix-copy-with-user-name="false"
@close="showRegLink = false"
@copied="showRegLink = false; authStore.showMessage('Link copied to clipboard!', 'success', 2500)"
/>
</section>
</template> </template>
<script setup> <script setup>
import { ref, onMounted, onUnmounted, computed } from 'vue' import { ref, onMounted, onUnmounted, computed, watch } from 'vue'
import { useAuthStore } from '@/stores/auth' import Breadcrumbs from '@/components/Breadcrumbs.vue'
import { formatDate } from '@/utils/helpers' import CredentialList from '@/components/CredentialList.vue'
import passkey from '@/utils/passkey'
import UserBasicInfo from '@/components/UserBasicInfo.vue' import UserBasicInfo from '@/components/UserBasicInfo.vue'
import Modal from '@/components/Modal.vue'
import NameEditForm from '@/components/NameEditForm.vue'
import SessionList from '@/components/SessionList.vue'
import RegistrationLinkModal from '@/components/RegistrationLinkModal.vue'
import { useAuthStore } from '@/stores/auth'
import { adminUiPath, makeUiHref } from '@/utils/settings'
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 showRegLink = ref(false)
const newName = ref('')
const saving = ref(false)
const hoveredCredentialUuid = ref(null)
const hoveredSession = ref(null)
watch(showNameDialog, (newVal) => { 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 getCredentialAuthName = (credential) => {
const authInfo = authStore.userInfo?.aaguid_info?.[credential.aaguid]
return authInfo ? authInfo.name : 'Unknown Authenticator'
}
const getCredentialAuthIcon = (credential) => {
const authInfo = authStore.userInfo?.aaguid_info?.[credential.aaguid]
if (!authInfo) return null
const isDarkMode = window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches
const iconKey = isDarkMode ? 'icon_dark' : 'icon_light'
return authInfo[iconKey] || null
}
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 deleteCredential = async (credentialId) => { const handleDelete = async (credential) => {
const credentialId = credential?.credential_uuid
if (!credentialId) return
if (!confirm('Are you sure you want to delete this passkey?')) return if (!confirm('Are you sure you want to delete this passkey?')) return
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 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 entries = [{ label: 'Auth', href: makeUiHref() }]; if (isAdmin.value) entries.push({ label: 'Admin', href: adminUiPath() }); return entries })
const saveName = async () => {
const name = newName.value.trim()
if (!name) { authStore.showMessage('Name cannot be empty', 'error'); return }
try {
saving.value = true
await apiJson('/auth/api/user/display-name', { method: 'PUT', body: { display_name: name } })
showNameDialog.value = false
await authStore.loadUserInfo()
authStore.showMessage('Name updated successfully!', 'success', 3000)
} catch (e) { authStore.showMessage(e.message || 'Failed to update name', 'error') }
finally { saving.value = false }
}
</script> </script>
<style scoped> <style scoped>
/* Removed inline user info styles; now provided by UserBasicInfo component */ .view-lede { margin: 0; color: var(--color-text-muted); font-size: 1rem; }
.admin-link { .section-header { display: flex; flex-direction: column; gap: 0.4rem; }
font-size: 0.6em; .section-description { margin: 0; color: var(--color-text-muted); }
margin-left: 0.75rem; .empty-state { margin: 0; color: var(--color-text-muted); text-align: center; padding: 1rem 0; }
text-decoration: none; .logout-button { align-self: flex-start; }
background: var(--color-background-soft, #eee); .logout-row { gap: 1rem; }
padding: 0.2em 0.6em; .logout-row.single { justify-content: flex-start; }
border-radius: 999px; .logout-note { margin: 0.75rem 0 0; color: var(--color-text-muted); font-size: 0.875rem; }
border: 1px solid var(--color-border, #ccc); @media (max-width: 720px) { .logout-button { width: 100%; } }
vertical-align: middle;
line-height: 1.2;
}
.admin-link:hover {
background: var(--color-background-mute, #ddd);
}
</style> </style>
@@ -1,29 +1,54 @@
<template> <template>
<div class="dialog-overlay" @keydown.esc.prevent="$emit('close')"> <div v-if="!inline && url" 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>Device Registration Link</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"> <div class="qr-container">
<a v-if="url" :href="url" @click.prevent="copy" class="qr-link"> <a :href="url" @click.prevent="copy" class="qr-link">
<canvas ref="qrCanvas" class="qr-code"></canvas> <canvas ref="qrCanvas" class="qr-code"></canvas>
<p>{{ displayUrl }}</p> <p>{{ displayUrl }}</p>
</a> </a>
<div v-else> <p class="reg-help">
<em>Generating link...</em> <span v-if="userName">The user should open this link on the device where they want to register.</span>
</div> <span v-else>Open or scan this link on the device you wish to register to your account.</span>
<p> <br><small>{{ expirationMessage }}</small>
<strong>Scan and visit the URL on another device.</strong><br>
<small> Expires in 24 hours and one-time use.</small>
</p> </p>
<div v-if="expires" style="font-size:12px; margin-top:6px;">Expires: {{ new Date(expires).toLocaleString() }}</div>
</div> </div>
</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> <button class="btn-primary" @click="copy">Copy Link</button>
</div>
</div>
</div>
<div v-else-if="inline && url" class="registration-inline-wrapper">
<div class="registration-inline-block section-block">
<div class="section-header">
<h2 class="inline-heading">📱 <span v-if="userName">Registration for {{ userName }}</span><span v-else>Device Registration Link</span></h2>
</div>
<div class="section-body">
<div class="device-link-section">
<div class="qr-container">
<a :href="url" @click.prevent="copy" class="qr-link">
<canvas ref="qrCanvas" class="qr-code"></canvas>
<p>{{ displayUrl }}</p>
</a>
<p class="reg-help">
<span v-if="userName">The user should open this link on the device where they want to register.</span>
<span v-else>Open this link on the device you wish to connect with.</span>
<br><small>{{ expirationMessage }}</small>
</p>
</div>
</div>
<div class="button-row" style="margin-top:1rem;">
<button class="btn-primary" @click="copy">Copy Link</button>
<button v-if="showCloseInInline" class="btn-secondary" @click="$emit('close')">Close</button>
</div>
</div> </div>
</div> </div>
</div> </div>
@@ -32,10 +57,19 @@
<script setup> <script setup>
import { ref, onMounted, watch, computed, nextTick } from 'vue' import { ref, onMounted, watch, computed, nextTick } from 'vue'
import QRCode from 'qrcode/lib/browser' import QRCode from 'qrcode/lib/browser'
import { formatDate } from '@/utils/helpers'
import { useAuthStore } from '@/stores/auth'
import { apiJson, getUserFriendlyErrorMessage, shouldShowErrorToast } from '@/utils/api'
const authStore = useAuthStore()
const props = defineProps({ const props = defineProps({
endpoint: { type: String, required: true }, // POST endpoint returning {url, expires} endpoint: { type: String, required: true },
autoCopy: { type: Boolean, default: true } autoCopy: { type: Boolean, default: true },
userName: { type: String, default: null },
inline: { type: Boolean, default: false },
showCloseInInline: { type: Boolean, default: false },
prefixCopyWithUserName: { type: Boolean, default: false }
}) })
const emit = defineEmits(['close','generated','copied']) const emit = defineEmits(['close','generated','copied'])
@@ -46,11 +80,14 @@ const qrCanvas = ref(null)
const displayUrl = computed(() => url.value ? url.value.replace(/^[^:]+:\/\//,'') : '') const displayUrl = computed(() => url.value ? url.value.replace(/^[^:]+:\/\//,'') : '')
const expirationMessage = computed(() => {
const timeStr = formatDate(expires.value)
return `⚠️ Expires ${timeStr.startsWith('In ') ? timeStr.substring(3) : timeStr} and can only be used once.`
})
async function fetchLink() { 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.detail) throw new Error(data.detail)
url.value = data.url url.value = data.url
expires.value = data.expires expires.value = data.expires
emit('generated', { url: data.url, expires: data.expires }) emit('generated', { url: data.url, expires: data.expires })
@@ -58,9 +95,12 @@ async function fetchLink() {
drawQR() drawQR()
if (props.autoCopy) copy() if (props.autoCopy) copy()
} catch (e) { } catch (e) {
url.value = null
expires.value = null
console.error('Failed to create link', e) console.error('Failed to create link', e)
if (shouldShowErrorToast(e)) {
authStore.showMessage(getUserFriendlyErrorMessage(e), 'error', 4000)
}
// Close the dialog on any error (auth cancelled, network error, etc.)
emit('close')
} }
} }
@@ -73,15 +113,35 @@ async function drawQR() {
async function copy() { async function copy() {
if (!url.value) return if (!url.value) return
try { await navigator.clipboard.writeText(url.value); emit('copied', url.value); emit('close') } catch (_) { /* ignore */ } let text = url.value
if (props.prefixCopyWithUserName && props.userName) {
text = `${props.userName} ${text}`
}
try {
await navigator.clipboard.writeText(text)
emit('copied', text)
if (!props.inline) emit('close')
} catch (_) {
/* ignore */
}
} }
onMounted(fetchLink) onMounted(fetchLink)
watch(url, () => drawQR(), { flush: 'post' }) 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 */ /* Minimal extra styling; main look comes from global styles */
.qr-link { text-decoration:none; color:inherit; } .qr-link { text-decoration:none; color:inherit; }
.reg-header-row { display:flex; justify-content:space-between; align-items:center; gap:.75rem; margin-bottom:.75rem; }
.reg-title { margin:0; font-size:1.25rem; font-weight:600; }
.device-dialog { background: var(--color-surface); padding: 1.25rem 1.25rem 1rem; border-radius: var(--radius-md); max-width:480px; width:100%; box-shadow:0 6px 28px rgba(0,0,0,.25); }
.qr-container { display:flex; flex-direction:column; align-items:center; gap:.5rem; }
.qr-code { display:block; }
.reg-help { margin-top:.5rem; margin-bottom:.75rem; font-size:.85rem; line-height:1.25rem; text-align:center; }
.reg-actions { display:flex; justify-content:flex-end; gap:.5rem; margin-top:.25rem; }
.registration-inline-block .qr-container { align-items:flex-start; }
.registration-inline-block .reg-help { text-align:left; }
</style> </style>
-55
View File
@@ -1,55 +0,0 @@
<template>
<div class="container">
<div class="view active">
<h1>🔑 Add New Credential</h1>
<label class="name-edit">
<span>👤 Name:</span>
<input
type="text"
v-model="user_name"
:placeholder="authStore.userInfo?.user?.user_name || 'Your name'"
:disabled="authStore.isLoading"
maxlength="64"
@keyup.enter="register"
/>
</label>
<p>Proceed to complete {{authStore.userInfo?.session_type}}:</p>
<button
class="btn-primary"
:disabled="authStore.isLoading"
@click="register"
>
{{ authStore.isLoading ? 'Registering...' : 'Register Passkey' }}
</button>
</div>
</div>
</template>
<script setup>
import { useAuthStore } from '@/stores/auth'
import passkey from '@/utils/passkey'
import { ref } from 'vue'
const authStore = useAuthStore()
const user_name = ref('') // intentionally blank; original shown via placeholder
async function register() {
authStore.isLoading = true
authStore.showMessage('Starting registration...', 'info')
try {
const result = await passkey.register(authStore.resetToken, user_name.value)
console.log("Result", result)
await authStore.setSessionCookie(result.session_token)
// resetToken cleared by setSessionCookie; ensure again
authStore.resetToken = null
authStore.showMessage('Passkey registered successfully!', 'success', 2000)
await authStore.loadUserInfo()
authStore.selectView()
} catch (error) {
authStore.showMessage(`Registration failed: ${error.message}`, 'error')
} finally {
authStore.isLoading = false
}
}
</script>
+225
View File
@@ -0,0 +1,225 @@
<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">{{ headerMessage }}</p>
</header>
<section class="section-block">
<div class="section-body center">
<div class="button-row center">
<slot name="actions"
:loading="loading"
:can-authenticate="canAuthenticate"
:is-authenticated="isAuthenticated"
:authenticate="authenticateUser"
:logout="logoutUser"
:mode="mode">
<!-- Default actions -->
<button class="btn-secondary" :disabled="loading" @click="$emit('back')">Back</button>
<button v-if="canAuthenticate" class="btn-primary" :disabled="loading" @click="authenticateUser">
{{ loading ? (mode === 'reauth' ? 'Verifying' : 'Signing in') : (mode === 'reauth' ? 'Verify' : 'Login') }}
</button>
<button v-if="isAuthenticated && mode !== 'reauth'" class="btn-danger" :disabled="loading" @click="logoutUser">Logout</button>
<button v-if="isAuthenticated && mode !== 'reauth'" class="btn-primary" :disabled="loading" @click="openProfile">Profile</button>
</slot>
</div>
</div>
</section>
</div>
</main>
</div>
</template>
<script setup>
import { computed, onMounted, reactive, ref } from 'vue'
import passkey from '@/utils/passkey'
import { getSettings } from '@/utils/settings'
import { fetchJson, getUserFriendlyErrorMessage } from '@/utils/api'
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'
let statusTimer = null
const isAuthenticated = computed(() => !!userInfo.value?.authenticated)
const canAuthenticate = computed(() => {
if (initializing.value) return false
// In reauth mode, allow authentication even if already authenticated
if (props.mode === 'reauth') return true
// In forbidden view (authenticated but lacking permissions), don't allow authentication
if (currentView.value === 'forbidden') return false
// In login view or initial state, allow authentication
return true
})
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.'
}
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' })
// Determine view based on authentication status
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)
// For 401/403 just go to login, for other errors show message
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
// Switch to login view after logout
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() {
// Open profile in a new window with a specific name to reuse the same tab
const profileWindow = window.open('/auth/', 'passkey_auth_profile')
if (profileWindow) profileWindow.focus()
}
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}` }
})
}
onMounted(async () => {
await fetchSettings()
await fetchUserInfo()
initializing.value = false
})
defineExpose({
showMessage,
isAuthenticated,
userInfo
})
</script>
<style scoped>
.button-row.center { display: flex; justify-content: center; gap: 0.75rem; }
.user-line { margin: 0.5rem 0 0; font-weight: 500; color: var(--color-text); }
/* Vertically center the restricted "dialog" surface in the viewport */
main.view-root { min-height: 100vh; align-items: center; justify-content: center; padding: 2rem 1rem; }
.surface.surface--tight {
max-width: 520px;
margin: 0 auto;
width: 100%;
display: flex;
flex-direction: column;
gap: 1.75rem;
}
</style>
+206
View File
@@ -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>
+9 -45
View File
@@ -2,21 +2,9 @@
<div v-if="userLoaded" class="user-info"> <div v-if="userLoaded" class="user-info">
<h3 class="user-name-heading"> <h3 class="user-name-heading">
<span class="icon">👤</span> <span class="icon">👤</span>
<span v-if="!editingName" class="user-name-row"> <span class="user-name-row">
<span class="display-name" :title="name">{{ name }}</span> <span class="display-name" :title="name">{{ name }}</span>
<button v-if="canEdit && updateEndpoint" class="mini-btn" @click="startEdit" title="Edit name"></button> <button v-if="canEdit && updateEndpoint" class="mini-btn" @click="emit('editName')" title="Edit name"></button>
</span>
<span v-else class="user-name-row editing">
<input
v-model="newName"
class="name-input"
:placeholder="name"
:disabled="busy || loading"
maxlength="64"
@keyup.enter="saveName"
/>
<button class="mini-btn" @click="saveName" :disabled="busy || loading" title="Save name">💾</button>
<button class="mini-btn" @click="cancelEdit" :disabled="busy || loading" title="Cancel"></button>
</span> </span>
</h3> </h3>
<div v-if="orgDisplayName || roleName" class="org-role-sub"> <div v-if="orgDisplayName || roleName" class="org-role-sub">
@@ -49,53 +37,29 @@ const props = defineProps({
roleName: { type: String, default: '' } roleName: { type: String, default: '' }
}) })
const emit = defineEmits(['saved']) const emit = defineEmits(['saved', 'editName'])
const authStore = useAuthStore() const authStore = useAuthStore()
const editingName = ref(false)
const newName = ref('')
const busy = ref(false)
const userLoaded = computed(() => !!props.name) const userLoaded = computed(() => !!props.name)
function startEdit() { editingName.value = true; newName.value = '' }
function cancelEdit() { editingName.value = false }
async function saveName() {
if (!props.updateEndpoint) { editingName.value = false; return }
try {
busy.value = true
authStore.isLoading = true
const bodyName = newName.value.trim()
if (!bodyName) { cancelEdit(); return }
const res = await fetch(props.updateEndpoint, { method: 'PUT', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ display_name: bodyName }) })
let data = {}
try { data = await res.json() } catch (_) {}
if (!res.ok || data.detail) throw new Error(data.detail || 'Update failed')
editingName.value = false
authStore.showMessage('Name updated', 'success', 1500)
emit('saved')
} catch (e) { authStore.showMessage(e.message || 'Failed to update name', 'error') }
finally { busy.value = false; authStore.isLoading = false }
}
watch(() => props.name, () => { if (!props.name) editingName.value = false })
</script> </script>
<style scoped> <style scoped>
.user-info { display: grid; grid-template-columns: auto 1fr; gap: 10px; } .user-info { display: grid; grid-template-columns: auto 1fr; gap: 10px; }
.user-info h3 { grid-column: span 2; } .user-info h3 { grid-column: span 2; }
.org-role-sub { grid-column: span 2; display:flex; flex-direction:column; margin: -0.15rem 0 0.25rem; } .org-role-sub { grid-column: span 2; display:flex; flex-direction:column; margin: -0.15rem 0 0.25rem; }
.org-line { font-size: .7rem; font-weight:600; line-height:1.1; } .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:.6rem; color:#555; line-height:1.1; } .role-line { font-size:.65rem; color: var(--color-text-muted); line-height:1.1; }
.user-info span { text-align: left; } .user-info span { text-align: left; }
.user-name-heading { display: flex; align-items: center; gap: 0.4rem; flex-wrap: wrap; margin: 0 0 0.25rem 0; } .user-name-heading { display: flex; align-items: center; gap: 0.4rem; flex-wrap: wrap; margin: 0 0 0.25rem 0; }
.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; }
.display-name { font-weight: 600; font-size: 1.05em; line-height: 1.2; max-width: 14ch; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .display-name { font-weight: 600; font-size: 1.05em; line-height: 1.2; max-width: 14ch; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.name-input { width: auto; flex: 1 1 140px; min-width: 120px; padding: 6px 8px; font-size: 0.9em; border: 1px solid #a9c5d6; border-radius: 6px; } .name-input { width: auto; flex: 1 1 140px; min-width: 120px; padding: 6px 8px; font-size: 0.9em; border: 1px solid var(--color-border-strong); border-radius: 6px; background: var(--color-surface); color: var(--color-text); }
.user-name-heading .name-input { width: auto; } .user-name-heading .name-input { width: auto; }
.name-input:focus { outline: 2px solid #667eea55; border-color: #667eea; } .name-input:focus { outline: none; border-color: var(--color-accent); box-shadow: var(--focus-ring); }
.mini-btn { width: auto; padding: 4px 6px; margin: 0; font-size: 0.75em; line-height: 1; background: #eef5fa; border: 1px solid #b7d2e3; border-radius: 6px; cursor: pointer; transition: background 0.2s, transform 0.15s; } .mini-btn { width: auto; padding: 4px 6px; margin: 0; font-size: 0.75em; line-height: 1; background: var(--color-surface-muted); border: 1px solid var(--color-border-strong); border-radius: 6px; cursor: pointer; transition: background 0.2s, transform 0.15s, color 0.2s ease; color: var(--color-text); }
.mini-btn:hover:not(:disabled) { background: #dcecf6; } .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: 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%; } }
+71 -67
View File
@@ -1,14 +1,16 @@
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,
resetToken: null, // transient reset token
restrictedMode: false, // Anywhere other than /auth/: restrict to login or permission denied // Settings
settings: null,
// UI State // UI State
currentView: 'login', currentView: 'login',
@@ -18,7 +20,12 @@ export const useAuthStore = defineStore('auth', {
show: false show: false
}, },
}), }),
getters: {
},
actions: { actions: {
setLoading(flag) {
this.isLoading = !!flag
},
showMessage(message, type = 'info', duration = 3000) { showMessage(message, type = 'info', duration = 3000) {
this.status = { this.status = {
message, message,
@@ -31,26 +38,23 @@ export const useAuthStore = defineStore('auth', {
}, duration) }, duration)
} }
}, },
async setSessionCookie(sessionToken) { async setSessionCookie(result) {
const response = await fetch('/auth/api/set-session', { if (!result?.session_token) {
method: 'POST', console.error('setSessionCookie called with missing session_token:', result)
headers: {'Authorization': `Bearer ${sessionToken}`}, throw new Error('Authentication response missing session_token')
})
const result = await response.json()
if (result.detail) {
throw new Error(result.detail)
} }
// On successful session establishment, discard any reset token to avoid return await apiJson('/auth/api/set-session', {
// sending stale Authorization headers on subsequent API calls. method: 'POST',
this.resetToken = null headers: {'Authorization': `Bearer ${result.session_token}`},
return result })
}, },
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()
return result return result
} finally { } finally {
this.isLoading = false this.isLoading = false
@@ -61,8 +65,9 @@ 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()
return result return result
} finally { } finally {
@@ -70,70 +75,69 @@ export const useAuthStore = defineStore('auth', {
} }
}, },
selectView() { selectView() {
if (this.restrictedMode) {
// In restricted mode only allow login or show permission denied if already authenticated
if (!this.userInfo) this.currentView = 'login'
else if (this.userInfo.authenticated) this.currentView = 'permission-denied'
else this.currentView = 'login' // do not expose reset/registration flows outside /auth/
return
}
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 = 'reset'
},
setRestrictedMode(flag) {
this.restrictedMode = !!flag
},
async loadUserInfo() {
const headers = {}
// Reset tokens are only passed via query param now, not Authorization header
const url = this.resetToken ? `/auth/api/user-info?reset=${encodeURIComponent(this.resetToken)}` : '/auth/api/user-info'
const response = await fetch(url, { method: 'POST', headers })
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()
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')
}
} }
}, },
} }
+378
View File
@@ -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
+3 -8
View File
@@ -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
@@ -51,17 +51,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) {
+13 -9
View File
@@ -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()
+59 -17
View File
@@ -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)
+29
View File
@@ -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
}
+69 -29
View File
@@ -1,55 +1,95 @@
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, '../passkey/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,
fs: {
allow: ['..']
},
proxy: { proxy: {
'/auth/': { // Only proxy these two specific backend API paths
'/auth/api': {
target: 'http://localhost:4402', target: 'http://localhost:4402',
ws: true, headers: { connection: 'close' }
changeOrigin: false, },
// We proxy API + WS under /auth/, but want Vite to serve the SPA entrypoints '/auth/ws': {
// and static assets so that HMR works. Bypass tells http-proxy to skip target: 'http://localhost:4402',
// proxying when we return a (possibly rewritten) local path. ws: true
bypass(req) {
const rawUrl = req.url || ''
// Strip query/hash to match path-only for SPA entrypoints with query params (e.g. ?reset=token)
const url = rawUrl.split('?')[0].split('#')[0]
// 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/, '')
// Everything else (including /auth/admin/* APIs) should proxy.
}
} }
} }
}, },
build: { build: {
outDir: '../passkey/frontend-build', outDir: '../passkey/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'),
restricted: resolve(__dirname, 'auth/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]'
}
}
} }
} }
})) }))
+69 -25
View File
@@ -8,61 +8,105 @@ independent of any web framework:
- Credential management - Credential management
""" """
from datetime import datetime, timedelta from datetime import datetime, timezone
from uuid import UUID from uuid import UUID
from .db import Session from .config import SESSION_LIFETIME
from .globals import db from .db import ResetToken, Session
from .globals import db, passkey
from .util import hostutil
from .util.tokens import create_token, reset_key, session_key from .util.tokens import create_token, reset_key, session_key
EXPIRES = timedelta(hours=24) EXPIRES = SESSION_LIFETIME
def expires() -> datetime: def expires() -> datetime:
return datetime.now() + EXPIRES return datetime.now(timezone.utc) + EXPIRES
async def create_session(user_uuid: UUID, credential_uuid: UUID, info: dict) -> str: 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.""" """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() token = create_token()
now = datetime.now(timezone.utc)
await db.instance.create_session( await db.instance.create_session(
user_uuid=user_uuid, user_uuid=user_uuid,
credential_uuid=credential_uuid, credential_uuid=credential_uuid,
key=session_key(token), key=session_key(token),
expires=datetime.now() + EXPIRES, host=normalized_host,
info=info, ip=ip,
user_agent=user_agent,
renewed=now,
) )
return token return token
async def get_reset(token: str) -> Session: 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).""" """Validate a credential reset token. Returns None if the token is not well formed (i.e. it is another type of token)."""
session = await db.instance.get_session(reset_key(token)) record = await db.instance.get_reset_token(reset_key(token))
if not session: if record and record.expiry >= datetime.now(timezone.utc):
raise ValueError("Invalid or expired session token") return record
return session raise ValueError("This reset link is invalid or has expired")
async def get_session(token: str) -> Session: async def get_session(token: str, host: str | None = None) -> Session:
"""Validate a session token and return session data if valid.""" """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)) session = await db.instance.get_session(session_key(token))
if not session: if session and session_expiry(session) >= datetime.now(timezone.utc):
raise ValueError("Invalid or expired session token") if session.host is None:
return session # 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): async def refresh_session_token(token: str, *, ip: str, user_agent: str):
"""Refresh a session extending its expiry.""" """Refresh a session extending its expiry."""
# Get the current session session_record = await db.instance.get_session(session_key(token))
s = await db.instance.update_session( if not session_record:
session_key(token), datetime.now() + EXPIRES, {} 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:
if not s:
raise ValueError("Session not found or expired") raise ValueError("Session not found or expired")
async def delete_credential(credential_uuid: UUID, auth: str): async def delete_credential(credential_uuid: UUID, auth: str, host: str | None = None):
"""Delete a specific credential for the current user.""" """Delete a specific credential for the current user."""
s = await get_session(auth) s = await get_session(auth, host=host)
await db.instance.delete_credential(credential_uuid, s.user_uuid) await db.instance.delete_credential(credential_uuid, s.user_uuid)
+8 -7
View File
@@ -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 . import authsession, globals
from .db import Org, Permission, Role, User from .db import Org, Permission, Role, User
from .util import passphrase, tokens from .util import hostutil, passphrase, tokens
def _init_logger() -> logging.Logger: def _init_logger() -> logging.Logger:
@@ -41,13 +41,14 @@ 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 = f"{globals.passkey.instance.origin}/auth/{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
@@ -90,7 +91,7 @@ async def bootstrap_system(
uuid=uuid7.create(), uuid=uuid7.create(),
display_name=user_name or "Admin", display_name=user_name or "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)
+7
View File
@@ -0,0 +1,7 @@
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)
+91 -29
View File
@@ -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",
+281 -68
View File
@@ -6,7 +6,7 @@ for managing users and credentials in a WebAuthn authentication system.
""" """
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 (
@@ -17,19 +17,23 @@ from sqlalchemy import (
String, String,
delete, delete,
event, event,
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 ..config import SESSION_LIFETIME
from ..globals import db from ..globals import db
from . import ( from . import (
Credential, Credential,
DatabaseInterface, DatabaseInterface,
Org, Org,
Permission, Permission,
ResetToken,
Role, Role,
Session, Session,
SessionContext, SessionContext,
@@ -39,6 +43,14 @@ from . import (
DB_PATH = "sqlite+aiosqlite:///passkey-auth.sqlite" DB_PATH = "sqlite+aiosqlite:///passkey-auth.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.instance = DB()
await db.instance.init_db() await db.instance.init_db()
@@ -97,8 +109,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:
@@ -106,8 +122,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,
) )
@@ -117,7 +133,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,
) )
@@ -136,9 +152,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):
@@ -146,23 +182,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
@@ -170,9 +214,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,
) )
@@ -256,6 +321,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:
@@ -322,7 +439,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,
@@ -408,9 +525,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:
@@ -433,10 +552,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:
@@ -451,8 +570,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)
@@ -475,17 +595,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)
@@ -496,29 +620,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:
@@ -971,8 +1154,10 @@ class DB(DatabaseInterface):
) )
if role.permissions: if role.permissions:
for perm_id in set(role.permissions): for perm_id in set(role.permissions):
session.add( await session.execute(
RolePermission(role_uuid=role.uuid.bytes, permission_id=perm_id) insert(RolePermission).values(
role_uuid=role.uuid.bytes, permission_id=perm_id
)
) )
async def delete_role(self, role_uuid: UUID) -> None: async def delete_role(self, role_uuid: UUID) -> None:
@@ -1112,29 +1297,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
@@ -1150,18 +1347,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()
@@ -1176,11 +1378,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(
@@ -1200,10 +1407,16 @@ class DB(DatabaseInterface):
org_perm_result = await session.execute(org_perm_stmt) org_perm_result = await session.execute(org_perm_stmt)
organization.permissions = [row[0] for row in org_perm_result.fetchall()] organization.permissions = [row[0] for row in org_perm_result.fetchall()]
# Filter effective permissions: only include permissions that the org can grant
effective_permissions = [
p for p in permissions if p.id in organization.permissions
]
return SessionContext( return SessionContext(
session=session_obj, session=session_obj,
user=user_obj, user=user_obj,
org=organization, org=organization,
role=role, role=role,
permissions=permissions if permissions else None, credential=credential_obj,
permissions=effective_permissions if effective_permissions else None,
) )
+42 -3
View File
@@ -14,6 +14,27 @@ DEFAULT_SERVE_PORT = 4401
DEFAULT_DEV_PORT = 4402 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(
value: str | None, default_port: int value: str | None, default_port: int
) -> tuple[str | None, int | None, str | None, bool]: ) -> tuple[str | None, int | None, str | None, bool]:
@@ -94,6 +115,13 @@ def add_common_options(p: argparse.ArgumentParser) -> None:
) )
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", help="Origin URL (default: https://<rp-id>)")
p.add_argument(
"--auth-host",
help=(
"Dedicated host (optionally with scheme/port) to serve the auth UI at the root,"
" e.g. auth.example.com or https://auth.example.com"
),
)
def main(): def main():
@@ -168,6 +196,17 @@ def main():
os.environ["PASSKEY_RP_NAME"] = args.rp_name os.environ["PASSKEY_RP_NAME"] = args.rp_name
if origin: if origin:
os.environ["PASSKEY_ORIGIN"] = origin os.environ["PASSKEY_ORIGIN"] = origin
if getattr(args, "auth_host", None):
os.environ["PASSKEY_AUTH_HOST"] = args.auth_host
else:
# Preserve pre-set env variable if CLI option omitted
args.auth_host = os.environ.get("PASSKEY_AUTH_HOST")
if args.auth_host:
validate_auth_host(args.auth_host, args.rp_id)
from passkey.util import hostutil as _hostutil # local import
_hostutil.reload_config()
# One-time initialization + bootstrap before starting any server processes. # One-time initialization + bootstrap before starting any server processes.
# Lifespan in worker processes will call globals.init with bootstrap disabled. # Lifespan in worker processes will call globals.init with bootstrap disabled.
@@ -194,6 +233,7 @@ def main():
if args.command in {"serve", "dev"}: if args.command in {"serve", "dev"}:
run_kwargs: dict = { run_kwargs: dict = {
"reload": devmode, "reload": devmode,
"reload_dirs": ["passkey"] if devmode else None,
"log_level": "info", "log_level": "info",
} }
if uds: if uds:
@@ -204,9 +244,8 @@ def main():
run_kwargs["port"] = port run_kwargs["port"] = port
if devmode: if devmode:
if os.environ.get("PASSKEY_BUN_PARENT") != "1": os.environ["PASSKEY_DEVMODE"] = "1"
os.environ["PASSKEY_BUN_PARENT"] = "1" frontend.run_dev()
frontend.run_dev()
if all_ifaces and not uds: if all_ifaces and not uds:
if devmode: if devmode:
+481 -70
View File
@@ -1,14 +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 from fastapi import Body, FastAPI, HTTPException, Request, Response
from fastapi.responses import FileResponse, JSONResponse from fastapi.responses import JSONResponse
from ..authsession import expires from ..authsession import reset_expires
from ..globals import db from ..globals import db
from ..globals import passkey as global_passkey from ..util import (
from ..util import frontend, passphrase, permutil, querysafe, tokens frontend,
hostutil,
passphrase,
permutil,
querysafe,
tokens,
useragent,
)
from ..util.tokens import encode_session_key, session_key
from . import authz from . import authz
from .session import AUTH_COOKIE
app = FastAPI() app = FastAPI()
@@ -18,6 +28,15 @@ 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):
logging.exception("Unhandled exception in admin app") logging.exception("Unhandled exception in admin app")
@@ -25,20 +44,21 @@ async def general_exception_handler(_request, exc: Exception):
@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]
@@ -74,60 +94,125 @@ 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
org_uuid = uuid4() org_uuid = uuid4()
display_name = payload.get("display_name") or "New Organization" display_name = payload.get("display_name") or "New Organization"
permissions = payload.get("permissions") or [] permissions = payload.get("permissions") or []
org = OrgDC(uuid=org_uuid, display_name=display_name, permissions=permissions) org = OrgDC(uuid=org_uuid, display_name=display_name, permissions=permissions)
await db.instance.create_organization(org) await db.instance.create_organization(org)
# Automatically create Administration role with org admin permission
role_uuid = uuid4()
admin_role = RoleDC(
uuid=role_uuid,
org_uuid=org_uuid,
display_name="Administration",
permissions=[f"auth:org:{org_uuid}"],
)
await db.instance.create_role(admin_role)
return {"uuid": str(org_uuid)} return {"uuid": str(org_uuid)}
@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,
): ):
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") or current.permissions or []
# Sanity check: prevent removing permissions that would break current user's admin access
org_admin_perm = f"auth:org:{org_uuid}"
# If current user is org admin (not global admin), ensure org admin perm remains
if (
"auth:admin" not in ctx.role.permissions
and f"auth:org:{org_uuid}" in ctx.role.permissions
):
if org_admin_perm not in permissions:
raise ValueError(
"Cannot remove organization admin permission from your own organization"
)
org = OrgDC(uuid=org_uuid, display_name=display_name, permissions=permissions) org = OrgDC(uuid=org_uuid, display_name=display_name, permissions=permissions)
await db.instance.update_organization(org) await db.instance.update_organization(org)
return {"status": "ok"} return {"status": "ok"}
@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")
# Delete organization-specific permissions
org_perm_pattern = f"org:{str(org_uuid).lower()}"
all_permissions = await db.instance.list_permissions()
for perm in all_permissions:
perm_id_lower = perm.id.lower()
# Check if permission contains "org:{uuid}" separated by colons or at boundaries
if (
f":{org_perm_pattern}:" in perm_id_lower
or perm_id_lower.startswith(f"{org_perm_pattern}:")
or perm_id_lower.endswith(f":{org_perm_pattern}")
or perm_id_lower == org_perm_pattern
):
await db.instance.delete_permission(perm.id)
await db.instance.delete_organization(org_uuid) await db.instance.delete_organization(org_uuid)
return {"status": "ok"} return {"status": "ok"}
@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"}
@@ -137,9 +222,17 @@ 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(auth, ["auth:admin", f"auth:org:{org_uuid}"]) await authz.verify(
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
role_uuid = uuid4() role_uuid = uuid4()
@@ -163,11 +256,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
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:
@@ -175,13 +275,25 @@ async def admin_update_role(
from ..db import Role as RoleDC from ..db import Role as RoleDC
display_name = payload.get("display_name") or role.display_name display_name = payload.get("display_name") or role.display_name
permissions = payload.get("permissions") or role.permissions permissions = payload.get("permissions")
if permissions is None:
permissions = role.permissions
org = await db.instance.get_organization(str(org_uuid)) org = await db.instance.get_organization(str(org_uuid))
grantable = set(org.permissions or []) grantable = set(org.permissions or [])
existing_permissions = set(role.permissions)
for pid in permissions: for pid in permissions:
await db.instance.get_permission(pid) await db.instance.get_permission(pid)
if pid not in grantable: if pid not in existing_permissions and pid not in grantable:
raise ValueError(f"Permission not grantable by org: {pid}") raise ValueError(f"Permission not grantable by org: {pid}")
# Sanity check: prevent admin from removing their own access via role update
if ctx.org.uuid == org_uuid and ctx.role.uuid == role_uuid:
has_admin_access = (
"auth:admin" in permissions or f"auth:org:{org_uuid}" in permissions
)
if not has_admin_access:
raise ValueError("Cannot update your own role to remove admin permissions")
updated = RoleDC( updated = RoleDC(
uuid=role_uuid, uuid=role_uuid,
org_uuid=org_uuid, org_uuid=org_uuid,
@@ -193,13 +305,27 @@ 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(
await authz.verify( org_uuid: UUID,
auth, ["auth:admin", f"auth:org:{org_uuid}"], match=permutil.has_any role_uuid: UUID,
request: Request,
auth=AUTH_COOKIE,
):
ctx = await authz.verify(
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:
raise HTTPException(status_code=404, detail="Role not found in organization") raise HTTPException(status_code=404, detail="Role not found in organization")
# Sanity check: prevent admin from deleting their own role
if ctx.role.uuid == role_uuid:
raise ValueError("Cannot delete your own role")
await db.instance.delete_role(role_uuid) await db.instance.delete_role(role_uuid)
return {"status": "ok"} return {"status": "ok"}
@@ -209,10 +335,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")
@@ -238,10 +370,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,
): ):
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:
@@ -255,13 +394,30 @@ async def admin_update_user_role(
roles = await db.instance.get_roles_by_organization(str(org_uuid)) roles = await db.instance.get_roles_by_organization(str(org_uuid))
if not any(r.display_name == new_role for r in roles): if not any(r.display_name == new_role for r in roles):
raise ValueError("Role not found in organization") raise ValueError("Role not found in organization")
# Sanity check: prevent admin from removing their own access
if ctx.user.uuid == user_uuid:
new_role_obj = next((r for r in roles if r.display_name == new_role), None)
if new_role_obj:
has_admin_access = (
"auth:admin" in new_role_obj.permissions
or f"auth:org:{org_uuid}" in new_role_obj.permissions
)
if not has_admin_access:
raise ValueError(
"Cannot change your own role to one without admin permissions"
)
await db.instance.update_user_role_in_organization(user_uuid, new_role) await db.instance.update_user_role_in_organization(user_uuid, new_role)
return {"status": "ok"} return {"status": "ok"}
@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, 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)
@@ -270,27 +426,52 @@ 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 (
"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,
) )
origin = global_passkey.instance.origin url = hostutil.reset_link_url(
url = f"{origin}/auth/{token}" token, request.url.scheme, request.headers.get("host")
return {"url": url, "expires": expires().isoformat()} )
return {
"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")
),
}
@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:
@@ -298,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 (
"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] = []
@@ -320,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,
@@ -331,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)
@@ -354,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 (
"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")
@@ -370,19 +649,120 @@ async def admin_update_user_display_name(
return {"status": "ok"} return {"status": "ok"}
@app.delete("/orgs/{org_uuid}/users/{user_uuid}/credentials/{credential_uuid}")
async def admin_delete_user_credential(
org_uuid: UUID,
user_uuid: UUID,
credential_uuid: UUID,
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"),
max_age="5m",
)
if (
"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"
)
await db.instance.delete_credential(credential_uuid, user_uuid)
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 (
"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):
await authz.verify(auth, ["auth:admin"], 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()
return [{"id": p.id, "display_name": p.display_name} for p in perms]
# Global admins see all permissions
if "auth:admin" in ctx.role.permissions:
return [{"id": p.id, "display_name": p.display_name} for p in perms]
# Org admins only see permissions their org can grant
grantable = set(ctx.org.permissions or [])
filtered_perms = [p for p in perms if p.id in grantable]
return [{"id": p.id, "display_name": p.display_name} for p in filtered_perms]
@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")
@@ -396,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:
@@ -411,13 +796,24 @@ 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")
if not old_id or not new_id: if not old_id or not new_id:
raise ValueError("old_id and new_id required") raise ValueError("old_id and new_id required")
# Sanity check: prevent renaming critical permissions
if old_id == "auth:admin":
raise ValueError("Cannot rename the master admin permission")
querysafe.assert_safe(old_id, field="old_id") querysafe.assert_safe(old_id, field="old_id")
querysafe.assert_safe(new_id, field="new_id") querysafe.assert_safe(new_id, field="new_id")
if display_name is None: if display_name is None:
@@ -431,8 +827,23 @@ async def admin_rename_permission(payload: dict = Body(...), auth=Cookie(None)):
@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
if permission_id == "auth:admin":
raise ValueError("Cannot delete the master admin permission")
await db.instance.delete_permission(permission_id) await db.instance.delete_permission(permission_id)
return {"status": "ok"} return {"status": "ok"}
+151 -147
View File
@@ -1,11 +1,8 @@
import logging import logging
from contextlib import suppress from contextlib import suppress
from datetime import datetime, timedelta from datetime import datetime, timedelta, timezone
from uuid import UUID
from fastapi import ( from fastapi import (
Body,
Cookie,
Depends, Depends,
FastAPI, FastAPI,
HTTPException, HTTPException,
@@ -13,30 +10,42 @@ from fastapi import (
Request, Request,
Response, Response,
) )
from fastapi.responses import FileResponse, JSONResponse from fastapi.responses import JSONResponse
from fastapi.security import HTTPBearer from fastapi.security import HTTPBearer
from passkey.util import frontend from passkey.util import frontend
from .. import aaguid
from ..authsession import ( from ..authsession import (
EXPIRES, EXPIRES,
delete_credential,
expires,
get_reset, get_reset,
get_session, get_session,
refresh_session_token, refresh_session_token,
session_expiry,
) )
from ..globals import db from ..globals import db
from ..globals import passkey as global_passkey from ..globals import passkey as global_passkey
from ..util import passphrase, permutil, tokens from ..util import hostutil, htmlutil, passphrase, userinfo
from ..util.tokens import session_key from ..util.tokens import session_key
from . import authz, session from . import authz, session, user
from .session import AUTH_COOKIE
bearer_auth = HTTPBearer(auto_error=True) bearer_auth = HTTPBearer(auto_error=True)
app = FastAPI() 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*. # Refresh only if at least this much of the session lifetime has been *consumed*.
# Consumption is derived from (now + EXPIRES) - current_expires. # Consumption is derived from (now + EXPIRES) - current_expires.
# This guarantees a minimum spacing between DB writes even with frequent /validate calls. # This guarantees a minimum spacing between DB writes even with frequent /validate calls.
@@ -48,6 +57,15 @@ async def value_error_handler(_request: Request, exc: ValueError):
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: 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: Request, exc: Exception): async def general_exception_handler(_request: Request, exc: Exception):
logging.exception("Unhandled exception in API app") logging.exception("Unhandled exception in API app")
@@ -56,7 +74,10 @@ async def general_exception_handler(_request: Request, exc: Exception):
@app.post("/validate") @app.post("/validate")
async def validate_token( async def validate_token(
response: Response, perm: list[str] = Query([]), auth=Cookie(None) request: Request,
response: Response,
perm: list[str] = Query([]),
auth=AUTH_COOKIE,
): ):
"""Validate the current session and extend its expiry. """Validate the current session and extend its expiry.
@@ -64,18 +85,29 @@ async def validate_token(
renewed max-age. This keeps active users logged in without needing a separate renewed max-age. This keeps active users logged in without needing a separate
refresh endpoint. refresh endpoint.
""" """
ctx = await authz.verify(auth, perm) try:
ctx = await authz.verify(auth, perm, host=request.headers.get("host"))
except HTTPException:
# Global handler will clear cookie if 401
raise
renewed = False renewed = False
if auth: if auth:
consumed = EXPIRES - (ctx.session.expires - datetime.now()) current_expiry = session_expiry(ctx.session)
consumed = EXPIRES - (current_expiry - datetime.now(timezone.utc))
if not timedelta(0) < consumed < _REFRESH_INTERVAL: if not timedelta(0) < consumed < _REFRESH_INTERVAL:
try: try:
await refresh_session_token(auth) 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) session.set_session_cookie(response, auth)
renewed = True renewed = True
except ValueError: except ValueError:
# Session disappeared, e.g. due to concurrent logout # Session disappeared, e.g. due to concurrent logout; global handler will clear
raise HTTPException(status_code=401, detail="Session expired") raise authz.AuthException(
status_code=401, detail="Session expired", mode="login"
)
return { return {
"valid": True, "valid": True,
"user_uuid": str(ctx.session.user_uuid), "user_uuid": str(ctx.session.user_uuid),
@@ -84,17 +116,31 @@ async def validate_token(
@app.get("/forward") @app.get("/forward")
async def forward_authentication(perm: list[str] = Query([]), auth=Cookie(None)): 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. """Forward auth validation for Caddy/Nginx.
Query Params: Query Params:
- perm: repeated permission IDs the authenticated user must possess (ALL required). - 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. Success: 204 No Content with Remote-* headers describing the authenticated user.
Failure (unauthenticated / unauthorized): 4xx JSON body with detail. 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: try:
ctx = await authz.verify(auth, perm) ctx = await authz.verify(
auth, perm, host=request.headers.get("host"), max_age=max_age
)
role_permissions = set(ctx.role.permissions or []) role_permissions = set(ctx.role.permissions or [])
if ctx.permissions: if ctx.permissions:
role_permissions.update(permission.id for permission in ctx.permissions) role_permissions.update(permission.id for permission in ctx.permissions)
@@ -107,171 +153,129 @@ async def forward_authentication(perm: list[str] = Query([]), auth=Cookie(None))
"Remote-Org-Name": ctx.org.display_name, "Remote-Org-Name": ctx.org.display_name,
"Remote-Role": str(ctx.role.uuid), "Remote-Role": str(ctx.role.uuid),
"Remote-Role-Name": ctx.role.display_name, "Remote-Role-Name": ctx.role.display_name,
"Remote-Session-Expires": ctx.session.expires.isoformat(), "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), "Remote-Credential": str(ctx.session.credential_uuid),
} }
return Response(status_code=204, headers=remote_headers) return Response(status_code=204, headers=remote_headers)
except HTTPException as e: except authz.AuthException as e:
return FileResponse(frontend.file("index.html"), status_code=e.status_code) # 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") @app.get("/settings")
async def get_settings(): async def get_settings():
pk = global_passkey.instance pk = global_passkey.instance
return {"rp_id": pk.rp_id, "rp_name": pk.rp_name} 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.configured_auth_host(),
}
@app.post("/user-info") @app.post("/user-info")
async def api_user_info(reset: str | None = None, auth=Cookie(None)): 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 authenticated = False
session_record = None
reset_token = None
try: try:
if reset: if reset:
if not passphrase.is_well_formed(reset): if not passphrase.is_well_formed(reset):
raise ValueError("Invalid reset token") raise ValueError("Invalid reset token")
s = await get_reset(reset) reset_token = await get_reset(reset)
target_user_uuid = reset_token.user_uuid
else: else:
if auth is None: if auth is None:
raise ValueError("Authentication Required") raise authz.AuthException(
s = await get_session(auth) status_code=401,
detail="Authentication required",
mode="login",
)
session_record = await get_session(auth, host=request.headers.get("host"))
authenticated = True authenticated = True
target_user_uuid = session_record.user_uuid
except ValueError as e: except ValueError as e:
raise HTTPException(401, str(e)) raise HTTPException(401, str(e))
u = await db.instance.get_user_by_uuid(s.user_uuid) # Return minimal response for reset tokens
if not authenticated and reset_token:
return await userinfo.format_reset_user_info(target_user_uuid, reset_token)
if not authenticated: # minimal response for reset tokens # Return full user info for authenticated users
return { assert auth is not None
"authenticated": False, assert session_record is not None
"session_type": s.info.get("type"),
"user": {"user_uuid": str(u.uuid), "user_name": u.display_name},
}
assert authenticated and auth is not None return await userinfo.format_user_info(
user_uuid=target_user_uuid,
ctx = await permutil.session_context(auth) auth=auth,
credential_ids = await db.instance.get_credentials_by_user_uuid(s.user_uuid) session_record=session_record,
credentials: list[dict] = [] request_host=request.headers.get("host"),
user_aaguids: set[str] = set() )
for cred_id in credential_ids:
try:
c = await db.instance.get_credential_by_id(cred_id)
except ValueError:
continue
aaguid_str = str(c.aaguid)
user_aaguids.add(aaguid_str)
credentials.append(
{
"credential_uuid": str(c.uuid),
"aaguid": aaguid_str,
"created_at": c.created_at.isoformat(),
"last_used": c.last_used.isoformat() if c.last_used else None,
"last_verified": c.last_verified.isoformat()
if c.last_verified
else None,
"sign_count": c.sign_count,
"is_current_session": s.credential_uuid == c.uuid,
}
)
credentials.sort(key=lambda cred: cred["created_at"])
aaguid_info = aaguid.filter(user_aaguids)
role_info = None
org_info = None
effective_permissions: list[str] = []
is_global_admin = False
is_org_admin = False
if ctx:
role_info = {
"uuid": str(ctx.role.uuid),
"display_name": ctx.role.display_name,
"permissions": ctx.role.permissions,
}
org_info = {
"uuid": str(ctx.org.uuid),
"display_name": ctx.org.display_name,
"permissions": ctx.org.permissions,
}
effective_permissions = [p.id for p in (ctx.permissions or [])]
is_global_admin = "auth:admin" in (role_info["permissions"] or [])
if org_info:
is_org_admin = f"auth:org:{org_info['uuid']}" in (
role_info["permissions"] or []
)
return {
"authenticated": True,
"session_type": s.info.get("type"),
"user": {
"user_uuid": str(u.uuid),
"user_name": u.display_name,
"created_at": u.created_at.isoformat() if u.created_at else None,
"last_seen": u.last_seen.isoformat() if u.last_seen else None,
"visits": u.visits,
},
"org": org_info,
"role": role_info,
"permissions": effective_permissions,
"is_global_admin": is_global_admin,
"is_org_admin": is_org_admin,
"credentials": credentials,
"aaguid_info": aaguid_info,
}
@app.put("/user/display-name")
async def user_update_display_name(payload: dict = Body(...), auth=Cookie(None)):
if not auth:
raise HTTPException(status_code=401, detail="Authentication Required")
s = await get_session(auth)
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") @app.post("/logout")
async def api_logout(response: Response, auth=Cookie(None)): async def api_logout(request: Request, response: Response, auth=AUTH_COOKIE):
if not auth: if not auth:
return {"message": "Already logged out"} 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): with suppress(Exception):
await db.instance.delete_session(session_key(auth)) await db.instance.delete_session(session_key(auth))
response.delete_cookie("auth") session.clear_session_cookie(response)
return {"message": "Logged out successfully"} return {"message": "Logged out successfully"}
@app.post("/set-session") @app.post("/set-session")
async def api_set_session(response: Response, auth=Depends(bearer_auth)): async def api_set_session(
user = await get_session(auth.credentials) 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) session.set_session_cookie(response, auth.credentials)
return { return {
"message": "Session cookie set successfully", "message": "Session cookie set successfully",
"user_uuid": str(user.user_uuid), "user_uuid": str(user.user_uuid),
} }
@app.delete("/credential/{uuid}")
async def api_delete_credential(uuid: UUID, auth: str = Cookie(None)):
await delete_credential(uuid, auth)
return {"message": "Credential deleted successfully"}
@app.post("/create-link")
async def api_create_link(request: Request, auth=Cookie(None)):
s = await get_session(auth)
token = passphrase.generate()
await db.instance.create_session(
user_uuid=s.user_uuid,
key=tokens.reset_key(token),
expires=expires(),
info=session.infodict(request, "device addition"),
)
origin = global_passkey.instance.origin.rstrip("/")
url = f"{origin}/auth/{token}"
return {
"message": "Registration link generated successfully",
"url": url,
"expires": expires().isoformat(),
}
+97
View File
@@ -0,0 +1,97 @@
"""Middleware for handling auth host redirects."""
from fastapi import Request, Response
from fastapi.responses import RedirectResponse
from passkey.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.configured_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)
+80 -9
View File
@@ -2,26 +2,95 @@ import logging
from fastapi import HTTPException from fastapi import HTTPException
from ..util import permutil from ..util import permutil, sessionutil
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
async def verify(auth: str | None, perm: list[str], match=permutil.has_all): 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. """Validate session token and optional list of required permissions.
Returns the session context. Returns the session context.
Raises HTTPException on failure: Raises AuthException on failure with metadata for UI rendering.
401: unauthenticated / invalid session
403: required permissions missing
""" """
if not auth: if not auth:
raise HTTPException(status_code=401, detail="Authentication required") raise AuthException(
status_code=401,
detail="Authentication required",
mode="login",
)
ctx = await permutil.session_context(auth) ctx = await permutil.session_context(auth, host)
if not ctx: if not ctx:
raise HTTPException(status_code=401, detail="Session not found") 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): if not match(ctx, perm):
# Determine which permissions are missing for clearer diagnostics # Determine which permissions are missing for clearer diagnostics
@@ -34,6 +103,8 @@ async def verify(auth: str | None, perm: list[str], match=permutil.has_all):
perm, perm,
ctx.role.permissions, ctx.role.permissions,
) )
raise HTTPException(status_code=403, detail="Permission required") raise AuthException(
status_code=403, mode="forbidden", detail="Permission required"
)
return ctx return ctx
+53 -23
View File
@@ -2,13 +2,14 @@ import logging
import os import os
from contextlib import asynccontextmanager from contextlib import asynccontextmanager
from fastapi import FastAPI, HTTPException, Request from fastapi import FastAPI, HTTPException, Request, Response
from fastapi.responses import FileResponse, RedirectResponse from fastapi.responses import RedirectResponse
from fastapi.staticfiles import StaticFiles from fastapi.staticfiles import StaticFiles
from passkey.util import frontend, passphrase from passkey.util import frontend, hostutil, passphrase
from . import admin, api, ws from . import admin, api, auth_host, ws
from .session import AUTH_COOKIE
@asynccontextmanager @asynccontextmanager
@@ -41,38 +42,67 @@ async def lifespan(app: FastAPI): # pragma: no cover - startup path
logging.error(f"⚠️ {e}") logging.error(f"⚠️ {e}")
# Re-raise to fail fast # Re-raise to fail fast
raise raise
# In dev mode, Vite serves assets directly; in production, mount static files
# This is deferred to lifespan because PASSKEY_DEVMODE is set after module import
if not frontend.is_dev_mode():
app.mount(
"/auth/assets/",
StaticFiles(directory=frontend.file("auth", "assets")),
name="assets",
)
yield yield
# (Optional) add shutdown cleanup here later # (Optional) add shutdown cleanup here later
app = FastAPI(lifespan=lifespan) app = FastAPI(lifespan=lifespan)
app.mount("/auth/admin/", admin.app)
# 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/api/", api.app)
app.mount("/auth/ws/", ws.app) app.mount("/auth/ws/", ws.app)
app.mount(
"/auth/assets/", StaticFiles(directory=frontend.file("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("/")
async def frontapp_redirect(request: Request):
"""Redirect root (in case accessed on backend) to the main authentication app."""
return RedirectResponse(request.url_for("frontapp"), status_code=303)
@app.get("/auth/") @app.get("/auth/")
async def frontapp(): async def frontapp(request: Request, response: Response, auth=AUTH_COOKIE):
"""Serve the main authentication app.""" """Serve the user profile app.
return FileResponse(frontend.file("index.html"))
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
# Note: this catch-all handler must be the last route defined
@app.get("/{reset}")
@app.get("/auth/{reset}") @app.get("/auth/{reset}")
async def reset_link(request: Request, reset: str): async def reset_link(reset: str):
"""Pretty URL for reset links.""" """Serve the reset app directly with an injected reset token."""
if reset == "admin":
# Admin app missing trailing slash lands here, be friendly to user
return RedirectResponse(request.url_for("adminapp"), status_code=303)
if not passphrase.is_well_formed(reset): if not passphrase.is_well_formed(reset):
raise HTTPException(status_code=404) raise HTTPException(status_code=404)
url = request.url_for("frontapp").include_query_params(reset=reset) return Response(*await frontend.read("/int/reset/index.html"))
return RedirectResponse(url, status_code=303)
+6 -5
View File
@@ -17,7 +17,7 @@ from uuid import UUID
from passkey import authsession as _authsession from passkey import authsession as _authsession
from passkey import globals as _g from passkey import globals as _g
from passkey.util import passphrase from passkey.util import hostutil, passphrase
from passkey.util import tokens as _tokens from passkey.util import tokens as _tokens
@@ -63,13 +63,14 @@ 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 f"{_g.passkey.instance.origin}/auth/{token}", token return hostutil.reset_link_url(token), token
async def _main(query: str | None) -> int: async def _main(query: str | None) -> int:
+24 -5
View File
@@ -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 ..authsession import EXPIRES
AUTH_COOKIE_NAME = "__Host-auth"
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",
) )
+164
View File
@@ -0,0 +1,164 @@
from datetime import timezone
from uuid import UUID
from fastapi import (
Body,
FastAPI,
HTTPException,
Request,
Response,
)
from fastapi.responses import JSONResponse
from ..authsession import (
delete_credential,
expires,
get_session,
)
from ..globals import db
from ..util import hostutil, passphrase, tokens
from ..util.tokens import decode_session_key, session_key
from . import authz, session
from .session import AUTH_COOKIE
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, request.url.scheme, request.headers.get("host")
)
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")
),
}
+67 -20
View File
@@ -2,14 +2,15 @@ import logging
from functools import wraps from functools import wraps
from uuid import UUID from uuid import UUID
from fastapi import Cookie, FastAPI, WebSocket, WebSocketDisconnect from fastapi import FastAPI, WebSocket, WebSocketDisconnect
from webauthn.helpers.exceptions import InvalidAuthenticationResponse from webauthn.helpers.exceptions import InvalidAuthenticationResponse
from ..authsession import create_session, expires, get_reset, get_session from ..authsession import create_session, get_reset, get_session
from ..globals import db, passkey from ..globals import db, passkey
from ..util import passphrase from ..util import passphrase
from ..util.tokens import create_token, session_key from ..util.tokens import create_token, session_key
from .session import infodict from . import authz
from .session import AUTH_COOKIE, infodict
# WebSocket error handling decorator # WebSocket error handling decorator
@@ -21,11 +22,18 @@ def websocket_error_handler(func):
return await func(ws, *args, **kwargs) return await func(ws, *args, **kwargs)
except WebSocketDisconnect: except WebSocketDisconnect:
pass 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: except (ValueError, InvalidAuthenticationResponse) as e:
await ws.send_json({"detail": str(e)}) await ws.send_json({"status": 401, "detail": str(e)})
except Exception: except Exception:
logging.exception("Internal Server Error") logging.exception("Internal Server Error")
await ws.send_json({"detail": "Internal Server Error"}) await ws.send_json({"status": 500, "detail": "Internal Server Error"})
return wrapper return wrapper
@@ -48,7 +56,7 @@ async def register_chat(
credential_ids=credential_ids, credential_ids=credential_ids,
origin=origin, 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 +64,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 = ws.headers["origin"]
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)
@@ -89,14 +104,16 @@ async def websocket_register_add(
# 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,14 +130,41 @@ 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 = ws.headers["origin"]
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=origin)
# Update both credential and user's last_seen timestamp # Update both credential and user's last_seen timestamp
@@ -128,10 +172,13 @@ async def websocket_authenticate(ws: WebSocket):
# 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(
+4 -4
View File
@@ -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
@@ -163,7 +163,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 +184,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,
@@ -227,7 +227,7 @@ class Passkey:
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
+116 -22
View File
@@ -1,7 +1,42 @@
import asyncio
import atexit
import mimetypes
import os
import shutil
import signal
import subprocess
from importlib import resources from importlib import resources
from pathlib import Path from pathlib import Path
from sys import stderr
from threading import Thread
__all__ = ["path", "file", "run_dev"] import httpx
__all__ = ["path", "file", "read", "run_dev", "is_dev_mode"]
DEV_SERVER = "http://localhost:4403"
NO_FRONTEND_TOOL = """\
┃ ⚠️ deno, npm or bunx needed to run the frontend server.
"""
BUN_BUG = """\
┃ ⚠️ Bun cannot correctly proxy API requests to the backend.
┃ Bug report: https://github.com/oven-sh/bun/issues/9882
┃ Options:
┃ - sudo caddy run --config caddy/Caddyfile.dev
┃ - Install deno or npm instead
┃ Caddy will skip the Vite for API calls and serve everything at port 443.
┃ Otherwise Vite serves at port 8077 and proxies to backend (broken with bun).
"""
NO_FRONTEND = """\
┃ Note: only static build of the frontend is served at localhost:4402.
┃ The page will not update with frontend code changes.
"""
def _resolve_static_dir() -> Path: def _resolve_static_dir() -> Path:
@@ -25,13 +60,47 @@ def file(*parts: str) -> Path:
return path.joinpath(*parts) return path.joinpath(*parts)
def run_dev(): def is_dev_mode() -> bool:
"""Spawn the frontend dev server (bun or npm) as a background process.""" """Check if we're running in dev mode (Vite frontend server)."""
import atexit return os.environ.get("PASSKEY_DEVMODE") == "1"
import shutil
import signal
import subprocess
async def read(filepath: str) -> tuple[bytes, int, dict[str, str]]:
"""Read file content and return response tuple.
In dev mode, fetches from the Vite dev server.
In production, reads from the static build directory.
Args:
filepath: Path relative to frontend root, e.g. "/auth/index.html"
Returns:
Tuple of (content, status_code, headers) suitable for
FastAPI Response(*args) or Sanic raw response.
"""
if is_dev_mode():
async with httpx.AsyncClient() as client:
resp = await client.get(f"{DEV_SERVER}{filepath}")
resp.raise_for_status()
mime = resp.headers.get("content-type", "application/octet-stream")
# Strip charset suffix if present
mime = mime.split(";")[0].strip()
return resp.content, resp.status_code, {"content-type": mime}
else:
# Production: read from static build
file_path = path / filepath.lstrip("/")
content = await _read_file_async(file_path)
mime, _ = mimetypes.guess_type(str(file_path))
return content, 200, {"content-type": mime or "application/octet-stream"}
async def _read_file_async(file_path: Path) -> bytes:
"""Read file asynchronously using asyncio.to_thread."""
return await asyncio.to_thread(file_path.read_bytes)
def run_dev():
"""Spawn the frontend dev server (deno, npm, or bunx) as a background process."""
devpath = Path(__file__).parent.parent.parent / "frontend" devpath = Path(__file__).parent.parent.parent / "frontend"
if not (devpath / "package.json").exists(): if not (devpath / "package.json").exists():
raise RuntimeError( raise RuntimeError(
@@ -39,22 +108,47 @@ def run_dev():
if "site-packages" in devpath.parts if "site-packages" in devpath.parts
else f"Frontend source code not found at {devpath}" else f"Frontend source code not found at {devpath}"
) )
bun = shutil.which("bun")
npm = shutil.which("npm") if bun is None else None
if not bun and not npm:
raise RuntimeError("Neither bun nor npm found on PATH for dev server")
cmd: list[str] = [bun, "--bun", "run", "dev"] if bun else [npm, "run", "dev"] # type: ignore[list-item]
proc = subprocess.Popen(cmd, cwd=str(devpath))
def _terminate(): options = [
if proc.poll() is None: ("deno", "run", "dev"),
proc.terminate() ("npm", "run", "dev", "--"),
("bunx", "--bun", "vite"),
]
cmd = None
tool_name = None
for option in options:
if tool := shutil.which(option[0]):
cmd = [tool, *option[1:]]
tool_name = option[0]
break
atexit.register(_terminate) vite_process = None
def _signal_handler(signum, frame): def start_vite():
_terminate() nonlocal vite_process
raise SystemExit(0) if cmd is None:
stderr.write(NO_FRONTEND_TOOL)
stderr.write(NO_FRONTEND)
return
assert tool_name is not None
try:
if tool_name == "bunx":
stderr.write(BUN_BUG)
for sig in (signal.SIGINT, signal.SIGTERM): stderr.write(f">>> {' '.join([tool_name, *cmd[1:]])}\n")
signal.signal(sig, _signal_handler) vite_process = subprocess.Popen(cmd, cwd=str(devpath), shell=False)
except Exception as e:
stderr.write(f"┃ ⚠️ Vite couldn't start: {e}\n")
stderr.write(NO_FRONTEND)
def cleanup():
vite_process.terminate()
vite_process.wait()
# Start Vite in a separate thread
vite_thread = Thread(target=start_vite, daemon=True)
vite_thread.start()
atexit.register(cleanup)
signal.signal(signal.SIGTERM, lambda *_: cleanup())
signal.signal(signal.SIGINT, lambda *_: cleanup())
+92
View File
@@ -0,0 +1,92 @@
"""Utilities for determining the auth UI host and base URLs."""
import os
from functools import lru_cache
from urllib.parse import urlparse, urlsplit
from ..globals import passkey as global_passkey
_AUTH_HOST_ENV = "PASSKEY_AUTH_HOST"
def _default_origin_scheme() -> str:
origin_url = urlparse(global_passkey.instance.origin)
return origin_url.scheme or "https"
@lru_cache(maxsize=1)
def _load_config() -> tuple[str | None, str] | None:
raw = os.getenv(_AUTH_HOST_ENV)
if not raw:
return None
candidate = raw.strip()
if not candidate:
return None
parsed = urlparse(candidate if "://" in candidate else f"//{candidate}")
netloc = parsed.netloc or parsed.path
if not netloc:
return None
return (parsed.scheme or None, netloc.strip("/"))
def configured_auth_host() -> str | None:
cfg = _load_config()
return cfg[1] if cfg else None
def is_root_mode() -> bool:
return _load_config() is not None
def ui_base_path() -> str:
return "/" if is_root_mode() else "/auth/"
def auth_site_base_url(scheme: str | None = None, host: str | None = None) -> str:
cfg = _load_config()
if cfg:
cfg_scheme, cfg_host = cfg
scheme_to_use = cfg_scheme or scheme or _default_origin_scheme()
netloc = cfg_host
else:
if host:
scheme_to_use = scheme or _default_origin_scheme()
netloc = host.strip("/")
else:
origin = global_passkey.instance.origin.rstrip("/")
return f"{origin}{ui_base_path()}"
base = f"{scheme_to_use}://{netloc}".rstrip("/")
path = ui_base_path().lstrip("/")
return f"{base}/{path}" if path else f"{base}/"
def reset_link_url(
token: str, scheme: str | None = None, host: str | None = None
) -> str:
base = auth_site_base_url(scheme, host)
return f"{base}{token}"
def reload_config() -> None:
_load_config.cache_clear()
def normalize_host(raw_host: str | None) -> str | None:
"""Normalize a Host header preserving port (exact match required)."""
if not raw_host:
return None
candidate = raw_host.strip()
if not candidate:
return None
# urlsplit to parse (add // for scheme-less); prefer netloc to retain port.
parsed = urlsplit(candidate if "//" in candidate else f"//{candidate}")
netloc = parsed.netloc or parsed.path or ""
# Strip IPv6 brackets around host part but retain port suffix.
if netloc.startswith("["):
# format: [ipv6]:port or [ipv6]
if "]" in netloc:
host_part, _, rest = netloc.partition("]")
port_part = rest.lstrip(":")
netloc = host_part.strip("[]") + (f":{port_part}" if port_part else "")
return netloc.lower() or None
+47
View File
@@ -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")
+6 -2
View File
@@ -4,6 +4,7 @@ from collections.abc import Sequence
from fnmatch import fnmatchcase from fnmatch import fnmatchcase
from ..globals import db from ..globals import db
from .hostutil import normalize_host
from .tokens import session_key from .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)
+37
View File
@@ -0,0 +1,37 @@
"""Utility functions for session validation and checking."""
from datetime import datetime, timezone
from ..db import SessionContext
from .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
+47
View File
@@ -0,0 +1,47 @@
"""Utility functions for parsing time durations."""
import re
from datetime import timedelta
def parse_duration(duration_str: str) -> timedelta:
"""Parse a duration string into a timedelta.
Supports units: s, m, min, h, d
Examples: "30s", "5m", "5min", "2h", "1d"
Args:
duration_str: A string like "30s", "5m", "2h"
Returns:
A timedelta object
Raises:
ValueError: If the format is invalid
"""
duration_str = duration_str.strip().lower()
# Pattern matches: number + unit
# Units: s (seconds), m/min (minutes), h (hours), d (days)
pattern = r"^(\d+(?:\.\d+)?)(s|m|min|h|d)$"
match = re.match(pattern, duration_str)
if not match:
raise ValueError(
f"Invalid duration format: '{duration_str}'. "
"Expected format like '30s', '5m', '5min', '2h', or '1d'"
)
value = float(match.group(1))
unit = match.group(2)
if unit == "s":
return timedelta(seconds=value)
elif unit in ("m", "min"):
return timedelta(minutes=value)
elif unit == "h":
return timedelta(hours=value)
elif unit == "d":
return timedelta(days=value)
else:
raise ValueError(f"Unsupported time unit: {unit}")
+19
View File
@@ -15,6 +15,25 @@ def session_key(token: str) -> bytes:
return b"sess" + base64.urlsafe_b64decode(token) return b"sess" + base64.urlsafe_b64decode(token)
def encode_session_key(key: bytes) -> str:
"""Encode an opaque session key for external representation."""
return base64.urlsafe_b64encode(key).decode().rstrip("=")
def decode_session_key(encoded: str) -> bytes:
"""Decode an opaque session key from its public representation."""
if not encoded:
raise ValueError("Invalid session identifier")
padding = "=" * (-len(encoded) % 4)
try:
raw = base64.urlsafe_b64decode(encoded + padding)
except Exception as exc: # pragma: no cover - defensive
raise ValueError("Invalid session identifier") from exc
if not raw.startswith(b"sess"):
raise ValueError("Invalid session identifier")
return raw
def reset_key(passphrase: str) -> bytes: def reset_key(passphrase: str) -> bytes:
if not is_well_formed(passphrase): if not is_well_formed(passphrase):
raise ValueError( raise ValueError(
+10
View File
@@ -0,0 +1,10 @@
import user_agents
def compact_user_agent(ua: str | None) -> str:
if not ua:
return "-"
u = user_agents.parse(ua)
ver = u.browser.version_string.split(".")[0]
dev = u.device.family if u.device.family not in ["Other", "Mac"] else ""
return f"{u.browser.family}/{ver} {u.os.family} {dev}".strip()
+161
View File
@@ -0,0 +1,161 @@
"""User information formatting and retrieval logic."""
from datetime import timezone
from passkey.util import useragent
from .. import aaguid
from ..authsession import session_key
from ..globals import db
from . import hostutil, permutil, tokens
def _format_datetime(dt):
"""Format a datetime object to ISO 8601 string with UTC timezone."""
if dt is None:
return None
if dt.tzinfo:
return dt.astimezone(timezone.utc).isoformat().replace("+00:00", "Z")
else:
return dt.replace(tzinfo=timezone.utc).isoformat().replace("+00:00", "Z")
async def format_user_info(
*,
user_uuid,
auth: str,
session_record,
request_host: str | None,
) -> dict:
"""Format complete user information for authenticated users.
Args:
user_uuid: UUID of the user to fetch information for
auth: Authentication token
session_record: Current session record
request_host: Host header from the request
Returns:
Dictionary containing formatted user information including:
- User details
- Organization and role information
- Credentials list
- Sessions list
- Permissions
"""
u = await db.instance.get_user_by_uuid(user_uuid)
ctx = await permutil.session_context(auth, request_host)
# Fetch and format credentials
credential_ids = await db.instance.get_credentials_by_user_uuid(user_uuid)
credentials: list[dict] = []
user_aaguids: set[str] = set()
for cred_id in credential_ids:
try:
c = await db.instance.get_credential_by_id(cred_id)
except ValueError:
continue
aaguid_str = str(c.aaguid)
user_aaguids.add(aaguid_str)
credentials.append(
{
"credential_uuid": str(c.uuid),
"aaguid": aaguid_str,
"created_at": _format_datetime(c.created_at),
"last_used": _format_datetime(c.last_used),
"last_verified": _format_datetime(c.last_verified),
"sign_count": c.sign_count,
"is_current_session": session_record.credential_uuid == c.uuid,
}
)
credentials.sort(key=lambda cred: cred["created_at"])
aaguid_info = aaguid.filter(user_aaguids)
# Format role and org information
role_info = None
org_info = None
effective_permissions: list[str] = []
is_global_admin = False
is_org_admin = False
if ctx:
role_info = {
"uuid": str(ctx.role.uuid),
"display_name": ctx.role.display_name,
"permissions": ctx.role.permissions,
}
org_info = {
"uuid": str(ctx.org.uuid),
"display_name": ctx.org.display_name,
"permissions": ctx.org.permissions,
}
effective_permissions = [p.id for p in (ctx.permissions or [])]
is_global_admin = "auth:admin" in (role_info["permissions"] or [])
is_org_admin = any(
p.startswith("auth:org:") for p in (role_info["permissions"] or [])
)
# Format sessions
normalized_request_host = hostutil.normalize_host(request_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": tokens.encode_session_key(entry.key),
"credential_uuid": str(entry.credential_uuid),
"host": entry.host,
"ip": entry.ip,
"user_agent": useragent.compact_user_agent(entry.user_agent),
"last_renewed": _format_datetime(entry.renewed),
"is_current": entry.key == current_session_key,
"is_current_host": bool(
normalized_request_host
and entry.host
and entry.host == normalized_request_host
),
}
)
return {
"authenticated": True,
"user": {
"user_uuid": str(u.uuid),
"user_name": u.display_name,
"created_at": _format_datetime(u.created_at),
"last_seen": _format_datetime(u.last_seen),
"visits": u.visits,
},
"org": org_info,
"role": role_info,
"permissions": effective_permissions,
"is_global_admin": is_global_admin,
"is_org_admin": is_org_admin,
"credentials": credentials,
"aaguid_info": aaguid_info,
"sessions": sessions_payload,
}
async def format_reset_user_info(user_uuid, reset_token) -> dict:
"""Format minimal user information for reset token requests.
Args:
user_uuid: UUID of the user
reset_token: Reset token record
Returns:
Dictionary with minimal user info for password reset flow
"""
u = await db.instance.get_user_by_uuid(user_uuid)
return {
"authenticated": False,
"session_type": reset_token.token_type,
"user": {"user_uuid": str(u.uuid), "user_name": u.display_name},
}
+9 -2
View File
@@ -1,10 +1,10 @@
[build-system] [build-system]
requires = ["hatchling"] requires = ["hatchling", "hatch-vcs"]
build-backend = "hatchling.build" build-backend = "hatchling.build"
[project] [project]
name = "passkey" name = "passkey"
version = "0.1.0" dynamic = ["version"]
description = "Passkey Authentication for Web Services" description = "Passkey Authentication for Web Services"
authors = [ authors = [
{name = "Leo Vasanko"}, {name = "Leo Vasanko"},
@@ -18,9 +18,16 @@ dependencies = [
"aiosqlite>=0.19.0", "aiosqlite>=0.19.0",
"uuid7-standard>=1.0.0", "uuid7-standard>=1.0.0",
"pyjwt>=2.8.0", "pyjwt>=2.8.0",
"user-agents>=2.2.0",
] ]
requires-python = ">=3.10" requires-python = ">=3.10"
[tool.hatch.version]
source = "vcs"
[tool.hatch.build.hooks.vcs]
version-file = "passkey/_version.py"
[project.optional-dependencies] [project.optional-dependencies]
dev = [ dev = [
"ruff>=0.1.0", "ruff>=0.1.0",
+35 -34
View File
@@ -1,47 +1,48 @@
import os
import shutil import shutil
import subprocess import subprocess
from contextlib import contextmanager from pathlib import Path
from sys import stderr from sys import stderr
from hatchling.builders.hooks.plugin.interface import BuildHookInterface from hatchling.builders.hooks.plugin.interface import BuildHookInterface
@contextmanager def run(cmd, **kwargs):
def chdir(path): display_cmd = [Path(cmd[0]).name, *cmd[1:]]
original = os.getcwd() stderr.write(f"### {' '.join(display_cmd)}\n")
os.chdir(path) subprocess.run(cmd, check=True, **kwargs)
try:
yield
finally: def find_build_tool():
os.chdir(original) install = [
("deno", "install", "--allow-scripts=npm:vue-demi"),
("npm", "install"),
("bun", "--bun", "install"),
]
build = [
("deno", "task", "build"),
("npm", "run", "build"),
("bun", "--bun", "run", "build"),
]
for i, b in zip(install, build, strict=False):
if tool := shutil.which(i[0]):
return [tool, *i[1:]], [tool, *b[1:]]
raise RuntimeError("Deno, npm or Bun is required for building but none was found")
class CustomBuildHook(BuildHookInterface): class CustomBuildHook(BuildHookInterface):
def initialize(self, version, build_data): def initialize(self, version, build_data):
super().initialize(version, build_data) super().initialize(version, build_data)
stderr.write(">>> Building the frontend\n") stderr.write(">>> Building the frontend\n")
npm = None
bun = shutil.which("bun") install_cmd, build_cmd = find_build_tool()
if bun is None:
npm = shutil.which("npm") try:
if npm is None: run(install_cmd, cwd="frontend")
raise RuntimeError( stderr.write("\n")
"Bun or NodeJS `npm` is required for building but neither was found" run(build_cmd, cwd="frontend")
) except Exception as e:
# npm --prefix doesn't work on Windows, so we chdir instead stderr.write(f"Error occurred while building frontend: {e}\n")
with chdir("frontend"): raise
try:
if npm:
stderr.write("### npm install\n")
subprocess.run([npm, "install"], check=True) # noqa: S603
stderr.write("\n### npm run build\n")
subprocess.run([npm, "run", "build"], check=True) # noqa: S603
else:
assert bun
stderr.write("### bun --bun install\n")
subprocess.run([bun, "--bun", "install"], check=True) # noqa: S603
stderr.write("\n### bun --bun run build\n")
subprocess.run([bun, "--bun", "run", "build"], check=True) # noqa: S603
except Exception:
stderr.write("Error occurred while building frontend\n")