Compare commits

...
47 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
70 changed files with 3981 additions and 1334 deletions
+2 -1
View File
@@ -41,7 +41,6 @@ Notes:
| GET | `/auth/` | `/` | Main authentication SPA (non-auth hosts show an account summary view) | | GET | `/auth/` | `/` | Main authentication SPA (non-auth hosts show an account summary view) |
| GET | `/auth/admin/` | `/admin/` | Admin SPA root | | GET | `/auth/admin/` | `/admin/` | Admin SPA root |
| GET | `/auth/{reset_token}` | `/{reset_token}` | Reset / device addition SPA (token validated) | | GET | `/auth/{reset_token}` | `/{reset_token}` | Reset / device addition SPA (token validated) |
| GET | `/auth/restricted` | `/restricted` | Restricted / permission denied SPA |
## Core API (Unrestricted available on all hosts) ## Core API (Unrestricted available on all hosts)
@@ -49,6 +48,8 @@ Always under `/auth/api/` (even on auth host):
| Method | Path | Description | | 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 | | POST | `/auth/api/validate` | Validate & (conditionally) renew session |
| GET | `/auth/api/forward` | Auth proxy endpoint for reverse proxies (204 or 4xx) | | 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/set-session` | Set cookie from Bearer token |
+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
}
}
+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>
@@ -1,19 +1,25 @@
<script setup> <script setup>
import { ref, onMounted, onBeforeUnmount, computed, watch } from 'vue' import { ref, onMounted, onUnmounted, computed, watch } from 'vue'
import Breadcrumbs from '@/components/Breadcrumbs.vue' import Breadcrumbs from '@/components/Breadcrumbs.vue'
import CredentialList from '@/components/CredentialList.vue' import CredentialList from '@/components/CredentialList.vue'
import UserBasicInfo from '@/components/UserBasicInfo.vue' import UserBasicInfo from '@/components/UserBasicInfo.vue'
import RegistrationLinkModal from '@/components/RegistrationLinkModal.vue' import RegistrationLinkModal from '@/components/RegistrationLinkModal.vue'
import StatusMessage from '@/components/StatusMessage.vue' import StatusMessage from '@/components/StatusMessage.vue'
import AdminOverview from './AdminOverview.vue' import LoadingView from '@/components/LoadingView.vue'
import AdminOrgDetail from './AdminOrgDetail.vue' import AuthRequiredMessage from '@/components/AccessDenied.vue'
import AdminUserDetail from './AdminUserDetail.vue' import AdminOverview from '@/admin/AdminOverview.vue'
import AdminDialogs from './AdminDialogs.vue' import AdminOrgDetail from '@/admin/AdminOrgDetail.vue'
import AdminUserDetail from '@/admin/AdminUserDetail.vue'
import AdminDialogs from '@/admin/AdminDialogs.vue'
import { useAuthStore } from '@/stores/auth' import { useAuthStore } from '@/stores/auth'
import { getSettings, adminUiPath, makeUiHref } from '@/utils/settings' import { getSettings, adminUiPath, makeUiHref } from '@/utils/settings'
import { apiJson } from '@/utils/api'
const info = ref(null) const info = ref(null)
const loading = ref(true) const loading = ref(true)
const loadingMessage = ref('Loading...')
const authenticated = ref(false)
const showBackMessage = ref(false)
const error = ref(null) const error = ref(null)
const orgs = ref([]) const orgs = ref([])
const permissions = ref([]) const permissions = ref([])
@@ -43,11 +49,17 @@ function handleGlobalClick(e) {
} }
} }
onMounted(() => { onMounted(async () => {
document.addEventListener('click', handleGlobalClick) document.addEventListener('click', handleGlobalClick)
window.addEventListener('hashchange', parseHash)
const settings = await getSettings()
if (settings?.rp_name) document.title = settings.rp_name + ' Admin'
await load()
}) })
onBeforeUnmount(() => {
onUnmounted(() => {
document.removeEventListener('click', handleGlobalClick) document.removeEventListener('click', handleGlobalClick)
window.removeEventListener('hashchange', parseHash)
}) })
// Build a summary: for each permission id -> { orgs: Set(org_display_name), userCount } // Build a summary: for each permission id -> { orgs: Set(org_display_name), userCount }
@@ -56,7 +68,7 @@ const permissionSummary = computed(() => {
for (const o of orgs.value) { for (const o of orgs.value) {
const orgBase = { uuid: o.uuid, display_name: o.display_name } const orgBase = { uuid: o.uuid, display_name: o.display_name }
const orgPerms = new Set(o.permissions || []) const orgPerms = new Set(o.permissions || [])
// Org-level permissions (direct) - only count if org can grant them // Org-level permissions (direct) - only count if org can grant them
for (const pid of o.permissions || []) { for (const pid of o.permissions || []) {
if (!summary[pid]) summary[pid] = { orgs: [], orgSet: new Set(), userCount: 0 } if (!summary[pid]) summary[pid] = { orgs: [], orgSet: new Set(), userCount: 0 }
@@ -65,13 +77,13 @@ const permissionSummary = computed(() => {
summary[pid].orgSet.add(o.uuid) summary[pid].orgSet.add(o.uuid)
} }
} }
// Role-based permissions (inheritance) - only count if org can grant them // Role-based permissions (inheritance) - only count if org can grant them
for (const r of o.roles) { for (const r of o.roles) {
for (const pid of r.permissions) { for (const pid of r.permissions) {
// Only count if the org can grant this permission // Only count if the org can grant this permission
if (!orgPerms.has(pid)) continue if (!orgPerms.has(pid)) continue
if (!summary[pid]) summary[pid] = { orgs: [], orgSet: new Set(), userCount: 0 } if (!summary[pid]) summary[pid] = { orgs: [], orgSet: new Set(), userCount: 0 }
if (!summary[pid].orgSet.has(o.uuid)) { if (!summary[pid].orgSet.has(o.uuid)) {
summary[pid].orgs.push(orgBase) summary[pid].orgs.push(orgBase)
@@ -99,9 +111,7 @@ async function attachPermissionToOrg(pid, orgUuid) {
if (!orgUuid) return if (!orgUuid) return
try { try {
const params = new URLSearchParams({ permission_id: pid }) const params = new URLSearchParams({ permission_id: pid })
const res = await fetch(`/auth/admin/orgs/${orgUuid}/permission?${params.toString()}`, { method: 'POST' }) await apiJson(`/auth/api/admin/orgs/${orgUuid}/permission?${params.toString()}`, { method: 'POST' })
const data = await res.json()
if (data.detail) throw new Error(data.detail)
await loadOrgs() await loadOrgs()
} catch (e) { } catch (e) {
authStore.showMessage(e.message || 'Failed to add permission to org') authStore.showMessage(e.message || 'Failed to add permission to org')
@@ -112,9 +122,7 @@ async function detachPermissionFromOrg(pid, orgUuid) {
openDialog('confirm', { message: 'Remove permission from this org?', action: async () => { openDialog('confirm', { message: 'Remove permission from this org?', action: async () => {
try { try {
const params = new URLSearchParams({ permission_id: pid }) const params = new URLSearchParams({ permission_id: pid })
const res = await fetch(`/auth/admin/orgs/${orgUuid}/permission?${params.toString()}`, { method: 'DELETE' }) await apiJson(`/auth/api/admin/orgs/${orgUuid}/permission?${params.toString()}`, { method: 'DELETE' })
const data = await res.json()
if (data.detail) throw new Error(data.detail)
await loadOrgs() await loadOrgs()
} catch (e) { } catch (e) {
authStore.showMessage(e.message || 'Failed to remove permission from org') authStore.showMessage(e.message || 'Failed to remove permission from org')
@@ -134,10 +142,7 @@ function parseHash() {
} }
async function loadOrgs() { async function loadOrgs() {
const res = await fetch('/auth/admin/orgs') const data = await apiJson('/auth/api/admin/orgs')
const data = await res.json()
if (data.detail) throw new Error(data.detail)
// Restructure to attach users to roles instead of flat user list at org level
orgs.value = data.map(o => { orgs.value = data.map(o => {
const roles = o.roles.map(r => ({ ...r, org_uuid: o.uuid, users: [] })) const roles = o.roles.map(r => ({ ...r, org_uuid: o.uuid, users: [] }))
const roleMap = Object.fromEntries(roles.map(r => [r.display_name, r])) const roleMap = Object.fromEntries(roles.map(r => [r.display_name, r]))
@@ -149,25 +154,25 @@ async function loadOrgs() {
} }
async function loadPermissions() { async function loadPermissions() {
const res = await fetch('/auth/admin/permissions') permissions.value = await apiJson('/auth/api/admin/permissions')
const data = await res.json() }
if (data.detail) throw new Error(data.detail)
permissions.value = data async function loadUserInfo() {
info.value = await apiJson('/auth/api/user-info', { method: 'POST' })
authenticated.value = true
} }
async function load() { async function load() {
loading.value = true loading.value = true
loadingMessage.value = 'Loading...'
error.value = null error.value = null
try { try {
const res = await fetch('/auth/api/user-info', { method: 'POST' }) // Load admin data first - apiJson will handle 401/403 with iframe authentication
const data = await res.json() await Promise.all([loadOrgs(), loadPermissions()])
if (data.detail) throw new Error(data.detail) // If we get here, user has admin access - now fetch user info for display
info.value = data await loadUserInfo()
if (data.authenticated && (data.is_global_admin || data.is_org_admin)) {
await Promise.all([loadOrgs(), loadPermissions()]) if (!info.value.is_global_admin && info.value.is_org_admin && orgs.value.length === 1) {
}
// After loading orgs decide view if not global admin
if (!data.is_global_admin && data.is_org_admin && orgs.value.length === 1) {
if (!window.location.hash || window.location.hash === '#overview') { if (!window.location.hash || window.location.hash === '#overview') {
currentOrgId.value = orgs.value[0].uuid currentOrgId.value = orgs.value[0].uuid
window.location.hash = `#org/${currentOrgId.value}` window.location.hash = `#org/${currentOrgId.value}`
@@ -177,7 +182,11 @@ async function load() {
} }
} else parseHash() } else parseHash()
} catch (e) { } catch (e) {
error.value = e.message if (e.name === 'AuthCancelledError') {
showBackMessage.value = true
} else {
error.value = e.message
}
} finally { } finally {
loading.value = false loading.value = false
} }
@@ -193,8 +202,7 @@ function editUserName(user) { openDialog('user-update-name', { user, name: user.
function deleteOrg(org) { function deleteOrg(org) {
if (!info.value?.is_global_admin) { authStore.showMessage('Global admin only'); return } if (!info.value?.is_global_admin) { authStore.showMessage('Global admin only'); return }
openDialog('confirm', { message: `Delete organization ${org.display_name}?`, action: async () => { openDialog('confirm', { message: `Delete organization ${org.display_name}?`, action: async () => {
const res = await fetch(`/auth/admin/orgs/${org.uuid}`, { method: 'DELETE' }) await apiJson(`/auth/api/admin/orgs/${org.uuid}`, { method: 'DELETE' })
const data = await res.json(); if (data.detail) throw new Error(data.detail)
await Promise.all([loadOrgs(), loadPermissions()]) await Promise.all([loadOrgs(), loadPermissions()])
} }) } })
} }
@@ -203,14 +211,15 @@ function createUserInRole(org, role) { openDialog('user-create', { org, role })
async function moveUserToRole(org, user, targetRoleDisplayName) { async function moveUserToRole(org, user, targetRoleDisplayName) {
if (user.role === targetRoleDisplayName) return if (user.role === targetRoleDisplayName) return
const res = await fetch(`/auth/admin/orgs/${org.uuid}/users/${user.uuid}/role`, { try {
method: 'PUT', await apiJson(`/auth/api/admin/orgs/${org.uuid}/users/${user.uuid}/role`, {
headers: { 'content-type': 'application/json' }, method: 'PUT',
body: JSON.stringify({ role: targetRoleDisplayName }) body: { role: targetRoleDisplayName }
}) })
const data = await res.json() await loadOrgs()
if (data.detail) { authStore.showMessage(data.detail); return } } catch (e) {
await loadOrgs() authStore.showMessage(e.message || 'Failed to update user role')
}
} }
function onUserDragStart(e, user, org_uuid) { function onUserDragStart(e, user, org_uuid) {
@@ -245,30 +254,26 @@ function updateRole(role) { openDialog('role-update', { role, name: role.display
function deleteRole(role) { function deleteRole(role) {
openDialog('confirm', { message: `Delete role ${role.display_name}?`, action: async () => { openDialog('confirm', { message: `Delete role ${role.display_name}?`, action: async () => {
const res = await fetch(`/auth/admin/orgs/${role.org_uuid}/roles/${role.uuid}`, { method: 'DELETE' }) await apiJson(`/auth/api/admin/orgs/${role.org_uuid}/roles/${role.uuid}`, { method: 'DELETE' })
const data = await res.json(); if (data.detail) throw new Error(data.detail)
await loadOrgs() await loadOrgs()
} }) } })
} }
async function toggleRolePermission(role, pid, checked) { async function toggleRolePermission(role, pid, checked) {
// Calculate new permissions array // Calculate new permissions array
const newPermissions = checked const newPermissions = checked
? [...role.permissions, pid] ? [...role.permissions, pid]
: role.permissions.filter(p => p !== pid) : role.permissions.filter(p => p !== pid)
// Optimistic update // Optimistic update
const prevPermissions = [...role.permissions] const prevPermissions = [...role.permissions]
role.permissions = newPermissions role.permissions = newPermissions
try { try {
const res = await fetch(`/auth/admin/orgs/${role.org_uuid}/roles/${role.uuid}`, { await apiJson(`/auth/api/admin/orgs/${role.org_uuid}/roles/${role.uuid}`, {
method: 'PUT', method: 'PUT',
headers: { 'content-type': 'application/json' }, body: { display_name: role.display_name, permissions: newPermissions }
body: JSON.stringify({ display_name: role.display_name, permissions: newPermissions })
}) })
const data = await res.json()
if (data.detail) throw new Error(data.detail)
await loadOrgs() await loadOrgs()
} catch (e) { } catch (e) {
authStore.showMessage(e.message || 'Failed to update role permission') authStore.showMessage(e.message || 'Failed to update role permission')
@@ -282,18 +287,14 @@ function updatePermission(p) { openDialog('perm-display', { permission: p }) }
function deletePermission(p) { function deletePermission(p) {
openDialog('confirm', { message: `Delete permission ${p.id}?`, action: async () => { openDialog('confirm', { message: `Delete permission ${p.id}?`, action: async () => {
const params = new URLSearchParams({ permission_id: p.id }) const params = new URLSearchParams({ permission_id: p.id })
const res = await fetch(`/auth/admin/permission?${params.toString()}`, { method: 'DELETE' }) await apiJson(`/auth/api/admin/permission?${params.toString()}`, { method: 'DELETE' })
const data = await res.json(); if (data.detail) throw new Error(data.detail)
await loadPermissions() await loadPermissions()
} }) } })
} }
onMounted(async () => { function reloadPage() {
window.addEventListener('hashchange', parseHash) window.location.reload()
const settings = await getSettings() }
if (settings?.rp_name) document.title = settings.rp_name + ' Admin'
load()
})
const selectedOrg = computed(() => orgs.value.find(o => o.uuid === currentOrgId.value) || null) const selectedOrg = computed(() => orgs.value.find(o => o.uuid === currentOrgId.value) || null)
@@ -350,10 +351,7 @@ const breadcrumbEntries = computed(() => {
watch(selectedUser, async (u) => { watch(selectedUser, async (u) => {
if (!u) { userDetail.value = null; return } if (!u) { userDetail.value = null; return }
try { try {
const res = await fetch(`/auth/admin/orgs/${u.org_uuid}/users/${u.uuid}`) userDetail.value = await apiJson(`/auth/api/admin/orgs/${u.org_uuid}/users/${u.uuid}`)
const data = await res.json()
if (data.detail) throw new Error(data.detail)
userDetail.value = data
} catch (e) { } catch (e) {
userDetail.value = { error: e.message } userDetail.value = { error: e.message }
} }
@@ -389,9 +387,7 @@ async function toggleOrgPermission(org, permId, checked) {
org.permissions = next org.permissions = next
try { try {
const params = new URLSearchParams({ permission_id: permId }) const params = new URLSearchParams({ permission_id: permId })
const res = await fetch(`/auth/admin/orgs/${org.uuid}/permission?${params.toString()}`, { method: checked ? 'POST' : 'DELETE' }) await apiJson(`/auth/api/admin/orgs/${org.uuid}/permission?${params.toString()}`, { method: checked ? 'POST' : 'DELETE' })
const data = await res.json()
if (data.detail) throw new Error(data.detail)
await loadOrgs() await loadOrgs()
} catch (e) { } catch (e) {
authStore.showMessage(e.message || 'Failed to update organization permission') authStore.showMessage(e.message || 'Failed to update organization permission')
@@ -402,16 +398,17 @@ async function toggleOrgPermission(org, permId, checked) {
function openDialog(type, data) { dialog.value = { type, data, busy: false, error: '' } } function openDialog(type, data) { dialog.value = { type, data, busy: false, error: '' } }
function closeDialog() { dialog.value = { type: null, data: null, busy: false, error: '' } } function closeDialog() { dialog.value = { type: null, data: null, busy: false, error: '' } }
async function onUserNameSaved() { async function refreshUserDetail() {
await loadOrgs() await loadOrgs()
if (selectedUser.value) { if (selectedUser.value) {
try { try {
const r = await fetch(`/auth/admin/orgs/${selectedUser.value.org_uuid}/users/${selectedUser.value.uuid}`) userDetail.value = await apiJson(`/auth/api/admin/orgs/${selectedUser.value.org_uuid}/users/${selectedUser.value.uuid}`)
const jd = await r.json()
if (!r.ok || jd.detail) throw new Error(jd.detail || 'Reload failed')
userDetail.value = jd
} catch (e) { authStore.showMessage(e.message || 'Failed to reload user', 'error') } } catch (e) { authStore.showMessage(e.message || 'Failed to reload user', 'error') }
} }
}
async function onUserNameSaved() {
await refreshUserDetail()
authStore.showMessage('User renamed', 'success', 1500) authStore.showMessage('User renamed', 'success', 1500)
} }
@@ -422,53 +419,48 @@ async function submitDialog() {
const t = dialog.value.type const t = dialog.value.type
if (t === 'org-create') { if (t === 'org-create') {
const name = dialog.value.data.name?.trim(); if (!name) throw new Error('Name required') const name = dialog.value.data.name?.trim(); if (!name) throw new Error('Name required')
const res = await fetch('/auth/admin/orgs', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ display_name: name, permissions: [] }) }) await apiJson('/auth/api/admin/orgs', { method: 'POST', body: { display_name: name, permissions: [] } })
const d = await res.json(); if (d.detail) throw new Error(d.detail); await Promise.all([loadOrgs(), loadPermissions()]) await Promise.all([loadOrgs(), loadPermissions()])
} else if (t === 'org-update') { } else if (t === 'org-update') {
const { org } = dialog.value.data; const name = dialog.value.data.name?.trim(); if (!name) throw new Error('Name required') const { org } = dialog.value.data; const name = dialog.value.data.name?.trim(); if (!name) throw new Error('Name required')
const res = await fetch(`/auth/admin/orgs/${org.uuid}`, { method: 'PUT', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ display_name: name, permissions: org.permissions }) }) await apiJson(`/auth/api/admin/orgs/${org.uuid}`, { method: 'PUT', body: { display_name: name, permissions: org.permissions } })
const d = await res.json(); if (d.detail) throw new Error(d.detail); await loadOrgs() await loadOrgs()
} else if (t === 'role-create') { } else if (t === 'role-create') {
const { org } = dialog.value.data; const name = dialog.value.data.name?.trim(); if (!name) throw new Error('Name required') const { org } = dialog.value.data; const name = dialog.value.data.name?.trim(); if (!name) throw new Error('Name required')
const res = await fetch(`/auth/admin/orgs/${org.uuid}/roles`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ display_name: name, permissions: [] }) }) await apiJson(`/auth/api/admin/orgs/${org.uuid}/roles`, { method: 'POST', body: { display_name: name, permissions: [] } })
const d = await res.json(); if (d.detail) throw new Error(d.detail); await loadOrgs() await loadOrgs()
} else if (t === 'role-update') { } else if (t === 'role-update') {
const { role } = dialog.value.data; const name = dialog.value.data.name?.trim(); if (!name) throw new Error('Name required') const { role } = dialog.value.data; const name = dialog.value.data.name?.trim(); if (!name) throw new Error('Name required')
const res = await fetch(`/auth/admin/orgs/${role.org_uuid}/roles/${role.uuid}`, { method: 'PUT', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ display_name: name, permissions: role.permissions }) }) await apiJson(`/auth/api/admin/orgs/${role.org_uuid}/roles/${role.uuid}`, { method: 'PUT', body: { display_name: name, permissions: role.permissions } })
const d = await res.json(); if (d.detail) throw new Error(d.detail); await loadOrgs() await loadOrgs()
} else if (t === 'user-create') { } else if (t === 'user-create') {
const { org, role } = dialog.value.data; const name = dialog.value.data.name?.trim(); if (!name) throw new Error('Name required') const { org, role } = dialog.value.data; const name = dialog.value.data.name?.trim(); if (!name) throw new Error('Name required')
const res = await fetch(`/auth/admin/orgs/${org.uuid}/users`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ display_name: name, role: role.display_name }) }) await apiJson(`/auth/api/admin/orgs/${org.uuid}/users`, { method: 'POST', body: { display_name: name, role: role.display_name } })
const d = await res.json(); if (d.detail) throw new Error(d.detail); await loadOrgs() await loadOrgs()
} else if (t === 'user-update-name') { } else if (t === 'user-update-name') {
const { user } = dialog.value.data; const name = dialog.value.data.name?.trim(); if (!name) throw new Error('Name required') const { user } = dialog.value.data; const name = dialog.value.data.name?.trim(); if (!name) throw new Error('Name required')
const res = await fetch(`/auth/admin/orgs/${user.org_uuid}/users/${user.uuid}/display-name`, { method: 'PUT', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ display_name: name }) }) await apiJson(`/auth/api/admin/orgs/${user.org_uuid}/users/${user.uuid}/display-name`, { method: 'PUT', body: { display_name: name } })
const d = await res.json(); if (d.detail) throw new Error(d.detail); await onUserNameSaved() await onUserNameSaved()
} else if (t === 'perm-display') { } else if (t === 'perm-display') {
const { permission } = dialog.value.data const { permission } = dialog.value.data
const newId = dialog.value.data.id?.trim() const newId = dialog.value.data.id?.trim()
const newDisplay = dialog.value.data.display_name?.trim() const newDisplay = dialog.value.data.display_name?.trim()
if (!newDisplay) throw new Error('Display name required') if (!newDisplay) throw new Error('Display name required')
if (!newId) throw new Error('ID required') if (!newId) throw new Error('ID required')
if (newId !== permission.id) { if (newId !== permission.id) {
// ID changed, use rename endpoint // ID changed, use rename endpoint
const body = { old_id: permission.id, new_id: newId, display_name: newDisplay } await apiJson('/auth/api/admin/permission/rename', { method: 'POST', body: { old_id: permission.id, new_id: newId, display_name: newDisplay } })
const res = await fetch('/auth/admin/permission/rename', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) })
let data; try { data = await res.json() } catch(_) { data = {} }
if (!res.ok || data.detail) throw new Error(data.detail || data.error || `Failed (${res.status})`)
} else if (newDisplay !== permission.display_name) { } else if (newDisplay !== permission.display_name) {
// Only display name changed // Only display name changed
const params = new URLSearchParams({ permission_id: permission.id, display_name: newDisplay }) const params = new URLSearchParams({ permission_id: permission.id, display_name: newDisplay })
const res = await fetch(`/auth/admin/permission?${params.toString()}`, { method: 'PUT' }) await apiJson(`/auth/api/admin/permission?${params.toString()}`, { method: 'PUT' })
const d = await res.json(); if (d.detail) throw new Error(d.detail)
} }
await loadPermissions() await loadPermissions()
} else if (t === 'perm-create') { } else if (t === 'perm-create') {
const id = dialog.value.data.id?.trim(); if (!id) throw new Error('ID required') const id = dialog.value.data.id?.trim(); if (!id) throw new Error('ID required')
const display_name = dialog.value.data.display_name?.trim(); if (!display_name) throw new Error('Display name required') const display_name = dialog.value.data.display_name?.trim(); if (!display_name) throw new Error('Display name required')
const res = await fetch('/auth/admin/permissions', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ id, display_name }) }) await apiJson('/auth/api/admin/permissions', { method: 'POST', body: { id, display_name } })
const data = await res.json(); if (data.detail) throw new Error(data.detail)
await loadPermissions(); dialog.value.data.display_name = ''; dialog.value.data.id = '' await loadPermissions(); dialog.value.data.display_name = ''; dialog.value.data.id = ''
} else if (t === 'confirm') { } else if (t === 'confirm') {
const action = dialog.value.data.action; if (action) await action() const action = dialog.value.data.action; if (action) await action()
@@ -484,76 +476,71 @@ async function submitDialog() {
<div class="app-shell admin-shell"> <div class="app-shell admin-shell">
<StatusMessage /> <StatusMessage />
<main class="app-main"> <main class="app-main">
<section class="view-root view-admin"> <LoadingView v-if="loading" :message="loadingMessage" />
<div class="view-content view-content--wide"> <AuthRequiredMessage
<header class="view-header"> v-else-if="showBackMessage"
<h1>{{ pageHeading }}</h1> @reload="reloadPage"
<Breadcrumbs :entries="breadcrumbEntries" /> />
</header> <section v-else-if="authenticated && (info?.is_global_admin || info?.is_org_admin)" class="view-root view-root--wide view-admin">
<header class="view-header">
<h1>{{ pageHeading }}</h1>
<Breadcrumbs :entries="breadcrumbEntries" />
</header>
<section class="section-block admin-section"> <section class="section-block admin-section">
<div class="section-body admin-section-body"> <div class="section-body admin-section-body">
<div v-if="loading" class="surface surface--tight">Loading</div> <div v-if="error" class="surface surface--tight error">{{ error }}</div>
<div v-else-if="error" class="surface surface--tight error">{{ error }}</div> <div v-else class="admin-panels">
<template v-else> <AdminOverview
<div v-if="!info?.authenticated" class="surface surface--tight"> v-if="!selectedUser && !selectedOrg && (info.is_global_admin || info.is_org_admin)"
<p>You must be authenticated.</p> :info="info"
</div> :orgs="orgs"
<div v-else-if="!(info?.is_global_admin || info?.is_org_admin)" class="surface surface--tight"> :permissions="permissions"
<p>Insufficient permissions.</p> :permission-summary="permissionSummary"
</div> @create-org="createOrg"
<div v-else class="admin-panels"> @open-org="openOrg"
<AdminOverview @update-org="updateOrg"
v-if="!selectedUser && !selectedOrg && (info.is_global_admin || info.is_org_admin)" @delete-org="deleteOrg"
:info="info" @toggle-org-permission="toggleOrgPermission"
:orgs="orgs" @open-dialog="openDialog"
:permissions="permissions" @delete-permission="deletePermission"
:permission-summary="permissionSummary" @rename-permission-display="renamePermissionDisplay"
@create-org="createOrg" />
@open-org="openOrg"
@update-org="updateOrg"
@delete-org="deleteOrg"
@toggle-org-permission="toggleOrgPermission"
@open-dialog="openDialog"
@delete-permission="deletePermission"
@rename-permission-display="renamePermissionDisplay"
/>
<AdminUserDetail <AdminUserDetail
v-else-if="selectedUser" v-else-if="selectedUser"
:selected-user="selectedUser" :selected-user="selectedUser"
:user-detail="userDetail" :user-detail="userDetail"
:selected-org="selectedOrg" :selected-org="selectedOrg"
:loading="loading" :loading="loading"
:show-reg-modal="showRegModal" :show-reg-modal="showRegModal"
@generate-user-registration-link="generateUserRegistrationLink" @generate-user-registration-link="generateUserRegistrationLink"
@go-overview="goOverview" @go-overview="goOverview"
@open-org="openOrg" @open-org="openOrg"
@on-user-name-saved="onUserNameSaved" @on-user-name-saved="onUserNameSaved"
@edit-user-name="editUserName" @refresh-user-detail="refreshUserDetail"
@close-reg-modal="showRegModal = false" @edit-user-name="editUserName"
/> @close-reg-modal="showRegModal = false"
<AdminOrgDetail />
v-else-if="selectedOrg" <AdminOrgDetail
:selected-org="selectedOrg" v-else-if="selectedOrg"
:permissions="permissions" :selected-org="selectedOrg"
@update-org="updateOrg" :permissions="permissions"
@create-role="createRole" @update-org="updateOrg"
@update-role="updateRole" @create-role="createRole"
@delete-role="deleteRole" @update-role="updateRole"
@create-user-in-role="createUserInRole" @delete-role="deleteRole"
@open-user="openUser" @create-user-in-role="createUserInRole"
@toggle-role-permission="toggleRolePermission" @open-user="openUser"
@on-role-drag-over="onRoleDragOver" @toggle-role-permission="toggleRolePermission"
@on-role-drop="onRoleDrop" @on-role-drag-over="onRoleDragOver"
@on-user-drag-start="onUserDragStart" @on-role-drop="onRoleDrop"
/> @on-user-drag-start="onUserDragStart"
/>
</div> </div>
</template> </div>
</div> </section>
</section>
</div>
</section> </section>
</main> </main>
<AdminDialogs <AdminDialogs
@@ -7,6 +7,6 @@
</head> </head>
<body> <body>
<div id="admin-app"></div> <div id="admin-app"></div>
<script type="module" src="/src/admin/main.js"></script> <script type="module" src="./main.js"></script>
</body> </body>
</html> </html>
@@ -1,4 +1,4 @@
import '../assets/style.css' import '@/assets/style.css'
import { createApp } from 'vue' import { createApp } from 'vue'
import { createPinia } from 'pinia' import { createPinia } from 'pinia'
@@ -7,6 +7,6 @@
</head> </head>
<body> <body>
<div id="app"></div> <div id="app"></div>
<script type="module" src="/src/main.js"></script> <script type="module" src="main.js"></script>
</body> </body>
</html> </html>
@@ -1,4 +1,4 @@
import './assets/style.css' import '@/assets/style.css'
import { createApp } from 'vue' import { createApp } from 'vue'
import { createPinia } from 'pinia' import { createPinia } from 'pinia'
@@ -0,0 +1,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')
-12
View File
@@ -1,12 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Account Summary</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/host/main.js"></script>
</body>
</html>
@@ -0,0 +1,45 @@
<template>
<RestrictedAuth
:mode="authMode"
@authenticated="handleAuthenticated"
@back="goBack"
@home="returnHome"
/>
</template>
<script setup>
import { computed, onMounted } from 'vue'
import RestrictedAuth from '@/components/RestrictedAuth.vue'
import { uiBasePath } from '@/utils/settings'
import { goBack } from '@/utils/helpers'
const basePath = computed(() => uiBasePath())
// Detect mode from data attribute on html tag only
// (RestrictedApi uses URL query, RestrictedForward uses data injected by server)
const authMode = computed(() => {
const htmlElement = document.documentElement
const dataMode = htmlElement.getAttribute('data-mode')
if (dataMode === 'reauth') return 'reauth'
if (dataMode === 'forbidden') return 'forbidden'
return 'login'
})
function handleAuthenticated() {
// Reload page to re-trigger forward auth validation
location.reload()
}
function returnHome() {
const target = basePath.value || '/auth/'
if (window.location.pathname !== target) history.replaceState(null, '', target)
window.location.href = target
}
onMounted(() => {
// Handle Escape key to trigger back navigation
window.addEventListener('keydown', (event) => {
if (event.key === 'Escape') goBack()
})
})
</script>
@@ -7,6 +7,6 @@
</head> </head>
<body> <body>
<div id="app"></div> <div id="app"></div>
<script type="module" src="/src/restricted/main.js"></script> <script type="module" src="/int/forward/main.js"></script>
</body> </body>
</html> </html>
+5
View File
@@ -0,0 +1,5 @@
import { createApp } from 'vue'
import App from './RestrictedForward.vue'
import '@/assets/style.css'
createApp(App).mount('#app')
@@ -7,52 +7,50 @@
</div> </div>
<main class="view-root"> <main class="view-root">
<div class="view-content"> <div class="surface surface--tight" style="max-width: 560px; margin: 0 auto; width: 100%;">
<div class="surface surface--tight" style="max-width: 560px; margin: 0 auto; width: 100%;"> <header class="view-header" style="text-align: center;">
<header class="view-header" style="text-align: center;"> <h1>🔑 Registration</h1>
<h1>🔑 Registration</h1> <p class="view-lede">
<p class="view-lede"> {{ subtitleMessage }}
{{ subtitleMessage }} </p>
</p> </header>
</header>
<section class="section-block" v-if="initializing"> <section class="section-block" v-if="initializing">
<div class="section-body center"> <div class="section-body center">
<p>Loading reset details</p> <p>Loading reset details</p>
</div>
</section>
<section class="section-block" v-else-if="!canRegister">
<div class="section-body center">
<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> </div>
</section>
<section class="section-block" v-else-if="!canRegister"> <section class="section-block" v-else>
<div class="section-body center"> <div class="section-body">
<p>{{ errorMessage }}</p> <label class="name-edit">
<div class="button-row center" style="justify-content: center;"> <span>👤 Name</span>
<button class="btn-secondary" @click="goHome">Return to sign-in</button> <input
</div> type="text"
</div> v-model="displayName"
</section>
<section class="section-block" v-else>
<div class="section-body">
<label class="name-edit">
<span>👤 Name</span>
<input
type="text"
v-model="displayName"
:disabled="loading"
maxlength="64"
@keyup.enter="registerPasskey"
/>
</label>
<button
class="btn-primary"
:disabled="loading" :disabled="loading"
@click="registerPasskey" maxlength="64"
> @keyup.enter="registerPasskey"
{{ loading ? 'Registering…' : 'Register Passkey' }} />
</button> </label>
</div> <button
</section> class="btn-primary"
</div> :disabled="loading"
@click="registerPasskey"
>
{{ loading ? 'Registering…' : 'Register Passkey' }}
</button>
</div>
</section>
</div> </div>
</main> </main>
</div> </div>
@@ -62,6 +60,7 @@
import { computed, onMounted, reactive, ref } from 'vue' import { computed, onMounted, reactive, ref } from 'vue'
import passkey from '@/utils/passkey' import passkey from '@/utils/passkey'
import { getSettings, uiBasePath } from '@/utils/settings' import { getSettings, uiBasePath } from '@/utils/settings'
import { apiJson, ApiError, getUserFriendlyErrorMessage } from '@/utils/api'
const status = reactive({ const status = reactive({
show: false, show: false,
@@ -114,21 +113,15 @@ async function fetchSettings() {
async function fetchUserInfo() { async function fetchUserInfo() {
if (!token.value) return if (!token.value) return
try { try {
const res = await fetch(`/auth/api/user-info?reset=${encodeURIComponent(token.value)}`, { userInfo.value = await apiJson(`/auth/api/user-info?reset=${encodeURIComponent(token.value)}`, {
method: 'POST' method: 'POST'
}) })
if (!res.ok) {
const payload = await safeParseJson(res)
const detail = payload?.detail || 'Reset link is invalid or expired.'
errorMessage.value = detail
showMessage(detail, 'error', 0)
return
}
userInfo.value = await res.json()
displayName.value = userInfo.value?.user?.user_name || '' displayName.value = userInfo.value?.user?.user_name || ''
} catch (error) { } catch (error) {
console.error('Failed to load user info', error) console.error('Failed to load user info', error)
const message = 'We could not load your reset details. Try refreshing the page.' const message = error instanceof ApiError
? (error.data?.detail || 'Reset link is invalid or expired.')
: getUserFriendlyErrorMessage(error)
errorMessage.value = message errorMessage.value = message
showMessage(message, 'error', 0) showMessage(message, 'error', 0)
} }
@@ -152,7 +145,7 @@ async function registerPasskey() {
} }
try { try {
await setSessionCookie(result.session_token) await setSessionCookie(result)
} catch (error) { } catch (error) {
loading.value = false loading.value = false
const message = error?.message || 'Failed to establish session' const message = error?.message || 'Failed to establish session'
@@ -160,29 +153,23 @@ async function registerPasskey() {
return return
} }
showMessage('Passkey registered successfully!', 'success', 2000) showMessage('Passkey registered successfully!', 'success', 800)
setTimeout(() => { setTimeout(() => { loading.value = false; goHome() }, 800)
loading.value = false
redirectHome()
}, 800)
} }
async function setSessionCookie(sessionToken) { async function setSessionCookie(result) {
const response = await fetch('/auth/api/set-session', { if (!result?.session_token) {
throw new Error('Registration response missing session_token')
}
return await apiJson('/auth/api/set-session', {
method: 'POST', method: 'POST',
headers: { headers: {
Authorization: `Bearer ${sessionToken}` Authorization: `Bearer ${result.session_token}`
} }
}) })
const payload = await safeParseJson(response)
if (!response.ok || payload?.detail) {
const detail = payload?.detail || 'Session could not be established.'
throw new Error(detail)
}
return payload
} }
function redirectHome() { function goHome() {
const target = uiBasePath.value || '/auth/' const target = uiBasePath.value || '/auth/'
if (window.location.pathname !== target) { if (window.location.pathname !== target) {
history.replaceState(null, '', target) history.replaceState(null, '', target)
@@ -190,10 +177,6 @@ function redirectHome() {
window.location.reload() window.location.reload()
} }
function goHome() {
redirectHome()
}
function extractTokenFromPath() { function extractTokenFromPath() {
const segments = window.location.pathname.split('/').filter(Boolean) const segments = window.location.pathname.split('/').filter(Boolean)
if (!segments.length) return '' if (!segments.length) return ''
@@ -205,14 +188,6 @@ function extractTokenFromPath() {
return candidate return candidate
} }
async function safeParseJson(response) {
try {
return await response.json()
} catch (error) {
return null
}
}
onMounted(async () => { onMounted(async () => {
token.value = extractTokenFromPath() token.value = extractTokenFromPath()
await fetchSettings() await fetchSettings()
@@ -7,6 +7,6 @@
</head> </head>
<body> <body>
<div id="app"></div> <div id="app"></div>
<script type="module" src="/src/reset/main.js"></script> <script type="module" src="/int/reset/main.js"></script>
</body> </body>
</html> </html>
+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": {
-35
View File
@@ -1,35 +0,0 @@
<template>
<div class="app-shell">
<StatusMessage />
<main class="app-main">
<ProfileView v-if="initialized" />
<div v-else class="loading-container">
<div class="loading-spinner"></div>
<p>Loading...</p>
</div>
</main>
</div>
</template>
<script setup>
import { onMounted, ref } from 'vue'
import { useAuthStore } from '@/stores/auth'
import StatusMessage from '@/components/StatusMessage.vue'
import ProfileView from '@/components/ProfileView.vue'
const store = useAuthStore()
const initialized = ref(false)
onMounted(async () => {
await store.loadSettings()
if (store.settings?.rp_name) document.title = store.settings.rp_name
try { await store.loadUserInfo() } catch (_) { /* user info load errors ignored */ }
initialized.value = true
})
</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>
+53 -16
View File
@@ -5,6 +5,7 @@ import CredentialList from '@/components/CredentialList.vue'
import RegistrationLinkModal from '@/components/RegistrationLinkModal.vue' import RegistrationLinkModal from '@/components/RegistrationLinkModal.vue'
import SessionList from '@/components/SessionList.vue' import SessionList from '@/components/SessionList.vue'
import { useAuthStore } from '@/stores/auth' import { useAuthStore } from '@/stores/auth'
import { apiJson } from '@/utils/api'
const props = defineProps({ const props = defineProps({
selectedUser: Object, selectedUser: Object,
@@ -14,9 +15,12 @@ const props = defineProps({
showRegModal: Boolean showRegModal: Boolean
}) })
const emit = defineEmits(['generateUserRegistrationLink', 'goOverview', 'openOrg', 'onUserNameSaved', 'closeRegModal', 'editUserName']) const emit = defineEmits(['generateUserRegistrationLink', 'goOverview', 'openOrg', 'onUserNameSaved', 'closeRegModal', 'editUserName', 'refreshUserDetail'])
const authStore = useAuthStore() const authStore = useAuthStore()
const terminatingSessions = ref({})
const hoveredCredentialUuid = ref(null)
const hoveredSession = ref(null)
function onLinkCopied() { function onLinkCopied() {
authStore.showMessage('Link copied to clipboard!') authStore.showMessage('Link copied to clipboard!')
@@ -26,17 +30,44 @@ function handleEditName() {
emit('editUserName', props.selectedUser) emit('editUserName', props.selectedUser)
} }
function handleDelete(credential) { async function handleDelete(credential) {
fetch(`/auth/admin/orgs/${props.selectedUser.org_uuid}/users/${props.selectedUser.uuid}/credentials/${credential.credential_uuid}`, { method: 'DELETE' }) try {
.then(res => res.json()) const data = await apiJson(`/auth/api/admin/orgs/${props.selectedUser.org_uuid}/users/${props.selectedUser.uuid}/credentials/${credential.credential_uuid}`, { method: 'DELETE' })
.then(data => { if (data.status === 'ok') {
if (data.status === 'ok') { emit('onUserNameSaved') // Reuse to refresh user detail
emit('onUserNameSaved') // Reuse to refresh user detail } else {
} else { console.error('Failed to delete credential', data)
console.error('Failed to delete credential', data) }
} catch (err) {
console.error('Delete credential error', err)
}
}
async function handleTerminateSession(session) {
const sessionId = session?.id
if (!sessionId) return
terminatingSessions.value = { ...terminatingSessions.value, [sessionId]: true }
try {
const data = await apiJson(`/auth/api/admin/orgs/${props.selectedUser.org_uuid}/users/${props.selectedUser.uuid}/sessions/${sessionId}`, { method: 'DELETE' })
if (data.status === 'ok') {
if (data.current_session_terminated) {
sessionStorage.clear()
location.reload()
return
} }
}) emit('refreshUserDetail') // Refresh without showing rename message
.catch(err => console.error('Delete credential error', err)) authStore.showMessage('Session terminated', 'success', 2500)
} else {
authStore.showMessage(data.detail || 'Failed to terminate session', 'error')
}
} catch (err) {
console.error('Terminate session error', err)
authStore.showMessage(err.message || 'Failed to terminate session', 'error')
} finally {
const next = { ...terminatingSessions.value }
delete next[sessionId]
terminatingSessions.value = next
}
} }
</script> </script>
@@ -52,7 +83,7 @@ function handleDelete(credential) {
:loading="loading" :loading="loading"
:org-display-name="userDetail.org.display_name" :org-display-name="userDetail.org.display_name"
:role-name="userDetail.role" :role-name="userDetail.role"
:update-endpoint="`/auth/admin/orgs/${selectedUser.org_uuid}/users/${selectedUser.uuid}/display-name`" :update-endpoint="`/auth/api/admin/orgs/${selectedUser.org_uuid}/users/${selectedUser.uuid}/display-name`"
@saved="$emit('onUserNameSaved')" @saved="$emit('onUserNameSaved')"
@edit-name="handleEditName" @edit-name="handleEditName"
/> />
@@ -78,15 +109,21 @@ function handleDelete(credential) {
:credentials="userDetail.credentials" :credentials="userDetail.credentials"
:aaguid-info="userDetail.aaguid_info" :aaguid-info="userDetail.aaguid_info"
:allow-delete="true" :allow-delete="true"
:hovered-credential-uuid="hoveredCredentialUuid"
:hovered-session-credential-uuid="hoveredSession?.credential_uuid"
@delete="handleDelete" @delete="handleDelete"
@credential-hover="hoveredCredentialUuid = $event"
/> />
</div> </div>
</section> </section>
<SessionList <SessionList
:sessions="userDetail.sessions || []" :sessions="userDetail.sessions || []"
:allow-terminate="false" :terminating-sessions="terminatingSessions"
:hovered-credential-uuid="hoveredCredentialUuid"
:empty-message="'This user has no active sessions.'" :empty-message="'This user has no active sessions.'"
:section-description="'View the active sessions for this user.'" :section-description="'View and manage the active sessions for this user.'"
@terminate="handleTerminateSession"
@session-hover="hoveredSession = $event"
/> />
</template> </template>
<div class="actions ancillary-actions"> <div class="actions ancillary-actions">
@@ -94,7 +131,7 @@ function handleDelete(credential) {
</div> </div>
<RegistrationLinkModal <RegistrationLinkModal
v-if="showRegModal" v-if="showRegModal"
:endpoint="`/auth/admin/orgs/${selectedUser.org_uuid}/users/${selectedUser.uuid}/create-link`" :endpoint="`/auth/api/admin/orgs/${selectedUser.org_uuid}/users/${selectedUser.uuid}/create-link`"
:auto-copy="false" :auto-copy="false"
:user-name="userDetail?.display_name || selectedUser.display_name" :user-name="userDetail?.display_name || selectedUser.display_name"
@close="$emit('closeRegModal')" @close="$emit('closeRegModal')"
@@ -115,4 +152,4 @@ function handleDelete(credential) {
.error { color: var(--color-danger-text); } .error { color: var(--color-danger-text); }
.small { font-size: 0.9rem; } .small { font-size: 0.9rem; }
.muted { color: var(--color-text-muted); } .muted { color: var(--color-text-muted); }
</style> </style>
+64 -47
View File
@@ -1,6 +1,5 @@
:root { :root {
color-scheme: light dark;
--font-sans: "Inter", "Inter var", "Segoe UI", system-ui, -apple-system, "Helvetica Neue", sans-serif; --font-sans: "Inter", "Inter var", "Segoe UI", system-ui, -apple-system, "Helvetica Neue", sans-serif;
--font-mono: "DM Mono", "JetBrains Mono", "SFMono-Regular", Menlo, Monaco, Consolas, "Liberation Mono", monospace; --font-mono: "DM Mono", "JetBrains Mono", "SFMono-Regular", Menlo, Monaco, Consolas, "Liberation Mono", monospace;
--color-canvas: #f5f6f8; --color-canvas: #f5f6f8;
@@ -35,14 +34,13 @@
--space-lg: 1.5rem; --space-lg: 1.5rem;
--space-xl: 2.25rem; --space-xl: 2.25rem;
--space-xxl: 3.5rem; --space-xxl: 3.5rem;
--layout-max-width: 1080px; --layout-max-width: 1400px;
--layout-padding: clamp(1.5rem, 3vw + 1rem, 3.25rem); --layout-padding: clamp(1.5rem, 3vw + 1rem, 3.25rem);
--transition-base: 160ms ease; --transition-base: 160ms ease;
} }
@media (prefers-color-scheme: dark) { @media (prefers-color-scheme: dark) {
:root { :root {
color-scheme: dark;
--color-canvas: #0f172a; --color-canvas: #0f172a;
--color-surface: #141b2f; --color-surface: #141b2f;
--color-surface-subtle: #1b243b; --color-surface-subtle: #1b243b;
@@ -57,13 +55,13 @@
--color-accent-strong: #3b82f6; --color-accent-strong: #3b82f6;
--color-accent-contrast: #0b1120; --color-accent-contrast: #0b1120;
--color-success-text: #34d399; --color-success-text: #34d399;
--color-success-bg: rgba(34, 197, 94, 0.12); --color-success-bg: #1a4d2e;
--color-error-text: #fca5a5; --color-error-text: #fca5a5;
--color-error-bg: rgba(248, 113, 113, 0.16); --color-error-bg: #4a1f1f;
--color-info-text: #bae6fd; --color-info-text: #bae6fd;
--color-info-bg: rgba(59, 130, 246, 0.16); --color-info-bg: #1e3a5f;
--color-danger: #f87171; --color-danger: #f87171;
--shadow-soft: 0 0 0 rgba(0, 0, 0, 0); --shadow-soft: 0 0 0 #000000;
} }
} }
@@ -73,15 +71,16 @@
box-sizing: border-box; box-sizing: border-box;
} }
html, html {
body {
height: 100%; height: 100%;
background: var(--color-canvas);
} }
body { body {
height: 100%;
margin: 0; margin: 0;
font-family: var(--font-sans); font-family: var(--font-sans);
background: var(--color-canvas); background: none;
color: var(--color-text); color: var(--color-text);
line-height: 1.55; line-height: 1.55;
-webkit-font-smoothing: antialiased; -webkit-font-smoothing: antialiased;
@@ -123,7 +122,6 @@ a:focus-visible {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
min-height: 100vh; min-height: 100vh;
background: var(--color-canvas);
} }
.app-main { .app-main {
@@ -136,23 +134,22 @@ a:focus-visible {
flex: 1; flex: 1;
width: 100%; width: 100%;
display: flex; display: flex;
padding: var(--layout-padding);
box-sizing: border-box;
}
.view-content {
flex: 1;
display: flex;
flex-direction: column; flex-direction: column;
gap: 2rem; gap: 2rem;
padding: var(--layout-padding);
box-sizing: border-box;
margin: 0 auto; margin: 0 auto;
width: min(100%, var(--layout-max-width)); width: min(100%, var(--layout-max-width));
} }
.view-content--wide { .view-root--wide {
width: min(100%, 1200px); width: min(100%, 1200px);
} }
.view-root--narrow {
max-width: 540px;
}
.view-header { .view-header {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@@ -232,8 +229,8 @@ button:focus-visible {
} }
button:disabled { button:disabled {
opacity: 0.6;
cursor: not-allowed; cursor: not-allowed;
filter: opacity(0.6);
} }
.btn-primary { .btn-primary {
@@ -288,7 +285,7 @@ input:focus-visible,
textarea:focus-visible, textarea:focus-visible,
select:focus-visible { select:focus-visible {
border-color: var(--color-accent); border-color: var(--color-accent);
box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.15); box-shadow: 0 0 0 3px #c7d2fe;
outline: none; outline: none;
} }
@@ -372,19 +369,19 @@ th {
} }
.status.info { .status.info {
border-color: rgba(14, 96, 155, 0.28); border-color: #3b82f6;
color: var(--color-info-text); color: var(--color-info-text);
background: var(--color-info-bg); background: var(--color-info-bg);
} }
.status.success { .status.success {
border-color: rgba(6, 118, 71, 0.22); border-color: #16a34a;
color: var(--color-success-text); color: var(--color-success-text);
background: var(--color-success-bg); background: var(--color-success-bg);
} }
.status.error { .status.error {
border-color: rgba(180, 35, 24, 0.28); border-color: #dc2626;
color: var(--color-error-text); color: var(--color-error-text);
background: var(--color-error-bg); background: var(--color-error-bg);
} }
@@ -392,8 +389,9 @@ th {
.dialog-overlay { .dialog-overlay {
position: fixed; position: fixed;
inset: 0; inset: 0;
background: rgba(9, 14, 24, 0.55); background: transparent;
backdrop-filter: blur(6px); backdrop-filter: blur(.1rem) brightness(0.7);
-webkit-backdrop-filter: blur(.1rem) brightness(0.7);
z-index: 1100; z-index: 1100;
display: flex; display: flex;
align-items: center; align-items: center;
@@ -447,18 +445,11 @@ th {
width: 100%; width: 100%;
display: grid; display: grid;
grid-auto-flow: row; grid-auto-flow: row;
grid-template-columns: repeat(auto-fit, var(--card-width)); grid-template-columns: repeat(auto-fill, minmax(var(--card-width), 1fr));
justify-content: start; justify-content: start;
gap: 1rem 1.25rem; gap: 1rem 1.25rem;
align-items: stretch; align-items: stretch;
margin: 0 auto; margin: 0 auto;
max-width: calc(var(--card-width) * 4 + 3 * 1.25rem);
}
@media (max-width: 1100px) {
.record-list,
.credential-list,
.session-list { max-width: calc(var(--card-width) * 3 + 2 * 1.25rem); }
} }
@media (max-width: 720px) { @media (max-width: 720px) {
@@ -478,6 +469,7 @@ th {
height: 100%; height: 100%;
transition: border-color 0.2s ease, box-shadow 0.2s ease, transform 0.2s ease; transition: border-color 0.2s ease, box-shadow 0.2s ease, transform 0.2s ease;
position: relative; position: relative;
cursor: pointer;
} }
.record-item:hover, .record-item:hover,
@@ -490,7 +482,12 @@ th {
.record-item.is-current, .record-item.is-current,
.credential-item.current-session, .credential-item.current-session,
.session-item.is-current { border-color: var(--color-accent); } .credential-item.is-hovered,
.session-item.is-current,
.session-item.is-hovered { border-color: var(--color-accent); }
.credential-item.is-linked-session,
.session-item.is-linked-credential { border-color: var(--color-accent); background-color: var(--color-surface-subtle); }
.item-top { .item-top {
display: flex; display: flex;
@@ -568,8 +565,8 @@ th {
} }
.btn-card-delete { background: transparent; border: none; color: var(--color-danger); padding: 0.35rem 0.5rem; font-size: 1.05rem; line-height: 1; border-radius: var(--radius-sm); cursor: pointer; display: inline-flex; align-items: center; justify-content: center; } .btn-card-delete { background: transparent; border: none; color: var(--color-danger); padding: 0.35rem 0.5rem; font-size: 1.05rem; line-height: 1; border-radius: var(--radius-sm); cursor: pointer; display: inline-flex; align-items: center; justify-content: center; }
.btn-card-delete:hover:not(:disabled) { background: rgba(220, 38, 38, 0.08); } .btn-card-delete:hover:not(:disabled) { background: #fee; }
.btn-card-delete:disabled { opacity: 0.4; cursor: not-allowed; } .btn-card-delete:disabled { filter: opacity(0.4); cursor: not-allowed; }
.session-emoji { .session-emoji {
@@ -653,9 +650,6 @@ th {
@media (max-width: 720px) { @media (max-width: 720px) {
.view-root { .view-root {
padding: clamp(1rem, 3vw + 0.75rem, 2rem); padding: clamp(1rem, 3vw + 0.75rem, 2rem);
}
.view-content {
gap: 1.75rem; gap: 1.75rem;
} }
@@ -664,6 +658,13 @@ th {
grid-template-columns: auto auto; grid-template-columns: auto auto;
} }
.global-status {
top: 1rem;
}
}
/* Mobile portrait (touch) or very narrow screens: stack buttons */
@media (max-width: 500px) and (orientation: portrait) and (pointer: coarse), (max-width: 350px) {
button { button {
width: 100%; width: 100%;
} }
@@ -671,10 +672,6 @@ th {
.button-row { .button-row {
flex-direction: column; flex-direction: column;
} }
.global-status {
top: 1rem;
}
} }
.dialog-backdrop { .dialog-backdrop {
@@ -683,8 +680,9 @@ th {
left: 0; left: 0;
width: 100vw; width: 100vw;
height: 100vh; height: 100vh;
background: rgba(0, 0, 0, 0.5); background: transparent;
backdrop-filter: blur(4px); backdrop-filter: blur(.1rem) brightness(0.7);
-webkit-backdrop-filter: blur(.1rem) brightness(0.7);
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
@@ -704,7 +702,7 @@ th {
padding: 2rem; padding: 2rem;
background: var(--color-surface); background: var(--color-surface);
border-radius: var(--radius-lg); border-radius: var(--radius-lg);
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3); box-shadow: 0 20px 60px #1e293b;
border: 1px solid var(--color-border); border: 1px solid var(--color-border);
} }
@@ -721,3 +719,22 @@ th {
padding: 1.5rem; padding: 1.5rem;
} }
} }
/* Auth iframe overlay styles */
body:has(#auth-iframe) {
overflow: hidden;
}
#auth-iframe {
border: none;
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
z-index: 9999;
color-scheme: auto;
background: transparent;
backdrop-filter: blur(.1rem) brightness(0.7);
-webkit-backdrop-filter: blur(.1rem) brightness(0.7);
}
+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>
+27 -3
View File
@@ -6,7 +6,14 @@
<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="item-top"> <div class="item-top">
<div class="item-icon"> <div class="item-icon">
@@ -22,7 +29,9 @@
</div> </div>
<h4 class="item-title">{{ getCredentialAuthName(credential) }}</h4> <h4 class="item-title">{{ getCredentialAuthName(credential) }}</h4>
<div class="item-actions"> <div class="item-actions">
<span v-if="credential.is_current_session" class="badge badge-current">Current</span> <span v-if="credential.is_current_session && !hoveredCredentialUuid && !hoveredSessionCredentialUuid" class="badge badge-current">Current</span>
<span v-else-if="hoveredCredentialUuid === credential.credential_uuid" class="badge badge-current">Selected</span>
<span v-else-if="hoveredSessionCredentialUuid === credential.credential_uuid" class="badge badge-current">Linked</span>
<button <button
v-if="allowDelete" v-if="allowDelete"
@click="$emit('delete', credential)" @click="$emit('delete', credential)"
@@ -48,7 +57,7 @@
</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({
@@ -56,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'
+16 -22
View File
@@ -1,22 +1,20 @@
<template> <template>
<section class="view-root view-device-link"> <section class="view-root view-root--narrow view-device-link">
<div class="view-content view-content--narrow"> <header class="view-header">
<header class="view-header"> <h1>📱 Add Another Device</h1>
<h1>📱 Add Another Device</h1> <p class="view-lede">Generate a one-time link to set up passkeys on a new device.</p>
<p class="view-lede">Generate a one-time link to set up passkeys on a new device.</p> </header>
</header> <RegistrationLinkModal
<RegistrationLinkModal inline
inline :endpoint="'/auth/api/user/create-link'"
:endpoint="'/auth/api/user/create-link'" :user-name="userName"
:user-name="userName" :auto-copy="false"
:auto-copy="false" :prefix-copy-with-user-name="!!userName"
:prefix-copy-with-user-name="!!userName" show-close-in-inline
show-close-in-inline @copied="onCopied"
@copied="onCopied" />
/> <div class="button-row" style="margin-top:1rem;">
<div class="button-row" style="margin-top:1rem;"> <button @click="authStore.currentView = 'profile'" class="btn-secondary">Back to Profile</button>
<button @click="authStore.currentView = 'profile'" class="btn-secondary">Back to Profile</button>
</div>
</div> </div>
</section> </section>
</template> </template>
@@ -43,10 +41,6 @@ onMounted(async () => {
</script> </script>
<style scoped> <style scoped>
.view-content--narrow {
max-width: 540px;
}
.view-lede { .view-lede {
margin: 0; margin: 0;
color: var(--color-text-muted); color: var(--color-text-muted);
+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>
+5 -4
View File
@@ -17,8 +17,9 @@ defineEmits(['close'])
left: 0; left: 0;
right: 0; right: 0;
bottom: 0; bottom: 0;
background: rgba(0, 0, 0, 0.5); background: transparent;
backdrop-filter: blur(.1rem); backdrop-filter: blur(.1rem) brightness(0.7);
-webkit-backdrop-filter: blur(.1rem) brightness(0.7);
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
@@ -80,7 +81,7 @@ defineEmits(['close'])
.modal :deep(.modal-form textarea:focus) { .modal :deep(.modal-form textarea:focus) {
outline: none; outline: none;
border-color: var(--color-accent); border-color: var(--color-accent);
box-shadow: 0 0 0 2px rgba(37, 99, 235, 0.1); box-shadow: 0 0 0 2px #c7d2fe;
} }
.modal :deep(.modal-actions) { .modal :deep(.modal-actions) {
@@ -90,4 +91,4 @@ defineEmits(['close'])
margin-top: var(--space-md); margin-top: var(--space-md);
margin-bottom: var(--space-xs); margin-bottom: var(--space-xs);
} }
</style> </style>
+92 -88
View File
@@ -1,92 +1,95 @@
<template> <template>
<section class="view-root" data-view="profile"> <section class="view-root" data-view="profile">
<div class="view-content"> <header class="view-header">
<header class="view-header"> <h1>👋 Welcome!</h1>
<h1>👋 Welcome!</h1> <Breadcrumbs :entries="breadcrumbEntries" />
<Breadcrumbs :entries="breadcrumbEntries" /> <p class="view-lede">Manage your account details and passkeys.</p>
<p class="view-lede">Manage your account details and passkeys.</p> </header>
</header>
<section class="section-block"> <section class="section-block">
<UserBasicInfo <UserBasicInfo
v-if="authStore.userInfo?.user" v-if="authStore.userInfo?.user"
:name="authStore.userInfo.user.user_name" :name="authStore.userInfo.user.user_name"
:visits="authStore.userInfo.user.visits || 0" :visits="authStore.userInfo.user.visits || 0"
:created-at="authStore.userInfo.user.created_at" :created-at="authStore.userInfo.user.created_at"
:last-seen="authStore.userInfo.user.last_seen" :last-seen="authStore.userInfo.user.last_seen"
:loading="authStore.isLoading"
update-endpoint="/auth/api/user/display-name"
@saved="authStore.loadUserInfo()"
@edit-name="openNameDialog"
/>
</section>
<section class="section-block">
<div class="section-header">
<h2>Your Passkeys</h2>
<p class="section-description">Keep at least one trusted passkey so you can always sign in.</p>
</div>
<div class="section-body">
<CredentialList
:credentials="authStore.userInfo?.credentials || []"
:aaguid-info="authStore.userInfo?.aaguid_info || {}"
:loading="authStore.isLoading" :loading="authStore.isLoading"
update-endpoint="/auth/api/user/display-name" :hovered-credential-uuid="hoveredCredentialUuid"
@saved="authStore.loadUserInfo()" :hovered-session-credential-uuid="hoveredSession?.credential_uuid"
@edit-name="openNameDialog" allow-delete
@delete="handleDelete"
@credential-hover="hoveredCredentialUuid = $event"
/> />
</section> <div class="button-row">
<button @click="addNewCredential" class="btn-primary">Add New Passkey</button>
<section class="section-block"> <button @click="showRegLink = true" class="btn-secondary">Add Another Device</button>
<div class="section-header">
<h2>Your Passkeys</h2>
<p class="section-description">Keep at least one trusted passkey so you can always sign in.</p>
</div> </div>
<div class="section-body"> </div>
<CredentialList </section>
:credentials="authStore.userInfo?.credentials || []"
:aaguid-info="authStore.userInfo?.aaguid_info || {}"
:loading="authStore.isLoading"
allow-delete
@delete="handleDelete"
/>
<div class="button-row">
<button @click="addNewCredential" class="btn-primary">Add New Passkey</button>
<button @click="showRegLink = true" class="btn-secondary">Add Another Device</button>
</div>
</div>
</section>
<SessionList <SessionList
:sessions="sessions" :sessions="sessions"
:terminating-sessions="terminatingSessions" :terminating-sessions="terminatingSessions"
@terminate="terminateSession" :hovered-credential-uuid="hoveredCredentialUuid"
section-description="Review where you're signed in and end any sessions you no longer recognize." @terminate="terminateSession"
/> @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"> <Modal v-if="showNameDialog" @close="showNameDialog = false">
<h3>Edit Display Name</h3> <h3>Edit Display Name</h3>
<form @submit.prevent="saveName" class="modal-form"> <form @submit.prevent="saveName" class="modal-form">
<NameEditForm <NameEditForm
label="Display Name" label="Display Name"
v-model="newName" v-model="newName"
:busy="saving" :busy="saving"
@cancel="showNameDialog = false" @cancel="showNameDialog = false"
/> />
</form> </form>
</Modal> </Modal>
<section class="section-block"> <section class="section-block">
<div class="button-row logout-row" :class="{ single: !hasMultipleSessions }"> <div class="button-row logout-row" :class="{ single: !hasMultipleSessions }">
<button <button
type="button" type="button"
class="btn-secondary" class="btn-secondary"
@click="history.back()" @click="goBack"
> >
Back Back
</button> </button>
<button v-if="!hasMultipleSessions" @click="logoutEverywhere" class="btn-danger logout-button" :disabled="authStore.isLoading">Logout</button> <button v-if="!hasMultipleSessions" @click="logoutEverywhere" class="btn-danger logout-button" :disabled="authStore.isLoading">Logout</button>
<template v-else> <template v-else>
<button @click="logout" class="btn-danger logout-button" :disabled="authStore.isLoading">Logout</button> <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> <button @click="logoutEverywhere" class="btn-danger logout-button" :disabled="authStore.isLoading">All</button>
</template> </template>
</div> </div>
<p class="logout-note" v-if="!hasMultipleSessions"><strong>Logout</strong> from {{ currentSessionHost }}.</p> <p class="logout-note" v-if="!hasMultipleSessions"><strong>Logout</strong> from {{ currentSessionHost }}.</p>
<p class="logout-note" v-else><strong>Logout</strong> this session on {{ currentSessionHost }}, or <strong>All</strong> sessions across all sites and devices for {{ rpName }}. You'll need to log in again with your passkey afterwards.</p> <p class="logout-note" v-else><strong>Logout</strong> this session on {{ currentSessionHost }}, or <strong>All</strong> sessions across all sites and devices for {{ rpName }}. You'll need to log in again with your passkey afterwards.</p>
</section> </section>
<RegistrationLinkModal <RegistrationLinkModal
v-if="showRegLink" v-if="showRegLink"
:endpoint="'/auth/api/user/create-link'" :endpoint="'/auth/api/user/create-link'"
:auto-copy="false" :auto-copy="false"
:prefix-copy-with-user-name="false" :prefix-copy-with-user-name="false"
@close="showRegLink = false" @close="showRegLink = false"
@copied="showRegLink = false; authStore.showMessage('Link copied to clipboard!', 'success', 2500)" @copied="showRegLink = false; authStore.showMessage('Link copied to clipboard!', 'success', 2500)"
/> />
</div>
</section> </section>
</template> </template>
@@ -102,6 +105,8 @@ import RegistrationLinkModal from '@/components/RegistrationLinkModal.vue'
import { useAuthStore } from '@/stores/auth' import { useAuthStore } from '@/stores/auth'
import { adminUiPath, makeUiHref } from '@/utils/settings' import { adminUiPath, makeUiHref } from '@/utils/settings'
import passkey from '@/utils/passkey' import passkey from '@/utils/passkey'
import { goBack } from '@/utils/helpers'
import { apiJson } from '@/utils/api'
const authStore = useAuthStore() const authStore = useAuthStore()
const updateInterval = ref(null) const updateInterval = ref(null)
@@ -109,6 +114,8 @@ const showNameDialog = ref(false)
const showRegLink = ref(false) const showRegLink = ref(false)
const newName = ref('') const newName = ref('')
const saving = ref(false) const saving = ref(false)
const hoveredCredentialUuid = ref(null)
const hoveredSession = ref(null)
watch(showNameDialog, (newVal) => { if (newVal) newName.value = authStore.userInfo?.user?.user_name || '' }) watch(showNameDialog, (newVal) => { if (newVal) newName.value = authStore.userInfo?.user?.user_name || '' })
@@ -120,15 +127,15 @@ onUnmounted(() => { if (updateInterval.value) clearInterval(updateInterval.value
const addNewCredential = async () => { const addNewCredential = async () => {
try { try {
authStore.isLoading = true await passkey.register(null, null, () => {
authStore.showMessage('Adding new passkey...', 'info') authStore.showMessage('Adding new passkey...', 'info')
await passkey.register() })
await authStore.loadUserInfo() await authStore.loadUserInfo()
authStore.showMessage('New passkey added successfully!', 'success', 3000) authStore.showMessage('New passkey added successfully!', 'success', 3000)
} catch (error) { } catch (error) {
console.error('Failed to add new passkey:', error) console.error('Failed to add new passkey:', error)
authStore.showMessage(error.message, 'error') authStore.showMessage(error.message, 'error')
} finally { authStore.isLoading = false } }
} }
const handleDelete = async (credential) => { const handleDelete = async (credential) => {
@@ -174,9 +181,7 @@ const saveName = async () => {
if (!name) { authStore.showMessage('Name cannot be empty', 'error'); return } if (!name) { authStore.showMessage('Name cannot be empty', 'error'); return }
try { try {
saving.value = true saving.value = true
const res = await fetch('/auth/api/user/display-name', { method: 'PUT', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ display_name: name }) }) await apiJson('/auth/api/user/display-name', { method: 'PUT', body: { display_name: name } })
const data = await res.json()
if (!res.ok || data.detail) throw new Error(data.detail || 'Update failed')
showNameDialog.value = false showNameDialog.value = false
await authStore.loadUserInfo() await authStore.loadUserInfo()
authStore.showMessage('Name updated successfully!', 'success', 3000) authStore.showMessage('Name updated successfully!', 'success', 3000)
@@ -196,4 +201,3 @@ const saveName = async () => {
.logout-note { margin: 0.75rem 0 0; color: var(--color-text-muted); font-size: 0.875rem; } .logout-note { margin: 0.75rem 0 0; color: var(--color-text-muted); font-size: 0.875rem; }
@media (max-width: 720px) { .logout-button { width: 100%; } } @media (max-width: 720px) { .logout-button { width: 100%; } }
</style> </style>
@@ -1,5 +1,5 @@
<template> <template>
<div v-if="!inline" 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 class="reg-header-row"> <div class="reg-header-row">
<h2 id="regTitle" class="reg-title"> <h2 id="regTitle" class="reg-title">
@@ -9,13 +9,10 @@
</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>
<em>Generating link...</em>
</div>
<p class="reg-help"> <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-if="userName">The user should open this link on the device where they want to register.</span>
<span v-else>Open or scan this link on the device you wish to register to your account.</span> <span v-else>Open or scan this link on the device you wish to register to your account.</span>
@@ -25,11 +22,11 @@
</div> </div>
<div class="reg-actions"> <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>
</div> </div>
<div v-else class="registration-inline-wrapper"> <div v-else-if="inline && url" class="registration-inline-wrapper">
<div class="registration-inline-block section-block"> <div class="registration-inline-block section-block">
<div class="section-header"> <div class="section-header">
<h2 class="inline-heading">📱 <span v-if="userName">Registration for {{ userName }}</span><span v-else>Device Registration Link</span></h2> <h2 class="inline-heading">📱 <span v-if="userName">Registration for {{ userName }}</span><span v-else>Device Registration Link</span></h2>
@@ -37,13 +34,10 @@
<div class="section-body"> <div class="section-body">
<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>
<em>Generating link...</em>
</div>
<p class="reg-help"> <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-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> <span v-else>Open this link on the device you wish to connect with.</span>
@@ -52,7 +46,7 @@
</div> </div>
</div> </div>
<div class="button-row" style="margin-top:1rem;"> <div class="button-row" style="margin-top:1rem;">
<button class="btn-primary" :disabled="!url" @click="copy">Copy Link</button> <button class="btn-primary" @click="copy">Copy Link</button>
<button v-if="showCloseInInline" class="btn-secondary" @click="$emit('close')">Close</button> <button v-if="showCloseInInline" class="btn-secondary" @click="$emit('close')">Close</button>
</div> </div>
</div> </div>
@@ -64,6 +58,10 @@
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 { 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 }, endpoint: { type: String, required: true },
@@ -89,9 +87,7 @@ const expirationMessage = computed(() => {
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 })
@@ -99,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')
} }
} }
+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>
+168 -35
View File
@@ -7,33 +7,45 @@
<div class="section-body"> <div class="section-body">
<div :class="['session-list']"> <div :class="['session-list']">
<template v-if="Array.isArray(sessions) && sessions.length"> <template v-if="Array.isArray(sessions) && sessions.length">
<div <div v-for="(group, host) in groupedSessions" :key="host" class="session-group">
v-for="session in sessions" <h3 :class="['session-group-host', { 'is-current-site': group.isCurrentSite }]">
:key="session.id" <template v-if="host"><a :href="hostUrl(host)">🌐 {{ host }}</a></template>
:class="['session-item', { 'is-current': session.is_current }]" <template v-else>🌐 Unbound host</template>
> </h3>
<div class="item-top"> <div class="session-group-sessions">
<div class="item-icon"> <div
<span class="session-emoji">🌐</span> v-for="session in group.sessions"
</div> :key="session.id"
<h4 class="item-title">{{ sessionHostLabel(session) }}</h4> :class="['session-item', {
<div class="item-actions"> 'is-current': session.is_current && !hoveredIp && !hoveredCredentialUuid,
<span v-if="session.is_current" class="badge badge-current">Current</span> 'is-hovered': hoveredSession?.id === session.id,
<span v-else-if="session.is_current_host" class="badge">This host</span> 'is-linked-credential': hoveredCredentialUuid === session.credential_uuid
<button }]"
v-if="allowTerminate" tabindex="0"
@click="$emit('terminate', session)" @focusin="handleSessionFocus(session)"
class="btn-card-delete" @focusout="handleSessionBlur($event)"
:disabled="isTerminating(session.id)" >
:title="isTerminating(session.id) ? 'Terminating...' : 'Terminate session'" <div class="item-top">
>🗑</button> <h4 class="item-title">{{ session.user_agent }}</h4>
</div> <div class="item-actions">
</div> <span v-if="session.is_current && !hoveredIp && !hoveredCredentialUuid" class="badge badge-current">Current</span>
<div class="item-details"> <span v-else-if="hoveredSession?.id === session.id" class="badge badge-current">Selected</span>
<div class="session-dates"> <span v-else-if="hoveredCredentialUuid === session.credential_uuid" class="badge badge-current">Linked</span>
<span class="date-label">Last used:</span> <span v-else-if="!hoveredCredentialUuid && isSameNetwork(session.ip)" class="badge">Same IP</span>
<span class="date-value">{{ formatDate(session.last_renewed) }}</span> <button
<span class="session-meta-info">{{ session.user_agent }} {{ session.ip }}</span> @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> </div>
</div> </div>
@@ -45,29 +57,150 @@
</template> </template>
<script setup> <script setup>
import { } from 'vue' import { computed, ref } from 'vue'
import { formatDate } from '@/utils/helpers' import { formatDate } from '@/utils/helpers'
const props = defineProps({ const props = defineProps({
sessions: { type: Array, default: () => [] }, sessions: { type: Array, default: () => [] },
allowTerminate: { type: Boolean, default: true },
emptyMessage: { type: String, default: 'You currently have no other active sessions.' }, 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." }, sectionDescription: { type: String, default: "Review where you're signed in and end any sessions you no longer recognize." },
terminatingSessions: { type: Object, default: () => ({}) } terminatingSessions: { type: Object, default: () => ({}) },
hoveredCredentialUuid: { type: String, default: null },
}) })
const emit = defineEmits(['terminate']) 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 isTerminating = (sessionId) => !!props.terminatingSessions[sessionId]
const sessionHostLabel = (session) => { const hostUrl = (host) => {
if (!session || !session.host) return 'Unbound host' // Assume http if there's a port number, https otherwise
return session.host 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> </script>
<style> <style>
.session-meta-info { .session-meta-info {
grid-column: span 2; grid-column: span 2;
} }
</style> [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>
-138
View File
@@ -1,138 +0,0 @@
<template>
<div class="app-shell">
<StatusMessage />
<main class="view-root host-view">
<div class="view-content">
<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="history.back()"
>
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>
</div>
</main>
</div>
</template>
<script setup>
import { computed, onMounted, ref } from 'vue'
import StatusMessage from '@/components/StatusMessage.vue'
import UserBasicInfo from '@/components/UserBasicInfo.vue'
import { useAuthStore } from '@/stores/auth'
const authStore = useAuthStore()
const initializing = ref(true)
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(() => {
const service = authStore.settings?.rp_name || 'this service'
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()
}
onMounted(async () => {
try {
await authStore.loadSettings()
const service = authStore.settings?.rp_name
if (service) document.title = `${service} · Account summary`
await authStore.loadUserInfo()
} catch (error) {
const message = error instanceof Error ? error.message : 'Unable to load session details'
authStore.showMessage(message, 'error', 4000)
} finally {
initializing.value = false
}
})
</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); }
.link { color: var(--color-accent); text-decoration: none; }
.link:hover { text-decoration: underline; }
.view-hint { margin-top: 0.5rem; 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>
-11
View File
@@ -1,11 +0,0 @@
import '@/assets/style.css'
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import HostApp from './HostApp.vue'
const app = createApp(HostApp)
app.use(createPinia())
app.mount('#app')
-176
View File
@@ -1,176 +0,0 @@
<template>
<div class="app-shell">
<div v-if="status.show" class="global-status" style="display: block;">
<div :class="['status', status.type]">
{{ status.message }}
</div>
</div>
<main class="view-root">
<div class="view-content">
<div 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">
<button class="btn-secondary" :disabled="loading" @click="backNav">Back</button>
<button v-if="canAuthenticate" class="btn-primary" :disabled="loading" @click="authenticateUser">
{{ loading ? 'Signing in' : 'Login' }}
</button>
<button v-if="isAuthenticated" class="btn-danger" :disabled="loading" @click="logoutUser">Logout</button>
<button v-if="isAuthenticated" class="btn-primary" :disabled="loading" @click="returnHome">Profile</button>
</div>
</div>
</section>
</div>
</div>
</main>
</div>
</template>
<script setup>
import { computed, onMounted, reactive, ref } from 'vue'
import passkey from '@/utils/passkey'
import { getSettings, uiBasePath } from '@/utils/settings'
const status = reactive({ show: false, message: '', type: 'info' })
const initializing = ref(true)
const loading = ref(false)
const settings = ref(null)
const userInfo = ref(null)
let statusTimer = null
const isAuthenticated = computed(() => !!userInfo.value?.authenticated)
const canAuthenticate = computed(() => !initializing.value && !isAuthenticated.value)
const basePath = computed(() => uiBasePath())
const headingTitle = computed(() => {
if (!isAuthenticated.value) return `🔐 ${settings.value?.rp_name || location.origin}`
return '🚫 Forbidden'
})
const headerMessage = computed(() => {
if (!isAuthenticated.value) return 'Please sign in to access this page.'
return 'You lack the permissions required to access this page.'
})
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) document.title = isAuthenticated.value ? `${data.rp_name} · Forbidden` : `${data.rp_name} · Sign In`
} catch (error) {
console.warn('Unable to load settings', error)
}
}
async function fetchUserInfo() {
try {
const res = await fetch('/auth/api/user-info', { method: 'POST' })
console.log("fetchUserInfo response:", res); // Debug log
if (!res.ok) {
const payload = await safeParseJson(res)
showMessage(payload.detail || 'Unable to load user session info.', 'error', 2000)
return
}
userInfo.value = await res.json()
// If the user is authenticated but still here, they lack permissions.
if (isAuthenticated.value) showMessage('Permission Denied', 'error', 2000)
} catch (error) {
console.error('Failed to load user info', error)
showMessage('Could not contact the authentication server', 'error', 2000)
}
}
async function authenticateUser() {
if (!canAuthenticate.value || loading.value) return
loading.value = true
showMessage('Starting authentication…', 'info')
let result
try { result = await passkey.authenticate() } catch (error) {
loading.value = false
const message = error?.message || 'Passkey authentication cancelled'
const cancelled = message === 'Passkey authentication cancelled'
showMessage(cancelled ? message : `Authentication failed: ${message}`, cancelled ? 'info' : 'error', 4000)
return
}
try { await setSessionCookie(result.session_token) } catch (error) {
loading.value = false
const message = error?.message || 'Failed to establish session'
showMessage(message, 'error', 4000)
return
}
location.reload()
}
async function logoutUser() {
if (loading.value) return
loading.value = true
try { await fetch('/auth/api/logout', { method: 'POST' }) } catch (_) { /* ignore */ }
finally { loading.value = false; window.location.reload() }
}
async function setSessionCookie(sessionToken) {
const response = await fetch('/auth/api/set-session', {
method: 'POST', headers: { Authorization: `Bearer ${sessionToken}` }
})
const payload = await safeParseJson(response)
if (!response.ok || payload?.detail) throw new Error(payload?.detail || 'Session could not be established.')
return payload
}
function returnHome() {
const target = basePath.value || '/auth/'
if (window.location.pathname !== target) history.replaceState(null, '', target)
window.location.href = target
}
function backNav() {
try {
if (history.length > 1) {
history.back()
return
}
} catch (_) { /* ignore */ }
returnHome()
}
async function safeParseJson(response) { try { return await response.json() } catch (_) { return null } }
onMounted(async () => {
await fetchSettings()
await fetchUserInfo()
initializing.value = false
})
</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; display: flex; align-items: center; justify-content: center; padding: 2rem 1rem; }
main.view-root .view-content { width: 100%; }
.surface.surface--tight {
max-width: 520px;
margin: 0 auto;
width: 100%;
display: flex;
flex-direction: column;
gap: 1.75rem;
}
</style>
-5
View File
@@ -1,5 +0,0 @@
import { createApp } from 'vue'
import RestrictedApp from './RestrictedApp.vue'
import '@/assets/style.css'
createApp(RestrictedApp).mount('#app')
+33 -66
View File
@@ -1,6 +1,7 @@
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 { getSettings } from '@/utils/settings'
import { apiJson } from '@/utils/api'
export const useAuthStore = defineStore('auth', { export const useAuthStore = defineStore('auth', {
state: () => ({ state: () => ({
@@ -37,22 +38,21 @@ 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)
} }
return result return await apiJson('/auth/api/set-session', {
method: 'POST',
headers: {'Authorization': `Bearer ${result.session_token}`},
})
}, },
async register() { async register() {
this.isLoading = true this.isLoading = true
try { try {
const result = await register() const result = await register()
await this.setSessionCookie(result.session_token) await this.setSessionCookie(result)
await this.loadUserInfo() await this.loadUserInfo()
this.selectView() this.selectView()
return result return result
@@ -65,7 +65,7 @@ export const useAuthStore = defineStore('auth', {
try { try {
const result = await authenticate() const result = await authenticate()
await this.setSessionCookie(result.session_token) await this.setSessionCookie(result)
await this.loadUserInfo() await this.loadUserInfo()
this.selectView() this.selectView()
@@ -82,45 +82,26 @@ export const useAuthStore = defineStore('auth', {
this.settings = await getSettings() this.settings = await getSettings()
}, },
async loadUserInfo() { async loadUserInfo() {
const response = await fetch('/auth/api/user-info', { method: 'POST' })
let result = null
try { try {
result = await response.json() this.userInfo = await apiJson('/auth/api/user-info', { method: 'POST' })
} catch (_) { console.log('User info loaded:', this.userInfo)
// ignore JSON parse errors (unlikely) } catch (error) {
// Suppress toast for 401/403 errors - the auth iframe will handle these
if (error.status === 401 || error.status === 403) {
console.log('Authentication required:', error.message)
} else {
this.showMessage(error.message || 'Failed to load user info', 'error', 5000)
}
throw error
} }
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 deleteCredential(uuid) { async deleteCredential(uuid) {
const response = await fetch(`/auth/api/user/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) { async terminateSession(sessionId) {
try { try {
const res = await fetch(`/auth/api/user/session/${sessionId}`, { method: 'DELETE' }) const payload = await apiJson(`/auth/api/user/session/${sessionId}`, { method: 'DELETE' })
let payload = null
try {
payload = await res.json()
} catch (_) {
// ignore JSON parse errors
}
if (!res.ok || payload?.detail) {
const message = payload?.detail || 'Failed to terminate session'
throw new Error(message)
}
if (payload?.current_session_terminated) { if (payload?.current_session_terminated) {
sessionStorage.clear() sessionStorage.clear()
location.reload() location.reload()
@@ -135,42 +116,28 @@ export const useAuthStore = defineStore('auth', {
}, },
async logout() { async logout() {
try { try {
const res = await fetch('/auth/api/logout', {method: 'POST'}) await apiJson('/auth/api/logout', {method: 'POST'})
if (!res.ok) {
let message = 'Logout failed'
try {
const data = await res.json()
if (data?.detail) message = data.detail
} catch (_) {
// ignore JSON parse errors
}
throw new Error(message)
}
sessionStorage.clear() sessionStorage.clear()
location.reload() location.reload()
} catch (error) { } catch (error) {
console.error('Logout error:', error) console.error('Logout error:', error)
this.showMessage(error.message, 'error') // Suppress toast for 401/403 errors - the auth iframe will handle these
if (error.status !== 401 && error.status !== 403) {
this.showMessage(error.message, 'error')
}
} }
}, },
async logoutEverywhere() { async logoutEverywhere() {
try { try {
const res = await fetch('/auth/api/user/logout-all', {method: 'POST'}) await apiJson('/auth/api/user/logout-all', {method: 'POST'})
if (!res.ok) {
let message = 'Logout failed'
try {
const data = await res.json()
if (data?.detail) message = data.detail
} catch (_) {
// ignore JSON parse errors
}
throw new Error(message)
}
sessionStorage.clear() sessionStorage.clear()
location.reload() location.reload()
} catch (error) { } catch (error) {
console.error('Logout-all error:', error) console.error('Logout-all 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')
}
} }
}, },
} }
+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) {
+2
View File
@@ -24,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()
+49 -15
View File
@@ -1,6 +1,7 @@
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 { getSettings } from '@/utils/settings'
import { showAuthIframe } from '@/utils/api'
// Generic path normalizer: if an auth_host is configured and differs from current // 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. // host, return absolute URL (scheme derived by aWebSocket). Otherwise, keep as-is.
@@ -10,33 +11,66 @@ async function makeUrl(path) {
return h && location.host !== h ? `//${h}${path}` : path return h && location.host !== h ? `//${h}${path}` : path
} }
export async function register(resetToken = null, displayName = null) { 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 ws = await aWebSocket(await makeUrl(`/auth/ws/register${qs}`))
try { while (true) {
const optionsJSON = await ws.receive_json() const ws = await aWebSocket(await makeUrl(`/auth/ws/register${qs}`))
const registrationResponse = await startRegistration({ optionsJSON }) try {
ws.send_json(registrationResponse) const res = await ws.receive_json()
return await ws.receive_json()
} catch (error) { // Handle auth errors (401/403) with iframe
console.error('Registration error:', error) if ((res.status === 401 || res.status === 403) && res.auth?.iframe) {
// Replace useless and ugly error message from startRegistration ws.close()
throw Error(error.name === "NotAllowedError" ? 'Passkey registration cancelled' : error.message) await showAuthIframe(res.auth.iframe)
} finally { continue
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(await makeUrl('/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)
+69 -38
View File
@@ -1,64 +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/host' || url === '/auth/host/') return '/host/index.html'
if (url === '/host' || url === '/host/') return '/host/index.html'
if (url === '/auth/admin' || url === '/auth/admin/') return '/admin/'
if (url.startsWith('/auth/assets/')) return url.replace(/^\/auth/, '')
if (/^\/auth\/([a-z]+\.){4}[a-z]+\/?$/.test(url)) return '/reset/index.html'
if (/^\/([a-z]+\.){4}[a-z]+\/?$/.test(url)) return '/reset/index.html'
if (url === '/auth/restricted' || url === '/auth/restricted/') return '/restricted/index.html'
if (url === '/restricted' || url === '/restricted/') return '/restricted/index.html'
// Everything else (including /auth/admin/* APIs) should proxy.
}
} }
} }
}, },
build: { build: {
outDir: '../passkey/frontend-build', outDir: '../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'),
reset: resolve(__dirname, 'reset/index.html'), restricted: resolve(__dirname, 'auth/restricted/index.html'),
restricted: resolve(__dirname, 'restricted/index.html'), reset: resolve(__dirname, 'int/reset/index.html'),
host: resolve(__dirname, 'host/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]'
}
}
} }
} }
})) }))
+14 -24
View File
@@ -69,36 +69,26 @@ async def create_session(
async def get_reset(token: str) -> ResetToken: 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)."""
record = await db.instance.get_reset_token(reset_key(token)) record = await db.instance.get_reset_token(reset_key(token))
if not record: if record and record.expiry >= datetime.now(timezone.utc):
raise ValueError("Invalid or expired session token") return record
if record.expiry < datetime.now(timezone.utc): raise ValueError("This reset link is invalid or has expired")
await db.instance.delete_reset_token(record.key)
raise ValueError("Invalid or expired session token")
return record
async def get_session(token: str, host: str | None = None) -> 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:
if session_expiry(session) < datetime.now(timezone.utc):
await db.instance.delete_session(session.key)
raise ValueError("Invalid or expired session token")
if host is not None:
normalized_host = hostutil.normalize_host(host)
if not normalized_host:
raise ValueError("Invalid host")
current = session.host
if current is None:
# First time binding: store exact host:port (or IPv6 form) now. # First time binding: store exact host:port (or IPv6 form) now.
await db.instance.set_session_host(session.key, normalized_host) await db.instance.set_session_host(session.key, host)
session.host = normalized_host session.host = host
elif current == normalized_host: elif session.host != host:
pass # exact match ok raise ValueError("Session host mismatch")
else: return session
raise ValueError("Invalid or expired session token") raise ValueError("Your session has expired. Please sign in again!")
return session
async def refresh_session_token(token: str, *, ip: str, user_agent: str): async def refresh_session_token(token: str, *, ip: str, user_agent: str):
+1
View File
@@ -92,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
+17 -4
View File
@@ -439,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,
@@ -1314,19 +1314,24 @@ class DB(DatabaseInterface):
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
@@ -1342,7 +1347,9 @@ 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
if host is not None: if host is not None:
@@ -1371,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(
@@ -1405,5 +1417,6 @@ class DB(DatabaseInterface):
user=user_obj, user=user_obj,
org=organization, org=organization,
role=role, role=role,
credential=credential_obj,
permissions=effective_permissions if effective_permissions else None, permissions=effective_permissions if effective_permissions else None,
) )
+3 -3
View File
@@ -233,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:
@@ -243,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:
+84 -24
View File
@@ -2,8 +2,8 @@ import logging
from datetime import timezone from datetime import timezone
from uuid import UUID, uuid4 from uuid import UUID, uuid4
from fastapi import Body, FastAPI, HTTPException, Request from fastapi import Body, FastAPI, HTTPException, Request, Response
from fastapi.responses import FileResponse, JSONResponse from fastapi.responses import JSONResponse
from ..authsession import reset_expires from ..authsession import reset_expires
from ..globals import db from ..globals import db
@@ -28,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")
@@ -36,22 +45,7 @@ async def general_exception_handler(_request, exc: Exception):
@app.get("/") @app.get("/")
async def adminapp(request: Request, auth=AUTH_COOKIE): async def adminapp(request: Request, auth=AUTH_COOKIE):
"""Serve admin SPA only for authenticated users with admin/org permissions. return Response(*await frontend.read("/auth/admin/index.html"))
On missing/invalid session or insufficient permissions, serve restricted SPA.
"""
try:
await authz.verify(
auth,
["auth:admin", "auth:org:*"],
match=permutil.has_any,
host=request.headers.get("host"),
)
return FileResponse(frontend.file("admin/index.html"))
except HTTPException as e:
return FileResponse(
frontend.file("restricted", "index.html"), status_code=e.status_code
)
# -------------------- Organizations -------------------- # -------------------- Organizations --------------------
@@ -172,6 +166,7 @@ async def admin_delete_org(org_uuid: UUID, request: Request, auth=AUTH_COOKIE):
["auth:admin", f"auth:org:{org_uuid}"], ["auth:admin", f"auth:org:{org_uuid}"],
match=permutil.has_any, match=permutil.has_any,
host=request.headers.get("host"), 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")
@@ -321,6 +316,7 @@ async def admin_delete_role(
["auth:admin", f"auth:org:{org_uuid}"], ["auth:admin", f"auth:org:{org_uuid}"],
match=permutil.has_any, match=permutil.has_any,
host=request.headers.get("host"), 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:
@@ -434,12 +430,15 @@ async def admin_create_user_registration_link(
["auth:admin", f"auth:org:{org_uuid}"], ["auth:admin", f"auth:org:{org_uuid}"],
match=permutil.has_any, match=permutil.has_any,
host=request.headers.get("host"), 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 # Check if user has existing credentials
credentials = await db.instance.get_credentials_by_user_uuid(user_uuid) credentials = await db.instance.get_credentials_by_user_uuid(user_uuid)
@@ -489,7 +488,9 @@ async def admin_get_user_detail(
"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] = []
@@ -636,7 +637,9 @@ async def admin_update_user_display_name(
"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")
@@ -665,16 +668,65 @@ async def admin_delete_user_credential(
["auth:admin", f"auth:org:{org_uuid}"], ["auth:admin", f"auth:org:{org_uuid}"],
match=permutil.has_any, match=permutil.has_any,
host=request.headers.get("host"), 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"
)
await db.instance.delete_credential(credential_uuid, user_uuid) await db.instance.delete_credential(credential_uuid, user_uuid)
return {"status": "ok"} return {"status": "ok"}
@app.delete("/orgs/{org_uuid}/users/{user_uuid}/sessions/{session_id}")
async def admin_delete_user_session(
org_uuid: UUID,
user_uuid: UUID,
session_id: str,
request: Request,
auth=AUTH_COOKIE,
):
try:
user_org, _role_name = await db.instance.get_user_organization(user_uuid)
except ValueError:
raise HTTPException(status_code=404, detail="User not found")
if user_org.uuid != org_uuid:
raise HTTPException(status_code=404, detail="User not found in organization")
ctx = await authz.verify(
auth,
["auth:admin", f"auth:org:{org_uuid}"],
match=permutil.has_any,
host=request.headers.get("host"),
)
if (
"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) --------------------
@@ -705,7 +757,11 @@ async def admin_create_permission(
auth=AUTH_COOKIE, auth=AUTH_COOKIE,
): ):
await authz.verify( await authz.verify(
auth, ["auth:admin"], host=request.headers.get("host"), match=permutil.has_all 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
@@ -777,7 +833,11 @@ async def admin_delete_permission(
auth=AUTH_COOKIE, auth=AUTH_COOKIE,
): ):
await authz.verify( await authz.verify(
auth, ["auth:admin"], host=request.headers.get("host"), match=permutil.has_all 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")
+69 -173
View File
@@ -13,9 +13,8 @@ from fastapi import (
from fastapi.responses import JSONResponse from fastapi.responses import JSONResponse
from fastapi.security import HTTPBearer from fastapi.security import HTTPBearer
from passkey.util import frontend, useragent from passkey.util import frontend
from .. import aaguid
from ..authsession import ( from ..authsession import (
EXPIRES, EXPIRES,
get_reset, get_reset,
@@ -25,8 +24,8 @@ from ..authsession import (
) )
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 hostutil, passphrase, permutil from ..util import hostutil, htmlutil, passphrase, userinfo
from ..util.tokens import encode_session_key, session_key from ..util.tokens import session_key
from . import authz, session, user from . import authz, session, user
from .session import AUTH_COOKIE from .session import AUTH_COOKIE
@@ -58,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")
@@ -85,7 +93,7 @@ async def validate_token(
renewed = False renewed = False
if auth: if auth:
current_expiry = session_expiry(ctx.session) current_expiry = session_expiry(ctx.session)
consumed = EXPIRES - (current_expiry - datetime.now()) 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( await refresh_session_token(
@@ -97,7 +105,9 @@ async def validate_token(
renewed = True renewed = True
except ValueError: except ValueError:
# Session disappeared, e.g. due to concurrent logout; global handler will clear # 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),
@@ -110,18 +120,27 @@ async def forward_authentication(
request: Request, request: Request,
response: Response, response: Response,
perm: list[str] = Query([]), perm: list[str] = Query([]),
max_age: str | None = Query(None),
auth=AUTH_COOKIE, 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, host=request.headers.get("host")) 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)
@@ -148,14 +167,29 @@ async def forward_authentication(
"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:
# Let global handler clear cookie; still return HTML surface instead of JSON # Clear cookie only if session is invalid (not for reauth)
html = frontend.file("restricted", "index.html").read_bytes() if e.clear_session:
status = e.status_code
# If 401 we still want cookie cleared; rely on handler by raising again not feasible (we need HTML)
if status == 401:
session.clear_session_cookie(response) session.clear_session_cookie(response)
return Response(html, status_code=status, media_type="text/html")
# 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")
@@ -177,6 +211,12 @@ async def api_user_info(
reset: str | None = None, reset: str | None = None,
auth=AUTH_COOKIE, 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 session_record = None
reset_token = None reset_token = None
@@ -188,175 +228,31 @@ async def api_user_info(
target_user_uuid = reset_token.user_uuid target_user_uuid = reset_token.user_uuid
else: else:
if auth is None: if auth is None:
raise ValueError("Authentication Required") raise authz.AuthException(
status_code=401,
detail="Authentication required",
mode="login",
)
session_record = await get_session(auth, host=request.headers.get("host")) session_record = await get_session(auth, host=request.headers.get("host"))
authenticated = True authenticated = True
target_user_uuid = session_record.user_uuid 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(target_user_uuid) # Return minimal response for reset tokens
if not authenticated and reset_token:
if not authenticated and reset_token: # minimal response for reset tokens return await userinfo.format_reset_user_info(target_user_uuid, reset_token)
return {
"authenticated": False,
"session_type": reset_token.token_type,
"user": {"user_uuid": str(u.uuid), "user_name": u.display_name},
}
# Return full user info for authenticated users
assert auth is not None assert auth is not None
assert session_record is not None assert session_record is not None
ctx = await permutil.session_context(auth, request.headers.get("host")) return await userinfo.format_user_info(
credential_ids = await db.instance.get_credentials_by_user_uuid( user_uuid=target_user_uuid,
session_record.user_uuid auth=auth,
session_record=session_record,
request_host=request.headers.get("host"),
) )
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": (
c.created_at.astimezone(timezone.utc)
.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
else None,
"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)
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 [])
)
normalized_request_host = hostutil.normalize_host(request.headers.get("host"))
session_records = await db.instance.list_sessions_for_user(session_record.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 {
"authenticated": True,
"user": {
"user_uuid": str(u.uuid),
"user_name": u.display_name,
"created_at": (
u.created_at.astimezone(timezone.utc).isoformat().replace("+00:00", "Z")
if u.created_at and u.created_at.tzinfo
else (
u.created_at.replace(tzinfo=timezone.utc)
.isoformat()
.replace("+00:00", "Z")
if u.created_at
else None
)
),
"last_seen": (
u.last_seen.astimezone(timezone.utc).isoformat().replace("+00:00", "Z")
if u.last_seen and u.last_seen.tzinfo
else (
u.last_seen.replace(tzinfo=timezone.utc)
.isoformat()
.replace("+00:00", "Z")
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,
"sessions": sessions_payload,
}
@app.post("/logout") @app.post("/logout")
+73 -7
View File
@@ -2,31 +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__)
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( async def verify(
auth: str | None, auth: str | None,
perm: list[str], perm: list[str],
match=permutil.has_all, match=permutil.has_all,
host: str | None = None, 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, host) 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
@@ -39,6 +103,8 @@ async def verify(
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
+27 -35
View File
@@ -3,7 +3,7 @@ import os
from contextlib import asynccontextmanager from contextlib import asynccontextmanager
from fastapi import FastAPI, HTTPException, Request, Response 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, hostutil, passphrase from passkey.util import frontend, hostutil, passphrase
@@ -42,6 +42,16 @@ 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
@@ -51,12 +61,16 @@ app = FastAPI(lifespan=lifespan)
# Apply redirections to auth-host if configured (deny access to restricted endpoints, remove /auth/) # Apply redirections to auth-host if configured (deny access to restricted endpoints, remove /auth/)
app.middleware("http")(auth_host.redirect_middleware) app.middleware("http")(auth_host.redirect_middleware)
app.mount("/auth/admin/", admin.app) 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 # 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 # / is used on a dedicated auth site, /auth/ on app domains with auth
@@ -65,29 +79,12 @@ app.mount(
@app.get("/") @app.get("/")
@app.get("/auth/") @app.get("/auth/")
async def frontapp(request: Request, response: Response, auth=AUTH_COOKIE): async def frontapp(request: Request, response: Response, auth=AUTH_COOKIE):
"""Serve the user profile SPA only for authenticated sessions; otherwise restricted SPA. """Serve the user profile app.
Login / authentication UX is centralized in the restricted app. The frontend handles mode detection (host mode vs full profile) based on settings.
Access control is handled via APIs.
""" """
if not auth: return Response(*await frontend.read("/auth/index.html"))
return FileResponse(frontend.file("restricted", "index.html"), status_code=401)
from ..authsession import get_session # local import
try:
await get_session(auth, host=request.headers.get("host"))
cfg_host = hostutil.configured_auth_host()
if cfg_host:
cur_host = hostutil.normalize_host(request.headers.get("host"))
cfg_normalized = hostutil.normalize_host(cfg_host)
if cur_host and cfg_normalized and cur_host != cfg_normalized:
return FileResponse(frontend.file("host", "index.html"))
return FileResponse(frontend.file("index.html"))
except Exception:
if auth:
from . import session as session_mod
session_mod.clear_session_cookie(response)
return FileResponse(frontend.file("restricted", "index.html"), status_code=401)
@app.get("/admin", include_in_schema=False) @app.get("/admin", include_in_schema=False)
@@ -98,19 +95,14 @@ async def admin_root_redirect():
@app.get("/admin/", include_in_schema=False) @app.get("/admin/", include_in_schema=False)
async def admin_root(request: Request, auth=AUTH_COOKIE): async def admin_root(request: Request, auth=AUTH_COOKIE):
return await admin.adminapp(request, auth) # Delegated (enforces access control) 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("/{reset}")
@app.get("/auth/{reset}") @app.get("/auth/{reset}")
async def reset_link(reset: str): async def reset_link(reset: str):
"""Serve the SPA directly with an injected reset token.""" """Serve the reset app directly with an injected reset token."""
if not passphrase.is_well_formed(reset): if not passphrase.is_well_formed(reset):
raise HTTPException(status_code=404) raise HTTPException(status_code=404)
return FileResponse(frontend.file("reset", "index.html")) return Response(*await frontend.read("/int/reset/index.html"))
@app.get("/restricted", include_in_schema=False)
@app.get("/auth/restricted", include_in_schema=False)
async def restricted_view():
return FileResponse(frontend.file("restricted", "index.html"))
+36 -8
View File
@@ -8,6 +8,7 @@ from fastapi import (
Request, Request,
Response, Response,
) )
from fastapi.responses import JSONResponse
from ..authsession import ( from ..authsession import (
delete_credential, delete_credential,
@@ -17,12 +18,21 @@ from ..authsession import (
from ..globals import db from ..globals import db
from ..util import hostutil, passphrase, tokens from ..util import hostutil, passphrase, tokens
from ..util.tokens import decode_session_key, session_key from ..util.tokens import decode_session_key, session_key
from . import session from . import authz, session
from .session import AUTH_COOKIE from .session import AUTH_COOKIE
app = FastAPI() 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") @app.put("/display-name")
async def user_update_display_name( async def user_update_display_name(
request: Request, request: Request,
@@ -31,11 +41,15 @@ async def user_update_display_name(
auth=AUTH_COOKIE, auth=AUTH_COOKIE,
): ):
if not auth: if not auth:
raise HTTPException(status_code=401, detail="Authentication Required") raise authz.AuthException(
status_code=401, detail="Authentication Required", mode="login"
)
try: try:
s = await get_session(auth, host=request.headers.get("host")) s = await get_session(auth, host=request.headers.get("host"))
except ValueError as e: except ValueError as e:
raise HTTPException(status_code=401, detail="Session expired") from e raise authz.AuthException(
status_code=401, detail="Session expired", mode="login"
) from e
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")
@@ -52,7 +66,9 @@ async def api_logout_all(request: Request, response: Response, auth=AUTH_COOKIE)
try: try:
s = await get_session(auth, host=request.headers.get("host")) s = await get_session(auth, host=request.headers.get("host"))
except ValueError: except ValueError:
raise HTTPException(status_code=401, detail="Session expired") raise authz.AuthException(
status_code=401, detail="Session expired", mode="login"
)
await db.instance.delete_sessions_for_user(s.user_uuid) await db.instance.delete_sessions_for_user(s.user_uuid)
session.clear_session_cookie(response) session.clear_session_cookie(response)
return {"message": "Logged out from all hosts"} return {"message": "Logged out from all hosts"}
@@ -66,11 +82,15 @@ async def api_delete_session(
auth=AUTH_COOKIE, auth=AUTH_COOKIE,
): ):
if not auth: if not auth:
raise HTTPException(status_code=401, detail="Authentication Required") raise authz.AuthException(
status_code=401, detail="Authentication Required", mode="login"
)
try: try:
current_session = await get_session(auth, host=request.headers.get("host")) current_session = await get_session(auth, host=request.headers.get("host"))
except ValueError as exc: except ValueError as exc:
raise HTTPException(status_code=401, detail="Session expired") from exc raise authz.AuthException(
status_code=401, detail="Session expired", mode="login"
) from exc
try: try:
target_key = decode_session_key(session_id) target_key = decode_session_key(session_id)
@@ -97,10 +117,14 @@ async def api_delete_credential(
uuid: UUID, uuid: UUID,
auth: str = AUTH_COOKIE, auth: str = AUTH_COOKIE,
): ):
# Require recent authentication for sensitive operation
await authz.verify(auth, [], host=request.headers.get("host"), max_age="5m")
try: try:
await delete_credential(uuid, auth, host=request.headers.get("host")) await delete_credential(uuid, auth, host=request.headers.get("host"))
except ValueError as e: except ValueError as e:
raise HTTPException(status_code=401, detail="Session expired") from e raise authz.AuthException(
status_code=401, detail="Session expired", mode="login"
) from e
return {"message": "Credential deleted successfully"} return {"message": "Credential deleted successfully"}
@@ -110,10 +134,14 @@ async def api_create_link(
response: Response, response: Response,
auth=AUTH_COOKIE, auth=AUTH_COOKIE,
): ):
# Require recent authentication for sensitive operation
await authz.verify(auth, [], host=request.headers.get("host"), max_age="5m")
try: try:
s = await get_session(auth, host=request.headers.get("host")) s = await get_session(auth, host=request.headers.get("host"))
except ValueError as e: except ValueError as e:
raise HTTPException(status_code=401, detail="Session expired") from e raise authz.AuthException(
status_code=401, detail="Session expired", mode="login"
) from e
token = passphrase.generate() token = passphrase.generate()
expiry = expires() expiry = expires()
await db.instance.create_reset_token( await db.instance.create_reset_token(
+50 -13
View File
@@ -9,6 +9,7 @@ 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 . import authz
from .session import AUTH_COOKIE, infodict from .session import AUTH_COOKIE, infodict
@@ -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)
@@ -64,20 +72,23 @@ async def websocket_register_add(
"""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] 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, host=host) 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)
@@ -119,15 +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"]
host = origin.split("://", 1)[1] host = origin.split("://", 1)[1]
options, challenge = passkey.instance.auth_generate_options()
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
+1 -1
View File
@@ -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,
+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())
+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")
+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}")
+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},
}
+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")