Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
051e1bbb41 | ||
|
|
4b156b712c | ||
|
|
df8a7c0026 | ||
|
|
9b28250391 | ||
|
|
b9aec6bb58 | ||
|
|
c79cb497ee | ||
|
|
9f50c8c20d | ||
|
|
816c7a681e | ||
|
|
d31c09084e | ||
|
|
cc938dd306 | ||
|
|
36db1e7e56 | ||
|
|
95c163e37a | ||
|
|
2d0d17c307 | ||
|
|
10980ad39b | ||
|
|
42b54cf645 | ||
|
|
232d0e1ae0 | ||
|
|
e97a2b3291 | ||
|
|
cde709e252 | ||
|
|
72d76df35d | ||
|
|
1a742fc0e7 | ||
|
|
0b29654d6f | ||
|
|
76f24a755b | ||
|
|
5c452f325a | ||
|
|
e9b6bc7a3d | ||
|
|
f5545b48f0 | ||
|
|
c1b2bcf76c | ||
|
|
1806bcab5c | ||
|
|
be177cbafc | ||
|
|
f5ccc204be | ||
|
|
dd2031eef5 | ||
|
|
3cb24bfee9 | ||
|
|
3f51d06f13 | ||
|
|
528a728eb8 | ||
|
|
727625ef4f | ||
|
|
5f7a5ed9b1 | ||
|
|
d64e63527b | ||
|
|
733439b446 | ||
|
|
4e6f63e9ef | ||
|
|
d431c75297 | ||
|
|
6257071efe | ||
|
|
7958b6f365 | ||
|
|
b3cb540098 | ||
|
|
22ba7231b1 | ||
|
|
9a9979fb62 | ||
|
|
9b7855c0af | ||
|
|
dfc4c76d43 | ||
|
|
e1f0fdf664 | ||
|
|
f26ac8f33b | ||
|
|
880ced3b8c | ||
|
|
af80b5eefc | ||
|
|
fa1e69d58b | ||
|
|
39000ef831 | ||
|
|
49119fac81 |
@@ -6,6 +6,7 @@ dist/
|
||||
package-lock.json
|
||||
paskia.sqlite
|
||||
*.paskiadb
|
||||
*.data
|
||||
/paskia/frontend-build
|
||||
/paskia/_version.py
|
||||
coverage-html/
|
||||
|
||||
@@ -35,7 +35,7 @@ Paskia includes set of login, reauthentication and forbidden dialogs that it can
|
||||
|
||||
Install [UV](https://docs.astral.sh/uv/getting-started/installation/) and run:
|
||||
|
||||
```fish
|
||||
```sh
|
||||
uvx paskia --rp-id example.com
|
||||
```
|
||||
|
||||
@@ -45,7 +45,7 @@ For production you need a web server such as [Caddy](https://caddyserver.com/) t
|
||||
|
||||
For a permanent install of `paskia` CLI command, not needing `uvx`:
|
||||
|
||||
```fish
|
||||
```sh
|
||||
uv tool install paskia
|
||||
```
|
||||
|
||||
@@ -66,33 +66,23 @@ paskia [options]
|
||||
| --auth-host *url* | Dedicated authentication site, e.g. **auth.example.com** | Use **/auth/** path on each site |
|
||||
| --save | Save current options to database | (only --rp-id required on further invocations) |
|
||||
|
||||
To clear a stored setting, pass an empty value like `--auth-host=`. The database is stored in `{rp-id}.paskiadb` in current directory. This can be overridden by environment `PASKIA_DB` if needed.
|
||||
To clear a stored setting, pass an empty value like `--auth-host=`. The database is stored in `{rp-id}.paskiadb` folder in current directory. This can be overridden by environment `PASKIA_DB` if needed.
|
||||
|
||||
## Tutorial: From Local Testing to Production
|
||||
|
||||
This section walks you through a complete example, from running Paskia locally to protecting a real site in production.
|
||||
|
||||
### Step 1: Local Testing
|
||||
|
||||
For development and testing, run Paskia without any arguments:
|
||||
|
||||
```fish
|
||||
paskia
|
||||
```
|
||||
|
||||
This starts the server on [localhost:4401](http://localhost:4401) with passkeys bound to `localhost`. On first run, Paskia prints a registration link for the Master Admin—click it to register your first passkey.
|
||||
|
||||
### Step 2: Production Configuration
|
||||
### Step 1: Production Configuration
|
||||
|
||||
For a real deployment, configure Paskia with your domain name (rp-id). This enables SSO setup for that domain and any subdomains.
|
||||
|
||||
```fish
|
||||
paskia --rp-id=example.com --rp-name="Example Corp" --save
|
||||
```sh
|
||||
uvx paskia --rp-id=example.com --rp-name="Example Corp"
|
||||
```
|
||||
|
||||
This binds passkeys to the rp-id, allowing them to be used there or on any subdomain of it. The `--rp-name` is the branding shown in UI and registered with passkeys for everything on your domain (rp id). The `--save` option stores these settings in the database, so future runs only need `paskia --rp-id example.com`, of which we will make use of with the systemd config later on.
|
||||
This binds passkeys to the rp-id, allowing them to be used there or on any subdomain of it. The `--rp-name` is the branding shown in UI and registered with passkeys for everything on your domain (rp id). On the first run, you'll see a registration link—use it to create your Admin account. You may enter your real name here for a more suitable account name.
|
||||
|
||||
### Step 3: Set Up Caddy
|
||||
### Step 2: Set Up Caddy
|
||||
|
||||
Install [Caddy](https://caddyserver.com/) and copy the [auth folder](caddy/auth) to `/etc/caddy/auth`. Say your current unprotected Caddyfile looks like this:
|
||||
|
||||
@@ -116,7 +106,7 @@ app.example.com {
|
||||
|
||||
Run `systemctl reload caddy`. Now `app.example.com` requires the `myapp:login` permission. Try accessing it and you'll land on a login dialog.
|
||||
|
||||
### Step 4: Assign Permissions via Admin Panel
|
||||
### Step 3: Assign Permissions via Admin Panel
|
||||
|
||||

|
||||
|
||||
@@ -129,7 +119,7 @@ Now you have granted yourself the new permission.
|
||||
|
||||
Permission scopes are text identifiers with colons as separators that we can use for permission checks. The `myapp:` prefix is a convention to namespace permissions per application—you but you can use other forms as you see fit (urlsafe characters, no spaces allowed).
|
||||
|
||||
### Step 5: Add API Authentication to Your App
|
||||
### Step 4: Add API Authentication to Your App
|
||||
|
||||
Your backend already receives `Remote-*` headers from Caddy's forward-auth. For frontend API calls, we provide a [JS paskia module](https://www.npmjs.com/package/paskia):
|
||||
|
||||
@@ -170,13 +160,23 @@ You may also remove the `myapp:login` protection from the rest of your site path
|
||||
}
|
||||
```
|
||||
|
||||
### Step 6: Run Paskia as a Service
|
||||
### Step 5: Run Paskia as a Service
|
||||
|
||||
Create a system user paskia, install UV on the system, and create a systemd unit:
|
||||
|
||||
```fish
|
||||
```sh
|
||||
sudo useradd --system --home-dir /srv/paskia --create-home paskia
|
||||
```
|
||||
|
||||
Install UV on the system (or arch btw `pacman -S uv`):
|
||||
|
||||
```sh
|
||||
curl -LsSf https://astral.sh/uv/install.sh | sudo env UV_INSTALL_DIR=/usr/local/bin sh
|
||||
```
|
||||
|
||||
Create a systemd unit:
|
||||
|
||||
```sh
|
||||
sudo systemctl edit --force --full paskia@.service
|
||||
```
|
||||
|
||||
@@ -190,28 +190,20 @@ Description=Paskia for %i
|
||||
Type=simple
|
||||
User=paskia
|
||||
WorkingDirectory=/srv/paskia
|
||||
ExecStart=uvx paskia --rp-id=%i
|
||||
ExecStart=uvx paskia@latest --rp-id=%i
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
```
|
||||
|
||||
Then enable and start, view output for registration link:
|
||||
Run the service and view log:
|
||||
|
||||
```fish
|
||||
sudo systemctl enable --now paskia@example.com && sudo journalctl -u paskia@example.com -f -n 30 -o cat
|
||||
```sh
|
||||
sudo systemctl enable --now paskia@example.com && sudo journalctl -n30 -ocat -fu paskia@example.com
|
||||
```
|
||||
|
||||
### Optional: Dedicated Authentication Site
|
||||
|
||||
By default, Paskia serves login dialogs and admin interface at the `/auth/` path on each protected site. For a cleaner setup, you can use a dedicated authentication subdomain instead. We assume you have your DNS setup for that domain or a wildcard of all subdomains to current machine.
|
||||
|
||||
Configure Paskia with the authentication host:
|
||||
|
||||
```fish
|
||||
paskia --rp-id example.com --auth-host=auth.example.com --save
|
||||
```
|
||||
|
||||
Add a Caddy configuration for the authentication domain:
|
||||
|
||||
```caddyfile
|
||||
@@ -220,8 +212,9 @@ auth.example.com {
|
||||
}
|
||||
```
|
||||
|
||||
Now all authentication happens at `auth.example.com` instead of `/auth/` paths on your apps. No other changes are needed. Your existing protected sites continue to work as before but they just forward to the dedicated site for user profile and other such functionality.
|
||||
Now all authentication happens at `auth.example.com` instead of `/auth/` paths on your apps. Your existing protected sites continue to work as before but they just forward to the dedicated site for user profile and other such functionality.
|
||||
|
||||
Enter your auth site domain on Admin / Server Options panel or use `--auth-host=auth.example.com` when starting the server.
|
||||
|
||||
|
||||
## Further Documentation
|
||||
|
||||
+40
-2
@@ -26,13 +26,17 @@ The `validate` and `forward` endpoints take query arguments `perm=` and `max_age
|
||||
|
||||
| Method | Path | Used for | Notes |
|
||||
|---:|---|---|---|
|
||||
| PUT | `/auth/api/user/display-name` | Update the user’s display name | Body: JSON `{ "display_name": "..." }` |
|
||||
| PATCH | `/auth/api/user/display-name` | Update the user’s display name | Body: JSON `{ "display_name": "..." }` |
|
||||
| GET | `/auth/api/user/{uuid}/profile.webp` | Canonical avatar image URL | Public on the auth host; serves `image/webp` with `ETag` and short-lived cache headers |
|
||||
| PUT | `/auth/api/user/{uuid}/profile.webp` | Upload or replace a user avatar | Multipart form with `file`; upload must already be square WebP prepared in the browser |
|
||||
| DELETE | `/auth/api/user/{uuid}/profile.webp` | Remove a user avatar | Allowed for the user, master admin, or org admin for users in the same org |
|
||||
| POST | `/auth/api/user/logout-all` | Terminate all user sessions | Clears current host cookie |
|
||||
| DELETE | `/auth/api/user/session/{session_id}` | Terminate one session | Session IDs are server-issued |
|
||||
| DELETE | `/auth/api/user/credential/{uuid}` | Delete a credential | Requires recent authentication |
|
||||
| POST | `/auth/api/user/create-link` | Create a device-add link | Requires recent authentication |
|
||||
|
||||
These are used mostly from the user profile panel and modify the current user.
|
||||
These are used mostly from the user profile panel. The avatar route is also used by admins when managing other users.
|
||||
`GET /auth/api/user-info` includes `user.avatar_url` when the user has an uploaded avatar, using the same canonical `/auth/api/user/{uuid}/profile.webp` path.
|
||||
|
||||
### Admin API: `/auth/api/admin/*`
|
||||
|
||||
@@ -40,6 +44,40 @@ Normally only used via admin panel, requires auth admin permissions and can modi
|
||||
|
||||
E.g. Org admin cannot see anything of the other orgs that he has no admin access to. Master admin `auth:admin` can see everything and create and manage orgs.
|
||||
|
||||
| Method | Path | Used for | Notes |
|
||||
|---:|---|---|---|
|
||||
| GET | `/auth/api/admin/info` | Admin overview | Returns orgs, permissions, OIDC clients info |
|
||||
| POST | `/auth/api/admin/permissions/` | Create permission | Body: JSON with scope, display_name, domain |
|
||||
| PATCH | `/auth/api/admin/permissions/{uuid}` | Update permission | Query params: display_name, scope, domain |
|
||||
| DELETE | `/auth/api/admin/permissions/{uuid}` | Delete permission | |
|
||||
| POST | `/auth/api/admin/orgs/` | Create organization | Body: JSON with display_name, permissions |
|
||||
| GET | `/auth/api/admin/orgs/{uuid}` | Get organization details | |
|
||||
| PATCH | `/auth/api/admin/orgs/{uuid}` | Update organization | Body: JSON with display_name |
|
||||
| DELETE | `/auth/api/admin/orgs/{uuid}` | Delete organization | |
|
||||
| POST | `/auth/api/admin/orgs/{uuid}/users` | Create user in org | Body: JSON with display_name, role_uuid |
|
||||
| POST | `/auth/api/admin/orgs/{uuid}/roles` | Create role in org | Body: JSON with display_name, permissions |
|
||||
| POST | `/auth/api/admin/orgs/{uuid}/permission` | Grant permission to org | Query param: permission_uuid |
|
||||
| DELETE | `/auth/api/admin/orgs/{uuid}/permission` | Revoke permission from org | Query param: permission_uuid |
|
||||
| PATCH | `/auth/api/admin/roles/{uuid}` | Update role | Body: JSON with display_name |
|
||||
| POST | `/auth/api/admin/roles/{uuid}/permissions/{uuid}` | Add permission to role | |
|
||||
| DELETE | `/auth/api/admin/roles/{uuid}/permissions/{uuid}` | Remove permission from role | |
|
||||
| DELETE | `/auth/api/admin/roles/{uuid}` | Delete role | |
|
||||
| PATCH | `/auth/api/admin/users/{uuid}/role` | Update user role | Body: JSON with role_uuid |
|
||||
| PATCH | `/auth/api/admin/users/{uuid}/info` | Update user info | Body: JSON with display_name |
|
||||
| GET | `/auth/api/admin/users/{uuid}` | Get user details | |
|
||||
| DELETE | `/auth/api/admin/users/{uuid}` | Delete user | |
|
||||
| POST | `/auth/api/admin/users/{uuid}/create-link` | Create device add link | |
|
||||
| DELETE | `/auth/api/admin/users/{uuid}/credentials/{uuid}` | Delete user credential | |
|
||||
| DELETE | `/auth/api/admin/users/{uuid}/sessions/{key}` | Delete user session | |
|
||||
| POST | `/auth/api/admin/oidc-clients/` | Create OIDC client | Body: JSON with client_name, redirect_uris |
|
||||
| PATCH | `/auth/api/admin/oidc-clients/{uuid}` | Update OIDC client | Body: JSON with client_name, redirect_uris |
|
||||
| PATCH | `/auth/api/admin/oidc-clients/{uuid}/reset-secret` | Reset client secret | |
|
||||
| DELETE | `/auth/api/admin/oidc-clients/{uuid}` | Delete OIDC client | |
|
||||
| GET | `/auth/api/admin/server-config/` | Get server config | Returns rp_name, auth_host, origins |
|
||||
| PATCH | `/auth/api/admin/server-config/` | Update server config | Body: JSON with rp_name, auth_host, origins |
|
||||
|
||||
Admins edit user avatars through the same canonical `/auth/api/user/{uuid}/profile.webp` PUT and DELETE endpoints.
|
||||
|
||||
### WebSockets: `/auth/ws/*`
|
||||
|
||||
| Path | Used for | Notes |
|
||||
|
||||
@@ -216,7 +216,7 @@ test.describe('API Mode - 401 Login Flow', () => {
|
||||
await clearSessionCookie(page)
|
||||
|
||||
// Make API call that triggers 401 (don't await - it blocks until iframe resolves)
|
||||
const apiCallPromise = makeApiCall(page, '/auth/api/user-info', 'POST').catch(e => e)
|
||||
const apiCallPromise = makeApiCall(page, '/auth/api/user-info', 'GET').catch(e => e)
|
||||
console.log('✓ Auth iframe appeared on 401')
|
||||
|
||||
// Verify it's in login mode (not reauth)
|
||||
@@ -268,7 +268,7 @@ test.describe('API Mode - 401 Login Flow', () => {
|
||||
await setupTestHarness(page)
|
||||
|
||||
// Make API call that triggers 401
|
||||
const apiCallPromise = makeApiCall(page, '/auth/api/user-info', 'POST')
|
||||
const apiCallPromise = makeApiCall(page, '/auth/api/user-info', 'GET')
|
||||
|
||||
// Wait for auth iframe to appear
|
||||
await waitForAuthIframe(page)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { spawn } from 'child_process'
|
||||
import { execSync, spawn } from 'child_process'
|
||||
import { join, dirname } from 'path'
|
||||
import { existsSync, mkdirSync, writeFileSync } from 'fs'
|
||||
import { existsSync, mkdirSync, rmSync, writeFileSync } from 'fs'
|
||||
import { fileURLToPath } from 'url'
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url))
|
||||
@@ -31,6 +31,11 @@ export default async function globalSetup() {
|
||||
mkdirSync(testDataDir, { recursive: true })
|
||||
}
|
||||
|
||||
// Build the package first
|
||||
console.log(' Building package with uv build...')
|
||||
execSync('uv build', { cwd: projectRoot, stdio: 'inherit' })
|
||||
console.log(' ✅ Build complete\n')
|
||||
|
||||
console.log(' Starting server with in-memory database...')
|
||||
if (COLLECT_COVERAGE) {
|
||||
console.log(' 📊 Coverage collection enabled for Python backend')
|
||||
@@ -53,6 +58,11 @@ export default async function globalSetup() {
|
||||
// Use a fresh database file for tests
|
||||
const testDbFile = join(testDataDir, 'test.paskiadb')
|
||||
|
||||
if (existsSync(testDbFile)) {
|
||||
console.log(' Removing stale test database...')
|
||||
rmSync(testDbFile, { force: true, recursive: true })
|
||||
}
|
||||
|
||||
// Start the server using Node's spawn
|
||||
const serverProcess = spawn('uv', serverArgs, {
|
||||
cwd: projectRoot,
|
||||
|
||||
@@ -63,7 +63,7 @@ export default async function globalTeardown() {
|
||||
const testDbFile = join(testDataDir, 'test.paskiadb')
|
||||
if (existsSync(testDbFile)) {
|
||||
console.log(' Removing test database...')
|
||||
rmSync(testDbFile)
|
||||
rmSync(testDbFile, { force: true, recursive: true })
|
||||
}
|
||||
|
||||
// Generate Python coverage report if coverage was collected
|
||||
|
||||
+23
-74
@@ -13,8 +13,8 @@
|
||||
<script setup>
|
||||
import { computed, onMounted, onUnmounted, ref } from 'vue'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { apiJson, SessionValidator, createAuthIframe, removeAuthIframe } from 'paskia'
|
||||
import { getAuthIframeUrl } from '@/utils/api'
|
||||
import { apiJson, SessionValidator, settings as paskiaSettings } from 'paskia'
|
||||
import { updateThemeFromSession } from '@/utils/theme'
|
||||
import StatusMessage from '@/components/StatusMessage.vue'
|
||||
import ProfileView from '@/components/ProfileView.vue'
|
||||
import HostProfileView from '@/components/HostProfileView.vue'
|
||||
@@ -48,90 +48,49 @@ const isHostMode = computed(() => {
|
||||
return currentHost !== configuredHost
|
||||
})
|
||||
|
||||
function terminateSession() {
|
||||
function onSessionLost(e) {
|
||||
store.userInfo = null
|
||||
viewState.value = 'terminal'
|
||||
store.ctx = null
|
||||
if (e?.name === 'AuthCancelledError') {
|
||||
viewState.value = 'terminal'
|
||||
} else {
|
||||
store.showMessage(e?.message || 'Session lost', 'error', 5000)
|
||||
viewState.value = 'terminal'
|
||||
}
|
||||
}
|
||||
|
||||
const userUuidGetter = () => store.ctx?.user.uuid
|
||||
const sessionValidator = new SessionValidator(userUuidGetter, terminateSession)
|
||||
const sessionValidator = new SessionValidator(userUuidGetter, onSessionLost)
|
||||
|
||||
onMounted(() => sessionValidator.start())
|
||||
onUnmounted(() => sessionValidator.stop())
|
||||
|
||||
async function loadUserInfo() {
|
||||
viewState.value = 'loading'
|
||||
loadingMessage.value = 'Loading...'
|
||||
try {
|
||||
// apiJson handles 401/403 with auth.iframe automatically:
|
||||
// shows overlay iframe, waits for auth, retries the request.
|
||||
const [validateData, userInfoData] = await Promise.all([
|
||||
apiJson('/auth/api/validate', { method: 'POST' }),
|
||||
apiJson('/auth/api/user-info', { method: 'GET' })
|
||||
apiJson('/auth/api/validate', { method: 'POST', timeout: paskiaSettings.auth_ms }),
|
||||
apiJson('/auth/api/user-info', { method: 'GET', timeout: paskiaSettings.auth_ms })
|
||||
])
|
||||
store.userInfo = userInfoData
|
||||
store.ctx = validateData.ctx
|
||||
updateThemeFromSession(store.userInfo)
|
||||
// Verify that the user UUIDs match between user-info and validate responses
|
||||
if (store.userInfo.user.uuid !== store.ctx.user.uuid) {
|
||||
console.error('User UUID mismatch between user-info and validate responses')
|
||||
window.location.reload()
|
||||
return false
|
||||
return
|
||||
}
|
||||
viewState.value = 'profile'
|
||||
return true
|
||||
} catch {
|
||||
store.userInfo = null
|
||||
store.ctx = null
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
async function showAuthIframe() {
|
||||
const url = await getAuthIframeUrl('login')
|
||||
createAuthIframe(url)
|
||||
loadingMessage.value = 'Authentication required...'
|
||||
}
|
||||
|
||||
function handleAuthMessage(event) {
|
||||
const data = event.data
|
||||
if (!data?.type) return
|
||||
|
||||
switch (data.type) {
|
||||
case 'auth-success':
|
||||
// Authentication successful - reload user info
|
||||
removeAuthIframe()
|
||||
viewState.value = 'loading'
|
||||
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 terminal state
|
||||
removeAuthIframe()
|
||||
terminateSession()
|
||||
break
|
||||
|
||||
case 'auth-close-request':
|
||||
// Legacy support - treat as back
|
||||
removeAuthIframe()
|
||||
break
|
||||
} catch (e) {
|
||||
onSessionLost(e)
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
// Listen for postMessage from auth iframe
|
||||
window.addEventListener('message', handleAuthMessage)
|
||||
|
||||
// Load settings
|
||||
await store.loadSettings()
|
||||
|
||||
@@ -145,17 +104,7 @@ onMounted(async () => {
|
||||
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)
|
||||
removeAuthIframe()
|
||||
// Load user info (apiJson handles auth iframe if needed)
|
||||
await loadUserInfo()
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -13,7 +13,8 @@ import AdminOidcDetail from '@/admin/AdminOidcDetail.vue'
|
||||
import AdminDialogs from '@/admin/AdminDialogs.vue'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { adminUiPath, makeUiHref } from '@/utils/settings'
|
||||
import { apiJson, SessionValidator } from 'paskia'
|
||||
import { apiJson, SessionValidator, settings as paskiaSettings } from 'paskia'
|
||||
import { updateThemeFromSession } from '@/utils/theme'
|
||||
import { uuidv7 } from 'uuidv7'
|
||||
import { getDirection } from '@/utils/keynav'
|
||||
import { goBack } from '@/utils/helpers'
|
||||
@@ -195,8 +196,9 @@ function orgUserCount(org) {
|
||||
}
|
||||
|
||||
async function loadUserInfo() {
|
||||
const data = await apiJson('/auth/api/validate', { method: 'POST' })
|
||||
const data = await apiJson('/auth/api/validate', { method: 'POST', timeout: paskiaSettings.auth_ms })
|
||||
info.value = data
|
||||
updateThemeFromSession(data.ctx)
|
||||
authenticated.value = true
|
||||
}
|
||||
|
||||
@@ -337,24 +339,9 @@ async function moveUserToRole(userUuid, user, targetRoleUuid) {
|
||||
}
|
||||
}
|
||||
|
||||
function onUserDragStart(e, userUuid, org) {
|
||||
e.dataTransfer.effectAllowed = 'move'
|
||||
e.dataTransfer.setData('text/plain', JSON.stringify({ user_uuid: userUuid, org }))
|
||||
}
|
||||
|
||||
function onRoleDragOver(e) {
|
||||
e.preventDefault()
|
||||
e.dataTransfer.dropEffect = 'move'
|
||||
}
|
||||
|
||||
function onRoleDrop(e, org, role) {
|
||||
e.preventDefault()
|
||||
try {
|
||||
const data = JSON.parse(e.dataTransfer.getData('text/plain'))
|
||||
if (data.org !== org.uuid) return // only within same org
|
||||
const user = org.users[data.user_uuid]
|
||||
if (user) moveUserToRole(data.user_uuid, user, role.uuid)
|
||||
} catch (_) { /* ignore */ }
|
||||
function moveUserToRoleFromDrag(userUuid, newRoleUuid) {
|
||||
const user = selectedOrg.value?.users?.[userUuid]
|
||||
if (user) moveUserToRole(userUuid, user, newRoleUuid)
|
||||
}
|
||||
|
||||
// Role actions
|
||||
@@ -475,6 +462,23 @@ function createPermissionForClient(clientId) {
|
||||
openDialog('perm-create', { display_name: '', scope: '', domain: clientId })
|
||||
}
|
||||
|
||||
async function openServerConfig() {
|
||||
try {
|
||||
const config = await apiJson('/auth/api/admin/server-config')
|
||||
// Strip https:// scheme from stored origins and auth_host for editing
|
||||
const origins = (config.origins || []).map(o => o.replace(/^https:\/\//, ''))
|
||||
const auth_host = (config.auth_host || '').replace(/^https:\/\//, '')
|
||||
openDialog('server-config', {
|
||||
rp_name: config.rp_name || '',
|
||||
auth_host,
|
||||
origins,
|
||||
originValidation: origins.map(() => null),
|
||||
})
|
||||
} catch (e) {
|
||||
authStore.showMessage(e.message || 'Failed to load server configuration', 'error')
|
||||
}
|
||||
}
|
||||
|
||||
function deleteOidcClient(client) {
|
||||
openDialog('confirm', {
|
||||
message: `Delete OIDC client "${client.name}"? This will break any applications using this client.`,
|
||||
@@ -732,10 +736,7 @@ async function refreshUserDetail() {
|
||||
}
|
||||
}
|
||||
|
||||
async function onUserNameSaved() {
|
||||
await refreshUserDetail()
|
||||
authStore.showMessage('User renamed', 'success', 1500)
|
||||
}
|
||||
|
||||
|
||||
async function submitDialog() {
|
||||
if (!dialog.value.type || dialog.value.busy) return
|
||||
@@ -792,7 +793,7 @@ async function submitDialog() {
|
||||
apiJson(`/auth/api/admin/roles/${role.uuid}`, { method: 'PATCH', body: { display_name: name } })
|
||||
.then(() => {
|
||||
authStore.showMessage(`Role renamed to "${name}".`, 'success', 2500)
|
||||
loadOrgs()
|
||||
loadAdminData()
|
||||
})
|
||||
.catch(e => {
|
||||
authStore.showMessage(e.message || 'Failed to update role', 'error')
|
||||
@@ -820,7 +821,7 @@ async function submitDialog() {
|
||||
apiJson(`/auth/api/admin/users/${user.uuid}/info`, { method: 'PATCH', body: { display_name: name } })
|
||||
.then(() => {
|
||||
authStore.showMessage(`User renamed to "${name}".`, 'success', 2500)
|
||||
onUserNameSaved()
|
||||
refreshUserDetail()
|
||||
})
|
||||
.catch(e => {
|
||||
authStore.showMessage(e.message || 'Failed to update user name', 'error')
|
||||
@@ -899,6 +900,27 @@ async function submitDialog() {
|
||||
authStore.showMessage(e.message || `Failed to ${isNew ? 'create' : 'update'} OIDC client`, 'error')
|
||||
})
|
||||
return // Don't call closeDialog() again
|
||||
} else if (t === 'server-config') {
|
||||
const rp_name = dialog.value.data.rp_name?.trim() || ''
|
||||
const auth_host = dialog.value.data.auth_host?.trim() || ''
|
||||
// Origins are stored as-is (hostnames); backend normalizes with https://
|
||||
const origins = dialog.value.data.origins
|
||||
.map(o => o.trim())
|
||||
.filter(o => o)
|
||||
|
||||
closeDialog()
|
||||
apiJson('/auth/api/admin/server-config', { method: 'PATCH', body: { rp_name, auth_host, origins } })
|
||||
.then(() => {
|
||||
authStore.showMessage('Server configuration updated.', 'success', 2500)
|
||||
// Reload settings to reflect rp_name changes
|
||||
authStore.loadSettings().then(() => {
|
||||
if (authStore.settings?.rp_name) document.title = authStore.settings.rp_name + ' Admin'
|
||||
})
|
||||
})
|
||||
.catch(e => {
|
||||
authStore.showMessage(e.message || 'Failed to update server configuration', 'error')
|
||||
})
|
||||
return // Don't call closeDialog() again
|
||||
} else if (t === 'confirm') {
|
||||
const action = dialog.value.data.action
|
||||
// Close dialog first, then perform action (errors shown via showMessage)
|
||||
@@ -964,6 +986,7 @@ async function submitDialog() {
|
||||
@create-oidc-client="createOidcClient"
|
||||
@open-oidc-client="openOidcClient"
|
||||
@delete-oidc-client="deleteOidcClient"
|
||||
@open-server-config="openServerConfig"
|
||||
@navigate-out="handlePanelNavigateOut"
|
||||
/>
|
||||
|
||||
@@ -977,9 +1000,7 @@ async function submitDialog() {
|
||||
:show-reg-modal="showRegModal"
|
||||
:navigation-disabled="hasActiveModal"
|
||||
@generate-user-registration-link="generateUserRegistrationLink"
|
||||
@go-overview="goOverview"
|
||||
@open-org="openOrg"
|
||||
@on-user-name-saved="onUserNameSaved"
|
||||
@refresh-user-detail="refreshUserDetail"
|
||||
@edit-user-name="editUserName"
|
||||
@close-reg-modal="showRegModal = false"
|
||||
@@ -999,10 +1020,8 @@ async function submitDialog() {
|
||||
@create-user-in-role="createUserInRole"
|
||||
@open-user="openUser"
|
||||
@toggle-role-permission="toggleRolePermission"
|
||||
@on-role-drag-over="onRoleDragOver"
|
||||
@move-user-to-role="moveUserToRoleFromDrag"
|
||||
@navigate-out="handlePanelNavigateOut"
|
||||
@on-role-drop="onRoleDrop"
|
||||
@on-user-drag-start="onUserDragStart"
|
||||
/>
|
||||
|
||||
<AdminOidcDetail
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<script>{let t=localStorage.getItem('paskia-theme');if(t!=='light'&&t!=='dark')t=new URLSearchParams(location.hash.slice(1)).get('theme');(t==='dark'||t!=='light'&&matchMedia('(prefers-color-scheme:dark)').matches)&&document.documentElement.classList.add('dark');if(window.location.pathname==='/auth/restricted/iframe')document.documentElement.style.background='transparent'}</script>
|
||||
<script>{let t=localStorage.getItem('paskia-theme');if(!t){let p=new URLSearchParams(location.hash.slice(1)).get('theme');if(p==='light'||p==='dark')t=p}(t==='dark'||t!=='light'&&matchMedia('(prefers-color-scheme:dark)').matches)&&document.documentElement.classList.add('dark');if(window.location.pathname==='/auth/restricted/iframe')document.documentElement.style.background='transparent'}</script>
|
||||
<link rel="stylesheet" href="/src/assets/style.css">
|
||||
</head>
|
||||
<body>
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
// Early theme for restricted app - first URL param wins, then localStorage
|
||||
// Early theme for restricted app - user preference (localStorage) wins, then URL param
|
||||
import { applyTheme, getCachedTheme } from '@/utils/theme.js'
|
||||
|
||||
function getTheme() {
|
||||
const params = new URLSearchParams(location.hash.slice(1))
|
||||
return params.get('theme') || getCachedTheme() || ''
|
||||
return getCachedTheme() || params.get('theme') || ''
|
||||
}
|
||||
|
||||
// Apply theme class to document root
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
|
||||
<main class="view-root">
|
||||
<div class="surface surface--tight reset-container">
|
||||
<header class="view-header reset-header">
|
||||
<header class="view-header center">
|
||||
<h1>🔑 Registration</h1>
|
||||
<p class="view-lede">
|
||||
{{ subtitleMessage }}
|
||||
@@ -59,7 +59,8 @@
|
||||
import { computed, onMounted, reactive, ref } from 'vue'
|
||||
import passkey from '@/utils/passkey'
|
||||
import { getSettings, uiBasePath } from '@/utils/settings'
|
||||
import { apiJson, ApiError, getUserFriendlyErrorMessage } from 'paskia'
|
||||
import { apiJson, ApiError, getUserFriendlyErrorMessage, settings as paskiaSettings } from 'paskia'
|
||||
import { updateThemeFromSession } from '@/utils/theme'
|
||||
|
||||
const status = reactive({
|
||||
show: false,
|
||||
@@ -80,7 +81,7 @@ const sessionDescriptor = computed(() => tokenInfo.value?.token_type || 'your en
|
||||
const subtitleMessage = computed(() => {
|
||||
if (initializing.value) return 'Preparing your secure enrollment…'
|
||||
if (!canRegister.value) return 'This authentication link is no longer valid.'
|
||||
return `Finish up ${sessionDescriptor.value}. You may edit the name below if needed, and it will be saved to your passkey.`
|
||||
return `Finish up ${sessionDescriptor.value}. The name entered will be stored on your passkey and on our system.`
|
||||
})
|
||||
|
||||
const basePath = computed(() => uiBasePath())
|
||||
@@ -117,6 +118,7 @@ async function fetchTokenInfo() {
|
||||
headers: { 'Authorization': `Bearer ${token.value}` },
|
||||
})
|
||||
displayName.value = tokenInfo.value.display_name
|
||||
if (tokenInfo.value.theme) updateThemeFromSession({ user: { theme: tokenInfo.value.theme } })
|
||||
} catch (error) {
|
||||
console.error('Failed to load token info', error)
|
||||
const message = error instanceof ApiError
|
||||
@@ -162,7 +164,8 @@ async function exchangeCode(result) {
|
||||
}
|
||||
return await apiJson('/auth/api/set-session', {
|
||||
method: 'POST',
|
||||
headers: { 'Authorization': `Bearer ${result.exchange_code}` }
|
||||
headers: { 'Authorization': `Bearer ${result.exchange_code}` },
|
||||
timeout: paskiaSettings.auth_ms,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -201,14 +204,14 @@ onMounted(async () => {
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
main.view-root { min-height: 100vh; align-items: center; justify-content: center; padding: 2rem 1rem; }
|
||||
.reset-container {
|
||||
max-width: 560px;
|
||||
max-width: 520px;
|
||||
margin: 0 auto;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.reset-header {
|
||||
text-align: center;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.75rem;
|
||||
}
|
||||
|
||||
.section-body {
|
||||
|
||||
@@ -15,7 +15,8 @@
|
||||
"qrcode": "^1.5.4",
|
||||
"sirv": "^3.0.2",
|
||||
"uuidv7": "^1.1.0",
|
||||
"vue": "^3.5.17"
|
||||
"vue": "^3.5.17",
|
||||
"vuedraggable": "^4.1.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vitejs/plugin-vue": "^6.0.0",
|
||||
|
||||
@@ -17,6 +17,21 @@ const NO_SUBMIT_TYPES = new Set([])
|
||||
const rpId = computed(() => props.settings?.rp_id || 'the configured domain')
|
||||
const discoveryUrl = computed(() => `${window.location.origin}/.well-known/openid-configuration`)
|
||||
|
||||
// Initialize validation properties
|
||||
if (props.dialog?.data && props.dialog.type === 'server-config') {
|
||||
if (!('authHostValidation' in props.dialog.data)) {
|
||||
props.dialog.data.authHostValidation = null
|
||||
}
|
||||
}
|
||||
|
||||
const isValidationInvalid = computed(() => {
|
||||
if (props.dialog?.type !== 'server-config') return false
|
||||
const d = props.dialog.data
|
||||
if (d.authHostValidation?.startsWith('invalid') || d.authHostValidation === 'validating') return true
|
||||
if (d.originValidation?.some(v => v === 'invalid' || v === 'validating')) return true
|
||||
return false
|
||||
})
|
||||
|
||||
// Copy-to-clipboard helper
|
||||
const authStore = useAuthStore()
|
||||
function copyText(value, label) {
|
||||
@@ -24,6 +39,135 @@ function copyText(value, label) {
|
||||
authStore.showMessage(`${label} copied to clipboard`, 'success', 1500)
|
||||
})
|
||||
}
|
||||
|
||||
function addOrigin() {
|
||||
const d = props.dialog?.data
|
||||
if (d) {
|
||||
d.origins.push(rpId.value)
|
||||
d.originValidation.push(null)
|
||||
validateOrigin(d.origins[d.origins.length - 1], d.origins.length - 1)
|
||||
}
|
||||
}
|
||||
function removeOrigin(i) {
|
||||
const d = props.dialog?.data
|
||||
if (d) {
|
||||
d.origins.splice(i, 1)
|
||||
d.originValidation.splice(i, 1)
|
||||
}
|
||||
}
|
||||
function stripScheme(val, i) {
|
||||
const d = props.dialog?.data
|
||||
if (d) d.origins[i] = val.replace(/^https:\/\//, '').replace(/\/+$/, '')
|
||||
}
|
||||
function stripSchemeAuthHost() {
|
||||
const d = props.dialog?.data
|
||||
if (d && d.auth_host) d.auth_host = d.auth_host.replace(/^https:\/\//, '').replace(/\/+$/, '')
|
||||
}
|
||||
function focusOriginStart(e) {
|
||||
e.target.setSelectionRange(0, 0)
|
||||
}
|
||||
|
||||
function validateOriginDomain(origin, rpId) {
|
||||
if (!origin.trim()) return false
|
||||
try {
|
||||
const url = origin.startsWith('http') ? new URL(origin) : new URL('https://' + origin)
|
||||
const hostname = url.hostname
|
||||
return hostname === rpId || hostname.endsWith('.' + rpId)
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
async function validateOriginConnectivity(origin, i) {
|
||||
const d = props.dialog?.data
|
||||
if (!d) return
|
||||
|
||||
d.originValidation[i] = 'validating'
|
||||
try {
|
||||
const cleanOrigin = origin.replace(/\/+$/, '')
|
||||
const testUrl = cleanOrigin.startsWith('http') ? cleanOrigin : 'https://' + cleanOrigin
|
||||
const response = await fetch(testUrl + '/auth/api/settings', {
|
||||
method: 'GET',
|
||||
headers: { 'Accept': 'application/json' }
|
||||
})
|
||||
if (response.ok) {
|
||||
const data = await response.json()
|
||||
// Check if it returns valid settings (has rp_id and matches current rp_id)
|
||||
const result = (data.rp_id && data.rp_id === rpId.value) ? 'valid' : 'invalid'
|
||||
// Only update if the origin hasn't changed
|
||||
if (d.origins[i] === origin) {
|
||||
d.originValidation[i] = result
|
||||
}
|
||||
} else {
|
||||
if (d.origins[i] === origin) {
|
||||
d.originValidation[i] = 'invalid'
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
if (d.origins[i] === origin) {
|
||||
d.originValidation[i] = 'invalid'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function validateOrigin(origin, i) {
|
||||
const d = props.dialog?.data
|
||||
if (!d) return
|
||||
|
||||
const id = rpId.value
|
||||
if (validateOriginDomain(origin, id)) {
|
||||
validateOriginConnectivity(origin, i)
|
||||
} else {
|
||||
d.originValidation[i] = 'invalid'
|
||||
}
|
||||
}
|
||||
|
||||
async function validateAuthHostConnectivity(authHost) {
|
||||
const d = props.dialog?.data
|
||||
if (!d) return
|
||||
|
||||
d.authHostValidation = 'validating'
|
||||
try {
|
||||
const cleanAuthHost = authHost.replace(/\/+$/, '')
|
||||
const testUrl = cleanAuthHost.startsWith('http') ? cleanAuthHost : 'https://' + cleanAuthHost
|
||||
const response = await fetch(testUrl + '/auth/api/settings', {
|
||||
method: 'GET',
|
||||
headers: { 'Accept': 'application/json' }
|
||||
})
|
||||
if (response.ok) {
|
||||
const data = await response.json()
|
||||
// Check if it returns valid settings (has rp_id and matches current rp_id)
|
||||
const result = (data.rp_id && data.rp_id === rpId.value) ? 'valid' : 'invalid'
|
||||
// Only update if the auth_host hasn't changed
|
||||
if (d.auth_host === authHost) {
|
||||
d.authHostValidation = result
|
||||
}
|
||||
} else {
|
||||
if (d.auth_host === authHost) {
|
||||
d.authHostValidation = 'invalid-connectivity'
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
if (d.auth_host === authHost) {
|
||||
d.authHostValidation = 'invalid-connectivity'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function validateAuthHost() {
|
||||
const d = props.dialog?.data
|
||||
if (!d || !d.auth_host?.trim()) {
|
||||
d.authHostValidation = null // Allow empty
|
||||
return
|
||||
}
|
||||
|
||||
const id = rpId.value
|
||||
if (validateOriginDomain(d.auth_host, id)) {
|
||||
validateAuthHostConnectivity(d.auth_host)
|
||||
} else {
|
||||
d.authHostValidation = 'invalid-domain'
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -37,6 +181,7 @@ function copyText(value, label) {
|
||||
<template v-else-if="dialog.type==='user-update-name'">Edit User Name</template>
|
||||
<template v-else-if="dialog.type==='perm-create' || dialog.type==='perm-display'">{{ dialog.type === 'perm-create' ? 'Create Permission' : 'Edit Permission' }}</template>
|
||||
<template v-else-if="dialog.type==='oidc-edit'">{{ dialog.data?.isNew ? 'New OIDC Client' : 'OIDC Client' }}</template>
|
||||
<template v-else-if="dialog.type==='server-config'">Server Options</template>
|
||||
<template v-else-if="dialog.type==='confirm'">Confirm</template>
|
||||
</h3>
|
||||
<form @submit.prevent="$emit('submitDialog')" class="modal-form">
|
||||
@@ -96,6 +241,38 @@ function copyText(value, label) {
|
||||
</label>
|
||||
<p class="small muted">A domain ({{ rpId }} or subdomain) restricts this permission to that host. An OIDC client UUID sends it as a <em>groups</em> claim to that client.</p>
|
||||
</template>
|
||||
<template v-else-if="dialog.type==='server-config'">
|
||||
<label>Site Branding (rp-name)
|
||||
<input v-model="dialog.data.rp_name" :placeholder="rpId" />
|
||||
</label>
|
||||
<label>Dedicated Authentication Site (auth-host)
|
||||
<input v-model="dialog.data.auth_host" @input="validateAuthHost()" :class="{ 'input-error': dialog.data.authHostValidation?.startsWith('invalid') }" />
|
||||
</label>
|
||||
<p v-if="dialog.data.authHostValidation === 'validating'" class="small muted">Validating...</p>
|
||||
<p v-else-if="dialog.data.authHostValidation === 'valid'" class="small muted">Valid</p>
|
||||
<p v-else-if="dialog.data.authHostValidation === 'invalid-domain'" class="small muted">Invalid domain</p>
|
||||
<p v-else-if="dialog.data.authHostValidation === 'invalid-connectivity'" class="small muted">Well-formed but unreachable</p>
|
||||
<p v-else-if="dialog.data.authHostValidation === 'invalid'" class="small muted">Invalid configuration</p>
|
||||
<p v-else-if="dialog.data.authHostValidation === 'invalid'" class="small muted">Enter {{ rpId }} or any subdomain of it.</p>
|
||||
<div class="origin-label">
|
||||
Allowed Origins
|
||||
<button type="button" class="icon-btn origin-add-btn" @click="addOrigin" aria-label="Add origin" title="Add origin">➕</button>
|
||||
</div>
|
||||
<div v-if="dialog.data.origins.length" class="origin-list">
|
||||
<div v-for="(_, i) in dialog.data.origins" :key="i" class="origin-row">
|
||||
<input
|
||||
:value="dialog.data.origins[i]"
|
||||
@input="e => { dialog.data.origins[i] = e.target.value; validateOrigin(e.target.value, i) }"
|
||||
@focus="focusOriginStart"
|
||||
class="origin-input"
|
||||
:class="{ 'input-error': dialog.data.originValidation[i] === 'invalid' }"
|
||||
/>
|
||||
<button type="button" class="icon-btn delete-icon" @click="removeOrigin(i)" aria-label="Remove origin" title="Remove origin">❌</button>
|
||||
</div>
|
||||
</div>
|
||||
<p v-if="!dialog.data.origins.length" class="small muted">{{ rpId }} and all subdomains allowed.</p>
|
||||
<p v-else class="small muted">Only the above sites are allowed to authenticate.</p>
|
||||
</template>
|
||||
<template v-else-if="dialog.type==='confirm'">
|
||||
<p>{{ dialog.data.message }}</p>
|
||||
</template>
|
||||
@@ -112,7 +289,7 @@ function copyText(value, label) {
|
||||
<button
|
||||
type="submit"
|
||||
class="btn-primary"
|
||||
:disabled="dialog.busy"
|
||||
:disabled="dialog.busy || isValidationInvalid"
|
||||
>
|
||||
{{ dialog.type==='confirm' ? 'OK' : 'Save' }}
|
||||
</button>
|
||||
@@ -141,4 +318,17 @@ function copyText(value, label) {
|
||||
.oidc-groups { cursor: default; }
|
||||
.oidc-group { cursor: pointer; }
|
||||
.oidc-group output { white-space: normal; word-break: break-all; }
|
||||
|
||||
/* Server config origins */
|
||||
.origin-label { font-weight: 600; font-size: 0.95rem; margin-top: var(--space-sm); display: flex; align-items: center; gap: var(--space-sm); }
|
||||
.origin-list { display: flex; flex-direction: column; gap: 0.4rem; }
|
||||
.origin-row { display: flex; align-items: center; gap: var(--space-xs); }
|
||||
.origin-input { flex: 1; min-width: 8rem; font-family: var(--font-mono, monospace); }
|
||||
.origin-row .delete-icon { flex-shrink: 0; }
|
||||
.origin-add-btn { font-size: 1.2rem; }
|
||||
|
||||
.input-error {
|
||||
border-color: var(--color-error);
|
||||
background: var(--color-error-bg, rgba(239, 68, 68, 0.05));
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
<script setup>
|
||||
import { computed, ref } from 'vue'
|
||||
import draggable from 'vuedraggable'
|
||||
import ProfilePicture from '@/components/ProfilePicture.vue'
|
||||
import { getDirection, navigateButtonRow, focusPreferred } from '@/utils/keynav'
|
||||
|
||||
const props = defineProps({
|
||||
@@ -8,7 +10,7 @@ const props = defineProps({
|
||||
navigationDisabled: { type: Boolean, default: false }
|
||||
})
|
||||
|
||||
const emit = defineEmits(['updateOrg', 'createRole', 'updateRole', 'deleteRole', 'createUserInRole', 'openUser', 'toggleRolePermission', 'onRoleDragOver', 'onRoleDrop', 'onUserDragStart', 'navigateOut'])
|
||||
const emit = defineEmits(['updateOrg', 'createRole', 'updateRole', 'deleteRole', 'createUserInRole', 'openUser', 'toggleRolePermission', 'moveUserToRole', 'navigateOut'])
|
||||
|
||||
// Template refs for navigation
|
||||
const orgTitleRef = ref(null)
|
||||
@@ -35,14 +37,26 @@ const orgPermissions = computed(() => {
|
||||
})
|
||||
|
||||
// Get users for a role as sorted array of { uuid, ...user }
|
||||
function getNormalizedName(name) {
|
||||
let cleaned = name.replace(/\([^)]*\)/g, '').trim();
|
||||
if (cleaned.includes(',')) {
|
||||
return cleaned.toLowerCase();
|
||||
} else {
|
||||
const parts = cleaned.split(/\s+/);
|
||||
const last = parts.pop();
|
||||
const first = parts.join(' ');
|
||||
return `${last}, ${first}`.toLowerCase();
|
||||
}
|
||||
}
|
||||
|
||||
function roleUsers(roleUuid) {
|
||||
return Object.entries(props.selectedOrg.users)
|
||||
.filter(([_, u]) => u.role === roleUuid)
|
||||
.map(([uuid, u]) => ({ uuid, ...u }))
|
||||
.sort((a, b) => {
|
||||
const nameA = a.display_name.toLowerCase()
|
||||
const nameB = b.display_name.toLowerCase()
|
||||
return nameA.localeCompare(nameB)
|
||||
const normA = getNormalizedName(a.display_name);
|
||||
const normB = getNormalizedName(b.display_name);
|
||||
return normA.localeCompare(normB);
|
||||
})
|
||||
}
|
||||
|
||||
@@ -50,8 +64,12 @@ function roleUserCount(roleUuid) {
|
||||
return Object.values(props.selectedOrg.users).filter(u => u.role === roleUuid).length
|
||||
}
|
||||
|
||||
function permissionDisplayName(scope) {
|
||||
return props.permissions.find(p => p.scope === scope)?.display_name || scope
|
||||
function onUserChange(evt, targetRoleUuid) {
|
||||
// Only handle 'added' events (when a user is dropped into this role)
|
||||
if (evt.added) {
|
||||
const userUuid = evt.added.element.uuid
|
||||
emit('moveUserToRole', userUuid, targetRoleUuid)
|
||||
}
|
||||
}
|
||||
|
||||
function toggleRolePermission(role, pid, checked) {
|
||||
@@ -350,8 +368,6 @@ defineExpose({ focusFirstElement })
|
||||
v-for="(r, roleIndex) in sortedRoles"
|
||||
:key="r.uuid"
|
||||
class="role-column"
|
||||
@dragover="$emit('onRoleDragOver', $event)"
|
||||
@drop="e => $emit('onRoleDrop', e, selectedOrg, r)"
|
||||
>
|
||||
<div class="role-header" @keydown="e => handleRoleHeaderKeydown(e, roleIndex)">
|
||||
<strong class="role-name" :title="r.uuid">
|
||||
@@ -363,26 +379,43 @@ defineExpose({ focusFirstElement })
|
||||
<button @click="$emit('createUserInRole', selectedOrg, r)" class="plus-btn" aria-label="Add user" title="Add user">➕</button>
|
||||
</div>
|
||||
</div>
|
||||
<template v-if="roleUserCount(r.uuid) > 0">
|
||||
<ul class="user-list" @keydown="handleUserListKeydown">
|
||||
<li
|
||||
v-for="u in roleUsers(r.uuid)"
|
||||
:key="u.uuid"
|
||||
class="user-chip"
|
||||
tabindex="0"
|
||||
draggable="true"
|
||||
@dragstart="e => $emit('onUserDragStart', e, u.uuid, selectedOrg.uuid)"
|
||||
@click="$emit('openUser', u)"
|
||||
@keydown.enter="$emit('openUser', u)"
|
||||
:title="u.uuid"
|
||||
>
|
||||
<span class="name">{{ u.display_name }}</span>
|
||||
<span class="meta">{{ u.last_seen ? new Date(u.last_seen).toLocaleDateString() : '—' }}</span>
|
||||
</li>
|
||||
</ul>
|
||||
</template>
|
||||
<div v-else class="empty-role">
|
||||
<p class="empty-text muted">No members</p>
|
||||
<div class="user-list-wrapper">
|
||||
<draggable
|
||||
:list="roleUsers(r.uuid)"
|
||||
group="users"
|
||||
item-key="uuid"
|
||||
tag="ul"
|
||||
class="user-list"
|
||||
@change="evt => onUserChange(evt, r.uuid)"
|
||||
@keydown="handleUserListKeydown"
|
||||
>
|
||||
<template #item="{ element: u }">
|
||||
<li
|
||||
class="user-chip"
|
||||
tabindex="0"
|
||||
@click="$emit('openUser', u)"
|
||||
@keydown.enter="$emit('openUser', u)"
|
||||
:title="u.uuid"
|
||||
>
|
||||
<ProfilePicture
|
||||
class="user-chip-picture"
|
||||
:src="u.avatar_url"
|
||||
:title="u.display_name"
|
||||
width="3.25rem"
|
||||
height="100%"
|
||||
radius="0"
|
||||
fallback-size="1.3rem"
|
||||
/>
|
||||
<span class="user-chip-body">
|
||||
<span class="name">{{ u.display_name }}</span>
|
||||
<span class="meta">{{ u.last_seen ? new Date(u.last_seen).toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' }) : '—' }}</span>
|
||||
</span>
|
||||
</li>
|
||||
</template>
|
||||
</draggable>
|
||||
<div v-if="roleUserCount(r.uuid) === 0" class="empty-role">
|
||||
<p class="empty-text muted">No members</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -391,23 +424,30 @@ defineExpose({ focusFirstElement })
|
||||
|
||||
<style scoped>
|
||||
.card.surface { padding: var(--space-lg); }
|
||||
.org-title { display: flex; align-items: center; gap: var(--space-sm); margin-bottom: var(--space-lg); }
|
||||
.org-name { font-size: 1.5rem; font-weight: 600; color: var(--color-heading); }
|
||||
.org-title { display: flex; align-items: center; gap: var(--space-sm); margin-bottom: var(--space-lg); font-size: 1.65rem; }
|
||||
.org-name { font-weight: 600; color: var(--color-heading); }
|
||||
.perm-matrix-grid .role-head { display: flex; align-items: flex-end; justify-content: center; }
|
||||
.perm-matrix-grid .role-head span { writing-mode: vertical-rl; transform: rotate(180deg); font-size: 0.65rem; }
|
||||
.perm-matrix-grid .add-role-head { cursor: pointer; }
|
||||
.roles-grid { display: flex; gap: var(--space-lg); margin-top: var(--space-lg); }
|
||||
.role-column { flex: 1; min-width: 200px; border-radius: var(--radius-md); padding: var(--space-md); }
|
||||
.roles-grid { display: flex; flex-wrap: wrap; gap: 0; margin-top: var(--space-lg); justify-content: flex-start; align-items: stretch; }
|
||||
.role-column { flex: 0 0 17em; border-radius: var(--radius-md); padding: var(--space-md); display: flex; flex-direction: column; }
|
||||
.role-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: var(--space-md); }
|
||||
.role-name { display: flex; align-items: center; gap: var(--space-xs); font-size: 1.1rem; color: var(--color-heading); }
|
||||
.role-actions { display: flex; gap: var(--space-xs); }
|
||||
.plus-btn { background: none; color: var(--color-accent); border: none; border-radius: var(--radius-sm); padding: 0.25rem 0.45rem; font-size: 1.1rem; cursor: pointer; }
|
||||
.plus-btn:hover { background: rgba(37, 99, 235, 0.18); }
|
||||
.user-list { list-style: none; padding: 0; margin: 0; display: flex; flex-direction: column; gap: var(--space-xs); }
|
||||
.user-chip { background: var(--color-surface); border: 1px solid var(--color-border); border-radius: var(--radius-md); padding: 0.45rem 0.6rem; display: flex; justify-content: space-between; gap: var(--space-sm); cursor: grab; }
|
||||
.user-list-wrapper { position: relative; flex: 1; display: flex; flex-direction: column; min-height: 5.5rem; }
|
||||
.user-list { list-style: none; padding: 0; margin: 0; display: flex; flex-direction: column; gap: var(--space-xs); flex: 1; }
|
||||
.user-chip { background: var(--color-accent-strong); color: var(--color-accent-contrast); border: none; border-radius: var(--radius-md); padding: 0; display: grid; grid-template-columns: 3.25rem minmax(0, 1fr); align-items: stretch; gap: 0; cursor: grab; overflow: hidden; min-height: 3.25rem; }
|
||||
.user-chip:focus { outline: 2px solid var(--color-accent); outline-offset: 1px; }
|
||||
.user-chip .meta { font-size: 0.7rem; color: var(--color-text-muted); }
|
||||
.empty-role { border: 1px dashed var(--color-border-strong); border-radius: var(--radius-md); padding: var(--space-sm); display: flex; flex-direction: column; gap: var(--space-xs); align-items: flex-start; }
|
||||
.user-chip-picture { align-self: stretch; }
|
||||
.user-chip-body { display: flex; min-width: 0; flex-direction: column; justify-content: center; gap: 0.1rem; padding: 0.45rem 0.6rem; }
|
||||
.user-chip .name { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.user-chip .meta { font-size: 0.7rem; opacity: 0.85; }
|
||||
.user-chip.sortable-ghost { opacity: 0.5; }
|
||||
.user-chip.sortable-chosen { box-shadow: 0 4px 12px rgba(0, 0, 0, 0.2); }
|
||||
.empty-role { position: absolute; inset: 0; border: 1px dashed var(--color-border-strong); border-radius: var(--radius-md); display: flex; align-items: center; justify-content: center; pointer-events: none; }
|
||||
.user-list:has(.sortable-ghost) + .empty-role { display: none; }
|
||||
.empty-text { margin: 0; }
|
||||
|
||||
@media (max-width: 720px) {
|
||||
|
||||
@@ -12,17 +12,14 @@ const props = defineProps({
|
||||
navigationDisabled: { type: Boolean, default: false }
|
||||
})
|
||||
|
||||
const emit = defineEmits(['createOrg', 'openOrg', 'updateOrg', 'deleteOrg', 'toggleOrgPermission', 'openDialog', 'deletePermission', 'renamePermissionDisplay', 'createOidcClient', 'openOidcClient', 'deleteOidcClient', 'navigateOut'])
|
||||
const emit = defineEmits(['createOrg', 'openOrg', 'updateOrg', 'deleteOrg', 'toggleOrgPermission', 'openDialog', 'deletePermission', 'renamePermissionDisplay', 'createOidcClient', 'openOidcClient', 'deleteOidcClient', 'openServerConfig', 'navigateOut'])
|
||||
|
||||
// Template refs for navigation
|
||||
const orgSection = ref(null)
|
||||
const orgActionsRef = ref(null)
|
||||
const orgTableRef = ref(null)
|
||||
const permMatrixRef = ref(null)
|
||||
const permActionsRef = ref(null)
|
||||
const permTableRef = ref(null)
|
||||
const oidcActionsRef = ref(null)
|
||||
const oidcTableRef = ref(null)
|
||||
|
||||
const sortedOrgs = computed(() => [...props.orgs].sort((a,b)=> {
|
||||
const nameCompare = a.org.display_name.localeCompare(b.org.display_name)
|
||||
@@ -62,10 +59,6 @@ const sortedPermissions = computed(() => [...props.permissions].sort((a,b)=> a.s
|
||||
const isMasterAdmin = computed(() => props.info?.ctx.permissions.includes('auth:admin'))
|
||||
const isOrgAdmin = computed(() => props.info?.ctx.permissions.includes('auth:org:admin'))
|
||||
|
||||
function permissionDisplayName(scope) {
|
||||
return props.permissions.find(p => p.scope === scope)?.display_name || scope
|
||||
}
|
||||
|
||||
function getRoleNames(org) {
|
||||
// org.roles is dict[UUID, Role]
|
||||
return Object.values(org.roles)
|
||||
@@ -431,6 +424,16 @@ defineExpose({ focusFirstElement })
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div v-if="isMasterAdmin" class="server-options-section">
|
||||
<div class="section-header">
|
||||
<h2>Server</h2>
|
||||
<p class="section-description">
|
||||
Configure core server settings such as the display name, authentication host, and allowed origins.
|
||||
</p>
|
||||
</div>
|
||||
<button @click="$emit('openServerConfig')">⚙ Server Options</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
@@ -455,4 +458,8 @@ defineExpose({ focusFirstElement })
|
||||
.oidc-clients-section { margin-bottom: var(--space-xl); margin-top: var(--space-2xl); }
|
||||
.oidc-clients-section .section-header { display: flex; flex-direction: column; gap: 0.4rem; margin-bottom: var(--space-md); }
|
||||
.client-groups { font-size: 0.85rem; color: var(--color-text-muted); max-width: 200px; font-family: var(--font-mono, monospace); }
|
||||
|
||||
/* Server Options Section */
|
||||
.server-options-section { margin-top: var(--space-2xl); }
|
||||
.server-options-section .section-header { display: flex; flex-direction: column; gap: 0.4rem; margin-bottom: var(--space-md); }
|
||||
</style>
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
import { ref, computed } from 'vue'
|
||||
import UserBasicInfo from '@/components/UserBasicInfo.vue'
|
||||
import CredentialList from '@/components/CredentialList.vue'
|
||||
import ProfilePictureEditorModal from '@/components/ProfilePictureEditorModal.vue'
|
||||
import RegistrationLinkModal from '@/components/RegistrationLinkModal.vue'
|
||||
import SessionList from '@/components/SessionList.vue'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
@@ -17,12 +18,14 @@ const props = defineProps({
|
||||
navigationDisabled: { type: Boolean, default: false }
|
||||
})
|
||||
|
||||
const emit = defineEmits(['generateUserRegistrationLink', 'goOverview', 'openOrg', 'onUserNameSaved', 'closeRegModal', 'editUserName', 'refreshUserDetail', 'navigateOut', 'deleteUser'])
|
||||
const emit = defineEmits(['generateUserRegistrationLink', 'openOrg', 'closeRegModal', 'editUserName', 'refreshUserDetail', 'navigateOut', 'deleteUser'])
|
||||
|
||||
const authStore = useAuthStore()
|
||||
const terminatingSessions = ref({})
|
||||
const hoveredCredentialUuid = ref(null)
|
||||
const hoveredSession = ref(null)
|
||||
const showPictureDialog = ref(false)
|
||||
const avatarRenderVersion = ref(0)
|
||||
|
||||
// Convert credentials dict to array with uuid attached as 'credential'
|
||||
const credentials = computed(() =>
|
||||
@@ -48,15 +51,31 @@ function handleEditName() {
|
||||
emit('editUserName', props.selectedUser)
|
||||
}
|
||||
|
||||
function openPictureDialog() {
|
||||
if (!props.userDetail || props.userDetail.error) return
|
||||
showPictureDialog.value = true
|
||||
}
|
||||
|
||||
function closePictureDialog() {
|
||||
showPictureDialog.value = false
|
||||
}
|
||||
|
||||
function handlePictureUpdated() {
|
||||
avatarRenderVersion.value += 1
|
||||
emit('refreshUserDetail')
|
||||
}
|
||||
|
||||
async function handleDelete(credential) {
|
||||
try {
|
||||
const data = await apiJson(`/auth/api/admin/users/${props.selectedUser.uuid}/credentials/${credential.credential}`, { method: 'DELETE' })
|
||||
if (data.status === 'ok') {
|
||||
emit('onUserNameSaved') // Reuse to refresh user detail
|
||||
emit('refreshUserDetail')
|
||||
authStore.showMessage('Passkey removed', 'success', 2500)
|
||||
} else {
|
||||
console.error('Failed to delete credential', data)
|
||||
authStore.showMessage(data.detail || 'Failed to remove passkey', 'error')
|
||||
}
|
||||
} catch (err) {
|
||||
authStore.showMessage(err.message || 'Failed to remove passkey', 'error')
|
||||
console.error('Delete credential error', err)
|
||||
}
|
||||
}
|
||||
@@ -73,7 +92,7 @@ async function handleTerminateSession(session) {
|
||||
location.reload()
|
||||
return
|
||||
}
|
||||
emit('refreshUserDetail') // Refresh without showing rename message
|
||||
emit('refreshUserDetail')
|
||||
authStore.showMessage('Session terminated', 'success', 2500)
|
||||
} else {
|
||||
authStore.showMessage(data.detail || 'Failed to terminate session', 'error')
|
||||
@@ -102,7 +121,7 @@ function handleUserInfoKeydown(event) {
|
||||
event.preventDefault()
|
||||
|
||||
if (direction === 'left' || direction === 'right') {
|
||||
navigateButtonRow(userInfoRef.value, event.target, direction, { itemSelector: '.mini-btn' })
|
||||
navigateButtonRow(userInfoRef.value, event.target, direction, { itemSelector: '.user-picture-btn, .mini-btn' })
|
||||
} else if (direction === 'up') {
|
||||
emit('navigateOut', 'up')
|
||||
} else if (direction === 'down') {
|
||||
@@ -124,7 +143,7 @@ function handleRegActionsKeydown(event) {
|
||||
navigateButtonRow(regActionsRef.value, event.target, direction, { itemSelector: 'button' })
|
||||
} else if (direction === 'up') {
|
||||
// Move to user info edit button
|
||||
focusPreferred(userInfoRef.value, { itemSelector: '.mini-btn' })
|
||||
focusPreferred(userInfoRef.value, { itemSelector: '.user-picture-btn, .mini-btn' })
|
||||
} else if (direction === 'down') {
|
||||
// Move to credential list
|
||||
credentialListRef.value?.$el?.focus()
|
||||
@@ -174,10 +193,22 @@ function handleBackButtonKeydown(event) {
|
||||
|
||||
// Focus helper for external navigation
|
||||
function focusFirstElement() {
|
||||
focusPreferred(userInfoRef.value, { itemSelector: '.mini-btn' })
|
||||
focusPreferred(userInfoRef.value, { itemSelector: '.user-picture-btn, .mini-btn' })
|
||||
}
|
||||
|
||||
defineExpose({ focusFirstElement })
|
||||
|
||||
const currentPictureEndpoint = computed(() => {
|
||||
if (!props.selectedUser?.uuid) return null
|
||||
return `/auth/api/user/${props.selectedUser.uuid}/profile.webp`
|
||||
})
|
||||
|
||||
const adminPictureTitle = computed(() => {
|
||||
const username = props.userDetail?.user?.preferred_username || props.selectedUser?.preferred_username
|
||||
const displayName = props.userDetail?.user?.display_name || props.selectedUser?.display_name
|
||||
const label = username || displayName || 'User'
|
||||
return `Profile Picture for ${label}`
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -186,6 +217,9 @@ defineExpose({ focusFirstElement })
|
||||
<UserBasicInfo
|
||||
v-if="userDetail && !userDetail.error"
|
||||
:name="userDetail.user.display_name || selectedUser.display_name"
|
||||
:avatar-url="userDetail.user.avatar_url"
|
||||
:avatar-render-version="avatarRenderVersion"
|
||||
avatar-clickable
|
||||
:visits="userDetail.user.visits"
|
||||
:created-at="userDetail.user.created_at"
|
||||
:last-seen="userDetail.user.last_seen"
|
||||
@@ -195,7 +229,7 @@ defineExpose({ focusFirstElement })
|
||||
:org-display-name="userDetail.org.display_name"
|
||||
:role-name="userDetail.role.display_name"
|
||||
:update-endpoint="`/auth/api/admin/users/${selectedUser.uuid}/info`"
|
||||
@saved="$emit('onUserNameSaved')"
|
||||
@avatar-click="openPictureDialog"
|
||||
@edit="handleEditName"
|
||||
>
|
||||
<div class="admin-actions">
|
||||
@@ -254,10 +288,19 @@ defineExpose({ focusFirstElement })
|
||||
<RegistrationLinkModal
|
||||
v-if="showRegModal"
|
||||
:endpoint="`/auth/api/admin/users/${selectedUser.uuid}/create-link`"
|
||||
:user-name="userDetail?.display_name || selectedUser.display_name"
|
||||
:user-name="userDetail?.user?.display_name || selectedUser.display_name"
|
||||
@close="$emit('closeRegModal')"
|
||||
@copied="onLinkCopied"
|
||||
/>
|
||||
<ProfilePictureEditorModal
|
||||
v-if="showPictureDialog && currentPictureEndpoint"
|
||||
:endpoint="currentPictureEndpoint"
|
||||
:picture-url="userDetail?.user?.avatar_url"
|
||||
:render-version="avatarRenderVersion"
|
||||
:title="adminPictureTitle"
|
||||
@close="closePictureDialog"
|
||||
@updated="handlePictureUpdated"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
<?xml version="1.0"?>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="340" height="340">
|
||||
<path fill="#DDD" d="m169,.5a169,169 0 1,0 2,0zm0,86a76,76 0 1
|
||||
1-2,0zM57,287q27-35 67-35h92q40,0 67,35a164,164 0 0,1-226,0"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 220 B |
@@ -9,8 +9,8 @@
|
||||
--font-sans: "Inter", "Inter var", "Segoe UI", system-ui, -apple-system, "Helvetica Neue", sans-serif;
|
||||
--font-mono: "DM Mono", "JetBrains Mono", "SFMono-Regular", Menlo, Monaco, Consolas, "Liberation Mono", monospace;
|
||||
--color-canvas: white;
|
||||
--color-surface: white;
|
||||
--color-surface-subtle: white;
|
||||
--color-surface: #def;
|
||||
--color-surface-subtle: #bcf;
|
||||
--color-surface-hover: oklab(0.97 -0.01 -0.02);
|
||||
--color-dialog: oklab(0.96 -0.01 -0.03);
|
||||
--color-border: oklab(0.82 -0.02 -0.06);
|
||||
@@ -21,7 +21,7 @@
|
||||
--color-link: oklab(0.5 -0.06 -0.17);
|
||||
--color-link-hover: oklab(0.45 -0.06 -0.19);
|
||||
--color-accent: oklab(0.55 -0.06 -0.19);
|
||||
--color-accent-strong: oklab(0.45 -0.06 -0.19);
|
||||
--color-accent-strong: #46f;
|
||||
--color-accent-contrast: white;
|
||||
--color-secondary: oklab(0.55 -0.02 -0.05);
|
||||
--color-secondary-strong: oklab(0.45 -0.02 -0.05);
|
||||
@@ -823,9 +823,6 @@ th {
|
||||
|
||||
.user-info {
|
||||
display: grid;
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--color-surface);
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.user-details {
|
||||
|
||||
@@ -10,9 +10,10 @@
|
||||
<UserBasicInfo
|
||||
v-if="ctx"
|
||||
:name="ctx.user.display_name"
|
||||
:visits="authStore.userInfo?.visits || 0"
|
||||
:created-at="authStore.userInfo?.created_at"
|
||||
:last-seen="authStore.userInfo?.last_seen"
|
||||
:avatar-url="authStore.userInfo.user.avatar_url"
|
||||
:visits="authStore.userInfo.user.visits"
|
||||
:created-at="authStore.userInfo.user.created_at"
|
||||
:last-seen="authStore.userInfo.user.last_seen"
|
||||
:email="ctx.user.email"
|
||||
:telephone="ctx.user.telephone"
|
||||
:org-display-name="orgDisplayName"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<template>
|
||||
<div class="dialog-overlay" @click="$emit('close')">
|
||||
<div ref="dialog" class="modal-panel" @keydown="handleDialogKeydown" @click.stop>
|
||||
<div ref="dialog" :class="['modal-panel', panelClass]" @keydown="handleDialogKeydown" @click.stop>
|
||||
<slot />
|
||||
</div>
|
||||
</div>
|
||||
@@ -17,7 +17,9 @@ const props = defineProps({
|
||||
// Optional: index to help find next sibling when item is deleted
|
||||
focusIndex: { type: Number, default: -1 },
|
||||
// Optional: selector for finding siblings when restoring focus
|
||||
focusSiblingSelector: { type: String, default: '' }
|
||||
focusSiblingSelector: { type: String, default: '' },
|
||||
// Optional: extra class name(s) for the modal panel
|
||||
panelClass: { type: [String, Array, Object], default: '' }
|
||||
})
|
||||
|
||||
const emit = defineEmits(['close'])
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
<template>
|
||||
<component
|
||||
:is="rootTag"
|
||||
v-bind="rootAttrs"
|
||||
class="profile-picture"
|
||||
:class="{ 'profile-picture-btn': clickable }"
|
||||
:style="pictureStyle"
|
||||
@click="handleClick"
|
||||
>
|
||||
<img
|
||||
v-if="showPicture"
|
||||
:key="`${src || 'none'}:${renderVersion}`"
|
||||
:src="src"
|
||||
alt=""
|
||||
class="profile-picture-image"
|
||||
@error="handleError"
|
||||
/>
|
||||
<img
|
||||
v-else
|
||||
:src="profileGeneric"
|
||||
alt=""
|
||||
class="profile-picture-fallback"
|
||||
/>
|
||||
</component>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import profileGeneric from '@/assets/profile-generic.svg'
|
||||
import { computed, ref, watch } from 'vue'
|
||||
|
||||
const props = defineProps({
|
||||
src: { type: String, default: null },
|
||||
clickable: { type: Boolean, default: false },
|
||||
loading: { type: Boolean, default: false },
|
||||
title: { type: String, default: '' },
|
||||
renderVersion: { type: [Number, String], default: 0 },
|
||||
width: { type: String, default: '3rem' },
|
||||
height: { type: String, default: '3rem' },
|
||||
radius: { type: String, default: '0.9rem' },
|
||||
fit: { type: String, default: 'cover' },
|
||||
filter: { type: String, default: 'none' },
|
||||
fallbackSize: { type: String, default: '2em' }
|
||||
})
|
||||
|
||||
const emit = defineEmits(['click'])
|
||||
const pictureAvailable = ref(true)
|
||||
|
||||
const rootTag = computed(() => (props.clickable ? 'button' : 'div'))
|
||||
const showPicture = computed(() => !!props.src && pictureAvailable.value)
|
||||
const pictureStyle = computed(() => ({
|
||||
'--profile-picture-width': props.width,
|
||||
'--profile-picture-height': props.height,
|
||||
'--profile-picture-radius': props.radius,
|
||||
'--profile-picture-fit': props.fit,
|
||||
'--profile-picture-filter': props.filter,
|
||||
'--profile-picture-fallback-size': props.fallbackSize
|
||||
}))
|
||||
const rootAttrs = computed(() => {
|
||||
if (!props.clickable) return { title: props.title || undefined }
|
||||
return {
|
||||
type: 'button',
|
||||
disabled: props.loading,
|
||||
title: props.title || undefined
|
||||
}
|
||||
})
|
||||
|
||||
watch(() => props.src, () => {
|
||||
pictureAvailable.value = true
|
||||
})
|
||||
|
||||
const handleError = () => {
|
||||
pictureAvailable.value = false
|
||||
}
|
||||
|
||||
const handleClick = () => {
|
||||
if (!props.clickable || props.loading) return
|
||||
emit('click')
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.profile-picture {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: var(--profile-picture-width);
|
||||
height: var(--profile-picture-height);
|
||||
font-size: var(--profile-picture-fallback-size);
|
||||
line-height: 1;
|
||||
overflow: hidden;
|
||||
border-radius: var(--profile-picture-radius);
|
||||
background: transparent;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.profile-picture-btn {
|
||||
padding: 0;
|
||||
border: 0;
|
||||
transition: transform 0.12s ease, box-shadow 0.12s ease;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.profile-picture-btn:hover:not(:disabled) {
|
||||
transform: translateY(-1px);
|
||||
box-shadow: inset 0 0 0 1px var(--color-accent);
|
||||
}
|
||||
|
||||
.profile-picture-btn:disabled {
|
||||
cursor: progress;
|
||||
}
|
||||
|
||||
.profile-picture-image {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: var(--profile-picture-fit);
|
||||
display: block;
|
||||
filter: var(--profile-picture-filter);
|
||||
}
|
||||
|
||||
.profile-picture-fallback {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: contain;
|
||||
display: block;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,489 @@
|
||||
<template>
|
||||
<Modal panel-class="modal-panel--avatar" @close="closeEditor">
|
||||
<h3>{{ title }}</h3>
|
||||
<input
|
||||
ref="pictureInput"
|
||||
type="file"
|
||||
accept="image/*"
|
||||
class="profile-picture-editor-input"
|
||||
:disabled="saving"
|
||||
@change="handlePictureSelected"
|
||||
/>
|
||||
<div ref="picturePreview" class="profile-picture-editor-preview" :style="previewStyle">
|
||||
<img
|
||||
v-if="editorImageUrl && displayMetrics"
|
||||
:src="editorImageUrl"
|
||||
alt=""
|
||||
class="profile-picture-editor-image"
|
||||
:style="editorImageStyle"
|
||||
/>
|
||||
<img
|
||||
v-if="editorImageUrl && displayMetrics"
|
||||
:src="editorImageUrl"
|
||||
alt=""
|
||||
class="profile-picture-editor-image profile-picture-editor-image--overlay"
|
||||
:style="editorOverlayStyle"
|
||||
/>
|
||||
<div
|
||||
v-if="editorImageUrl && displayMetrics"
|
||||
class="profile-picture-editor-crop"
|
||||
:style="cropBoxStyle"
|
||||
@pointerdown="startMove"
|
||||
>
|
||||
<div class="profile-picture-editor-guides" aria-hidden="true">
|
||||
<div class="profile-picture-editor-guide profile-picture-editor-guide--circle"></div>
|
||||
<div class="profile-picture-editor-guide profile-picture-editor-guide--eyes"></div>
|
||||
<div class="profile-picture-editor-guide profile-picture-editor-guide--cheek-left"></div>
|
||||
<div class="profile-picture-editor-guide profile-picture-editor-guide--cheek-right"></div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
class="profile-picture-editor-handle profile-picture-editor-handle--nw"
|
||||
@pointerdown.stop="startResize($event, 'nw')"
|
||||
></button>
|
||||
<button
|
||||
type="button"
|
||||
class="profile-picture-editor-handle profile-picture-editor-handle--ne"
|
||||
@pointerdown.stop="startResize($event, 'ne')"
|
||||
></button>
|
||||
<button
|
||||
type="button"
|
||||
class="profile-picture-editor-handle profile-picture-editor-handle--sw"
|
||||
@pointerdown.stop="startResize($event, 'sw')"
|
||||
></button>
|
||||
<button
|
||||
type="button"
|
||||
class="profile-picture-editor-handle profile-picture-editor-handle--se"
|
||||
@pointerdown.stop="startResize($event, 'se')"
|
||||
></button>
|
||||
</div>
|
||||
<ProfilePicture
|
||||
v-else
|
||||
class="profile-picture-editor-trigger"
|
||||
:src="pictureUrl"
|
||||
:render-version="renderVersion"
|
||||
clickable
|
||||
:loading="saving"
|
||||
title="Choose profile picture"
|
||||
width="100%"
|
||||
height="100%"
|
||||
radius="0"
|
||||
fit="contain"
|
||||
fallback-size="5rem"
|
||||
@click="triggerPictureSelect"
|
||||
/>
|
||||
</div>
|
||||
<div v-if="errorMessage" class="error small">{{ errorMessage }}</div>
|
||||
<div class="modal-actions">
|
||||
<button type="button" class="btn-secondary" :disabled="saving" @click="closeEditor">Back</button>
|
||||
<button
|
||||
v-if="!editorImageUrl && pictureUrl"
|
||||
type="button"
|
||||
class="btn-danger"
|
||||
:disabled="saving"
|
||||
@click="removePicture"
|
||||
>Delete</button>
|
||||
<button
|
||||
v-if="editorImageUrl"
|
||||
type="button"
|
||||
class="btn-primary"
|
||||
:disabled="saving"
|
||||
@click="savePicture"
|
||||
>Save</button>
|
||||
</div>
|
||||
</Modal>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, nextTick, onMounted, onUnmounted, reactive, ref, watch } from 'vue'
|
||||
import { apiJson } from 'paskia'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import Modal from '@/components/Modal.vue'
|
||||
import ProfilePicture from '@/components/ProfilePicture.vue'
|
||||
|
||||
const AVATAR_UPLOAD_SIZE = 720
|
||||
const MIN_CROP_SIZE = 36
|
||||
|
||||
const props = defineProps({
|
||||
endpoint: { type: String, required: true },
|
||||
pictureUrl: { type: String, default: null },
|
||||
renderVersion: { type: [Number, String], default: 0 },
|
||||
title: { type: String, default: 'Profile Picture' }
|
||||
})
|
||||
|
||||
const emit = defineEmits(['close', 'updated'])
|
||||
const authStore = useAuthStore()
|
||||
|
||||
const pictureInput = ref(null)
|
||||
const picturePreview = ref(null)
|
||||
const editorImage = ref(null)
|
||||
const editorImageUrl = ref('')
|
||||
const previewObjectUrl = ref(null)
|
||||
const saving = ref(false)
|
||||
const errorMessage = ref('')
|
||||
const cropRect = reactive({ x: 0, y: 0, size: 0 })
|
||||
const previewRect = reactive({ width: 0, height: 0 })
|
||||
const viewportSize = reactive({ width: 0, height: 0 })
|
||||
let dragState = null
|
||||
let previewObserver = null
|
||||
|
||||
onMounted(async () => {
|
||||
viewportSize.width = window.innerWidth
|
||||
viewportSize.height = window.innerHeight
|
||||
window.addEventListener('pointermove', handlePointerMove)
|
||||
window.addEventListener('pointerup', endPointerInteraction)
|
||||
window.addEventListener('resize', syncPreviewRect)
|
||||
await nextTick()
|
||||
syncPreviewRect()
|
||||
if (picturePreview.value && typeof ResizeObserver !== 'undefined') {
|
||||
previewObserver = new ResizeObserver(() => syncPreviewRect())
|
||||
previewObserver.observe(picturePreview.value)
|
||||
}
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
window.removeEventListener('pointermove', handlePointerMove)
|
||||
window.removeEventListener('pointerup', endPointerInteraction)
|
||||
window.removeEventListener('resize', syncPreviewRect)
|
||||
previewObserver?.disconnect()
|
||||
clearPreviewObjectUrl()
|
||||
})
|
||||
|
||||
watch(editorImage, async (image) => {
|
||||
if (!image) return
|
||||
await nextTick()
|
||||
syncPreviewRect()
|
||||
initializeCrop()
|
||||
})
|
||||
|
||||
const clearPreviewObjectUrl = () => {
|
||||
if (!previewObjectUrl.value) return
|
||||
URL.revokeObjectURL(previewObjectUrl.value)
|
||||
previewObjectUrl.value = null
|
||||
}
|
||||
|
||||
const resetEditor = () => {
|
||||
clearPreviewObjectUrl()
|
||||
editorImage.value = null
|
||||
editorImageUrl.value = ''
|
||||
cropRect.x = 0
|
||||
cropRect.y = 0
|
||||
cropRect.size = 0
|
||||
errorMessage.value = ''
|
||||
if (pictureInput.value) pictureInput.value.value = ''
|
||||
}
|
||||
|
||||
const syncPreviewRect = () => {
|
||||
viewportSize.width = window.innerWidth
|
||||
viewportSize.height = window.innerHeight
|
||||
const element = picturePreview.value
|
||||
if (!element) return
|
||||
previewRect.width = element.clientWidth
|
||||
previewRect.height = element.clientHeight
|
||||
}
|
||||
|
||||
const previewStyle = computed(() => {
|
||||
const image = editorImage.value
|
||||
if (!image) {
|
||||
const size = Math.min(viewportSize.width * 0.72, viewportSize.height * 0.42, 352)
|
||||
return {
|
||||
width: `${Math.max(160, Math.round(size))}px`,
|
||||
height: `${Math.max(160, Math.round(size))}px`
|
||||
}
|
||||
}
|
||||
|
||||
const maxWidth = Math.min(viewportSize.width * 0.88, 928)
|
||||
const maxHeight = Math.min(viewportSize.height * 0.62, 620)
|
||||
const scale = Math.min(maxWidth / image.naturalWidth, maxHeight / image.naturalHeight)
|
||||
|
||||
return {
|
||||
width: `${Math.max(1, Math.round(image.naturalWidth * scale))}px`,
|
||||
height: `${Math.max(1, Math.round(image.naturalHeight * scale))}px`
|
||||
}
|
||||
})
|
||||
|
||||
const displayMetrics = computed(() => {
|
||||
const image = editorImage.value
|
||||
if (!image || !previewRect.width || !previewRect.height) return null
|
||||
const scale = Math.min(previewRect.width / image.naturalWidth, previewRect.height / image.naturalHeight)
|
||||
const width = image.naturalWidth * scale
|
||||
const height = image.naturalHeight * scale
|
||||
return {
|
||||
x: (previewRect.width - width) / 2,
|
||||
y: (previewRect.height - height) / 2,
|
||||
width,
|
||||
height
|
||||
}
|
||||
})
|
||||
|
||||
const editorImageStyle = computed(() => {
|
||||
const metrics = displayMetrics.value
|
||||
if (!metrics) return null
|
||||
return {
|
||||
width: `${metrics.width}px`,
|
||||
height: `${metrics.height}px`,
|
||||
left: `${metrics.x}px`,
|
||||
top: `${metrics.y}px`
|
||||
}
|
||||
})
|
||||
|
||||
const editorOverlayStyle = computed(() => {
|
||||
const metrics = displayMetrics.value
|
||||
if (!metrics || !cropRect.size) return editorImageStyle.value
|
||||
|
||||
const left = cropRect.x
|
||||
const top = cropRect.y
|
||||
const right = cropRect.x + cropRect.size
|
||||
const bottom = cropRect.y + cropRect.size
|
||||
|
||||
return {
|
||||
...editorImageStyle.value,
|
||||
clipPath: `polygon(evenodd, 0 0, 100% 0, 100% 100%, 0 100%, 0 0, ${left}px ${top}px, ${left}px ${bottom}px, ${right}px ${bottom}px, ${right}px ${top}px, ${left}px ${top}px)`
|
||||
}
|
||||
})
|
||||
|
||||
const cropBoxStyle = computed(() => {
|
||||
const metrics = displayMetrics.value
|
||||
if (!metrics || !cropRect.size) return null
|
||||
return {
|
||||
left: `${metrics.x + cropRect.x}px`,
|
||||
top: `${metrics.y + cropRect.y}px`,
|
||||
width: `${cropRect.size}px`,
|
||||
height: `${cropRect.size}px`
|
||||
}
|
||||
})
|
||||
|
||||
const initializeCrop = () => {
|
||||
const metrics = displayMetrics.value
|
||||
if (!metrics) return
|
||||
const size = Math.min(metrics.width, metrics.height)
|
||||
cropRect.size = size
|
||||
cropRect.x = (metrics.width - size) / 2
|
||||
cropRect.y = (metrics.height - size) / 2
|
||||
}
|
||||
|
||||
const triggerPictureSelect = () => {
|
||||
pictureInput.value?.click()
|
||||
}
|
||||
|
||||
const handlePictureSelected = async (event) => {
|
||||
const nextFile = event.target.files?.[0] || null
|
||||
resetEditor()
|
||||
if (!nextFile) return
|
||||
|
||||
previewObjectUrl.value = URL.createObjectURL(nextFile)
|
||||
editorImageUrl.value = previewObjectUrl.value
|
||||
const image = new Image()
|
||||
image.decoding = 'async'
|
||||
image.src = editorImageUrl.value
|
||||
try {
|
||||
await image.decode()
|
||||
editorImage.value = image
|
||||
} catch {
|
||||
errorMessage.value = 'Failed to load image'
|
||||
resetEditor()
|
||||
}
|
||||
}
|
||||
|
||||
const startMove = (event) => {
|
||||
if (!displayMetrics.value || saving.value) return
|
||||
event.preventDefault()
|
||||
dragState = {
|
||||
mode: 'move',
|
||||
startX: event.clientX,
|
||||
startY: event.clientY,
|
||||
initialX: cropRect.x,
|
||||
initialY: cropRect.y,
|
||||
initialSize: cropRect.size
|
||||
}
|
||||
}
|
||||
|
||||
const startResize = (event, handle) => {
|
||||
if (!displayMetrics.value || saving.value) return
|
||||
event.preventDefault()
|
||||
dragState = {
|
||||
mode: 'resize',
|
||||
handle,
|
||||
startX: event.clientX,
|
||||
startY: event.clientY,
|
||||
initialX: cropRect.x,
|
||||
initialY: cropRect.y,
|
||||
initialSize: cropRect.size
|
||||
}
|
||||
}
|
||||
|
||||
const handlePointerMove = (event) => {
|
||||
if (!dragState) return
|
||||
const metrics = displayMetrics.value
|
||||
if (!metrics) return
|
||||
|
||||
const dx = event.clientX - dragState.startX
|
||||
const dy = event.clientY - dragState.startY
|
||||
|
||||
if (dragState.mode === 'move') {
|
||||
cropRect.x = Math.max(0, Math.min(metrics.width - dragState.initialSize, dragState.initialX + dx))
|
||||
cropRect.y = Math.max(0, Math.min(metrics.height - dragState.initialSize, dragState.initialY + dy))
|
||||
return
|
||||
}
|
||||
|
||||
const directionMap = {
|
||||
nw: { deltaX: -1, deltaY: -1 },
|
||||
ne: { deltaX: 1, deltaY: -1 },
|
||||
sw: { deltaX: -1, deltaY: 1 },
|
||||
se: { deltaX: 1, deltaY: 1 }
|
||||
}
|
||||
const direction = directionMap[dragState.handle]
|
||||
if (!direction) return
|
||||
|
||||
const delta = Math.max(dx * direction.deltaX, dy * direction.deltaY)
|
||||
const nextSize = Math.max(
|
||||
MIN_CROP_SIZE,
|
||||
Math.min(getResizeLimit(metrics, dragState), dragState.initialSize + delta)
|
||||
)
|
||||
|
||||
applyResize(dragState, nextSize)
|
||||
}
|
||||
|
||||
const endPointerInteraction = () => {
|
||||
dragState = null
|
||||
}
|
||||
|
||||
const getResizeLimit = (metrics, state) => {
|
||||
const { initialX, initialY, initialSize, handle } = state
|
||||
|
||||
if (handle === 'nw') return Math.min(initialX + initialSize, initialY + initialSize)
|
||||
if (handle === 'ne') return Math.min(metrics.width - initialX, initialY + initialSize)
|
||||
if (handle === 'sw') return Math.min(initialX + initialSize, metrics.height - initialY)
|
||||
return Math.min(metrics.width - initialX, metrics.height - initialY)
|
||||
}
|
||||
|
||||
const applyResize = (state, size) => {
|
||||
const { initialX, initialY, initialSize, handle } = state
|
||||
|
||||
if (handle === 'nw') {
|
||||
cropRect.x = initialX + initialSize - size
|
||||
cropRect.y = initialY + initialSize - size
|
||||
cropRect.size = size
|
||||
return
|
||||
}
|
||||
|
||||
if (handle === 'ne') {
|
||||
cropRect.x = initialX
|
||||
cropRect.y = initialY + initialSize - size
|
||||
cropRect.size = size
|
||||
return
|
||||
}
|
||||
|
||||
if (handle === 'sw') {
|
||||
cropRect.x = initialX + initialSize - size
|
||||
cropRect.y = initialY
|
||||
cropRect.size = size
|
||||
return
|
||||
}
|
||||
|
||||
cropRect.x = initialX
|
||||
cropRect.y = initialY
|
||||
cropRect.size = size
|
||||
}
|
||||
|
||||
const renderPictureBlob = async () => {
|
||||
const image = editorImage.value
|
||||
if (!image) throw new Error('No image selected')
|
||||
const metrics = displayMetrics.value
|
||||
if (!metrics || !cropRect.size) throw new Error('Crop selection unavailable')
|
||||
|
||||
const canvas = document.createElement('canvas')
|
||||
canvas.width = AVATAR_UPLOAD_SIZE
|
||||
canvas.height = AVATAR_UPLOAD_SIZE
|
||||
const context = canvas.getContext('2d')
|
||||
if (!context) throw new Error('Canvas unavailable')
|
||||
|
||||
const sourceScale = image.naturalWidth / metrics.width
|
||||
const sourceX = cropRect.x * sourceScale
|
||||
const sourceY = cropRect.y * sourceScale
|
||||
const sourceSize = cropRect.size * sourceScale
|
||||
context.drawImage(image, sourceX, sourceY, sourceSize, sourceSize, 0, 0, AVATAR_UPLOAD_SIZE, AVATAR_UPLOAD_SIZE)
|
||||
|
||||
return await new Promise((resolve, reject) => {
|
||||
canvas.toBlob((blob) => {
|
||||
if (!blob) {
|
||||
reject(new Error('Failed to export cropped picture'))
|
||||
return
|
||||
}
|
||||
resolve(blob)
|
||||
}, 'image/webp', 0.9)
|
||||
})
|
||||
}
|
||||
|
||||
const reloadPictureFromCache = async () => {
|
||||
const response = await fetch(props.endpoint, {
|
||||
method: 'GET',
|
||||
credentials: 'same-origin',
|
||||
cache: 'reload'
|
||||
})
|
||||
if (!response.ok) throw new Error('Failed to refresh profile picture')
|
||||
}
|
||||
|
||||
const savePicture = async () => {
|
||||
try {
|
||||
saving.value = true
|
||||
errorMessage.value = ''
|
||||
const blob = await renderPictureBlob()
|
||||
const formData = new FormData()
|
||||
formData.append('file', blob, 'profile.webp')
|
||||
await apiJson(props.endpoint, { method: 'PUT', body: formData })
|
||||
await reloadPictureFromCache()
|
||||
authStore.showMessage('Profile picture updated.', 'success', 3000)
|
||||
emit('updated')
|
||||
closeEditor()
|
||||
} catch (error) {
|
||||
errorMessage.value = error.message || 'Failed to update profile picture'
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const removePicture = async () => {
|
||||
try {
|
||||
saving.value = true
|
||||
errorMessage.value = ''
|
||||
await apiJson(props.endpoint, { method: 'DELETE' })
|
||||
authStore.showMessage('Profile picture removed.', 'success', 3000)
|
||||
emit('updated')
|
||||
closeEditor()
|
||||
} catch (error) {
|
||||
errorMessage.value = error.message || 'Failed to remove profile picture'
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const closeEditor = () => {
|
||||
resetEditor()
|
||||
emit('close')
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.profile-picture-editor-input { display: none; }
|
||||
.profile-picture-editor-preview { position: relative; display: flex; justify-content: center; align-items: center; width: auto; max-width: min(58rem, 88vw); min-height: 0; margin: 0 auto; overflow: visible; }
|
||||
.profile-picture-editor-trigger { min-width: 0; }
|
||||
.profile-picture-editor-image { position: absolute; user-select: none; pointer-events: none; object-fit: contain; }
|
||||
.profile-picture-editor-image--overlay { filter: grayscale(0.45) saturate(0.7) brightness(0.68); }
|
||||
.profile-picture-editor-crop { position: absolute; border: 2px solid white; cursor: move; touch-action: none; }
|
||||
.profile-picture-editor-guides { position: absolute; inset: 0; pointer-events: none; }
|
||||
.profile-picture-editor-guide { position: absolute; border-color: rgba(255, 255, 255, 0.52); }
|
||||
.profile-picture-editor-guide--circle { inset: 0; border: 1.5px solid rgba(255, 255, 255, 0.62); border-radius: 999px; box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.18); }
|
||||
.profile-picture-editor-guide--eyes { left: 18%; right: 18%; top: 38%; border-top: 1.5px solid rgba(255, 255, 255, 0.56); }
|
||||
.profile-picture-editor-guide--cheek-left { top: 24%; bottom: 18%; left: 24%; border-left: 1.5px solid rgba(255, 255, 255, 0.48); }
|
||||
.profile-picture-editor-guide--cheek-right { top: 24%; bottom: 18%; right: 24%; border-right: 1.5px solid rgba(255, 255, 255, 0.48); }
|
||||
.profile-picture-editor-handle { position: absolute; width: 1.1rem; height: 1.1rem; border-radius: 999px; border: 2px solid white; background: var(--color-accent); padding: 0; }
|
||||
.profile-picture-editor-handle--nw { left: -0.55rem; top: -0.55rem; cursor: nwse-resize; }
|
||||
.profile-picture-editor-handle--ne { right: -0.55rem; top: -0.55rem; cursor: nesw-resize; }
|
||||
.profile-picture-editor-handle--sw { left: -0.55rem; bottom: -0.55rem; cursor: nesw-resize; }
|
||||
.profile-picture-editor-handle--se { right: -0.55rem; bottom: -0.55rem; cursor: nwse-resize; }
|
||||
:deep(.modal-panel--avatar) { width: fit-content; max-width: min(58rem, 94vw); }
|
||||
@media (max-width: 720px) {
|
||||
.profile-picture-editor-preview { max-width: 100%; }
|
||||
}
|
||||
</style>
|
||||
@@ -15,6 +15,9 @@
|
||||
v-if="authStore.userInfo?.user"
|
||||
ref="userBasicInfo"
|
||||
:name="authStore.userInfo.user.display_name"
|
||||
:avatar-url="authStore.userInfo.user.avatar_url"
|
||||
:avatar-render-version="avatarRenderVersion"
|
||||
avatar-clickable
|
||||
:email="authStore.userInfo.user.email"
|
||||
:preferred_username="authStore.userInfo.user.preferred_username"
|
||||
:telephone="authStore.userInfo.user.telephone"
|
||||
@@ -22,10 +25,11 @@
|
||||
:created-at="authStore.userInfo.user.created_at"
|
||||
:last-seen="authStore.userInfo.user.last_seen"
|
||||
:loading="authStore.isLoading"
|
||||
:org-display-name="authStore.ctx?.org.display_name"
|
||||
:role-name="authStore.ctx?.role.display_name"
|
||||
:org-display-name="authStore.userInfo.org.display_name"
|
||||
:role-name="authStore.userInfo.role.display_name"
|
||||
update-endpoint="/auth/api/user/info"
|
||||
@saved="authStore.loadUserInfo()"
|
||||
@avatar-click="openAvatarDialog"
|
||||
@edit="openEditDialog"
|
||||
@keydown="handleUserInfoKeydown"
|
||||
>
|
||||
@@ -53,7 +57,7 @@
|
||||
<CredentialList
|
||||
ref="credentialList"
|
||||
:credentials="credentials"
|
||||
:aaguid-info="authStore.userInfo?.aaguid_info || {}"
|
||||
:aaguid-info="authStore.userInfo.aaguid_info"
|
||||
:loading="authStore.isLoading"
|
||||
:hovered-credential-uuid="hoveredCredentialUuid"
|
||||
:hovered-session-credential-uuid="hoveredSession?.credential"
|
||||
@@ -131,6 +135,15 @@
|
||||
</form>
|
||||
</Modal>
|
||||
|
||||
<ProfilePictureEditorModal
|
||||
v-if="showAvatarDialog && currentAvatarEndpoint"
|
||||
:endpoint="currentAvatarEndpoint"
|
||||
:picture-url="authStore.userInfo?.user?.avatar_url"
|
||||
:render-version="avatarRenderVersion"
|
||||
@close="closeAvatarDialog"
|
||||
@updated="handleProfilePictureUpdated"
|
||||
/>
|
||||
|
||||
<RegistrationLinkModal
|
||||
v-if="showRegLink"
|
||||
endpoint="/auth/api/user/create-link"
|
||||
@@ -144,6 +157,7 @@
|
||||
import { ref, onMounted, onUnmounted, computed, watch } from 'vue'
|
||||
import Breadcrumbs from '@/components/Breadcrumbs.vue'
|
||||
import CredentialList from '@/components/CredentialList.vue'
|
||||
import ProfilePictureEditorModal from '@/components/ProfilePictureEditorModal.vue'
|
||||
import ThemeSelector from '@/components/ThemeSelector.vue'
|
||||
import UserBasicInfo from '@/components/UserBasicInfo.vue'
|
||||
import Modal from '@/components/Modal.vue'
|
||||
@@ -160,11 +174,13 @@ import { navigateButtonRow, focusPreferred, focusAtIndex, getDirection } from '@
|
||||
const authStore = useAuthStore()
|
||||
const updateInterval = ref(null)
|
||||
const showEditDialog = ref(false)
|
||||
const showAvatarDialog = ref(false)
|
||||
const showRegLink = ref(false)
|
||||
const editName = ref('')
|
||||
const editEmail = ref('')
|
||||
const editUsername = ref('')
|
||||
const editTelephone = ref('')
|
||||
const avatarRenderVersion = ref(0)
|
||||
const saving = ref(false)
|
||||
const editError = ref('')
|
||||
const hoveredCredentialUuid = ref(null)
|
||||
@@ -176,19 +192,20 @@ const credentialButtons = ref(null)
|
||||
const sessionList = ref(null)
|
||||
const logoutButtons = ref(null)
|
||||
const breadcrumbs = ref(null)
|
||||
const userBasicInfo = ref(null)
|
||||
const userInfoSection = ref(null)
|
||||
|
||||
// Check if any modal/dialog is open (blocks arrow key navigation)
|
||||
const hasActiveModal = computed(() => showEditDialog.value || showRegLink.value)
|
||||
const hasActiveModal = computed(() => showEditDialog.value || showAvatarDialog.value || showRegLink.value)
|
||||
|
||||
watch(showEditDialog, (open) => {
|
||||
if (!open) return
|
||||
const user = authStore.userInfo?.user
|
||||
editName.value = user?.display_name ?? ''
|
||||
editEmail.value = user?.email ?? ''
|
||||
editUsername.value = user?.preferred_username ?? ''
|
||||
editTelephone.value = user?.telephone ?? ''
|
||||
if (!open) {
|
||||
return
|
||||
}
|
||||
const user = authStore.userInfo.user
|
||||
editName.value = user.display_name ?? ''
|
||||
editEmail.value = user.email ?? ''
|
||||
editUsername.value = user.preferred_username ?? ''
|
||||
editTelephone.value = user.telephone ?? ''
|
||||
editError.value = ''
|
||||
})
|
||||
|
||||
@@ -196,7 +213,28 @@ onMounted(() => {
|
||||
updateInterval.value = setInterval(() => { if (authStore.userInfo) authStore.userInfo = { ...authStore.userInfo } }, 60000)
|
||||
})
|
||||
|
||||
onUnmounted(() => { if (updateInterval.value) clearInterval(updateInterval.value) })
|
||||
onUnmounted(() => {
|
||||
if (updateInterval.value) clearInterval(updateInterval.value)
|
||||
})
|
||||
|
||||
const currentAvatarEndpoint = computed(() => {
|
||||
const userUuid = authStore.userInfo?.user?.uuid
|
||||
if (!userUuid) return null
|
||||
return `/auth/api/user/${userUuid}/profile.webp`
|
||||
})
|
||||
|
||||
const openAvatarDialog = () => {
|
||||
showAvatarDialog.value = true
|
||||
}
|
||||
|
||||
const closeAvatarDialog = () => {
|
||||
showAvatarDialog.value = false
|
||||
}
|
||||
|
||||
const handleProfilePictureUpdated = async () => {
|
||||
await authStore.loadUserInfo()
|
||||
avatarRenderVersion.value += 1
|
||||
}
|
||||
|
||||
const addNewCredential = async () => {
|
||||
try {
|
||||
@@ -245,7 +283,7 @@ const handleBreadcrumbKeydown = (event) => {
|
||||
if (direction === 'down') {
|
||||
event.preventDefault()
|
||||
// Move to user info section - always focus edit button first
|
||||
focusPreferred(userInfoSection.value, { primarySelector: '.mini-btn', itemSelector: '.mini-btn, .pairing-input' })
|
||||
focusPreferred(userInfoSection.value, { primarySelector: '.mini-btn', itemSelector: '.user-picture-btn, .mini-btn, .pairing-input' })
|
||||
}
|
||||
// ArrowUp at the top does nothing
|
||||
}
|
||||
@@ -257,7 +295,7 @@ const handleUserInfoKeydown = (event) => {
|
||||
if (!direction) return
|
||||
|
||||
event.preventDefault()
|
||||
const itemSelector = '.mini-btn, .pairing-input'
|
||||
const itemSelector = '.user-picture-btn, .mini-btn, .pairing-input'
|
||||
|
||||
if (direction === 'left' || direction === 'right') {
|
||||
navigateButtonRow(userInfoSection.value, event.target, direction, { itemSelector })
|
||||
@@ -278,7 +316,7 @@ const handleCredentialNavigateOut = (direction) => {
|
||||
focusPreferredButton(credentialButtons.value)
|
||||
} else if (direction === 'up' || direction === 'left') {
|
||||
// Focus user info section - always focus edit button first
|
||||
focusPreferred(userInfoSection.value, { primarySelector: '.mini-btn', itemSelector: '.mini-btn, .pairing-input' })
|
||||
focusPreferred(userInfoSection.value, { primarySelector: '.mini-btn', itemSelector: '.user-picture-btn, .mini-btn, .pairing-input' })
|
||||
}
|
||||
}
|
||||
|
||||
@@ -341,7 +379,7 @@ const handleDelete = async (credential) => {
|
||||
|
||||
const rpName = computed(() => authStore.settings?.rp_name || 'this service')
|
||||
const paskiaVersion = computed(() => authStore.settings?.version || '')
|
||||
const sessions = computed(() => authStore.userInfo?.sessions || {})
|
||||
const sessions = computed(() => authStore.userInfo.sessions)
|
||||
const currentSessionHost = computed(() => {
|
||||
const currentSession = Object.values(sessions.value).find(session => session.is_current)
|
||||
return currentSession?.host || 'this host'
|
||||
@@ -365,12 +403,12 @@ const logoutEverywhere = async () => { await authStore.logoutEverywhere() }
|
||||
const logout = async () => { await authStore.logout() }
|
||||
const openEditDialog = () => { showEditDialog.value = true }
|
||||
const isAdmin = computed(() => {
|
||||
const perms = authStore.ctx?.permissions
|
||||
return perms?.includes('auth:admin') || perms?.includes('auth:org:admin')
|
||||
const perms = Object.values(authStore.userInfo.permissions).map(p => p.scope)
|
||||
return perms.includes('auth:admin') || perms.includes('auth:org:admin')
|
||||
})
|
||||
const hasMultipleSessions = computed(() => Object.keys(sessions.value).length > 1)
|
||||
const credentials = computed(() =>
|
||||
Object.entries(authStore.userInfo?.credentials || {}).map(([uuid, c]) => ({ ...c, credential: uuid }))
|
||||
Object.entries(authStore.userInfo.credentials).map(([uuid, c]) => ({ ...c, credential: uuid }))
|
||||
)
|
||||
const useWideLayout = computed(() => {
|
||||
// Check if any single site has more than 8 sessions
|
||||
@@ -399,6 +437,7 @@ const saveProfile = async () => {
|
||||
try {
|
||||
editError.value = ''
|
||||
saving.value = true
|
||||
let changed = false
|
||||
const body = {}
|
||||
if (name !== user.display_name) body.display_name = name
|
||||
if (emailVal !== (user.email || null)) body.email = emailVal
|
||||
@@ -406,6 +445,9 @@ const saveProfile = async () => {
|
||||
if (telephoneVal !== (user.telephone || null)) body.telephone = telephoneVal
|
||||
if (Object.keys(body).length) {
|
||||
await apiJson('/auth/api/user/info', { method: 'PATCH', body })
|
||||
changed = true
|
||||
}
|
||||
if (changed) {
|
||||
await authStore.loadUserInfo()
|
||||
authStore.showMessage('Profile updated!', 'success', 3000)
|
||||
}
|
||||
|
||||
@@ -37,7 +37,7 @@
|
||||
<script setup>
|
||||
import { ref, computed, onMounted, onUnmounted, nextTick } from 'vue'
|
||||
import QRCodeDisplay from '@/components/QRCodeDisplay.vue'
|
||||
import { apiJson, holdGlobalBackdrop, releaseGlobalBackdrop } from 'paskia'
|
||||
import { apiJson, AuthCancelledError, holdGlobalBackdrop, releaseGlobalBackdrop } from 'paskia'
|
||||
import { formatDate } from '@/utils/helpers'
|
||||
import { getDirection } from '@/utils/keynav'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
@@ -90,7 +90,9 @@ async function generateLink() {
|
||||
emit('close')
|
||||
}
|
||||
} catch (e) {
|
||||
authStore.showMessage(e.message || 'Failed to generate link', 'error')
|
||||
if (!(e instanceof AuthCancelledError)) {
|
||||
authStore.showMessage(e.message || 'Failed to generate link', 'error')
|
||||
}
|
||||
emit('close')
|
||||
}
|
||||
}
|
||||
|
||||
@@ -163,6 +163,9 @@ async function startRemoteAuth() {
|
||||
|
||||
// PoW challenge
|
||||
const powChallenge = await ws.receive_json()
|
||||
if (powChallenge.status) {
|
||||
throw new Error(powChallenge.detail || `Failed to connect: ${powChallenge.status}`)
|
||||
}
|
||||
if (powChallenge.pow) {
|
||||
const challenge = b64dec(powChallenge.pow.challenge)
|
||||
const nonces = await solvePoW(challenge, powChallenge.pow.work)
|
||||
|
||||
@@ -58,9 +58,10 @@
|
||||
import { computed, nextTick, onMounted, onUnmounted, reactive, ref, watch } from 'vue'
|
||||
import passkey from '@/utils/passkey'
|
||||
import { getSettings, uiBasePath } from '@/utils/settings'
|
||||
import { fetchJson, getUserFriendlyErrorMessage } from 'paskia'
|
||||
import { fetchJson, getUserFriendlyErrorMessage, settings as paskiaSettings } from 'paskia'
|
||||
import RemoteAuthRequest from '@/components/RemoteAuthRequest.vue'
|
||||
import { focusDialogButton } from '@/utils/keynav'
|
||||
import { updateThemeFromSession } from '@/utils/theme'
|
||||
|
||||
const props = defineProps({
|
||||
mode: {
|
||||
@@ -146,7 +147,8 @@ async function fetchSettings() {
|
||||
|
||||
async function validateSession() {
|
||||
try {
|
||||
session.value = await fetchJson('/auth/api/validate', { method: 'POST' })
|
||||
session.value = await fetchJson('/auth/api/validate', { method: 'POST', timeout: paskiaSettings.auth_ms })
|
||||
updateThemeFromSession(session.value?.ctx)
|
||||
if (isAuthenticated.value && props.mode !== 'reauth') {
|
||||
currentView.value = 'forbidden'
|
||||
emit('forbidden', session.value)
|
||||
@@ -196,7 +198,7 @@ async function logoutUser() {
|
||||
if (loading.value) return
|
||||
loading.value = true
|
||||
try {
|
||||
await fetchJson('/auth/api/logout', { method: 'POST' })
|
||||
await fetchJson('/auth/api/logout', { method: 'POST', timeout: paskiaSettings.auth_ms })
|
||||
session.value = null
|
||||
currentView.value = 'login'
|
||||
showMessage('Logged out. You can sign in with a different account.', 'info', 3000)
|
||||
@@ -218,7 +220,7 @@ async function exchangeCode(result) {
|
||||
throw new Error('Authentication response missing exchange_code')
|
||||
}
|
||||
return await fetchJson('/auth/api/set-session', {
|
||||
method: 'POST', headers: { 'Authorization': `Bearer ${result.exchange_code}` }
|
||||
method: 'POST', headers: { 'Authorization': `Bearer ${result.exchange_code}` }, timeout: paskiaSettings.auth_ms
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -1,9 +1,20 @@
|
||||
<template>
|
||||
<div v-if="userLoaded" class="user-info" :class="{ 'has-extra': $slots.default }">
|
||||
<div class="user-info-content">
|
||||
<div class="user-picture">
|
||||
<span>👤</span>
|
||||
</div>
|
||||
<ProfilePicture
|
||||
:src="avatarUrl"
|
||||
:render-version="avatarRenderVersion"
|
||||
:clickable="avatarClickable"
|
||||
:loading="loading"
|
||||
:title="avatarClickable ? 'Change profile picture' : ''"
|
||||
width="5.25rem"
|
||||
height="5.25rem"
|
||||
radius="var(--radius-sm)"
|
||||
fallback-size="2.8em"
|
||||
class="user-picture"
|
||||
:class="avatarClickable ? 'user-picture-btn' : ''"
|
||||
@click="emit('avatar-click')"
|
||||
/>
|
||||
<h3 class="user-name-heading">
|
||||
<span class="user-name-row">
|
||||
<span class="display-name" :title="name">{{ name }}</span>
|
||||
@@ -42,11 +53,13 @@
|
||||
|
||||
<script setup>
|
||||
import { computed } from 'vue'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import ProfilePicture from '@/components/ProfilePicture.vue'
|
||||
import { formatDate } from '@/utils/helpers'
|
||||
|
||||
const props = defineProps({
|
||||
name: { type: String, required: true },
|
||||
avatarUrl: { type: String, default: null },
|
||||
avatarRenderVersion: { type: [Number, String], default: 0 },
|
||||
email: { type: String, default: null },
|
||||
preferred_username: { type: String, default: null },
|
||||
telephone: { type: String, default: null },
|
||||
@@ -55,14 +68,13 @@ const props = defineProps({
|
||||
lastSeen: { type: [String, Number, Date], default: null },
|
||||
updateEndpoint: { type: String, default: null },
|
||||
canEdit: { type: Boolean, default: true },
|
||||
avatarClickable: { type: Boolean, default: false },
|
||||
loading: { type: Boolean, default: false },
|
||||
orgDisplayName: { type: String, default: '' },
|
||||
roleName: { type: String, default: '' }
|
||||
})
|
||||
|
||||
const emit = defineEmits(['saved', 'edit'])
|
||||
const authStore = useAuthStore()
|
||||
|
||||
const emit = defineEmits(['saved', 'edit', 'avatar-click'])
|
||||
const userLoaded = computed(() => !!props.name)
|
||||
</script>
|
||||
|
||||
@@ -96,12 +108,12 @@ const userLoaded = computed(() => !!props.name)
|
||||
grid-template-areas:
|
||||
"picture heading fields"
|
||||
"picture org fields"
|
||||
". info info";
|
||||
"picture info info";
|
||||
gap: 0 1rem;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.user-picture { grid-area: picture; display: flex; align-items: flex-start; font-size: 2em; line-height: 1; }
|
||||
:deep(.user-picture) { grid-area: picture; align-self: stretch; }
|
||||
.user-name-heading { grid-area: heading; display: flex; align-items: center; flex-wrap: wrap; margin: 0 0 0.25rem 0; min-width: 0; }
|
||||
.org-role-sub { grid-area: org; display: flex; flex-direction: column; min-width: 0; }
|
||||
.org-line { font-size: .7rem; font-weight: 600; line-height: 1.1; color: var(--color-text-muted); text-transform: uppercase; letter-spacing: 0.05em; }
|
||||
@@ -118,7 +130,7 @@ const userLoaded = computed(() => !!props.name)
|
||||
.user-info-extra { grid-area: extra; padding-left: 1rem; border-left: 1px solid var(--color-border); flex-shrink: 0; }
|
||||
.user-name-row { display: inline-flex; align-items: center; gap: 0.35rem; max-width: 100%; min-width: 0; }
|
||||
.display-name { font-weight: 600; font-size: 1.05em; line-height: 1.2; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; min-width: 0; }
|
||||
.mini-btn { width: auto; padding: 4px 6px; margin: 0; font-size: 0.75em; line-height: 1; cursor: pointer; }
|
||||
.mini-btn { width: auto; padding: 4px 6px; margin: 0; font-size: 0.75em; line-height: 1; cursor: pointer; background: transparent; }
|
||||
.mini-btn:hover:not(:disabled) { background: var(--color-accent-soft); color: var(--color-accent); }
|
||||
.mini-btn:active:not(:disabled) { transform: translateY(1px); }
|
||||
.mini-btn:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { register, authenticate } from '@/utils/passkey'
|
||||
import { getSettings } from '@/utils/settings'
|
||||
import { apiJson } from 'paskia'
|
||||
import { apiJson, settings as paskiaSettings } from 'paskia'
|
||||
import { updateThemeFromSession } from '@/utils/theme'
|
||||
|
||||
export const useAuthStore = defineStore('auth', {
|
||||
@@ -50,6 +50,7 @@ export const useAuthStore = defineStore('auth', {
|
||||
return await apiJson('/auth/api/set-session', {
|
||||
method: 'POST',
|
||||
headers: {'Authorization': `Bearer ${result.session_token}`},
|
||||
timeout: paskiaSettings.auth_ms,
|
||||
})
|
||||
},
|
||||
async register() {
|
||||
@@ -87,8 +88,8 @@ export const useAuthStore = defineStore('auth', {
|
||||
},
|
||||
async loadUserInfo() {
|
||||
try {
|
||||
this.userInfo = await apiJson('/auth/api/user-info', { method: 'GET' })
|
||||
updateThemeFromSession(this.ctx)
|
||||
this.userInfo = await apiJson('/auth/api/user-info', { method: 'GET', timeout: paskiaSettings.auth_ms })
|
||||
updateThemeFromSession(this.userInfo)
|
||||
console.log('User info loaded:', this.userInfo)
|
||||
} catch (error) {
|
||||
// Suppress toast for 401/403 errors - the auth iframe will handle these
|
||||
@@ -121,7 +122,7 @@ export const useAuthStore = defineStore('auth', {
|
||||
},
|
||||
async logout() {
|
||||
try {
|
||||
await apiJson('/auth/api/logout', {method: 'POST'})
|
||||
await apiJson('/auth/api/logout', {method: 'POST', timeout: paskiaSettings.auth_ms})
|
||||
sessionStorage.clear()
|
||||
location.reload()
|
||||
} catch (error) {
|
||||
@@ -134,7 +135,7 @@ export const useAuthStore = defineStore('auth', {
|
||||
},
|
||||
async logoutEverywhere() {
|
||||
try {
|
||||
await apiJson('/auth/api/user/logout-all', {method: 'POST'})
|
||||
await apiJson('/auth/api/user/logout-all', {method: 'POST', timeout: paskiaSettings.auth_ms})
|
||||
sessionStorage.clear()
|
||||
location.reload()
|
||||
} catch (error) {
|
||||
|
||||
@@ -1,32 +0,0 @@
|
||||
// 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')
|
||||
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')
|
||||
}
|
||||
@@ -75,10 +75,16 @@ Discovery: `backchannel_logout_supported: true`
|
||||
- `GET /.well-known/openid-configuration` — Discovery
|
||||
- `GET /auth/oidc/keys` — Keys (EdDSA)
|
||||
- `POST /auth/oidc/token` — Exchange/refresh
|
||||
- `GET /auth/oidc/userinfo` — User (bearer token)
|
||||
- `GET /auth/oidc/userinfo` — User (bearer token, includes `picture` when `profile` scope is granted and avatar exists)
|
||||
- `POST /auth/oidc/backchannel-logout` — Logout
|
||||
- `POST /auth/api/exchange` — Native auth code → cookie
|
||||
|
||||
## Claims
|
||||
|
||||
- `profile` scope may include `name`, `preferred_username`, and `picture`
|
||||
- `email` scope may include `email`
|
||||
- `groups` is emitted from client-scoped permissions
|
||||
|
||||
## Files
|
||||
|
||||
**Created:** [paskia/authcode.py](paskia/authcode.py), [paskia/util/crypto.py](paskia/util/crypto.py), [paskia/fastapi/oid.py](paskia/fastapi/oid.py)
|
||||
|
||||
@@ -64,6 +64,30 @@ When a 401/403 response includes an auth iframe URL, the request automatically p
|
||||
|
||||
The JSON variants set headers automatically, with body and response in JSON.
|
||||
|
||||
### Timeout Settings
|
||||
|
||||
Paskia exports a mutable settings object for defaults used by fetch/auth/session validation timers. Default values shown below.
|
||||
|
||||
```js
|
||||
import { settings } from 'paskia'
|
||||
|
||||
// General fetch timeout used by apiFetch/apiJson/fetchJson when no timeout is passed
|
||||
settings.fetch_ms = 10000
|
||||
|
||||
// Fetch timeout used by SessionValidator (/auth/api/validate is fast)
|
||||
settings.auth_ms = 1000
|
||||
|
||||
// SessionValidator polling and idle timers
|
||||
settings.poll_ms = 60000
|
||||
settings.idle_ms = 300000
|
||||
```
|
||||
|
||||
You can still override timeout per request:
|
||||
|
||||
```js
|
||||
await apiJson('/api/upload', { method: 'POST', body: data, timeout: 30000 })
|
||||
```
|
||||
|
||||
### Authentication Overlay
|
||||
|
||||
Normally you use apiJson/apiFetch and they handle this automatically. If you need to wire it yourself, on a 401/403 response that includes `auth.iframe`, call `showAuthIframe(...)` and then retry the original request.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "paskia",
|
||||
"version": "1.1.0",
|
||||
"version": "1.4.0",
|
||||
"description": "Paskia authentication utilities for JavaScript",
|
||||
"author": "Leo Vasanko",
|
||||
"license": "Unlicense",
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
import { showAuthIframe, AuthCancelledError } from './overlay'
|
||||
import settings from './settings'
|
||||
|
||||
export { AuthCancelledError }
|
||||
|
||||
const DEFAULT_TIMEOUT_MS = 1000
|
||||
|
||||
export interface ApiFetchOptions extends RequestInit {
|
||||
timeout?: number
|
||||
}
|
||||
@@ -40,7 +39,7 @@ export class NetworkError extends Error {
|
||||
}
|
||||
|
||||
export async function apiFetch(url: string, options: ApiFetchOptions = {}): Promise<Response> {
|
||||
const { timeout = DEFAULT_TIMEOUT_MS, ...fetchOptions } = options
|
||||
const { timeout = settings.fetch_ms, ...fetchOptions } = options
|
||||
fetchOptions.credentials = fetchOptions.credentials || 'include'
|
||||
|
||||
while (true) {
|
||||
|
||||
@@ -12,14 +12,14 @@ export {
|
||||
|
||||
export type { ApiFetchOptions, FetchJsonOptions } from './fetch'
|
||||
|
||||
export { default as settings } from './settings'
|
||||
|
||||
export {
|
||||
holdGlobalBackdrop,
|
||||
releaseGlobalBackdrop,
|
||||
isAuthIframeOpen,
|
||||
hideAuthIframe,
|
||||
showAuthIframe,
|
||||
createAuthIframe,
|
||||
removeAuthIframe,
|
||||
} from './overlay'
|
||||
|
||||
export { SessionValidator } from './validate'
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
export default {
|
||||
fetch_ms: 10000,
|
||||
auth_ms: 1000,
|
||||
poll_ms: 60000,
|
||||
idle_ms: 300000,
|
||||
}
|
||||
@@ -1,7 +1,5 @@
|
||||
import { apiJson } from './fetch'
|
||||
|
||||
const POLL_INTERVAL = 60 * 1000
|
||||
const IDLE_TIMEOUT = 5 * 60 * 1000
|
||||
import settings from './settings'
|
||||
|
||||
export class SessionValidator {
|
||||
private userUuidGetter: () => string | undefined
|
||||
@@ -19,12 +17,12 @@ export class SessionValidator {
|
||||
resetIdleTimer(): void {
|
||||
if (this.idleTimer) clearTimeout(this.idleTimer)
|
||||
if (!this.active) this.startPolling()
|
||||
this.idleTimer = setTimeout(() => this.stopPolling(), IDLE_TIMEOUT)
|
||||
this.idleTimer = setTimeout(() => this.stopPolling(), settings.idle_ms)
|
||||
}
|
||||
|
||||
async validate(): Promise<void> {
|
||||
try {
|
||||
const data = await apiJson<{ ctx?: { user?: { uuid?: string } } }>('/auth/api/validate', { method: 'POST' })
|
||||
const data = await apiJson<{ ctx?: { user?: { uuid?: string } } }>('/auth/api/validate', { method: 'POST', timeout: settings.auth_ms })
|
||||
const newUuid = data.ctx?.user?.uuid
|
||||
if (newUuid !== this.userUuidGetter()) {
|
||||
window.location.reload()
|
||||
@@ -40,7 +38,7 @@ export class SessionValidator {
|
||||
startPolling(): void {
|
||||
if (this.active) return
|
||||
this.active = true
|
||||
this.pollTimer = setInterval(() => this.validate(), POLL_INTERVAL)
|
||||
this.pollTimer = setInterval(() => this.validate(), settings.poll_ms)
|
||||
}
|
||||
|
||||
stopPolling(): void {
|
||||
|
||||
+95
-152
@@ -1,24 +1,26 @@
|
||||
import argparse
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
from urllib.parse import urlparse
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import msgspec
|
||||
from fastapi_vue import server
|
||||
from fastapi_vue.hostutil import parse_endpoint
|
||||
from fastapi_vue.hostutil import parse_endpoints
|
||||
from kanta import Kanta
|
||||
|
||||
from paskia import db
|
||||
from paskia import globals as _globals
|
||||
from paskia.bootstrap import bootstrap_if_needed
|
||||
from paskia.config import PaskiaConfig
|
||||
from paskia.db.background import flush
|
||||
from paskia.db.structs import Config
|
||||
from paskia._version import __version__
|
||||
from paskia.db.paths import db_file_path
|
||||
from paskia.db.structs import DB, Config
|
||||
from paskia.util import startupbox
|
||||
from paskia.util.hostutil import normalize_origin
|
||||
|
||||
DEFAULT_PORT = 4401
|
||||
DEVMODE = os.getenv("PASKIA_DEV") == "1"
|
||||
from paskia.util.constants import DEFAULT_PORT, DEVMODE
|
||||
from paskia.util.hostutil import (
|
||||
normalize_auth_host_and_origins,
|
||||
normalize_origin,
|
||||
validate_auth_host,
|
||||
)
|
||||
from paskia.util.runtime import RuntimeConfig
|
||||
|
||||
EPILOG = """\
|
||||
Example:
|
||||
@@ -26,27 +28,6 @@ Example:
|
||||
"""
|
||||
|
||||
|
||||
def is_subdomain(sub: str, domain: str) -> bool:
|
||||
"""Check if sub is a subdomain of domain (or equal)."""
|
||||
sub_parts = sub.lower().split(".")
|
||||
domain_parts = domain.lower().split(".")
|
||||
if len(sub_parts) < len(domain_parts):
|
||||
return False
|
||||
return sub_parts[-len(domain_parts) :] == domain_parts
|
||||
|
||||
|
||||
def validate_auth_host(auth_host: str, rp_id: str) -> None:
|
||||
"""Validate that auth_host is a subdomain of rp_id."""
|
||||
parsed = urlparse(auth_host if "://" in auth_host else f"//{auth_host}")
|
||||
host = parsed.hostname or parsed.path
|
||||
if not host:
|
||||
raise SystemExit(f"Invalid auth-host: '{auth_host}'")
|
||||
if not is_subdomain(host, rp_id):
|
||||
raise SystemExit(
|
||||
f"auth-host '{auth_host}' is not a subdomain of rp-id '{rp_id}'"
|
||||
)
|
||||
|
||||
|
||||
def add_common_options(p: argparse.ArgumentParser) -> None:
|
||||
p.add_argument(
|
||||
"--rp-id", default="localhost", help="Relying Party ID (default: localhost)"
|
||||
@@ -70,6 +51,36 @@ def add_common_options(p: argparse.ArgumentParser) -> None:
|
||||
)
|
||||
|
||||
|
||||
def _load_stored_config(db_path: Path, *, rp_id: str) -> Config:
|
||||
"""Load the stored Config from disk using Kanta in read-only mode.
|
||||
|
||||
This must not depend on PASKIA_CONFIG or the global lifecycle Kanta.
|
||||
If the database file does not exist, a default config is returned.
|
||||
"""
|
||||
if not db_path.exists():
|
||||
return Config(rp_id=rp_id)
|
||||
|
||||
kanta = Kanta(
|
||||
str(db_path),
|
||||
DB(config=Config(rp_id=rp_id)),
|
||||
migrations="paskia.db.migrations",
|
||||
)
|
||||
kanta.ctx.rp_id = rp_id
|
||||
|
||||
async def _read() -> Config:
|
||||
await kanta.open(readonly=True)
|
||||
try:
|
||||
return kanta.data.config
|
||||
finally:
|
||||
await kanta.close()
|
||||
|
||||
try:
|
||||
return asyncio.run(_read())
|
||||
except Exception as e:
|
||||
logging.exception("Failed to load database")
|
||||
raise SystemExit(f"{e}") from e
|
||||
|
||||
|
||||
def main():
|
||||
# Configure logging to remove the "ERROR:root:" prefix
|
||||
logging.basicConfig(level=logging.INFO, format="%(message)s", force=True)
|
||||
@@ -95,138 +106,70 @@ def main():
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
# Handle clearing options
|
||||
if getattr(args, "auth_host", None) == "":
|
||||
args.auth_host = None
|
||||
if getattr(args, "rp_name", None) == "":
|
||||
args.rp_name = None
|
||||
if getattr(args, "listen", None) == "":
|
||||
args.listen = None
|
||||
# Load stored config using a local read-only Kanta instance.
|
||||
# This happens before PASKIA_CONFIG is set, so we must not import
|
||||
# modules that initialize the global database lifecycle.
|
||||
db_path = db_file_path(rp_id=args.rp_id, create_root=True)
|
||||
try:
|
||||
config = _load_stored_config(db_path, rp_id=args.rp_id)
|
||||
except SystemExit as e:
|
||||
print(f"🛑 Paskia {__version__} could not load")
|
||||
sys.exit(str(e))
|
||||
|
||||
# Init db and load stored config
|
||||
asyncio.run(db.init(rp_id=args.rp_id))
|
||||
stored_config = db.data().config
|
||||
# Override stored config with CLI args, or clear with empty string
|
||||
if args.rp_name is not None:
|
||||
config.rp_name = args.rp_name or None
|
||||
if args.auth_host is not None:
|
||||
config.auth_host = args.auth_host or None
|
||||
if args.origins is not None:
|
||||
config.origins = None if args.origins == [""] else args.origins
|
||||
if args.listen is not None:
|
||||
config.listen = None if args.listen == [""] else args.listen
|
||||
|
||||
# Apply defaults from stored config
|
||||
if args.rp_name is None and stored_config.rp_name is not None:
|
||||
args.rp_name = stored_config.rp_name
|
||||
if args.origins is None and stored_config.origins is not None:
|
||||
args.origins = stored_config.origins
|
||||
if args.auth_host is None and stored_config.auth_host is not None:
|
||||
args.auth_host = stored_config.auth_host
|
||||
if args.listen is None and stored_config.listen is not None:
|
||||
args.listen = stored_config.listen
|
||||
# Process and normalize auth_host and origins
|
||||
try:
|
||||
validate_auth_host(config.auth_host, config.rp_id) if config.auth_host else None
|
||||
except ValueError as e:
|
||||
raise SystemExit(str(e))
|
||||
if config.origins:
|
||||
config.origins = [normalize_origin(o) for o in config.origins]
|
||||
config.auth_host, config.origins = normalize_auth_host_and_origins(
|
||||
config.auth_host, config.origins
|
||||
)
|
||||
|
||||
# Parse first endpoint for config display and site_url
|
||||
first_listen = args.listen[0] if isinstance(args.listen, list) else args.listen
|
||||
endpoints = parse_endpoint(first_listen, DEFAULT_PORT)
|
||||
|
||||
# Extract host/port/uds from first endpoint for config display and site_url
|
||||
ep = endpoints[0] if endpoints else {}
|
||||
host = ep.get("host")
|
||||
# Parse first endpoint for site_url fallback
|
||||
ep = next(iter(parse_endpoints(config.listen, DEFAULT_PORT)), {})
|
||||
port = ep.get("port")
|
||||
uds = ep.get("uds")
|
||||
|
||||
# Collect and normalize origins, handle auth_host
|
||||
origins = [normalize_origin(o) for o in (getattr(args, "origins", None) or [])]
|
||||
if args.auth_host:
|
||||
# Normalize auth_host with scheme
|
||||
if "://" not in args.auth_host:
|
||||
args.auth_host = f"https://{args.auth_host}"
|
||||
|
||||
validate_auth_host(args.auth_host, args.rp_id)
|
||||
|
||||
# If origins are configured, ensure auth_host is included at top
|
||||
if origins:
|
||||
# Insert auth_host at the beginning
|
||||
origins.insert(0, args.auth_host)
|
||||
|
||||
# Remove duplicates while preserving order
|
||||
seen = set()
|
||||
origins = [x for x in origins if not (x in seen or seen.add(x))]
|
||||
|
||||
# Compute site_url and site_path for reset links
|
||||
# Priority: PASKIA_SITE_URL (explicit) > auth_host > first origin with localhost > http://localhost:port
|
||||
explicit_site_url = os.environ.get("PASKIA_SITE_URL")
|
||||
if explicit_site_url:
|
||||
# Explicit site URL from devserver or deployment config
|
||||
site_url = explicit_site_url.rstrip("/")
|
||||
site_path = "/" if args.auth_host else "/auth/"
|
||||
elif args.auth_host:
|
||||
site_url = args.auth_host.rstrip("/")
|
||||
site_path = "/"
|
||||
elif origins:
|
||||
# Find localhost origin if rp_id is localhost, else use first origin
|
||||
localhost_origin = (
|
||||
next((o for o in origins if "://localhost" in o), None)
|
||||
if args.rp_id == "localhost"
|
||||
else None
|
||||
)
|
||||
site_url = (localhost_origin or origins[0]).rstrip("/")
|
||||
site_path = "/auth/"
|
||||
elif args.rp_id == "localhost" and port:
|
||||
# Dev mode: use http with port
|
||||
site_url = f"http://localhost:{port}"
|
||||
site_path = "/auth/"
|
||||
# Compute site_url and site_path
|
||||
# Priority: auth_host > origins[0] > PASKIA_VITE_URL > http://localhost:port > https://rp_id
|
||||
site_path = "/auth/"
|
||||
if config.auth_host:
|
||||
site_url, site_path = config.auth_host, "/"
|
||||
elif config.origins:
|
||||
site_url = config.origins[0]
|
||||
elif vite_url := os.environ.get("PASKIA_VITE_URL"):
|
||||
site_url = vite_url.rstrip("/") # Devserver
|
||||
elif config.rp_id == "localhost" and port:
|
||||
site_url = f"http://localhost:{port}" # Backend directly if we can
|
||||
else:
|
||||
site_url = f"https://{args.rp_id}"
|
||||
site_path = "/auth/"
|
||||
site_url = f"https://{config.rp_id}" # Assume external reverse proxy
|
||||
|
||||
# Build runtime configuration
|
||||
config = PaskiaConfig(
|
||||
rp_id=args.rp_id,
|
||||
rp_name=args.rp_name or None,
|
||||
origins=origins or None,
|
||||
auth_host=args.auth_host or None,
|
||||
# Build runtime configuration for the server
|
||||
runtime = RuntimeConfig(
|
||||
config=config,
|
||||
site_url=site_url,
|
||||
site_path=site_path,
|
||||
host=host,
|
||||
port=port,
|
||||
uds=uds,
|
||||
save=args.save,
|
||||
)
|
||||
startupbox.print_startup_config(runtime)
|
||||
os.environ["PASKIA_CONFIG"] = msgspec.json.encode(runtime).decode()
|
||||
|
||||
# Export configuration via single JSON env variable for worker processes
|
||||
config_json = {
|
||||
"rp_id": config.rp_id,
|
||||
"rp_name": config.rp_name,
|
||||
"origins": config.origins,
|
||||
"auth_host": config.auth_host,
|
||||
"site_url": config.site_url,
|
||||
"site_path": config.site_path,
|
||||
}
|
||||
os.environ["PASKIA_CONFIG"] = json.dumps(config_json)
|
||||
|
||||
startupbox.print_startup_config(config)
|
||||
|
||||
# Build config to save (for bootstrap or explicit --save)
|
||||
cli_config = Config(
|
||||
rp_id=args.rp_id,
|
||||
rp_name=args.rp_name,
|
||||
origins=args.origins,
|
||||
auth_host=args.auth_host,
|
||||
listen=args.listen,
|
||||
)
|
||||
|
||||
async def startup():
|
||||
await _globals.init(
|
||||
rp_id=config.rp_id,
|
||||
rp_name=config.rp_name,
|
||||
origins=config.origins,
|
||||
bootstrap=False,
|
||||
)
|
||||
# Pass config to bootstrap - it will be saved within the bootstrap transaction
|
||||
await bootstrap_if_needed(config=cli_config)
|
||||
# Also save config if --save was explicitly used (even without bootstrap)
|
||||
if args.save:
|
||||
await db.update_config(cli_config)
|
||||
await flush()
|
||||
|
||||
asyncio.run(startup())
|
||||
|
||||
# Run the server (spawns processes in dev mode)
|
||||
dev = {"reload": True, "reload_dirs": ["paskia"]} if DEVMODE else {}
|
||||
server.run(
|
||||
"paskia.fastapi.mainapp:app",
|
||||
listen=args.listen,
|
||||
listen=config.listen,
|
||||
default_port=DEFAULT_PORT,
|
||||
log_level="warning",
|
||||
access_log=False,
|
||||
|
||||
@@ -23,6 +23,11 @@ if TYPE_CHECKING:
|
||||
EXPIRES = SESSION_LIFETIME
|
||||
|
||||
|
||||
def session_ctx(auth: str, host: str | None = None):
|
||||
"""Get session context with normalized host."""
|
||||
return db.data().session_ctx(auth, hostutil.normalize_host(host))
|
||||
|
||||
|
||||
def expires() -> datetime:
|
||||
return datetime.now(UTC) + EXPIRES
|
||||
|
||||
@@ -42,7 +47,7 @@ def get_reset(token: str) -> "ResetToken":
|
||||
|
||||
def delete_credential(credential_uuid: UUID, auth: str, host: str | None = None):
|
||||
"""Delete a specific credential for the current user."""
|
||||
ctx = db.data().session_ctx(auth, hostutil.normalize_host(host))
|
||||
ctx = session_ctx(auth, host)
|
||||
if not ctx:
|
||||
raise ValueError("Session expired")
|
||||
db.delete_credential(credential_uuid, ctx.user.uuid)
|
||||
|
||||
+28
-42
@@ -4,47 +4,37 @@ Bootstrap module for passkey authentication system.
|
||||
This module handles initial system setup when a new database is created,
|
||||
including creating default admin user, organization, permissions, and
|
||||
generating a reset link for initial admin setup.
|
||||
|
||||
The actual database seeding is performed by the module-level kanta bootstrap
|
||||
callback defined in :mod:`paskia.db.bootstrap` and registered during
|
||||
:func:`paskia.db.lifecycle.init`.
|
||||
"""
|
||||
|
||||
import logging
|
||||
|
||||
from paskia import authsession, db
|
||||
from paskia.db.bootstrap import log_reset_link
|
||||
from paskia.db.structs import Config
|
||||
from paskia.util import hostutil
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Shared log message template for admin reset links
|
||||
ADMIN_RESET_MESSAGE = """
|
||||
👤 Admin %s
|
||||
- Use this link to register a Passkey for the admin user!
|
||||
"""
|
||||
|
||||
def _configure_logger() -> None:
|
||||
if logger.handlers:
|
||||
return
|
||||
handler = logging.StreamHandler()
|
||||
handler.setFormatter(logging.Formatter("%(message)s"))
|
||||
logger.addHandler(handler)
|
||||
logger.setLevel(logging.INFO)
|
||||
logger.propagate = False
|
||||
|
||||
|
||||
_configure_logger()
|
||||
|
||||
|
||||
def _log_reset_link(passphrase: str, message: str | None = None) -> str:
|
||||
"""Log a reset link message and return the URL."""
|
||||
reset_link = hostutil.reset_link_url(passphrase)
|
||||
if message:
|
||||
logger.info(message)
|
||||
logger.info(ADMIN_RESET_MESSAGE, reset_link)
|
||||
return reset_link
|
||||
|
||||
|
||||
async def bootstrap_system(config: Config | None = None) -> None:
|
||||
"""
|
||||
Bootstrap the entire system with default data.
|
||||
|
||||
Uses db.bootstrap() which performs all operations in a single transaction.
|
||||
The transaction log will show a single "bootstrap" action with all changes.
|
||||
|
||||
Args:
|
||||
config: Configuration to store (rp_id, rp_name, origins, etc.)
|
||||
"""
|
||||
# Call the single-transaction bootstrap function
|
||||
reset_passphrase = db.bootstrap(config=config)
|
||||
|
||||
# Log the reset link (this is separate from the transaction log)
|
||||
_log_reset_link(reset_passphrase, "✅ Bootstrap completed!")
|
||||
return log_reset_link(passphrase, message)
|
||||
|
||||
|
||||
async def check_admin_credentials() -> bool:
|
||||
@@ -101,22 +91,18 @@ async def check_admin_credentials() -> bool:
|
||||
|
||||
async def bootstrap_if_needed(config: Config | None = None) -> bool:
|
||||
"""
|
||||
Check if system needs bootstrapping and perform it if necessary.
|
||||
Check if admin needs credentials and create a reset link if needed.
|
||||
|
||||
Database bootstrapping itself is now handled automatically during
|
||||
``db.init()`` via the registered kanta bootstrap callback. This function
|
||||
remains as a post-init hook for credential checks.
|
||||
|
||||
Args:
|
||||
config: Configuration to store during bootstrap (rp_id, rp_name, origins, etc.)
|
||||
config: Kept for backwards compatibility; config is now applied during
|
||||
``db.init()``.
|
||||
|
||||
Returns:
|
||||
bool: True if bootstrapping was performed, False if system was already set up
|
||||
bool: Always returns False (bootstrapping is performed during init).
|
||||
"""
|
||||
# Check if the admin permission exists - if it does, system is already bootstrapped
|
||||
if any(p.scope == "auth:admin" for p in db.data().permissions.values()):
|
||||
# Permission exists, system is already bootstrapped
|
||||
# Check if admin needs credentials (only for already-bootstrapped systems)
|
||||
await check_admin_credentials()
|
||||
return False
|
||||
|
||||
# No admin permission found, need to bootstrap
|
||||
# Bootstrap creates the admin user AND the reset link, so no need to check credentials after
|
||||
await bootstrap_system(config=config)
|
||||
return True
|
||||
await check_admin_credentials()
|
||||
return False
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
from dataclasses import dataclass
|
||||
from datetime import timedelta
|
||||
|
||||
# Shared configuration constants for session management.
|
||||
@@ -6,19 +5,3 @@ SESSION_LIFETIME = timedelta(hours=24)
|
||||
|
||||
# Lifetime for reset links created by admins
|
||||
RESET_LIFETIME = timedelta(days=14)
|
||||
|
||||
|
||||
@dataclass
|
||||
class PaskiaConfig:
|
||||
"""Runtime configuration for the Paskia authentication server."""
|
||||
|
||||
rp_id: str
|
||||
rp_name: str | None
|
||||
origins: list[str] | None
|
||||
auth_host: str | None
|
||||
site_url: str # Base URL without trailing path (e.g. https://example.com)
|
||||
site_path: str # Path to auth UI: "/" if auth_host, else "/auth/"
|
||||
# Listen address (one of host:port or uds)
|
||||
host: str | None = None
|
||||
port: int | None = None
|
||||
uds: str | None = None
|
||||
|
||||
+1
-23
@@ -1,7 +1,7 @@
|
||||
"""
|
||||
Database module for WebAuthn passkey authentication.
|
||||
|
||||
Read: Access data() directly, use build_* to convert to public structs.
|
||||
Read: Access data() directly for structs.
|
||||
CTX: data().session_ctx(key) returns SessionContext with effective permissions.
|
||||
Write: Functions validate and commit, or raise ValueError.
|
||||
|
||||
@@ -10,7 +10,6 @@ Usage:
|
||||
|
||||
# Read (after init)
|
||||
user_data = db.data().users[user_uuid]
|
||||
user = db.build_user(user_uuid)
|
||||
|
||||
# Context
|
||||
ctx = db.data().session_ctx(session_key)
|
||||
@@ -20,14 +19,7 @@ Usage:
|
||||
"""
|
||||
|
||||
import paskia.db.operations as operations
|
||||
from paskia.db.background import (
|
||||
start_background,
|
||||
start_cleanup,
|
||||
stop_background,
|
||||
stop_cleanup,
|
||||
)
|
||||
from paskia.db.bootstrap import bootstrap
|
||||
from paskia.db.lifecycle import cleanup_expired, init
|
||||
from paskia.db.operations import (
|
||||
add_permission_to_org,
|
||||
add_permission_to_role,
|
||||
@@ -101,25 +93,11 @@ __all__ = [
|
||||
"User",
|
||||
# Instance
|
||||
"data",
|
||||
"init",
|
||||
# Background
|
||||
"start_background",
|
||||
"stop_background",
|
||||
"start_cleanup",
|
||||
"stop_cleanup",
|
||||
# Builders
|
||||
"build_credential",
|
||||
"build_permission",
|
||||
"build_reset_token",
|
||||
"build_role",
|
||||
"build_session",
|
||||
"build_user",
|
||||
# Read ops
|
||||
# Write ops
|
||||
"add_permission_to_org",
|
||||
"add_permission_to_role",
|
||||
"bootstrap",
|
||||
"cleanup_expired",
|
||||
"create_credential",
|
||||
"create_credential_session",
|
||||
"create_org",
|
||||
|
||||
+10
-36
@@ -1,63 +1,38 @@
|
||||
"""
|
||||
Background task for database maintenance.
|
||||
|
||||
Periodically flushes pending changes to disk and cleans up expired items.
|
||||
Kanta handles periodic flushing to disk. This module keeps a small
|
||||
companion task that periodically cleans up expired sessions/tokens.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from datetime import UTC, datetime
|
||||
|
||||
import paskia.db.operations as _ops
|
||||
from paskia.db.lifecycle import cleanup_expired
|
||||
|
||||
FLUSH_INTERVAL = 0.1 # Flush to disk
|
||||
CLEANUP_INTERVAL = 1 # Expired item cleanup
|
||||
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
_background_task: asyncio.Task | None = None
|
||||
|
||||
|
||||
async def flush() -> None:
|
||||
"""Write all pending database changes to disk."""
|
||||
store = _ops._store
|
||||
if store is None:
|
||||
_logger.warning("flush() called but _store is None")
|
||||
return
|
||||
await store.flush()
|
||||
|
||||
|
||||
async def _background_loop():
|
||||
"""Background task that periodically flushes changes and cleans up."""
|
||||
"""Background task that periodically cleans up expired items."""
|
||||
# Run cleanup immediately on startup to clear old expired items
|
||||
cleanup_expired()
|
||||
await flush()
|
||||
|
||||
last_cleanup = datetime.now(UTC)
|
||||
|
||||
while True:
|
||||
try:
|
||||
await asyncio.sleep(FLUSH_INTERVAL)
|
||||
# Flush pending changes to disk
|
||||
await flush()
|
||||
|
||||
# Run cleanup periodically
|
||||
now = datetime.now(UTC)
|
||||
if (now - last_cleanup).total_seconds() >= CLEANUP_INTERVAL:
|
||||
cleanup_expired()
|
||||
await flush() # Flush cleanup changes
|
||||
last_cleanup = now
|
||||
await asyncio.sleep(CLEANUP_INTERVAL)
|
||||
cleanup_expired()
|
||||
except asyncio.CancelledError:
|
||||
# Final flush before exit
|
||||
await flush()
|
||||
break
|
||||
except Exception:
|
||||
_logger.debug("Error in database background loop", exc_info=True)
|
||||
|
||||
|
||||
async def start_background():
|
||||
"""Start the background flush/cleanup task."""
|
||||
"""Start the background cleanup task."""
|
||||
global _background_task
|
||||
|
||||
# Check if task exists but is no longer running (e.g., after uvicorn reload)
|
||||
@@ -71,16 +46,15 @@ async def start_background():
|
||||
# Check if task is in current event loop
|
||||
loop = asyncio.get_running_loop()
|
||||
task_loop = _background_task.get_loop()
|
||||
if loop is not task_loop:
|
||||
_logger.debug("Background task in different event loop, restarting")
|
||||
_background_task = None
|
||||
else:
|
||||
if loop is task_loop:
|
||||
# Task is already running in same loop - idempotent, just return
|
||||
# This happens with dual IPv4+IPv6 endpoints sharing the same process
|
||||
_logger.debug(
|
||||
"Background task already running in same loop, skipping"
|
||||
)
|
||||
return
|
||||
_logger.debug("Background task in different event loop, restarting")
|
||||
_background_task = None
|
||||
except Exception as e:
|
||||
_logger.debug("Error checking background task loop: %s, restarting", e)
|
||||
_background_task = None
|
||||
@@ -90,7 +64,7 @@ async def start_background():
|
||||
|
||||
|
||||
async def stop_background():
|
||||
"""Stop the background task and flush any pending changes."""
|
||||
"""Stop the background cleanup task."""
|
||||
global _background_task
|
||||
if _background_task:
|
||||
_background_task.cancel()
|
||||
|
||||
+96
-62
@@ -2,23 +2,60 @@
|
||||
Bootstrap operations for initial system setup.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import sys
|
||||
from datetime import UTC, datetime
|
||||
|
||||
import uuid7
|
||||
|
||||
import paskia.db.operations as _ops
|
||||
from paskia.db.structs import Config, Org, Permission, ResetToken, Role, User
|
||||
from paskia.authsession import reset_expires
|
||||
from paskia.db.structs import DB, Config, Org, Permission, ResetToken, Role, User
|
||||
from paskia.util.crypto import secret_key
|
||||
from paskia.util.hostutil import reset_link_url
|
||||
|
||||
_reset_link_logger = logging.getLogger("paskia.reset_link")
|
||||
|
||||
|
||||
def _configure_reset_link_logger() -> None:
|
||||
if _reset_link_logger.handlers:
|
||||
return
|
||||
handler = logging.StreamHandler(sys.stderr)
|
||||
handler.setFormatter(logging.Formatter("%(message)s"))
|
||||
_reset_link_logger.addHandler(handler)
|
||||
_reset_link_logger.setLevel(logging.INFO)
|
||||
_reset_link_logger.propagate = False
|
||||
|
||||
|
||||
_configure_reset_link_logger()
|
||||
|
||||
ADMIN_RESET_MESSAGE = """
|
||||
👤 Admin %s
|
||||
- Use this link to register a Passkey for the admin user!
|
||||
"""
|
||||
|
||||
|
||||
def log_reset_link(passphrase: str, message: str | None = None) -> str:
|
||||
"""Log a reset link message and return the URL."""
|
||||
reset_link = reset_link_url(passphrase)
|
||||
if message:
|
||||
_reset_link_logger.info(message)
|
||||
_reset_link_logger.info(ADMIN_RESET_MESSAGE, reset_link)
|
||||
return reset_link
|
||||
|
||||
|
||||
def bootstrap(
|
||||
data: "DB",
|
||||
org_name: str = "Organization",
|
||||
admin_name: str = "Admin",
|
||||
reset_passphrase: str | None = None,
|
||||
reset_expiry: datetime | None = None,
|
||||
config: Config | None = None,
|
||||
) -> str:
|
||||
"""Bootstrap the entire system in a single transaction.
|
||||
"""Bootstrap the entire system by seeding an empty database.
|
||||
|
||||
This is intended to be called from a ``@kanta.bootstrap`` callback during
|
||||
``kanta.open()``. It mutates the provided root ``data`` object directly;
|
||||
kanta queues the resulting state as the initial "bootstrap" change record.
|
||||
|
||||
Creates:
|
||||
- auth:admin permission (Master Admin)
|
||||
@@ -28,10 +65,8 @@ def bootstrap(
|
||||
- Reset token for admin registration
|
||||
- Config (if provided)
|
||||
|
||||
This is the only way to create a new database file.
|
||||
All data is created atomically - if any step fails, nothing is written.
|
||||
|
||||
Args:
|
||||
data: The live root database object (usually a ``DB`` instance).
|
||||
org_name: Display name for the organization (default: "Organization")
|
||||
admin_name: Display name for the admin user (default: "Admin")
|
||||
reset_passphrase: Passphrase for the reset token (generated if not provided)
|
||||
@@ -43,7 +78,7 @@ def bootstrap(
|
||||
"""
|
||||
|
||||
# Check if system is already bootstrapped
|
||||
for p in _ops._db.permissions.values():
|
||||
for p in data.permissions.values():
|
||||
if p.scope == "auth:admin":
|
||||
raise ValueError(
|
||||
"System already bootstrapped (auth:admin permission exists)"
|
||||
@@ -59,69 +94,68 @@ def bootstrap(
|
||||
|
||||
# Set reset token expiry (passphrase generated by ResetToken.create)
|
||||
if reset_expiry is None:
|
||||
from paskia.authsession import reset_expires # noqa: PLC0415
|
||||
|
||||
reset_expiry = reset_expires()
|
||||
|
||||
with _ops._db.transaction("bootstrap"):
|
||||
# Create auth:admin permission
|
||||
perm_admin = Permission(
|
||||
scope="auth:admin",
|
||||
display_name="Master Admin",
|
||||
orgs={org_uuid: True}, # Grant to org
|
||||
)
|
||||
perm_admin.uuid = perm_admin_uuid
|
||||
perm_admin.store()
|
||||
# Create auth:admin permission
|
||||
perm_admin = Permission(
|
||||
scope="auth:admin",
|
||||
display_name="Master Admin",
|
||||
orgs={org_uuid: True}, # Grant to org
|
||||
)
|
||||
perm_admin.uuid = perm_admin_uuid
|
||||
|
||||
# Create auth:org:admin permission
|
||||
perm_org_admin = Permission(
|
||||
scope="auth:org:admin",
|
||||
display_name="Org Admin",
|
||||
orgs={org_uuid: True}, # Grant to org
|
||||
)
|
||||
perm_org_admin.uuid = perm_org_admin_uuid
|
||||
perm_org_admin.store()
|
||||
# Create auth:org:admin permission
|
||||
perm_org_admin = Permission(
|
||||
scope="auth:org:admin",
|
||||
display_name="Org Admin",
|
||||
orgs={org_uuid: True}, # Grant to org
|
||||
)
|
||||
perm_org_admin.uuid = perm_org_admin_uuid
|
||||
|
||||
# Create organization
|
||||
new_org = Org.create(display_name=org_name)
|
||||
new_org.uuid = org_uuid
|
||||
new_org.store()
|
||||
# Create organization
|
||||
new_org = Org.create(display_name=org_name)
|
||||
new_org.uuid = org_uuid
|
||||
|
||||
# Create Administration role with both permissions
|
||||
admin_role = Role(
|
||||
org_uuid=org_uuid,
|
||||
display_name="Administration",
|
||||
permissions={perm_admin_uuid: True, perm_org_admin_uuid: True},
|
||||
)
|
||||
admin_role.uuid = role_uuid
|
||||
admin_role.store()
|
||||
# Create Administration role with both permissions
|
||||
admin_role = Role(
|
||||
org_uuid=org_uuid,
|
||||
display_name="Administration",
|
||||
permissions={perm_admin_uuid: True, perm_org_admin_uuid: True},
|
||||
)
|
||||
admin_role.uuid = role_uuid
|
||||
|
||||
# Create admin user
|
||||
admin_user = User(
|
||||
display_name=admin_name,
|
||||
role_uuid=role_uuid,
|
||||
created_at=now,
|
||||
last_seen=None,
|
||||
visits=0,
|
||||
theme="",
|
||||
)
|
||||
admin_user.uuid = user_uuid
|
||||
admin_user.store()
|
||||
# Create admin user
|
||||
admin_user = User(
|
||||
display_name=admin_name,
|
||||
role_uuid=role_uuid,
|
||||
created_at=now,
|
||||
last_seen=None,
|
||||
visits=0,
|
||||
theme="",
|
||||
)
|
||||
admin_user.uuid = user_uuid
|
||||
|
||||
# Create reset token
|
||||
reset_token, reset_passphrase = ResetToken.create(
|
||||
user=user_uuid,
|
||||
expiry=reset_expiry,
|
||||
token_type="admin bootstrap",
|
||||
passphrase=reset_passphrase,
|
||||
)
|
||||
reset_token.store()
|
||||
# Create reset token
|
||||
reset_token, reset_passphrase = ResetToken.create(
|
||||
user=user_uuid,
|
||||
expiry=reset_expiry,
|
||||
token_type="admin bootstrap",
|
||||
passphrase=reset_passphrase,
|
||||
)
|
||||
|
||||
# Set config if provided
|
||||
if config is not None:
|
||||
_ops._db.config = config
|
||||
# Set config if provided
|
||||
if config is not None:
|
||||
data.config = config
|
||||
|
||||
# Generate OIDC signing key
|
||||
_ops._db.oidc.key = secret_key()
|
||||
# Generate OIDC signing key
|
||||
data.oidc.key = secret_key()
|
||||
|
||||
# Store all bootstrapped objects in the live data object
|
||||
data.permissions[perm_admin_uuid] = perm_admin
|
||||
data.permissions[perm_org_admin_uuid] = perm_org_admin
|
||||
data.orgs[org_uuid] = new_org
|
||||
data.roles[role_uuid] = admin_role
|
||||
data.users[user_uuid] = admin_user
|
||||
data.reset_tokens[reset_token.key] = reset_token
|
||||
|
||||
return reset_passphrase
|
||||
|
||||
@@ -1,293 +0,0 @@
|
||||
"""
|
||||
JSONL persistence layer for the database.
|
||||
"""
|
||||
|
||||
import copy
|
||||
import logging
|
||||
import os
|
||||
import signal
|
||||
from collections import deque
|
||||
from contextlib import contextmanager
|
||||
from datetime import UTC, datetime
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
from uuid import UUID
|
||||
|
||||
import aiofiles
|
||||
import jsondiff
|
||||
import msgspec
|
||||
|
||||
from paskia.db.logging import log_change
|
||||
from paskia.db.migrations import DBVER, apply_all_migrations
|
||||
from paskia.db.structs import DB, SessionContext
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
# Default database path
|
||||
DB_PATH_DEFAULT = "paskia.jsonl"
|
||||
|
||||
|
||||
class _ChangeRecord(msgspec.Struct, omit_defaults=True):
|
||||
"""A single change record in the JSONL file."""
|
||||
|
||||
ts: datetime
|
||||
a: str # action - describes the operation (e.g., "migrate", "login", "create_user")
|
||||
v: int # schema version after this change
|
||||
u: str | None = None # user UUID who performed the action (None for system)
|
||||
diff: dict = {}
|
||||
|
||||
|
||||
# msgspec encoder for change records
|
||||
_change_encoder = msgspec.json.Encoder()
|
||||
|
||||
|
||||
def compute_diff(previous: dict, current: dict) -> dict | None:
|
||||
"""Compute JSON diff between two states.
|
||||
|
||||
Args:
|
||||
previous: Previous state (JSON-compatible dict)
|
||||
current: Current state (JSON-compatible dict)
|
||||
|
||||
Returns:
|
||||
The diff, or None if no changes
|
||||
"""
|
||||
diff = jsondiff.diff(previous, current, marshal=True)
|
||||
return diff if diff else None
|
||||
|
||||
|
||||
def create_change_record(
|
||||
action: str, version: int, diff: dict, user: str | None = None
|
||||
) -> _ChangeRecord:
|
||||
"""Create a change record for persistence."""
|
||||
return _ChangeRecord(
|
||||
ts=datetime.now(UTC),
|
||||
a=action,
|
||||
v=version,
|
||||
u=user,
|
||||
diff=diff,
|
||||
)
|
||||
|
||||
|
||||
# Actions that are allowed to create a new database file
|
||||
_BOOTSTRAP_ACTIONS = frozenset({"bootstrap"})
|
||||
|
||||
# Flag to prevent duplicate error messages on fatal flush failure
|
||||
_flush_failed = False
|
||||
|
||||
|
||||
async def flush_changes(
|
||||
db_path: Path,
|
||||
pending_changes: deque[_ChangeRecord],
|
||||
) -> None:
|
||||
"""Write all pending changes to disk.
|
||||
|
||||
Args:
|
||||
db_path: Path to the JSONL database file
|
||||
pending_changes: Queue of pending change records (will be cleared on success)
|
||||
|
||||
On failure, logs an error and sends SIGTERM to trigger graceful shutdown.
|
||||
"""
|
||||
global _flush_failed
|
||||
if _flush_failed or not pending_changes:
|
||||
return
|
||||
|
||||
if not db_path.exists():
|
||||
first_action = pending_changes[0].a
|
||||
if first_action not in _BOOTSTRAP_ACTIONS:
|
||||
_logger.error(
|
||||
"Refusing to create database file with action '%s' - "
|
||||
"only bootstrap can create a new database",
|
||||
first_action,
|
||||
)
|
||||
_flush_failed = True
|
||||
os.kill(os.getpid(), signal.SIGTERM)
|
||||
return
|
||||
|
||||
changes_to_write = list(pending_changes)
|
||||
|
||||
try:
|
||||
lines = [_change_encoder.encode(change) for change in changes_to_write]
|
||||
if not lines:
|
||||
pending_changes.clear()
|
||||
return
|
||||
|
||||
async with aiofiles.open(db_path, "ab") as f:
|
||||
await f.write(b"\n".join(lines) + b"\n")
|
||||
pending_changes.clear()
|
||||
except OSError as e:
|
||||
_logger.error("Failed to flush database: %s", e)
|
||||
_flush_failed = True
|
||||
os.kill(os.getpid(), signal.SIGTERM)
|
||||
|
||||
|
||||
class JsonlStore:
|
||||
"""JSONL persistence layer for a DB instance."""
|
||||
|
||||
def __init__(self, db: DB, db_path: str = DB_PATH_DEFAULT):
|
||||
self.db: DB = db
|
||||
self.db_path = Path(db_path)
|
||||
self._previous_builtins: dict[str, Any] = {}
|
||||
self._pending_changes: deque[_ChangeRecord] = deque()
|
||||
self._current_action: str = "system"
|
||||
self._current_user: str | None = None
|
||||
self._in_transaction: bool = False
|
||||
self._transaction_snapshot: dict[str, Any] | None = None
|
||||
self._current_version: int = DBVER # Schema version for new databases
|
||||
|
||||
async def load(
|
||||
self, db_path: str | None = None, *, rp_id: str = "localhost"
|
||||
) -> None:
|
||||
"""Load data from JSONL change log."""
|
||||
if db_path is not None:
|
||||
self.db_path = Path(db_path)
|
||||
self._rp_id = rp_id
|
||||
if not self.db_path.exists():
|
||||
return
|
||||
|
||||
# Replay change log to reconstruct state
|
||||
data_dict: dict = {}
|
||||
try:
|
||||
async with aiofiles.open(self.db_path, "rb") as f:
|
||||
content = await f.read()
|
||||
for line_num, line in enumerate(content.split(b"\n"), 1):
|
||||
line = line.strip()
|
||||
if not line:
|
||||
continue
|
||||
try:
|
||||
change = msgspec.json.decode(line)
|
||||
data_dict = jsondiff.patch(data_dict, change["diff"], marshal=True)
|
||||
self._current_version = change.get("v", 0)
|
||||
except Exception as e:
|
||||
raise ValueError(f"Error parsing line {line_num}: {e}")
|
||||
except OSError as e:
|
||||
raise SystemExit(f"Failed to load database: {e}")
|
||||
except (ValueError, msgspec.DecodeError) as e:
|
||||
raise SystemExit(f"Failed to load database: {e}")
|
||||
|
||||
if not data_dict:
|
||||
return
|
||||
|
||||
# Set previous state for diffing (will be updated by _queue_change)
|
||||
self._previous_builtins = copy.deepcopy(data_dict)
|
||||
|
||||
# Callback to persist each migration
|
||||
async def persist_migration(
|
||||
action: str, new_version: int, current: dict
|
||||
) -> None:
|
||||
self._current_version = new_version
|
||||
self._queue_change(action, new_version, current)
|
||||
|
||||
# Apply schema migrations one at a time
|
||||
await apply_all_migrations(
|
||||
data_dict, self._current_version, persist_migration, rp_id=rp_id
|
||||
)
|
||||
|
||||
# Decode to msgspec struct
|
||||
decoder = msgspec.json.Decoder(DB)
|
||||
self.db = decoder.decode(msgspec.json.encode(data_dict))
|
||||
self.db._store = self
|
||||
|
||||
# Normalize via msgspec round-trip (handles omit_defaults etc.)
|
||||
# This ensures _previous_builtins matches what msgspec would produce
|
||||
normalized_dict = msgspec.to_builtins(self.db)
|
||||
await persist_migration(
|
||||
"migrate:msgspec", self._current_version, normalized_dict
|
||||
)
|
||||
|
||||
def _queue_change(
|
||||
self, action: str, version: int, current: dict, user: str | None = None
|
||||
) -> None:
|
||||
"""Queue a change record and log it.
|
||||
|
||||
Args:
|
||||
action: The action name for the change record
|
||||
version: The schema version for the change record
|
||||
current: The current state as a plain dict
|
||||
user: Optional user UUID who performed the action
|
||||
"""
|
||||
diff = compute_diff(self._previous_builtins, current)
|
||||
if not diff:
|
||||
return
|
||||
self._pending_changes.append(create_change_record(action, version, diff, user))
|
||||
|
||||
# Log the change with user display name if available
|
||||
user_display = None
|
||||
if user:
|
||||
try:
|
||||
user_uuid = UUID(user)
|
||||
if user_uuid in self.db.users:
|
||||
user_display = self.db.users[user_uuid].display_name
|
||||
except (ValueError, KeyError):
|
||||
user_display = user
|
||||
|
||||
log_change(action, diff, user_display, self._previous_builtins, self.db)
|
||||
self._previous_builtins = copy.deepcopy(current)
|
||||
|
||||
@contextmanager
|
||||
def transaction(
|
||||
self,
|
||||
action: str,
|
||||
ctx: SessionContext | None = None,
|
||||
*,
|
||||
user: str | None = None,
|
||||
):
|
||||
"""Wrap writes in transaction. Queues change on successful exit.
|
||||
|
||||
Args:
|
||||
action: Describes the operation (e.g., "Created user", "Login")
|
||||
ctx: Session context of user performing the action (None for system operations)
|
||||
user: User UUID string (alternative to ctx when full context unavailable)
|
||||
"""
|
||||
if self._in_transaction:
|
||||
raise RuntimeError("Nested transactions are not supported")
|
||||
|
||||
# Check for out-of-transaction modifications
|
||||
current_state = msgspec.to_builtins(self.db)
|
||||
if current_state != self._previous_builtins:
|
||||
# Allow bootstrap to create a new database from empty state
|
||||
is_bootstrap = action in _BOOTSTRAP_ACTIONS
|
||||
if is_bootstrap and not self._previous_builtins:
|
||||
pass # Expected: creating database from scratch
|
||||
else:
|
||||
diff = compute_diff(self._previous_builtins, current_state)
|
||||
diff_json = msgspec.json.encode(diff).decode()
|
||||
_logger.critical(
|
||||
"Database state modified outside of transaction! "
|
||||
"This indicates a bug where DB changes occurred without a transaction wrapper.\n"
|
||||
f"Changes detected:\n{diff_json}"
|
||||
)
|
||||
raise SystemExit(1)
|
||||
|
||||
old_action = self._current_action
|
||||
old_user = self._current_user
|
||||
self._current_action = action
|
||||
# Prefer ctx.user.uuid if ctx provided, otherwise use user param
|
||||
self._current_user = str(ctx.user.uuid) if ctx else user
|
||||
self._in_transaction = True
|
||||
self._transaction_snapshot = current_state
|
||||
|
||||
try:
|
||||
yield
|
||||
current = msgspec.to_builtins(self.db)
|
||||
self._queue_change(
|
||||
self._current_action, self._current_version, current, self._current_user
|
||||
)
|
||||
except Exception:
|
||||
# Rollback on error: restore from snapshot
|
||||
_logger.warning("Transaction '%s' failed, rolling back changes", action)
|
||||
if self._transaction_snapshot is not None:
|
||||
decoder = msgspec.json.Decoder(DB)
|
||||
self.db = decoder.decode(
|
||||
msgspec.json.encode(self._transaction_snapshot)
|
||||
)
|
||||
self.db._store = self
|
||||
raise
|
||||
finally:
|
||||
self._current_action = old_action
|
||||
self._current_user = old_user
|
||||
self._in_transaction = False
|
||||
self._transaction_snapshot = None
|
||||
|
||||
async def flush(self) -> None:
|
||||
"""Write all pending changes to disk."""
|
||||
await flush_changes(self.db_path, self._pending_changes)
|
||||
+131
-18
@@ -2,44 +2,157 @@
|
||||
Database lifecycle: initialization and maintenance.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import os
|
||||
import signal
|
||||
from datetime import UTC, datetime
|
||||
from pathlib import Path
|
||||
from typing import Annotated, Any, Optional
|
||||
from uuid import UUID
|
||||
|
||||
from kanta import Kanta
|
||||
from kanta.exceptions import DatabaseError
|
||||
|
||||
import paskia.db.operations as _ops
|
||||
from paskia import oidc_notify
|
||||
from paskia.authsession import EXPIRES
|
||||
from paskia.db.bootstrap import bootstrap, log_reset_link
|
||||
from paskia.db.paths import db_file_path
|
||||
from paskia.db.structs import DB
|
||||
from paskia.util.runtime import config as runtime_config
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def init(rp_id: str = "localhost", *args, **kwargs):
|
||||
"""Load database from JSONL file."""
|
||||
if _ops._initialized:
|
||||
_logger.debug("Database already initialized, skipping reload")
|
||||
return
|
||||
default_path = f"{rp_id}.paskiadb"
|
||||
db_path = os.environ.get("PASKIA_DB", default_path)
|
||||
await _ops._store.load(db_path, rp_id=rp_id)
|
||||
_ops._db = _ops._store.db
|
||||
_ops._initialized = True
|
||||
runtime = runtime_config()
|
||||
if runtime is None:
|
||||
raise RuntimeError("PASKIA_CONFIG must be defined before importing db.lifecycle")
|
||||
|
||||
kanta = Kanta(
|
||||
str(db_file_path(rp_id=runtime.config.rp_id, create_root=False)),
|
||||
_ops._db,
|
||||
migrations="paskia.db.migrations",
|
||||
)
|
||||
kanta.ctx.rp_id = runtime.config.rp_id
|
||||
_ops._db._store = kanta
|
||||
|
||||
|
||||
def _lookup_uuid_in_state(state: dict | None, uuid_str: str) -> str | None:
|
||||
"""Resolve UUID to label from serialized state dict."""
|
||||
if not state:
|
||||
return None
|
||||
|
||||
# Display-name based entities.
|
||||
for bucket in ("users", "orgs", "roles", "permissions"):
|
||||
entity = state.get(bucket, {}).get(uuid_str)
|
||||
if isinstance(entity, dict):
|
||||
display_name = entity.get("display_name")
|
||||
if isinstance(display_name, str) and display_name:
|
||||
return display_name
|
||||
|
||||
# OIDC clients use "name" instead of "display_name".
|
||||
client = state.get("oidc", {}).get("clients", {}).get(uuid_str)
|
||||
if isinstance(client, dict):
|
||||
name = client.get("name")
|
||||
if isinstance(name, str) and name:
|
||||
return name
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def _resolve_uuid_label(
|
||||
uuid_str: str,
|
||||
*,
|
||||
previous: dict | None = None,
|
||||
current: dict | None = None,
|
||||
) -> str | None:
|
||||
"""Resolve known entity UUIDs to human-readable labels."""
|
||||
# Prefer previous state so deletions/renames still show a useful label.
|
||||
label = _lookup_uuid_in_state(previous, uuid_str)
|
||||
if label:
|
||||
return label
|
||||
label = _lookup_uuid_in_state(current, uuid_str)
|
||||
if label:
|
||||
return label
|
||||
|
||||
try:
|
||||
uid = UUID(uuid_str)
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
if uid in _ops._db.users:
|
||||
return _ops._db.users[uid].display_name
|
||||
if uid in _ops._db.orgs:
|
||||
return _ops._db.orgs[uid].display_name
|
||||
if uid in _ops._db.roles:
|
||||
return _ops._db.roles[uid].display_name
|
||||
if uid in _ops._db.permissions:
|
||||
return _ops._db.permissions[uid].display_name
|
||||
if uid in _ops._db.oidc.clients:
|
||||
return _ops._db.oidc.clients[uid].name
|
||||
return None
|
||||
|
||||
|
||||
@kanta.logfmt
|
||||
def format_log_uuid(
|
||||
value: Any,
|
||||
path: str,
|
||||
previous: Annotated[dict, "pre"] | None = None,
|
||||
current: Annotated[dict, "post"] | None = None,
|
||||
) -> Optional[str]: # noqa: UP045
|
||||
"""Format UUID values/keys/actor labels and censor secrets in transaction logs."""
|
||||
# Censor sensitive OIDC key material regardless of value type, but only
|
||||
# when formatting the value: path components are passed with the component
|
||||
# itself as value and must stay visible ("oidc.key = <hidden>").
|
||||
if (path == "oidc.key" or path.endswith(".oidc.key")) and value != "key":
|
||||
return "<hidden>"
|
||||
|
||||
if not isinstance(value, str):
|
||||
return None
|
||||
|
||||
# Works for transaction actor metadata ($user), values, and path components.
|
||||
return _resolve_uuid_label(value, previous=previous, current=current)
|
||||
|
||||
|
||||
@kanta.fatal_error
|
||||
def terminate(error: DatabaseError) -> None:
|
||||
"""Fatal error callback: terminate the process on background write failures."""
|
||||
logger.error("Fatal database error: %s", error)
|
||||
os.kill(os.getpid(), signal.SIGTERM)
|
||||
|
||||
|
||||
@kanta.bootstrap
|
||||
def bootstrap_db(data: DB) -> None:
|
||||
reset_passphrase = bootstrap(data, config=runtime.config)
|
||||
log_reset_link(reset_passphrase, "✅ Bootstrap completed!")
|
||||
|
||||
|
||||
async def init():
|
||||
"""Load database from JSONL file using kanta.
|
||||
|
||||
If the database file is empty, the configured bootstrap callback seeds it
|
||||
with default permissions, organization, role, admin user and a reset token.
|
||||
"""
|
||||
rootpath = Path(kanta.filename).parent
|
||||
try:
|
||||
await asyncio.to_thread(rootpath.mkdir, parents=True, exist_ok=True)
|
||||
await kanta.open()
|
||||
except Exception as e:
|
||||
raise SystemExit(f"{e}") from e
|
||||
|
||||
|
||||
def cleanup_expired() -> int:
|
||||
"""Remove expired sessions and reset tokens. Returns count removed."""
|
||||
now = datetime.now(UTC)
|
||||
count = 0
|
||||
limit = now - EXPIRES
|
||||
expired_sessions = [k for k, s in _ops._db.sessions.items() if s.validated < limit]
|
||||
if expired_sessions:
|
||||
from paskia import oidc_notify # noqa: PLC0415
|
||||
|
||||
oidc_notify.schedule_notifications(expired_sessions)
|
||||
with _ops._db.transaction("expiry"):
|
||||
with kanta.transaction("expiry"):
|
||||
for k in expired_sessions:
|
||||
del _ops._db.sessions[k]
|
||||
count += 1
|
||||
expired_tokens = [k for k, t in _ops._db.reset_tokens.items() if t.expiry < now]
|
||||
for k in expired_tokens:
|
||||
del _ops._db.reset_tokens[k]
|
||||
count += 1
|
||||
return count
|
||||
return len(expired_sessions) + len(expired_tokens)
|
||||
|
||||
@@ -1,466 +0,0 @@
|
||||
"""
|
||||
Database change logging with pretty-printed diffs.
|
||||
|
||||
Provides a logger for JSONL database changes that formats diffs
|
||||
in a human-readable path.notation style with color coding.
|
||||
|
||||
UUIDs are replaced with display names where available, or the full UUID string
|
||||
for types without display names.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import re
|
||||
import sys
|
||||
from typing import TYPE_CHECKING, Any
|
||||
from uuid import UUID
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from paskia.db.structs import DB
|
||||
|
||||
logger = logging.getLogger("paskia.db")
|
||||
|
||||
# UUID regex pattern (8-4-4-4-12 hex format)
|
||||
_UUID_PATTERN = re.compile(
|
||||
r"^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$"
|
||||
)
|
||||
|
||||
# Pattern to match control characters and bidirectional overrides
|
||||
_UNSAFE_CHARS = re.compile(
|
||||
r"[\x00-\x1f\x7f-\x9f" # C0 and C1 control characters
|
||||
r"\u200e\u200f" # LRM, RLM
|
||||
r"\u202a-\u202e" # LRE, RLE, PDF, LRO, RLO
|
||||
r"\u2066-\u2069" # LRI, RLI, FSI, PDI
|
||||
r"]"
|
||||
)
|
||||
|
||||
# ANSI color codes (matching FastAPI logging style)
|
||||
_RESET = "\033[0m"
|
||||
_DIM = "\033[2m"
|
||||
_PATH_PREFIX = "\033[1;30m" # Dark grey for path prefix (like host in access log)
|
||||
_PATH_FINAL = "\033[0m" # Default for final element (like path in access log)
|
||||
_DELETE = "\033[1;31m" # Red for deletions
|
||||
_ADD = "\033[0;32m" # Green for additions
|
||||
_ACTION = "\033[1;34m" # Bold blue for action name
|
||||
_USER = "\033[0;34m" # Blue for user display
|
||||
|
||||
|
||||
def _is_uuid(value: str) -> bool:
|
||||
"""Check if a string is a UUID."""
|
||||
return bool(_UUID_PATTERN.match(value))
|
||||
|
||||
|
||||
class UuidResolver:
|
||||
"""Resolve UUIDs to display names or short suffixes.
|
||||
|
||||
Uses the previous state for lookups to show the name before any changes.
|
||||
"""
|
||||
|
||||
def __init__(self, db: "DB | None" = None, previous: dict | None = None):
|
||||
self._db = db
|
||||
self._previous = previous
|
||||
|
||||
def resolve(self, uuid_str: str) -> str:
|
||||
"""Resolve a UUID to its display name or the full UUID string."""
|
||||
display = self._get_display_name(uuid_str)
|
||||
if display:
|
||||
return display
|
||||
return uuid_str
|
||||
|
||||
def _get_display_name(self, uuid_str: str) -> str | None:
|
||||
"""Look up display name for a UUID.
|
||||
|
||||
First checks the previous state (to show names before changes),
|
||||
then falls back to the current database.
|
||||
"""
|
||||
# Try previous state first (for showing name before a change)
|
||||
name = self._lookup_in_previous(uuid_str)
|
||||
if name:
|
||||
return name
|
||||
|
||||
# Fall back to current database
|
||||
return self._lookup_in_db(uuid_str)
|
||||
|
||||
def _lookup_in_previous(self, uuid_str: str) -> str | None:
|
||||
"""Look up display name in the previous state dict."""
|
||||
if not self._previous:
|
||||
return None
|
||||
|
||||
# Check users
|
||||
if "users" in self._previous and uuid_str in self._previous["users"]:
|
||||
user_data = self._previous["users"][uuid_str]
|
||||
if isinstance(user_data, dict) and "display_name" in user_data:
|
||||
return user_data["display_name"]
|
||||
|
||||
# Check orgs
|
||||
if "orgs" in self._previous and uuid_str in self._previous["orgs"]:
|
||||
org_data = self._previous["orgs"][uuid_str]
|
||||
if isinstance(org_data, dict) and "display_name" in org_data:
|
||||
return org_data["display_name"]
|
||||
|
||||
# Check roles
|
||||
if "roles" in self._previous and uuid_str in self._previous["roles"]:
|
||||
role_data = self._previous["roles"][uuid_str]
|
||||
if isinstance(role_data, dict) and "display_name" in role_data:
|
||||
return role_data["display_name"]
|
||||
|
||||
# Check permissions
|
||||
if (
|
||||
"permissions" in self._previous
|
||||
and uuid_str in self._previous["permissions"]
|
||||
):
|
||||
perm_data = self._previous["permissions"][uuid_str]
|
||||
if isinstance(perm_data, dict) and "display_name" in perm_data:
|
||||
return perm_data["display_name"]
|
||||
|
||||
return None
|
||||
|
||||
def _lookup_in_db(self, uuid_str: str) -> str | None:
|
||||
"""Look up display name in the current database."""
|
||||
if not self._db:
|
||||
return None
|
||||
|
||||
try:
|
||||
uuid_obj = UUID(uuid_str)
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
# Check users
|
||||
if uuid_obj in self._db.users:
|
||||
return self._db.users[uuid_obj].display_name
|
||||
|
||||
# Check orgs
|
||||
if uuid_obj in self._db.orgs:
|
||||
return self._db.orgs[uuid_obj].display_name
|
||||
|
||||
# Check roles
|
||||
if uuid_obj in self._db.roles:
|
||||
return self._db.roles[uuid_obj].display_name
|
||||
|
||||
# Check permissions
|
||||
if uuid_obj in self._db.permissions:
|
||||
return self._db.permissions[uuid_obj].display_name
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def _format_value(
|
||||
value: Any,
|
||||
max_len: int = 60,
|
||||
resolver: UuidResolver | None = None,
|
||||
) -> str:
|
||||
"""Format a value for display, truncating if needed.
|
||||
|
||||
If resolver is provided, UUIDs are replaced with display names or short suffixes.
|
||||
"""
|
||||
if value is None:
|
||||
return "null"
|
||||
|
||||
if isinstance(value, bool):
|
||||
return "true" if value else "false"
|
||||
|
||||
if isinstance(value, (int, float)):
|
||||
return str(value)
|
||||
|
||||
if isinstance(value, str):
|
||||
# Check if it's a UUID and resolve to display name
|
||||
if resolver and _is_uuid(value):
|
||||
return resolver.resolve(value)
|
||||
# Filter out control characters and bidirectional overrides
|
||||
value = _UNSAFE_CHARS.sub("", value)
|
||||
# Truncate long strings
|
||||
if len(value) > max_len:
|
||||
return value[: max_len - 3] + "..."
|
||||
return value
|
||||
|
||||
if isinstance(value, dict):
|
||||
if not value:
|
||||
return "{}"
|
||||
# Check if all values are True - render as set-like {key1, key2}
|
||||
all_true = all(v is True for v in value.values())
|
||||
parts = []
|
||||
for k, v in value.items():
|
||||
# Replace UUID keys with display names
|
||||
key_display = resolver.resolve(k) if resolver and _is_uuid(k) else k
|
||||
if all_true:
|
||||
parts.append(key_display)
|
||||
else:
|
||||
val_display = _format_value(v, max_len=30, resolver=resolver)
|
||||
parts.append(f"{key_display}: {val_display}")
|
||||
return "{" + ", ".join(parts) + "}"
|
||||
|
||||
if isinstance(value, list):
|
||||
if not value:
|
||||
return "[]"
|
||||
parts = [_format_value(v, max_len=30, resolver=resolver) for v in value]
|
||||
return "[" + ", ".join(parts) + "]"
|
||||
|
||||
# Fallback for other types
|
||||
text = str(value)
|
||||
if len(text) > max_len:
|
||||
text = text[: max_len - 3] + "..."
|
||||
return text
|
||||
|
||||
|
||||
def _format_path(path: list[str], resolver: UuidResolver | None = None) -> str:
|
||||
"""Format a path as dot notation with prefix in dark grey, final in default.
|
||||
|
||||
If resolver is provided, UUIDs in the path are replaced with display names.
|
||||
"""
|
||||
if not path:
|
||||
return ""
|
||||
|
||||
# Replace UUIDs in path with display names
|
||||
if resolver:
|
||||
path = [resolver.resolve(p) if _is_uuid(p) else p for p in path]
|
||||
|
||||
if len(path) == 1:
|
||||
return f"{_PATH_FINAL}{path[0]}{_RESET}"
|
||||
prefix = ".".join(path[:-1])
|
||||
final = path[-1]
|
||||
return f"{_PATH_PREFIX}{prefix}.{_RESET}{_PATH_FINAL}{final}{_RESET}"
|
||||
|
||||
|
||||
def _get_nested(data: dict | None, path: list[str]) -> Any:
|
||||
"""Get a nested value from a dict by path, or None if not found."""
|
||||
if data is None:
|
||||
return None
|
||||
current = data
|
||||
for key in path:
|
||||
if not isinstance(current, dict) or key not in current:
|
||||
return None
|
||||
current = current[key]
|
||||
return current
|
||||
|
||||
|
||||
def _collect_changes(
|
||||
diff: dict,
|
||||
path: list[str],
|
||||
changes: list[tuple[str, list[str], Any]],
|
||||
previous: dict | None,
|
||||
) -> None:
|
||||
"""
|
||||
Recursively collect changes from a diff into a flat list.
|
||||
|
||||
Each change is a tuple of (change_type, path, new_value).
|
||||
change_type is one of: 'add', 'update', 'delete'
|
||||
"""
|
||||
if not isinstance(diff, dict):
|
||||
# Leaf value - check if it existed before
|
||||
existed = _get_nested(previous, path) is not None
|
||||
changes.append(("update" if existed else "add", path, diff))
|
||||
return
|
||||
|
||||
for key, value in diff.items():
|
||||
if key == "$delete":
|
||||
# $delete contains a list of keys to delete
|
||||
if isinstance(value, list):
|
||||
for deleted_key in value:
|
||||
changes.append(("delete", path + [str(deleted_key)], None))
|
||||
else:
|
||||
changes.append(("delete", path + [str(value)], None))
|
||||
|
||||
elif key == "$replace":
|
||||
# $replace replaces the entire collection at this path
|
||||
# We need to track what was added and what was deleted
|
||||
old_collection = _get_nested(previous, path)
|
||||
old_keys = (
|
||||
set(old_collection.keys())
|
||||
if isinstance(old_collection, dict)
|
||||
else set()
|
||||
)
|
||||
new_keys = set(value.keys()) if isinstance(value, dict) else set()
|
||||
|
||||
# Items that existed before but not in new = deleted
|
||||
for deleted_key in old_keys - new_keys:
|
||||
changes.append(("delete", path + [str(deleted_key)], None))
|
||||
|
||||
# Items in new collection
|
||||
if isinstance(value, dict):
|
||||
for rkey, rval in value.items():
|
||||
existed = rkey in old_keys
|
||||
changes.append(
|
||||
("update" if existed else "add", path + [str(rkey)], rval)
|
||||
)
|
||||
elif value or not old_keys:
|
||||
# Non-dict replacement or empty replacement with nothing before
|
||||
changes.append(
|
||||
("update" if old_collection is not None else "add", path, value)
|
||||
)
|
||||
|
||||
elif key.startswith("$"):
|
||||
# Other special operations (future-proofing)
|
||||
changes.append(("add", path, {key: value}))
|
||||
|
||||
else:
|
||||
# Regular nested key - check if this item existed before
|
||||
new_path = path + [str(key)]
|
||||
existed = _get_nested(previous, new_path) is not None
|
||||
if existed:
|
||||
# Item exists - recurse to show specific field changes
|
||||
_collect_changes(value, new_path, changes, previous)
|
||||
else:
|
||||
# New item - record as add with full value, don't recurse
|
||||
changes.append(("add", new_path, value))
|
||||
|
||||
|
||||
def _format_change_lines(
|
||||
change_type: str,
|
||||
path: list[str],
|
||||
value: Any,
|
||||
resolver: UuidResolver | None = None,
|
||||
) -> list[str]:
|
||||
"""Format a single change as one or more lines.
|
||||
|
||||
If resolver is provided, UUIDs are replaced with display names.
|
||||
"""
|
||||
|
||||
# Helper to format a value, checking for censored paths
|
||||
def fmt_value(v: Any, child_path: list[str]) -> str:
|
||||
if child_path[-2:] == ["oidc", "key"]:
|
||||
return f"{_DIM}<hidden>{_RESET}"
|
||||
return _format_value(v, resolver=resolver)
|
||||
|
||||
# Helper to format path with UUID replacement
|
||||
def fmt_path(p: list[str]) -> list[str]:
|
||||
if resolver:
|
||||
return [resolver.resolve(x) if _is_uuid(x) else x for x in p]
|
||||
return p
|
||||
|
||||
formatted_path = fmt_path(path)
|
||||
|
||||
if change_type == "delete":
|
||||
if len(formatted_path) == 1:
|
||||
return [f" {_DELETE}{formatted_path[0]} ✗{_RESET}"]
|
||||
prefix = ".".join(formatted_path[:-1])
|
||||
final = formatted_path[-1]
|
||||
return [f" {_PATH_PREFIX}{prefix}.{_RESET}{_DELETE}{final} ✗{_RESET}"]
|
||||
|
||||
if change_type == "add":
|
||||
# New item being created - only final element in green
|
||||
# For dict values, show children on separate indented lines
|
||||
if isinstance(value, dict) and value:
|
||||
lines = []
|
||||
# First line: path with green final element and grey =
|
||||
if len(formatted_path) == 1:
|
||||
lines.append(f" {_ADD}{formatted_path[0]}{_RESET} {_DIM}={_RESET}")
|
||||
else:
|
||||
prefix = ".".join(formatted_path[:-1])
|
||||
final = formatted_path[-1]
|
||||
lines.append(
|
||||
f" {_PATH_PREFIX}{prefix}.{_RESET}{_ADD}{final}{_RESET} {_DIM}={_RESET}"
|
||||
)
|
||||
# Child lines: indented key: value, with aligned values
|
||||
# Format keys (may contain UUIDs)
|
||||
formatted_items = []
|
||||
for k, v in value.items():
|
||||
k_display = resolver.resolve(k) if resolver and _is_uuid(k) else k
|
||||
v_str = fmt_value(v, path + [k])
|
||||
formatted_items.append((k_display, v_str))
|
||||
max_key_len = max(len(k) for k, _ in formatted_items)
|
||||
field_width = max(max_key_len, 12) # minimum 12 chars
|
||||
for k_display, v_str in formatted_items:
|
||||
padding = " " * (field_width - len(k_display))
|
||||
lines.append(f" {k_display}{_DIM}:{_RESET}{padding} {v_str}")
|
||||
return lines
|
||||
else:
|
||||
value_str = fmt_value(value, path)
|
||||
if len(formatted_path) == 1:
|
||||
return [
|
||||
f" {_ADD}{formatted_path[0]}{_RESET} {_DIM}={_RESET} {value_str}"
|
||||
]
|
||||
prefix = ".".join(formatted_path[:-1])
|
||||
final = formatted_path[-1]
|
||||
return [
|
||||
f" {_PATH_PREFIX}{prefix}.{_RESET}{_ADD}{final}{_RESET} {_DIM}={_RESET} {value_str}"
|
||||
]
|
||||
|
||||
# update: Existing item being updated - normal path colors
|
||||
value_str = fmt_value(value, path)
|
||||
path_str = _format_path(path, resolver=resolver)
|
||||
return [f" {path_str} {_DIM}={_RESET} {value_str}"]
|
||||
|
||||
|
||||
def format_diff(
|
||||
diff: dict, previous: dict | None = None, db: "DB | None" = None
|
||||
) -> list[str]:
|
||||
"""
|
||||
Format a JSON diff as human-readable lines.
|
||||
|
||||
Args:
|
||||
diff: The JSON diff dict
|
||||
previous: The previous state dict (for determining add vs update)
|
||||
db: Optional database for looking up display names
|
||||
|
||||
Returns a list of formatted lines (without newlines).
|
||||
UUIDs are replaced with display names (using previous state for lookups).
|
||||
"""
|
||||
changes: list[tuple[str, list[str], Any]] = []
|
||||
_collect_changes(diff, [], changes, previous)
|
||||
|
||||
if not changes:
|
||||
return []
|
||||
|
||||
# Create resolver for UUID replacement (uses previous state for lookups)
|
||||
resolver = UuidResolver(db, previous)
|
||||
|
||||
# Format each change
|
||||
lines = []
|
||||
for change_type, path, value in changes:
|
||||
lines.extend(_format_change_lines(change_type, path, value, resolver))
|
||||
|
||||
return lines
|
||||
|
||||
|
||||
def format_action_header(action: str, user_display: str | None = None) -> str:
|
||||
"""Format the action header line."""
|
||||
action_str = f"{_ACTION}{action}{_RESET}"
|
||||
if user_display:
|
||||
user_str = f"{_USER}{user_display}{_RESET}"
|
||||
return f"{action_str} by {user_str}"
|
||||
return action_str
|
||||
|
||||
|
||||
def log_change(
|
||||
action: str,
|
||||
diff: dict,
|
||||
user_display: str | None = None,
|
||||
previous: dict | None = None,
|
||||
db: "DB | None" = None,
|
||||
) -> None:
|
||||
"""
|
||||
Log a database change with pretty-printed diff.
|
||||
|
||||
UUIDs are replaced with display names for readability. For types without
|
||||
display names, the full UUID string is used.
|
||||
|
||||
Args:
|
||||
action: The action name (e.g., "login", "admin:delete_user")
|
||||
diff: The JSON diff dict
|
||||
user_display: Optional display name of the user who performed the action
|
||||
previous: The previous state dict (for determining add vs update)
|
||||
db: Optional database for looking up display names
|
||||
"""
|
||||
header = format_action_header(action, user_display)
|
||||
diff_lines = format_diff(diff, previous, db)
|
||||
|
||||
if not diff_lines:
|
||||
logger.info(header)
|
||||
return
|
||||
|
||||
if len(diff_lines) == 1:
|
||||
# Single change - combine on one line
|
||||
logger.info(f"{header}{diff_lines[0]}")
|
||||
else:
|
||||
# Multiple changes - header on its own line, then changes
|
||||
logger.info(header)
|
||||
for line in diff_lines:
|
||||
logger.info(line)
|
||||
|
||||
|
||||
def configure_db_logging() -> None:
|
||||
"""Configure the database logger to output to stderr without prefix."""
|
||||
handler = logging.StreamHandler(sys.stderr)
|
||||
handler.setFormatter(logging.Formatter("%(message)s"))
|
||||
logger.addHandler(handler)
|
||||
logger.setLevel(logging.INFO)
|
||||
logger.propagate = False
|
||||
+16
-26
@@ -6,53 +6,43 @@ Each migration should be idempotent and only run when needed.
|
||||
"""
|
||||
|
||||
import base64
|
||||
from collections.abc import Awaitable, Callable
|
||||
|
||||
from kanta import Kanta
|
||||
|
||||
from paskia.util.crypto import secret_key
|
||||
|
||||
|
||||
def migrate_v1(d: dict, **kwargs) -> None:
|
||||
def migrate_v1(d: dict) -> None:
|
||||
"""Remove Org.created_at fields."""
|
||||
for org_data in d["orgs"].values():
|
||||
org_data.pop("created_at", None)
|
||||
|
||||
|
||||
def migrate_v2(d: dict, *, rp_id: str = "localhost") -> None:
|
||||
def migrate_v2(d: dict, kanta: Kanta) -> None:
|
||||
"""Add config field if missing."""
|
||||
if "config" not in d:
|
||||
d["config"] = {"rp_id": rp_id}
|
||||
d["config"] = {"rp_id": kanta.ctx.rp_id}
|
||||
|
||||
|
||||
def migrate_v3(d: dict, **kwargs) -> None:
|
||||
def migrate_v3(d: dict) -> None:
|
||||
"""Ensure all users have visits field."""
|
||||
for user_data in d["users"].values():
|
||||
user_data.setdefault("visits", 0)
|
||||
|
||||
|
||||
def migrate_v4(d: dict, **kwargs) -> None:
|
||||
def migrate_v4(d: dict) -> None:
|
||||
"""OpenID Connect support and hardened session keys."""
|
||||
# Session keys changed to hashes, drop old sessions
|
||||
d["sessions"] = {}
|
||||
# Create OIDC structure with a generated new key
|
||||
d["oidc"] = {"clients": {}, "key": base64.standard_b64encode(secret_key()).decode()}
|
||||
d["oidc"] = {
|
||||
"clients": {},
|
||||
"key": base64.standard_b64encode(secret_key()).decode(),
|
||||
}
|
||||
|
||||
|
||||
migrations = sorted(
|
||||
[f for n, f in globals().items() if n.startswith("migrate_v")],
|
||||
key=lambda f: int(f.__name__.removeprefix("migrate_v")),
|
||||
)
|
||||
|
||||
DBVER = len(migrations) # Used by bootstrap to set initial version
|
||||
|
||||
|
||||
async def apply_all_migrations(
|
||||
data_dict: dict,
|
||||
current_version: int,
|
||||
persist: Callable[[str, int, dict], Awaitable[None]],
|
||||
*,
|
||||
rp_id: str = "localhost",
|
||||
) -> None:
|
||||
while current_version < DBVER:
|
||||
migrations[current_version](data_dict, rp_id=rp_id)
|
||||
current_version += 1
|
||||
await persist(f"migrate:v{current_version}", current_version, data_dict)
|
||||
def migrate_v5(d: dict) -> None:
|
||||
"""Convert config.listen from str to list[str] if needed."""
|
||||
listen = d["config"].get("listen")
|
||||
if listen and isinstance(listen, str):
|
||||
d["config"]["listen"] = [listen]
|
||||
|
||||
+60
-50
@@ -11,13 +11,10 @@ import secrets
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from uuid import UUID
|
||||
|
||||
import base64url
|
||||
import uuid7
|
||||
|
||||
from paskia import oidc_notify
|
||||
from paskia.config import SESSION_LIFETIME
|
||||
from paskia.db.jsonl import (
|
||||
JsonlStore,
|
||||
)
|
||||
from paskia.db.structs import (
|
||||
DB,
|
||||
Client,
|
||||
@@ -41,9 +38,26 @@ _UNSET = object()
|
||||
|
||||
# Global database instance (empty until init() loads data)
|
||||
_db = DB(config=Config(rp_id="uninitialized.invalid"))
|
||||
_store = JsonlStore(_db)
|
||||
_db._store = _store
|
||||
_initialized = False
|
||||
|
||||
|
||||
def _store():
|
||||
"""Return active Kanta instance for the current DB object."""
|
||||
store = _db._store
|
||||
if store is None:
|
||||
raise RuntimeError("Kanta store is not initialized")
|
||||
return store
|
||||
|
||||
|
||||
def _transaction(
|
||||
action: str,
|
||||
ctx: SessionContext | None = None,
|
||||
*,
|
||||
user: str | None = None,
|
||||
mtime: bool | datetime = True,
|
||||
):
|
||||
"""Create a Kanta transaction with minimal metadata mapping."""
|
||||
user_id = str(ctx.user.uuid) if ctx else user
|
||||
return _store().transaction(action, user=user_id, mtime=mtime)
|
||||
|
||||
|
||||
def is_username_taken(username: str, exclude_uuid: UUID | None = None) -> bool:
|
||||
@@ -62,9 +76,9 @@ def is_username_taken(username: str, exclude_uuid: UUID | None = None) -> bool:
|
||||
# -------------------------------------------------------------------------
|
||||
|
||||
|
||||
async def update_config(config: Config) -> None:
|
||||
def update_config(config: Config) -> None:
|
||||
"""Update the stored configuration."""
|
||||
with _db.transaction("update_config"):
|
||||
with _transaction("update_config"):
|
||||
_db.config = config
|
||||
|
||||
|
||||
@@ -72,7 +86,7 @@ def create_permission(perm: Permission, *, ctx: SessionContext | None = None) ->
|
||||
"""Create a new permission."""
|
||||
if perm.uuid in _db.permissions:
|
||||
raise ValueError(f"Permission {perm.uuid} already exists")
|
||||
with _db.transaction("admin:create_permission", ctx):
|
||||
with _transaction("admin:create_permission", ctx):
|
||||
perm.store()
|
||||
|
||||
|
||||
@@ -90,7 +104,7 @@ def update_permission(
|
||||
"""
|
||||
if uuid not in _db.permissions:
|
||||
raise ValueError(f"Permission {uuid} not found")
|
||||
with _db.transaction("admin:update_permission", ctx):
|
||||
with _transaction("admin:update_permission", ctx):
|
||||
_db.permissions[uuid].scope = scope
|
||||
_db.permissions[uuid].display_name = display_name
|
||||
_db.permissions[uuid].domain = domain
|
||||
@@ -100,7 +114,7 @@ def delete_permission(uuid: UUID, *, ctx: SessionContext | None = None) -> None:
|
||||
"""Delete a permission and remove it from all roles."""
|
||||
if uuid not in _db.permissions:
|
||||
raise ValueError(f"Permission {uuid} not found")
|
||||
with _db.transaction("admin:delete_permission", ctx):
|
||||
with _transaction("admin:delete_permission", ctx):
|
||||
_db.permissions[uuid].delete()
|
||||
|
||||
|
||||
@@ -112,7 +126,7 @@ def create_org(org: Org, *, ctx: SessionContext | None = None) -> None:
|
||||
if org.uuid in _db.orgs:
|
||||
raise ValueError(f"Organization {org.uuid} already exists")
|
||||
now = datetime.now(UTC)
|
||||
with _db.transaction("admin:create_org", ctx):
|
||||
with _transaction("admin:create_org", ctx):
|
||||
new_org = Org.create(display_name=org.display_name, created_at=now)
|
||||
new_org.uuid = org.uuid
|
||||
new_org.store()
|
||||
@@ -144,7 +158,7 @@ def update_org_name(
|
||||
"""Update organization display name."""
|
||||
if uuid not in _db.orgs:
|
||||
raise ValueError(f"Organization {uuid} not found")
|
||||
with _db.transaction("admin:update_org_name", ctx):
|
||||
with _transaction("admin:update_org_name", ctx):
|
||||
_db.orgs[uuid].display_name = display_name
|
||||
|
||||
|
||||
@@ -152,7 +166,7 @@ def delete_org(uuid: UUID, *, ctx: SessionContext | None = None) -> None:
|
||||
"""Delete organization and all its roles/users."""
|
||||
if uuid not in _db.orgs:
|
||||
raise ValueError(f"Organization {uuid} not found")
|
||||
with _db.transaction("admin:delete_org", ctx):
|
||||
with _transaction("admin:delete_org", ctx):
|
||||
_db.orgs[uuid].delete()
|
||||
|
||||
|
||||
@@ -169,7 +183,7 @@ def add_permission_to_org(
|
||||
if permission_uuid not in _db.permissions:
|
||||
raise ValueError(f"Permission {permission_uuid} not found")
|
||||
|
||||
with _db.transaction("admin:add_permission_to_org", ctx):
|
||||
with _transaction("admin:add_permission_to_org", ctx):
|
||||
_db.permissions[permission_uuid].orgs[org_uuid] = True
|
||||
|
||||
|
||||
@@ -186,7 +200,7 @@ def remove_permission_from_org(
|
||||
if permission_uuid not in _db.permissions:
|
||||
return # Permission not found, silently return
|
||||
|
||||
with _db.transaction("admin:remove_permission_from_org", ctx):
|
||||
with _transaction("admin:remove_permission_from_org", ctx):
|
||||
_db.permissions[permission_uuid].orgs.pop(org_uuid, None)
|
||||
|
||||
|
||||
@@ -196,7 +210,7 @@ def create_role(role: Role, *, ctx: SessionContext | None = None) -> None:
|
||||
raise ValueError(f"Role {role.uuid} already exists")
|
||||
if role.org_uuid not in _db.orgs:
|
||||
raise ValueError(f"Organization {role.org_uuid} not found")
|
||||
with _db.transaction("admin:create_role", ctx):
|
||||
with _transaction("admin:create_role", ctx):
|
||||
role.store()
|
||||
|
||||
|
||||
@@ -209,7 +223,7 @@ def update_role_name(
|
||||
"""Update role display name."""
|
||||
if uuid not in _db.roles:
|
||||
raise ValueError(f"Role {uuid} not found")
|
||||
with _db.transaction("admin:update_role_name", ctx):
|
||||
with _transaction("admin:update_role_name", ctx):
|
||||
_db.roles[uuid].display_name = display_name
|
||||
|
||||
|
||||
@@ -224,7 +238,7 @@ def add_permission_to_role(
|
||||
raise ValueError(f"Role {role_uuid} not found")
|
||||
if permission_uuid not in _db.permissions:
|
||||
raise ValueError(f"Permission {permission_uuid} not found")
|
||||
with _db.transaction("admin:add_permission_to_role", ctx):
|
||||
with _transaction("admin:add_permission_to_role", ctx):
|
||||
_db.roles[role_uuid].permissions[permission_uuid] = True
|
||||
|
||||
|
||||
@@ -237,7 +251,7 @@ def remove_permission_from_role(
|
||||
"""Remove permission from role by UUID."""
|
||||
if role_uuid not in _db.roles:
|
||||
raise ValueError(f"Role {role_uuid} not found")
|
||||
with _db.transaction("admin:remove_permission_from_role", ctx):
|
||||
with _transaction("admin:remove_permission_from_role", ctx):
|
||||
_db.roles[role_uuid].permissions.pop(permission_uuid, None)
|
||||
|
||||
|
||||
@@ -249,7 +263,7 @@ def delete_role(uuid: UUID, *, ctx: SessionContext | None = None) -> None:
|
||||
role = _db.roles[uuid]
|
||||
if role.users:
|
||||
raise ValueError(f"Cannot delete role {uuid}: users still assigned")
|
||||
with _db.transaction("admin:delete_role", ctx):
|
||||
with _transaction("admin:delete_role", ctx):
|
||||
_db.roles[uuid].delete()
|
||||
|
||||
|
||||
@@ -259,7 +273,7 @@ def create_user(new_user: User, *, ctx: SessionContext | None = None) -> None:
|
||||
raise ValueError(f"User {new_user.uuid} already exists")
|
||||
if new_user.role_uuid not in _db.roles:
|
||||
raise ValueError(f"Role {new_user.role_uuid} not found")
|
||||
with _db.transaction("admin:create_user", ctx):
|
||||
with _transaction("admin:create_user", ctx):
|
||||
new_user.store()
|
||||
|
||||
|
||||
@@ -286,7 +300,7 @@ def update_user_display_name(
|
||||
if not display_name:
|
||||
raise ValueError("Display name cannot be empty")
|
||||
user = _db.users[uuid]
|
||||
with _db.transaction("update_user_display_name", ctx):
|
||||
with _transaction("update_user_display_name", ctx):
|
||||
user.display_name = display_name
|
||||
# Auto-fill preferred_username if not already set
|
||||
if user.preferred_username is None:
|
||||
@@ -360,7 +374,7 @@ def update_user_info(
|
||||
elif len(telephone) > 32:
|
||||
raise ValueError("telephone too long")
|
||||
|
||||
with _db.transaction("update_user_info", ctx):
|
||||
with _transaction("update_user_info", ctx):
|
||||
if display_name is not _UNSET:
|
||||
user.display_name = display_name
|
||||
if theme is not _UNSET:
|
||||
@@ -384,7 +398,7 @@ def update_user_role(
|
||||
raise ValueError(f"User {uuid} not found")
|
||||
if role_uuid not in _db.roles:
|
||||
raise ValueError(f"Role {role_uuid} not found")
|
||||
with _db.transaction("admin:update_user_role", ctx):
|
||||
with _transaction("admin:update_user_role", ctx):
|
||||
_db.users[uuid].role_uuid = role_uuid
|
||||
|
||||
|
||||
@@ -392,7 +406,7 @@ def delete_user(uuid: UUID, *, ctx: SessionContext | None = None) -> None:
|
||||
"""Delete user and their credentials/sessions."""
|
||||
if uuid not in _db.users:
|
||||
raise ValueError(f"User {uuid} not found")
|
||||
with _db.transaction("admin:delete_user", ctx):
|
||||
with _transaction("admin:delete_user", ctx):
|
||||
_db.users[uuid].delete()
|
||||
|
||||
|
||||
@@ -402,7 +416,7 @@ def create_credential(cred: Credential, *, ctx: SessionContext | None = None) ->
|
||||
raise ValueError(f"Credential {cred.uuid} already exists")
|
||||
if cred.user_uuid not in _db.users:
|
||||
raise ValueError(f"User {cred.user_uuid} not found")
|
||||
with _db.transaction("create_credential", ctx):
|
||||
with _transaction("create_credential", ctx):
|
||||
cred.store()
|
||||
|
||||
|
||||
@@ -416,7 +430,7 @@ def update_credential_sign_count(
|
||||
"""Update credential sign count and last_used."""
|
||||
if uuid not in _db.credentials:
|
||||
raise ValueError(f"Credential {uuid} not found")
|
||||
with _db.transaction("update_credential_sign_count", ctx):
|
||||
with _transaction("update_credential_sign_count", ctx):
|
||||
_db.credentials[uuid].sign_count = sign_count
|
||||
if last_used:
|
||||
_db.credentials[uuid].last_used = last_used
|
||||
@@ -438,12 +452,12 @@ def delete_credential(
|
||||
if user_uuid is not None:
|
||||
if cred.user_uuid != user_uuid:
|
||||
raise ValueError(f"Credential {uuid} does not belong to user {user_uuid}")
|
||||
with _db.transaction("delete_credential", ctx):
|
||||
with _transaction("delete_credential", ctx):
|
||||
cred.delete()
|
||||
|
||||
|
||||
def update_session(
|
||||
key: bytes,
|
||||
key: str,
|
||||
host: str | None = None,
|
||||
ip: str | None = None,
|
||||
user_agent: str | None = None,
|
||||
@@ -454,7 +468,7 @@ def update_session(
|
||||
"""Update session metadata."""
|
||||
if key not in _db.sessions:
|
||||
raise ValueError("Session not found")
|
||||
with _db.transaction("update_session", ctx):
|
||||
with _transaction("update_session", ctx):
|
||||
s = _db.sessions[key]
|
||||
if host is not None:
|
||||
s.host = host
|
||||
@@ -466,9 +480,7 @@ def update_session(
|
||||
s.validated = validated
|
||||
|
||||
|
||||
def set_session_host(
|
||||
key: bytes, host: str, *, ctx: SessionContext | None = None
|
||||
) -> None:
|
||||
def set_session_host(key: str, host: str, *, ctx: SessionContext | None = None) -> None:
|
||||
"""Set the host for a session (first-time binding)."""
|
||||
update_session(key, host=host, ctx=ctx)
|
||||
|
||||
@@ -484,10 +496,9 @@ def delete_session(
|
||||
"""
|
||||
if key not in _db.sessions:
|
||||
raise ValueError("Session not found")
|
||||
from paskia import oidc_notify # noqa: PLC0415
|
||||
|
||||
oidc_notify.schedule_notifications([key])
|
||||
with _db.transaction(action, ctx):
|
||||
with _transaction(action, ctx):
|
||||
_db.sessions[key].delete()
|
||||
|
||||
|
||||
@@ -503,11 +514,10 @@ def delete_sessions_for_user(
|
||||
user = _db.users.get(user_uuid)
|
||||
if not user:
|
||||
return
|
||||
from paskia import oidc_notify # noqa: PLC0415
|
||||
|
||||
keys = [s.key for s in user.sessions]
|
||||
oidc_notify.schedule_notifications(keys)
|
||||
with _db.transaction("admin:delete_sessions_for_user", ctx):
|
||||
with _transaction("admin:delete_sessions_for_user", ctx):
|
||||
for sess in user.sessions:
|
||||
sess.delete()
|
||||
|
||||
@@ -538,7 +548,7 @@ def create_reset_token(
|
||||
)
|
||||
if token.key in _db.reset_tokens:
|
||||
raise ValueError("Reset token already exists")
|
||||
with _db.transaction("create_reset_token", ctx, user=user):
|
||||
with _transaction("create_reset_token", ctx, user=user):
|
||||
token.store()
|
||||
return passphrase
|
||||
|
||||
@@ -547,7 +557,7 @@ def delete_reset_token(key: bytes, *, ctx: SessionContext | None = None) -> None
|
||||
"""Delete a reset token."""
|
||||
if key not in _db.reset_tokens:
|
||||
raise ValueError("Reset token not found")
|
||||
with _db.transaction("delete_reset_token", ctx):
|
||||
with _transaction("delete_reset_token", ctx):
|
||||
_db.reset_tokens[key].delete()
|
||||
|
||||
|
||||
@@ -589,14 +599,14 @@ def login(
|
||||
session = Session.create(
|
||||
user=user_uuid,
|
||||
credential=credential_uuid,
|
||||
key=base64url.enc(hash_secret("cookie", token)),
|
||||
key=hash_secret("cookie", token),
|
||||
host=host,
|
||||
ip=ip,
|
||||
user_agent=user_agent,
|
||||
validated=now,
|
||||
)
|
||||
user_str = str(user_uuid)
|
||||
with _db.transaction("login", user=user_str):
|
||||
with _transaction("login", user=user_str):
|
||||
session.store(now)
|
||||
# Update credential
|
||||
_db.credentials[credential_uuid].sign_count = sign_count
|
||||
@@ -623,7 +633,7 @@ def oidc_login(
|
||||
"""
|
||||
now = datetime.now(UTC)
|
||||
user_str = str(session.user_uuid)
|
||||
with _db.transaction("oidc_login", user=user_str):
|
||||
with _transaction("oidc_login", user=user_str):
|
||||
session.store(now)
|
||||
# Update credential
|
||||
_db.credentials[credential_uuid].sign_count = sign_count
|
||||
@@ -657,7 +667,7 @@ def create_credential_session(
|
||||
|
||||
# Generate token and derive key
|
||||
token = secrets.token_urlsafe(12)
|
||||
key = base64url.enc(hash_secret("cookie", token))
|
||||
key = hash_secret("cookie", token)
|
||||
|
||||
session = Session.create(
|
||||
user=user_uuid,
|
||||
@@ -669,7 +679,7 @@ def create_credential_session(
|
||||
validated=now,
|
||||
)
|
||||
user_str = str(user_uuid)
|
||||
with _db.transaction("create_credential_session", user=user_str):
|
||||
with _transaction("create_credential_session", user=user_str):
|
||||
# Update display name if provided
|
||||
if display_name:
|
||||
_db.users[user_uuid].display_name = display_name
|
||||
@@ -702,7 +712,7 @@ def create_oid_client(client: Client, *, ctx: SessionContext | None = None) -> N
|
||||
"""Create a new OIDC client."""
|
||||
if client.uuid in _db.oidc.clients:
|
||||
raise ValueError(f"OIDC client {client.uuid} already exists")
|
||||
with _db.transaction("admin:create_oid_client", ctx):
|
||||
with _transaction("admin:create_oid_client", ctx):
|
||||
_db.oidc.clients[client.uuid] = client
|
||||
|
||||
|
||||
@@ -743,7 +753,7 @@ def update_oid_client(
|
||||
else client.backchannel_logout_uri
|
||||
)
|
||||
|
||||
with _db.transaction("admin:update_oid_client", ctx):
|
||||
with _transaction("admin:update_oid_client", ctx):
|
||||
# Create updated client with new values
|
||||
updated_client = Client(
|
||||
client_secret_hash=secret_hash
|
||||
@@ -769,7 +779,7 @@ def reset_oid_client_secret(
|
||||
if client_uuid not in _db.oidc.clients:
|
||||
raise ValueError(f"OIDC client {client_uuid} not found")
|
||||
client = _db.oidc.clients[client_uuid]
|
||||
with _db.transaction("admin:reset_oid_client_secret", ctx):
|
||||
with _transaction("admin:reset_oid_client_secret", ctx):
|
||||
updated = Client(
|
||||
client_secret_hash=new_secret_hash,
|
||||
name=client.name,
|
||||
@@ -784,5 +794,5 @@ def delete_oid_client(client_uuid: UUID, *, ctx: SessionContext | None = None) -
|
||||
"""Delete an OIDC client."""
|
||||
if client_uuid not in _db.oidc.clients:
|
||||
raise ValueError(f"OIDC client {client_uuid} not found")
|
||||
with _db.transaction("admin:delete_oid_client", ctx):
|
||||
with _transaction("admin:delete_oid_client", ctx):
|
||||
del _db.oidc.clients[client_uuid]
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import shutil
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def db_root_path(*, rp_id: str = "localhost") -> Path:
|
||||
"""Return the configured persistence root directory."""
|
||||
return Path(os.environ.get("PASKIA_DB", f"{rp_id}.paskiadb"))
|
||||
|
||||
|
||||
def db_file_path(*, rp_id: str = "localhost", create_root: bool = False) -> Path:
|
||||
"""Return the JSONL database file path under the persistence root."""
|
||||
root = db_root_path(rp_id=rp_id)
|
||||
|
||||
if root.is_file():
|
||||
_migrate_legacy_db_file(root)
|
||||
|
||||
if create_root:
|
||||
root.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
return root / "main.db"
|
||||
|
||||
|
||||
def users_root_path(*, rp_id: str = "localhost", create_root: bool = False) -> Path:
|
||||
"""Return the filesystem root for persisted user files."""
|
||||
root = db_root_path(rp_id=rp_id)
|
||||
|
||||
if root.is_file():
|
||||
_migrate_legacy_db_file(root)
|
||||
|
||||
if create_root:
|
||||
root.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
return root / "users"
|
||||
|
||||
|
||||
def _migrate_legacy_db_file(legacy_path: Path) -> None:
|
||||
"""Upgrade a legacy single-file database path into a directory root."""
|
||||
temp_root = legacy_path.parent / f".{legacy_path.name}.migrating"
|
||||
shutil.rmtree(temp_root, ignore_errors=True)
|
||||
temp_root.unlink(missing_ok=True)
|
||||
|
||||
temp_root.mkdir(parents=True)
|
||||
legacy_path.replace(temp_root / "main.db")
|
||||
temp_root.rename(legacy_path)
|
||||
+15
-22
@@ -3,14 +3,13 @@ from __future__ import annotations
|
||||
import hashlib
|
||||
import secrets
|
||||
from datetime import UTC, datetime
|
||||
from typing import Any
|
||||
from uuid import UUID
|
||||
|
||||
import base64url
|
||||
import msgspec
|
||||
import uuid7
|
||||
|
||||
from paskia import db
|
||||
from paskia.util import hostutil
|
||||
from paskia.util import passphrase as passphrase_util
|
||||
from paskia.util.crypto import hash_secret
|
||||
|
||||
@@ -434,7 +433,7 @@ class Session(msgspec.Struct, dict=True, omit_defaults=True):
|
||||
"""Create a new Session with the provided key.
|
||||
|
||||
Args:
|
||||
key: The base64url-encoded hashed session key (derived from secret via hash_secret then base64url.enc)
|
||||
key: The hashed session key (derived from secret via hash_secret)
|
||||
|
||||
Returns:
|
||||
Session object with key set
|
||||
@@ -471,7 +470,7 @@ class ResetToken(msgspec.Struct, dict=True):
|
||||
|
||||
def __post_init__(self):
|
||||
if not hasattr(self, "key"):
|
||||
self.key: bytes = b""
|
||||
self.key: str = ""
|
||||
|
||||
@property
|
||||
def user(self) -> User:
|
||||
@@ -487,15 +486,15 @@ class ResetToken(msgspec.Struct, dict=True):
|
||||
del db.data().reset_tokens[self.key]
|
||||
|
||||
@staticmethod
|
||||
def hash(passphrase: str) -> bytes:
|
||||
"""Hash a passphrase to bytes for reset token storage."""
|
||||
def hash(passphrase: str) -> str:
|
||||
"""Hash a passphrase to string for reset token storage."""
|
||||
if not passphrase_util.is_well_formed(passphrase):
|
||||
raise ValueError(
|
||||
"Trying to reset with a session token in place of a passphrase"
|
||||
if len(passphrase) == 16
|
||||
else "Invalid passphrase format"
|
||||
)
|
||||
return hashlib.sha512(passphrase.encode()).digest()[:9]
|
||||
return hash_secret("reset", passphrase)
|
||||
|
||||
@classmethod
|
||||
def by_passphrase(cls, passphrase: str) -> ResetToken | None:
|
||||
@@ -602,14 +601,14 @@ class OIDC(msgspec.Struct, dict=True):
|
||||
key: bytes | None = None
|
||||
|
||||
|
||||
class Config(msgspec.Struct, frozen=True, dict=True, omit_defaults=True):
|
||||
class Config(msgspec.Struct, omit_defaults=True):
|
||||
"""Stored configuration for the instance."""
|
||||
|
||||
rp_id: str
|
||||
rp_name: str | None = None
|
||||
origins: list[str] | None = None
|
||||
auth_host: str | None = None
|
||||
listen: str | None = None
|
||||
origins: list[str] | None = None
|
||||
listen: list[str] | None = None
|
||||
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
@@ -620,20 +619,20 @@ class Config(msgspec.Struct, frozen=True, dict=True, omit_defaults=True):
|
||||
class DB(msgspec.Struct, dict=True, omit_defaults=False):
|
||||
"""In-memory database. Access fields directly for reads."""
|
||||
|
||||
config: Config
|
||||
config: Config = msgspec.field(default_factory=lambda: Config(rp_id="localhost"))
|
||||
permissions: dict[UUID, Permission] = {}
|
||||
orgs: dict[UUID, Org] = {}
|
||||
roles: dict[UUID, Role] = {}
|
||||
users: dict[UUID, User] = {}
|
||||
credentials: dict[UUID, Credential] = {}
|
||||
sessions: dict[str, Session] = {}
|
||||
reset_tokens: dict[bytes, ResetToken] = {}
|
||||
reset_tokens: dict[str, ResetToken] = {}
|
||||
# OIDC provider data
|
||||
oidc: OIDC = msgspec.field(default_factory=lambda: OIDC())
|
||||
|
||||
def __post_init__(self):
|
||||
# Store reference for persistence (not serialized)
|
||||
self._store = None
|
||||
# Optional store reference for non-global DB instances (e.g. tests).
|
||||
self._store: Any | None = None
|
||||
# Set the key fields on all stored objects
|
||||
for uuid, perm in self.permissions.items():
|
||||
perm.uuid = uuid
|
||||
@@ -653,10 +652,6 @@ class DB(msgspec.Struct, dict=True, omit_defaults=False):
|
||||
for uuid, client in self.oidc.clients.items():
|
||||
client.uuid = uuid
|
||||
|
||||
def transaction(self, action, ctx=None, *, user=None):
|
||||
"""Wrap writes in transaction. Delegates to JsonlStore."""
|
||||
return self._store.transaction(action, ctx, user=user)
|
||||
|
||||
def session_ctx(
|
||||
self, session_secret: str, host: str | None = None
|
||||
) -> SessionContext | None:
|
||||
@@ -670,7 +665,7 @@ class DB(msgspec.Struct, dict=True, omit_defaults=False):
|
||||
SessionContext if valid, None if session not found, expired, or host mismatch
|
||||
"""
|
||||
|
||||
key = base64url.enc(hash_secret("cookie", session_secret))
|
||||
key = hash_secret("cookie", session_secret)
|
||||
try:
|
||||
s = self.sessions[key]
|
||||
except KeyError:
|
||||
@@ -680,10 +675,8 @@ class DB(msgspec.Struct, dict=True, omit_defaults=False):
|
||||
if s.client_uuid is not None:
|
||||
return None
|
||||
|
||||
# Normalize host for comparison (stored hosts are already normalized)
|
||||
normalized_input = hostutil.normalize_host(host)
|
||||
|
||||
# Validate host matches (sessions are always created with a host)
|
||||
normalized_input = host
|
||||
if s.host != normalized_input:
|
||||
# Session bound to different host
|
||||
return None
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
from paskia.fastapi.mainapp import app
|
||||
|
||||
__all__ = ["app"]
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,3 @@
|
||||
from paskia.fastapi.admin.adminapp import app
|
||||
|
||||
__all__ = ["app"]
|
||||
@@ -0,0 +1,122 @@
|
||||
from uuid import UUID
|
||||
|
||||
from fastapi import FastAPI, Request
|
||||
|
||||
from paskia import db
|
||||
from paskia.fastapi import authz
|
||||
from paskia.fastapi.admin import (
|
||||
oidc_clients,
|
||||
orgs,
|
||||
permissions,
|
||||
roles,
|
||||
server_config,
|
||||
users,
|
||||
)
|
||||
from paskia.fastapi.admin.errors import install_error_handlers
|
||||
from paskia.fastapi.front import frontend
|
||||
from paskia.fastapi.response import MsgspecResponse
|
||||
from paskia.fastapi.session import AUTH_COOKIE
|
||||
from paskia.util import (
|
||||
avatar,
|
||||
permutil,
|
||||
vitedev,
|
||||
)
|
||||
from paskia.util.apistructs import (
|
||||
ApiAdminInfo,
|
||||
ApiOidcClient,
|
||||
ApiOrg,
|
||||
ApiOrgResponse,
|
||||
ApiPermission,
|
||||
ApiUser,
|
||||
)
|
||||
|
||||
app = FastAPI(docs_url=None, redoc_url=None, openapi_url=None)
|
||||
|
||||
install_error_handlers(app)
|
||||
app.mount("/oidc-clients", oidc_clients.app)
|
||||
app.mount("/orgs", orgs.app)
|
||||
app.mount("/roles", roles.app)
|
||||
app.mount("/users", users.app)
|
||||
app.mount("/permissions", permissions.app)
|
||||
app.mount("/server-config", server_config.app)
|
||||
|
||||
|
||||
def master_admin(ctx) -> bool:
|
||||
return any(p.scope == "auth:admin" for p in ctx.permissions)
|
||||
|
||||
|
||||
def org_admin(ctx, org_uuid: UUID) -> bool:
|
||||
return ctx.org.uuid == org_uuid and any(
|
||||
p.scope == "auth:org:admin" for p in ctx.permissions
|
||||
)
|
||||
|
||||
|
||||
def can_manage_org(ctx, org_uuid: UUID) -> bool:
|
||||
return master_admin(ctx) or org_admin(ctx, org_uuid)
|
||||
|
||||
|
||||
@app.get("/")
|
||||
async def adminapp(request: Request, auth=AUTH_COOKIE):
|
||||
return await vitedev.handle(request, frontend, "/auth/admin/")
|
||||
|
||||
|
||||
@app.get("/info")
|
||||
async def admin_info(request: Request, auth=AUTH_COOKIE):
|
||||
ctx = await authz.verify(
|
||||
auth,
|
||||
["auth:admin", "auth:org:admin"],
|
||||
match=permutil.has_any,
|
||||
host=request.headers.get("host"),
|
||||
)
|
||||
|
||||
# Orgs
|
||||
orgs = list(db.data().orgs.values())
|
||||
if not master_admin(ctx):
|
||||
# Org admins can only see their own organization
|
||||
orgs = [o for o in orgs if o.uuid == ctx.org.uuid]
|
||||
|
||||
def org_to_dict(o):
|
||||
roles = o.roles
|
||||
return ApiOrgResponse(
|
||||
org=ApiOrg.from_db(o),
|
||||
permissions={p.uuid: p for p in o.permissions},
|
||||
roles={r.uuid: r for r in roles},
|
||||
users={
|
||||
u.uuid: ApiUser.from_db(u, avatar_url=avatar.avatar_browser_url(u.uuid))
|
||||
for r in roles
|
||||
for u in r.users
|
||||
},
|
||||
)
|
||||
|
||||
orgs_dict = {o.uuid: org_to_dict(o) for o in orgs}
|
||||
|
||||
# Permissions
|
||||
perms = db.data().permissions.values() if master_admin(ctx) else ctx.org.permissions
|
||||
perms_dict = {p.uuid: ApiPermission.from_db(p) for p in perms}
|
||||
|
||||
# OIDC Clients (master admin only)
|
||||
oidc_clients_dict = {}
|
||||
if master_admin(ctx):
|
||||
clients = sorted(db.data().oidc.clients.values(), key=lambda c: c.uuid)
|
||||
sessions = db.data().sessions
|
||||
# Count active sessions per client
|
||||
client_session_counts = {}
|
||||
for session in sessions.values():
|
||||
if session.client_uuid:
|
||||
client_session_counts[session.client_uuid] = (
|
||||
client_session_counts.get(session.client_uuid, 0) + 1
|
||||
)
|
||||
oidc_clients_dict = {
|
||||
client.uuid: ApiOidcClient.from_db(
|
||||
client, client_session_counts.get(client.uuid, 0)
|
||||
)
|
||||
for client in clients
|
||||
}
|
||||
|
||||
return MsgspecResponse(
|
||||
ApiAdminInfo(
|
||||
orgs=orgs_dict,
|
||||
permissions=perms_dict,
|
||||
oidc_clients=oidc_clients_dict,
|
||||
)
|
||||
)
|
||||
@@ -0,0 +1,30 @@
|
||||
"""Shared exception handlers for admin sub-apps."""
|
||||
|
||||
import logging
|
||||
|
||||
from fastapi import FastAPI
|
||||
from fastapi.responses import JSONResponse
|
||||
|
||||
from paskia.fastapi import authz
|
||||
|
||||
|
||||
def install_error_handlers(app: FastAPI) -> None:
|
||||
"""Register standard exception handlers on *app*."""
|
||||
|
||||
@app.exception_handler(ValueError)
|
||||
async def value_error_handler(_request, exc: ValueError):
|
||||
return JSONResponse(status_code=400, content={"detail": str(exc)})
|
||||
|
||||
@app.exception_handler(authz.AuthException)
|
||||
async def auth_exception_handler(_request, exc: authz.AuthException):
|
||||
return JSONResponse(
|
||||
status_code=exc.status_code,
|
||||
content=await authz.auth_error_content(exc),
|
||||
)
|
||||
|
||||
@app.exception_handler(Exception)
|
||||
async def general_exception_handler(_request, exc: Exception): # pragma: no cover
|
||||
logging.exception("Unhandled exception in admin app")
|
||||
return JSONResponse(
|
||||
status_code=500, content={"detail": "Internal server error"}
|
||||
)
|
||||
@@ -0,0 +1,237 @@
|
||||
from uuid import UUID
|
||||
|
||||
from fastapi import Body, FastAPI, HTTPException, Request
|
||||
|
||||
from paskia import db
|
||||
from paskia.db.operations import _UNSET
|
||||
from paskia.db.structs import Client
|
||||
from paskia.fastapi import authz
|
||||
from paskia.fastapi.admin.errors import install_error_handlers
|
||||
from paskia.fastapi.session import AUTH_COOKIE
|
||||
from paskia.util import permutil
|
||||
|
||||
app = FastAPI(docs_url=None, redoc_url=None, openapi_url=None)
|
||||
|
||||
install_error_handlers(app)
|
||||
|
||||
|
||||
def master_admin(ctx) -> bool:
|
||||
return any(p.scope == "auth:admin" for p in ctx.permissions)
|
||||
|
||||
|
||||
@app.post("/")
|
||||
async def admin_create_oidc_client(
|
||||
request: Request,
|
||||
payload: dict = Body(...),
|
||||
auth=AUTH_COOKIE,
|
||||
):
|
||||
"""Create a new OIDC client (master admin only)."""
|
||||
ctx = await authz.verify(
|
||||
auth,
|
||||
["auth:admin"],
|
||||
host=request.headers.get("host"),
|
||||
match=permutil.has_all,
|
||||
max_age="5m",
|
||||
)
|
||||
if not master_admin(ctx):
|
||||
raise authz.AuthException(
|
||||
status_code=403,
|
||||
detail="Only master admin can manage OIDC clients",
|
||||
mode="forbidden",
|
||||
)
|
||||
|
||||
# Client ID and secret hash are generated client-side
|
||||
client_id = payload.get("client_id", "").strip()
|
||||
secret_hash_hex = payload.get("secret_hash", "").strip()
|
||||
name = payload.get("name", "").strip()
|
||||
redirect_uris = payload.get("redirect_uris", [])
|
||||
backchannel_logout_uri = payload.get("backchannel_logout_uri")
|
||||
if isinstance(backchannel_logout_uri, str):
|
||||
backchannel_logout_uri = backchannel_logout_uri.strip() or None
|
||||
|
||||
if not client_id or not secret_hash_hex:
|
||||
raise ValueError("client_id and secret_hash are required")
|
||||
|
||||
try:
|
||||
client_uuid = UUID(client_id)
|
||||
except (ValueError, AttributeError):
|
||||
raise ValueError("client_id must be a valid UUID")
|
||||
|
||||
try:
|
||||
secret_hash = bytes.fromhex(secret_hash_hex)
|
||||
except ValueError:
|
||||
raise ValueError("secret_hash must be a hex-encoded SHA-256 hash")
|
||||
if len(secret_hash) != 32:
|
||||
raise ValueError("secret_hash must be a SHA-256 hash (32 bytes)")
|
||||
|
||||
if not isinstance(redirect_uris, list):
|
||||
raise ValueError("redirect_uris must be a list")
|
||||
|
||||
# Validate redirect URIs
|
||||
for uri in redirect_uris:
|
||||
if not isinstance(uri, str) or not uri.startswith("http"):
|
||||
raise ValueError(f"Invalid redirect URI: {uri}")
|
||||
|
||||
if backchannel_logout_uri and not backchannel_logout_uri.startswith("http"):
|
||||
raise ValueError("backchannel_logout_uri must be an HTTP(S) URL")
|
||||
|
||||
client = Client(
|
||||
client_secret_hash=secret_hash,
|
||||
name=name,
|
||||
redirect_uris=redirect_uris,
|
||||
backchannel_logout_uri=backchannel_logout_uri,
|
||||
)
|
||||
client.uuid = client_uuid
|
||||
|
||||
db.create_oid_client(client, ctx=ctx)
|
||||
|
||||
return {"status": "ok", "client_id": str(client.uuid)}
|
||||
|
||||
|
||||
@app.patch("/{client_uuid}")
|
||||
async def admin_update_oidc_client(
|
||||
client_uuid: UUID,
|
||||
request: Request,
|
||||
payload: dict = Body(...),
|
||||
auth=AUTH_COOKIE,
|
||||
):
|
||||
"""Update an OIDC client's name and redirect URIs (master admin only)."""
|
||||
ctx = await authz.verify(
|
||||
auth,
|
||||
["auth:admin"],
|
||||
host=request.headers.get("host"),
|
||||
match=permutil.has_all,
|
||||
max_age="5m",
|
||||
)
|
||||
if not master_admin(ctx):
|
||||
raise authz.AuthException(
|
||||
status_code=403,
|
||||
detail="Only master admin can manage OIDC clients",
|
||||
mode="forbidden",
|
||||
)
|
||||
|
||||
name = payload.get("name", "").strip() if "name" in payload else None
|
||||
redirect_uris = payload.get("redirect_uris") if "redirect_uris" in payload else None
|
||||
secret_hash_hex = (
|
||||
payload.get("secret_hash", "").strip() if "secret_hash" in payload else None
|
||||
)
|
||||
backchannel_logout_uri = (
|
||||
payload.get("backchannel_logout_uri")
|
||||
if "backchannel_logout_uri" in payload
|
||||
else _UNSET
|
||||
)
|
||||
if isinstance(backchannel_logout_uri, str):
|
||||
backchannel_logout_uri = backchannel_logout_uri.strip() or None
|
||||
|
||||
if name is not None and not name:
|
||||
raise ValueError("Client name cannot be empty")
|
||||
|
||||
if redirect_uris is not None:
|
||||
if not isinstance(redirect_uris, list):
|
||||
raise ValueError("redirect_uris must be a list")
|
||||
# Validate redirect URIs
|
||||
for uri in redirect_uris:
|
||||
if not isinstance(uri, str) or not uri.startswith("http"):
|
||||
raise ValueError(f"Invalid redirect URI: {uri}")
|
||||
|
||||
if (
|
||||
backchannel_logout_uri is not _UNSET
|
||||
and backchannel_logout_uri
|
||||
and not backchannel_logout_uri.startswith("http")
|
||||
):
|
||||
raise ValueError("backchannel_logout_uri must be an HTTP(S) URL")
|
||||
|
||||
secret_hash = None
|
||||
if secret_hash_hex:
|
||||
try:
|
||||
secret_hash = bytes.fromhex(secret_hash_hex)
|
||||
except ValueError:
|
||||
raise ValueError("secret_hash must be a hex-encoded SHA-256 hash")
|
||||
if len(secret_hash) != 32:
|
||||
raise ValueError("secret_hash must be a SHA-256 hash (32 bytes)")
|
||||
|
||||
try:
|
||||
db.update_oid_client(
|
||||
client_uuid,
|
||||
name=name,
|
||||
redirect_uris=redirect_uris,
|
||||
secret_hash=secret_hash,
|
||||
backchannel_logout_uri=backchannel_logout_uri,
|
||||
ctx=ctx,
|
||||
)
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=404, detail=str(e))
|
||||
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
@app.post("/{client_uuid}/reset-secret")
|
||||
async def admin_reset_oidc_client_secret(
|
||||
client_uuid: UUID,
|
||||
request: Request,
|
||||
payload: dict = Body(...),
|
||||
auth=AUTH_COOKIE,
|
||||
):
|
||||
"""Reset an OIDC client's secret (master admin only).
|
||||
|
||||
The new secret is generated client-side; only the SHA-256 hash is sent.
|
||||
"""
|
||||
ctx = await authz.verify(
|
||||
auth,
|
||||
["auth:admin"],
|
||||
host=request.headers.get("host"),
|
||||
match=permutil.has_all,
|
||||
max_age="5m",
|
||||
)
|
||||
if not master_admin(ctx):
|
||||
raise authz.AuthException(
|
||||
status_code=403,
|
||||
detail="Only master admin can manage OIDC clients",
|
||||
mode="forbidden",
|
||||
)
|
||||
|
||||
secret_hash_hex = payload.get("secret_hash", "").strip()
|
||||
if not secret_hash_hex:
|
||||
raise ValueError("secret_hash is required")
|
||||
try:
|
||||
secret_hash = bytes.fromhex(secret_hash_hex)
|
||||
except ValueError:
|
||||
raise ValueError("secret_hash must be a hex-encoded SHA-256 hash")
|
||||
if len(secret_hash) != 32:
|
||||
raise ValueError("secret_hash must be a SHA-256 hash (32 bytes)")
|
||||
|
||||
try:
|
||||
db.reset_oid_client_secret(client_uuid, secret_hash, ctx=ctx)
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=404, detail=str(e))
|
||||
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
@app.delete("/{client_uuid}")
|
||||
async def admin_delete_oidc_client(
|
||||
client_uuid: UUID,
|
||||
request: Request,
|
||||
auth=AUTH_COOKIE,
|
||||
):
|
||||
"""Delete an OIDC client (master admin only)."""
|
||||
ctx = await authz.verify(
|
||||
auth,
|
||||
["auth:admin"],
|
||||
host=request.headers.get("host"),
|
||||
match=permutil.has_all,
|
||||
max_age="5m",
|
||||
)
|
||||
if not master_admin(ctx):
|
||||
raise authz.AuthException(
|
||||
status_code=403,
|
||||
detail="Only master admin can manage OIDC clients",
|
||||
mode="forbidden",
|
||||
)
|
||||
|
||||
try:
|
||||
db.delete_oid_client(client_uuid, ctx=ctx)
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=404, detail=str(e))
|
||||
|
||||
return {"status": "ok"}
|
||||
@@ -0,0 +1,234 @@
|
||||
from uuid import UUID
|
||||
|
||||
from fastapi import Body, FastAPI, HTTPException, Query, Request
|
||||
|
||||
from paskia import db
|
||||
from paskia.db import Org as OrgDC
|
||||
from paskia.db import Role as RoleDC
|
||||
from paskia.db import User as UserDC
|
||||
from paskia.fastapi import authz
|
||||
from paskia.fastapi.admin.errors import install_error_handlers
|
||||
from paskia.fastapi.response import MsgspecResponse
|
||||
from paskia.fastapi.session import AUTH_COOKIE
|
||||
from paskia.util import permutil
|
||||
from paskia.util.apistructs import ApiUuidResponse
|
||||
|
||||
app = FastAPI(docs_url=None, redoc_url=None, openapi_url=None)
|
||||
|
||||
install_error_handlers(app)
|
||||
|
||||
|
||||
def master_admin(ctx) -> bool:
|
||||
return any(p.scope == "auth:admin" for p in ctx.permissions)
|
||||
|
||||
|
||||
def org_admin(ctx, org_uuid: UUID) -> bool:
|
||||
return ctx.org.uuid == org_uuid and any(
|
||||
p.scope == "auth:org:admin" for p in ctx.permissions
|
||||
)
|
||||
|
||||
|
||||
def can_manage_org(ctx, org_uuid: UUID) -> bool:
|
||||
return master_admin(ctx) or org_admin(ctx, org_uuid)
|
||||
|
||||
|
||||
@app.post("/")
|
||||
async def admin_create_org(
|
||||
request: Request, payload: dict = Body(...), auth=AUTH_COOKIE
|
||||
):
|
||||
ctx = await authz.verify(
|
||||
auth, ["auth:admin"], host=request.headers.get("host"), match=permutil.has_all
|
||||
)
|
||||
|
||||
display_name = payload.get("display_name") or "New Organization"
|
||||
permissions = payload.get("permissions") or []
|
||||
org = OrgDC.create(display_name=display_name)
|
||||
db.create_org(org, ctx=ctx)
|
||||
# Grant requested permissions to the new org
|
||||
for perm in permissions:
|
||||
db.add_permission_to_org(str(org.uuid), perm, ctx=ctx)
|
||||
|
||||
return MsgspecResponse(ApiUuidResponse(uuid=str(org.uuid)))
|
||||
|
||||
|
||||
@app.patch("/{org_uuid}")
|
||||
async def admin_update_org_name(
|
||||
org_uuid: UUID,
|
||||
request: Request,
|
||||
payload: dict = Body(...),
|
||||
auth=AUTH_COOKIE,
|
||||
):
|
||||
"""Update organization display name only."""
|
||||
ctx = await authz.verify(
|
||||
auth,
|
||||
["auth:admin", "auth:org:admin"],
|
||||
match=permutil.has_any,
|
||||
host=request.headers.get("host"),
|
||||
)
|
||||
if not can_manage_org(ctx, org_uuid):
|
||||
raise authz.AuthException(
|
||||
status_code=403, detail="Insufficient permissions", mode="forbidden"
|
||||
)
|
||||
display_name = payload.get("display_name")
|
||||
if not display_name:
|
||||
raise ValueError("display_name is required")
|
||||
|
||||
db.update_org_name(org_uuid, display_name, ctx=ctx)
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
@app.delete("/{org_uuid}")
|
||||
async def admin_delete_org(org_uuid: UUID, request: Request, auth=AUTH_COOKIE):
|
||||
ctx = await authz.verify(
|
||||
auth,
|
||||
["auth:admin", "auth:org:admin"],
|
||||
match=permutil.has_any,
|
||||
host=request.headers.get("host"),
|
||||
max_age="5m",
|
||||
)
|
||||
if not can_manage_org(ctx, org_uuid):
|
||||
raise authz.AuthException(
|
||||
status_code=403, detail="Insufficient permissions", mode="forbidden"
|
||||
)
|
||||
if ctx.org.uuid == org_uuid:
|
||||
raise ValueError("Cannot delete the organization you belong to")
|
||||
|
||||
# Delete organization-specific permissions
|
||||
org_perm_pattern = f"org:{str(org_uuid).lower()}"
|
||||
all_permissions = list(db.data().permissions.values())
|
||||
for perm in all_permissions:
|
||||
perm_scope_lower = perm.scope.lower()
|
||||
# Check if permission contains "org:{uuid}" separated by colons or at boundaries
|
||||
if (
|
||||
f":{org_perm_pattern}:" in perm_scope_lower
|
||||
or perm_scope_lower.startswith(f"{org_perm_pattern}:")
|
||||
or perm_scope_lower.endswith(f":{org_perm_pattern}")
|
||||
or perm_scope_lower == org_perm_pattern
|
||||
):
|
||||
db.delete_permission(perm.uuid, ctx=ctx)
|
||||
|
||||
db.delete_org(org_uuid, ctx=ctx)
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
@app.post("/{org_uuid}/permission")
|
||||
async def admin_add_org_permission(
|
||||
org_uuid: UUID,
|
||||
request: Request,
|
||||
permission_uuid: UUID = Query(...),
|
||||
auth=AUTH_COOKIE,
|
||||
):
|
||||
ctx = await authz.verify(
|
||||
auth, ["auth:admin"], host=request.headers.get("host"), match=permutil.has_all
|
||||
)
|
||||
|
||||
db.add_permission_to_org(org_uuid, permission_uuid, ctx=ctx)
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
@app.delete("/{org_uuid}/permission")
|
||||
async def admin_remove_org_permission(
|
||||
org_uuid: UUID,
|
||||
request: Request,
|
||||
permission_uuid: UUID = Query(...),
|
||||
auth=AUTH_COOKIE,
|
||||
):
|
||||
ctx = await authz.verify(
|
||||
auth, ["auth:admin"], host=request.headers.get("host"), match=permutil.has_all
|
||||
)
|
||||
|
||||
db.remove_permission_from_org(org_uuid, permission_uuid, ctx=ctx)
|
||||
|
||||
# Guard rail: prevent removing auth:admin from your own org if it would lock you out
|
||||
perm = db.data().permissions.get(permission_uuid)
|
||||
if perm and perm.scope == "auth:admin" and ctx.org.uuid == org_uuid:
|
||||
# Check if any other org grants auth:admin that we're a member of
|
||||
# (we only know our current org, so this effectively means we can't remove it from our own org)
|
||||
raise ValueError(
|
||||
"Cannot remove auth:admin from your own organization. "
|
||||
"This would lock you out of admin access."
|
||||
)
|
||||
|
||||
db.remove_permission_from_org(org_uuid, permission_uuid, ctx=ctx)
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
@app.post("/{org_uuid}/roles")
|
||||
async def admin_create_role(
|
||||
org_uuid: UUID,
|
||||
request: Request,
|
||||
payload: dict = Body(...),
|
||||
auth=AUTH_COOKIE,
|
||||
):
|
||||
ctx = await authz.verify(
|
||||
auth,
|
||||
["auth:admin", "auth:org:admin"],
|
||||
match=permutil.has_any,
|
||||
host=request.headers.get("host"),
|
||||
)
|
||||
if not can_manage_org(ctx, org_uuid):
|
||||
raise authz.AuthException(
|
||||
status_code=403, detail="Insufficient permissions", mode="forbidden"
|
||||
)
|
||||
|
||||
display_name = payload.get("display_name") or "New Role"
|
||||
perms = payload.get("permissions") or []
|
||||
if org_uuid not in db.data().orgs:
|
||||
raise HTTPException(status_code=404, detail="Organization not found")
|
||||
org = db.data().orgs[org_uuid]
|
||||
grantable = {p.uuid for p in org.permissions}
|
||||
|
||||
# Normalize permission IDs to UUIDs
|
||||
permission_uuids: set[UUID] = set()
|
||||
for pid in perms:
|
||||
perm = db.data().permissions.get(UUID(pid))
|
||||
if not perm:
|
||||
raise ValueError(f"Permission {pid} not found")
|
||||
if perm.uuid not in grantable:
|
||||
raise ValueError(f"Permission not grantable by org: {pid}")
|
||||
permission_uuids.add(perm.uuid)
|
||||
|
||||
role = RoleDC.create(
|
||||
org=org_uuid,
|
||||
display_name=display_name,
|
||||
permissions=permission_uuids,
|
||||
)
|
||||
db.create_role(role, ctx=ctx)
|
||||
return MsgspecResponse(ApiUuidResponse(uuid=str(role.uuid)))
|
||||
|
||||
|
||||
@app.post("/{org_uuid}/users")
|
||||
async def admin_create_user(
|
||||
org_uuid: UUID,
|
||||
request: Request,
|
||||
payload: dict = Body(...),
|
||||
auth=AUTH_COOKIE,
|
||||
):
|
||||
ctx = await authz.verify(
|
||||
auth,
|
||||
["auth:admin", "auth:org:admin"],
|
||||
match=permutil.has_any,
|
||||
host=request.headers.get("host"),
|
||||
)
|
||||
if not can_manage_org(ctx, org_uuid):
|
||||
raise authz.AuthException(
|
||||
status_code=403, detail="Insufficient permissions", mode="forbidden"
|
||||
)
|
||||
display_name = payload.get("display_name")
|
||||
role_name = payload.get("role")
|
||||
if not display_name or not role_name:
|
||||
raise ValueError("display_name and role are required")
|
||||
|
||||
org = db.data().orgs[org_uuid]
|
||||
role_obj = next(
|
||||
(r for r in org.roles if r.display_name == role_name),
|
||||
None,
|
||||
)
|
||||
if not role_obj:
|
||||
raise ValueError("Role not found in organization")
|
||||
user = UserDC.create(
|
||||
display_name=display_name,
|
||||
role=role_obj.uuid,
|
||||
)
|
||||
db.create_user(user, ctx=ctx)
|
||||
return MsgspecResponse(ApiUuidResponse(uuid=str(user.uuid)))
|
||||
@@ -0,0 +1,215 @@
|
||||
from uuid import UUID
|
||||
|
||||
from fastapi import Body, FastAPI, Query, Request
|
||||
|
||||
from paskia import db
|
||||
from paskia.db import Permission as PermDC
|
||||
from paskia.fastapi import authz
|
||||
from paskia.fastapi.admin.errors import install_error_handlers
|
||||
from paskia.fastapi.session import AUTH_COOKIE
|
||||
from paskia.globals import passkey
|
||||
from paskia.util import hostutil, permutil, querysafe
|
||||
|
||||
app = FastAPI(docs_url=None, redoc_url=None, openapi_url=None)
|
||||
|
||||
install_error_handlers(app)
|
||||
|
||||
|
||||
def _validate_permission_domain(domain: str | None) -> None:
|
||||
"""Validate that domain is rp_id, a subdomain of it, or an OIDC client UUID."""
|
||||
if domain is None:
|
||||
return
|
||||
|
||||
# Allow OIDC client UUIDs (used for groups claim)
|
||||
try:
|
||||
client_uuid = UUID(domain)
|
||||
if client_uuid in db.data().oidc.clients:
|
||||
return
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
rp_id = passkey.rp_id
|
||||
if domain == rp_id or domain.endswith(f".{rp_id}"):
|
||||
return
|
||||
raise ValueError(
|
||||
f"Domain '{domain}' must be '{rp_id}', its subdomain, or an OIDC client UUID"
|
||||
)
|
||||
|
||||
|
||||
def _check_admin_lockout(
|
||||
perm_uuid: str, new_domain: str | None, current_host: str | None
|
||||
) -> None:
|
||||
"""Check if setting domain on auth:admin would lock out the admin.
|
||||
|
||||
Raises ValueError if this change would result in no auth:admin permissions
|
||||
being accessible from the current host.
|
||||
"""
|
||||
|
||||
normalized_host = hostutil.normalize_host(current_host)
|
||||
host_without_port = normalized_host.rsplit(":", 1)[0] if normalized_host else None
|
||||
|
||||
# Get all auth:admin permissions
|
||||
all_perms = list(db.data().permissions.values())
|
||||
admin_perms = [p for p in all_perms if p.scope == "auth:admin"]
|
||||
|
||||
# Check if at least one auth:admin would remain accessible
|
||||
for p in admin_perms:
|
||||
# If this is the permission being modified, use the new domain
|
||||
domain = new_domain if str(p.uuid) == perm_uuid else p.domain
|
||||
|
||||
# No domain restriction = accessible from anywhere
|
||||
if domain is None:
|
||||
return
|
||||
|
||||
# Check if domain matches current host
|
||||
if domain == normalized_host or domain == host_without_port:
|
||||
return
|
||||
|
||||
# Check if domain is a subdomain of current host or vice versa
|
||||
if normalized_host and normalized_host.endswith(f".{domain}"):
|
||||
return
|
||||
if host_without_port and host_without_port.endswith(f".{domain}"):
|
||||
return
|
||||
|
||||
raise ValueError(
|
||||
f"Setting domain '{new_domain}' on auth:admin permission would lock you out of "
|
||||
f"admin access from current host '{current_host}'"
|
||||
)
|
||||
|
||||
|
||||
def _check_admin_lockout_on_delete(perm_uuid: str, current_host: str | None) -> None:
|
||||
"""Check if deleting an auth:admin permission would lock out the admin.
|
||||
|
||||
Raises ValueError if this deletion would result in no auth:admin permissions
|
||||
being accessible from the current host.
|
||||
"""
|
||||
normalized_host = hostutil.normalize_host(current_host)
|
||||
host_without_port = normalized_host.rsplit(":", 1)[0] if normalized_host else None
|
||||
|
||||
# Get all auth:admin permissions except the one being deleted
|
||||
all_perms = list(db.data().permissions.values())
|
||||
admin_perms = [
|
||||
p for p in all_perms if p.scope == "auth:admin" and str(p.uuid) != perm_uuid
|
||||
]
|
||||
|
||||
# Check if at least one auth:admin would remain accessible
|
||||
for p in admin_perms:
|
||||
domain = p.domain
|
||||
|
||||
# No domain restriction = accessible from anywhere
|
||||
if domain is None:
|
||||
return
|
||||
|
||||
# Check if domain matches current host
|
||||
if domain == normalized_host or domain == host_without_port:
|
||||
return
|
||||
|
||||
# Check if domain is a subdomain of current host or vice versa
|
||||
if normalized_host and normalized_host.endswith(f".{domain}"):
|
||||
return
|
||||
if host_without_port and host_without_port.endswith(f".{domain}"):
|
||||
return
|
||||
|
||||
raise ValueError(
|
||||
f"Deleting this auth:admin permission would lock you out of "
|
||||
f"admin access from current host '{current_host}'"
|
||||
)
|
||||
|
||||
|
||||
@app.post("/")
|
||||
async def admin_create_permission(
|
||||
request: Request,
|
||||
payload: dict = Body(...),
|
||||
auth=AUTH_COOKIE,
|
||||
):
|
||||
ctx = await authz.verify(
|
||||
auth,
|
||||
["auth:admin"],
|
||||
host=request.headers.get("host"),
|
||||
match=permutil.has_all,
|
||||
max_age="5m",
|
||||
)
|
||||
|
||||
scope = payload.get("scope") or payload.get(
|
||||
"id"
|
||||
) # Support both for backwards compat
|
||||
display_name = payload.get("display_name")
|
||||
domain = payload.get("domain") or None # Treat empty string as None
|
||||
if not scope or not display_name:
|
||||
raise ValueError("scope and display_name are required")
|
||||
querysafe.assert_safe(scope, field="scope")
|
||||
_validate_permission_domain(domain)
|
||||
db.create_permission(
|
||||
PermDC.create(scope=scope, display_name=display_name, domain=domain),
|
||||
ctx=ctx,
|
||||
)
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
@app.patch("/{permission_uuid}")
|
||||
async def admin_update_permission(
|
||||
permission_uuid: UUID,
|
||||
request: Request,
|
||||
auth=AUTH_COOKIE,
|
||||
display_name: str | None = Query(None),
|
||||
scope: str | None = Query(None),
|
||||
domain: str | None = Query(None),
|
||||
):
|
||||
ctx = await authz.verify(
|
||||
auth, ["auth:admin"], host=request.headers.get("host"), match=permutil.has_all
|
||||
)
|
||||
|
||||
# Get existing permission
|
||||
perm = db.data().permissions.get(permission_uuid)
|
||||
|
||||
# Update fields that were provided
|
||||
new_scope = scope if scope is not None else perm.scope
|
||||
new_display_name = display_name if display_name is not None else perm.display_name
|
||||
domain_value = domain if domain else None
|
||||
|
||||
# Sanity check: prevent changing the auth:admin permission scope
|
||||
if perm.scope == "auth:admin" and new_scope != "auth:admin":
|
||||
raise ValueError("Cannot rename the master admin permission")
|
||||
|
||||
if not new_display_name:
|
||||
raise ValueError("display_name is required")
|
||||
querysafe.assert_safe(new_scope, field="scope")
|
||||
_validate_permission_domain(domain_value)
|
||||
|
||||
# Safety check: prevent admin lockout when setting domain on auth:admin
|
||||
if perm.scope == "auth:admin" or new_scope == "auth:admin":
|
||||
_check_admin_lockout(str(perm.uuid), domain_value, request.headers.get("host"))
|
||||
|
||||
db.update_permission(
|
||||
uuid=perm.uuid,
|
||||
scope=new_scope,
|
||||
display_name=new_display_name,
|
||||
domain=domain_value,
|
||||
ctx=ctx,
|
||||
)
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
@app.delete("/{permission_uuid}")
|
||||
async def admin_delete_permission(
|
||||
permission_uuid: UUID,
|
||||
request: Request,
|
||||
auth=AUTH_COOKIE,
|
||||
):
|
||||
ctx = await authz.verify(
|
||||
auth,
|
||||
["auth:admin"],
|
||||
host=request.headers.get("host"),
|
||||
match=permutil.has_all,
|
||||
max_age="5m",
|
||||
)
|
||||
|
||||
# Get the permission to check its scope
|
||||
perm = db.data().permissions.get(permission_uuid)
|
||||
|
||||
# Sanity check: prevent deleting critical permissions if it would lock out admin
|
||||
if perm.scope == "auth:admin":
|
||||
_check_admin_lockout_on_delete(str(perm.uuid), request.headers.get("host"))
|
||||
|
||||
db.delete_permission(permission_uuid, ctx=ctx)
|
||||
return {"status": "ok"}
|
||||
@@ -0,0 +1,160 @@
|
||||
from uuid import UUID
|
||||
|
||||
from fastapi import Body, FastAPI, HTTPException, Request
|
||||
|
||||
from paskia import db
|
||||
from paskia.fastapi import authz
|
||||
from paskia.fastapi.admin.errors import install_error_handlers
|
||||
from paskia.fastapi.session import AUTH_COOKIE
|
||||
from paskia.util import permutil
|
||||
|
||||
app = FastAPI(docs_url=None, redoc_url=None, openapi_url=None)
|
||||
|
||||
install_error_handlers(app)
|
||||
|
||||
|
||||
def master_admin(ctx) -> bool:
|
||||
return any(p.scope == "auth:admin" for p in ctx.permissions)
|
||||
|
||||
|
||||
def org_admin(ctx, org_uuid: UUID) -> bool:
|
||||
return ctx.org.uuid == org_uuid and any(
|
||||
p.scope == "auth:org:admin" for p in ctx.permissions
|
||||
)
|
||||
|
||||
|
||||
def can_manage_org(ctx, org_uuid: UUID) -> bool:
|
||||
return master_admin(ctx) or org_admin(ctx, org_uuid)
|
||||
|
||||
|
||||
@app.patch("/{role_uuid}")
|
||||
async def admin_update_role_name(
|
||||
role_uuid: UUID,
|
||||
request: Request,
|
||||
payload: dict = Body(...),
|
||||
auth=AUTH_COOKIE,
|
||||
):
|
||||
"""Update role display name only."""
|
||||
role = db.data().roles.get(role_uuid)
|
||||
if not role:
|
||||
raise HTTPException(status_code=404, detail="Role not found")
|
||||
ctx = await authz.verify(
|
||||
auth,
|
||||
["auth:admin", "auth:org:admin"],
|
||||
match=permutil.has_any,
|
||||
host=request.headers.get("host"),
|
||||
)
|
||||
if not can_manage_org(ctx, role.org_uuid):
|
||||
raise authz.AuthException(
|
||||
status_code=403, detail="Insufficient permissions", mode="forbidden"
|
||||
)
|
||||
|
||||
display_name = payload.get("display_name")
|
||||
if not display_name:
|
||||
raise ValueError("display_name is required")
|
||||
|
||||
db.update_role_name(role_uuid, display_name, ctx=ctx)
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
@app.post("/{role_uuid}/permissions/{permission_uuid}")
|
||||
async def admin_add_role_permission(
|
||||
role_uuid: UUID,
|
||||
permission_uuid: UUID,
|
||||
request: Request,
|
||||
auth=AUTH_COOKIE,
|
||||
):
|
||||
"""Add a permission to a role (intent-based API)."""
|
||||
role = db.data().roles.get(role_uuid)
|
||||
if not role:
|
||||
raise HTTPException(status_code=404, detail="Role not found")
|
||||
ctx = await authz.verify(
|
||||
auth,
|
||||
["auth:admin", "auth:org:admin"],
|
||||
match=permutil.has_any,
|
||||
host=request.headers.get("host"),
|
||||
)
|
||||
if not can_manage_org(ctx, role.org_uuid):
|
||||
raise authz.AuthException(
|
||||
status_code=403, detail="Insufficient permissions", mode="forbidden"
|
||||
)
|
||||
|
||||
# Verify permission exists and org can grant it
|
||||
perm = db.data().permissions.get(permission_uuid)
|
||||
if not perm:
|
||||
raise HTTPException(status_code=404, detail="Permission not found")
|
||||
if role.org_uuid not in perm.orgs:
|
||||
raise ValueError("Permission not grantable by organization")
|
||||
|
||||
db.add_permission_to_role(role_uuid, permission_uuid, ctx=ctx)
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
@app.delete("/{role_uuid}/permissions/{permission_uuid}")
|
||||
async def admin_remove_role_permission(
|
||||
role_uuid: UUID,
|
||||
permission_uuid: UUID,
|
||||
request: Request,
|
||||
auth=AUTH_COOKIE,
|
||||
):
|
||||
"""Remove a permission from a role (intent-based API)."""
|
||||
role = db.data().roles.get(role_uuid)
|
||||
if not role:
|
||||
raise HTTPException(status_code=404, detail="Role not found")
|
||||
ctx = await authz.verify(
|
||||
auth,
|
||||
["auth:admin", "auth:org:admin"],
|
||||
match=permutil.has_any,
|
||||
host=request.headers.get("host"),
|
||||
)
|
||||
if not can_manage_org(ctx, role.org_uuid):
|
||||
raise authz.AuthException(
|
||||
status_code=403, detail="Insufficient permissions", mode="forbidden"
|
||||
)
|
||||
|
||||
# Sanity check: prevent admin from removing their own access
|
||||
perm = db.data().permissions.get(permission_uuid)
|
||||
if ctx.org.uuid == role.org_uuid and ctx.role.uuid == role_uuid:
|
||||
if perm and perm.scope in ["auth:admin", "auth:org:admin"]:
|
||||
# Check if removing this permission would leave no admin access
|
||||
remaining_perms = role.permission_set - {permission_uuid}
|
||||
has_admin = False
|
||||
for rp_uuid in remaining_perms:
|
||||
rp = db.data().permissions.get(rp_uuid)
|
||||
if rp and rp.scope in ["auth:admin", "auth:org:admin"]:
|
||||
has_admin = True
|
||||
break
|
||||
if not has_admin:
|
||||
raise ValueError("Cannot remove your own admin permissions")
|
||||
|
||||
db.remove_permission_from_role(role_uuid, permission_uuid, ctx=ctx)
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
@app.delete("/{role_uuid}")
|
||||
async def admin_delete_role(
|
||||
role_uuid: UUID,
|
||||
request: Request,
|
||||
auth=AUTH_COOKIE,
|
||||
):
|
||||
role = db.data().roles.get(role_uuid)
|
||||
if not role:
|
||||
raise HTTPException(status_code=404, detail="Role not found")
|
||||
ctx = await authz.verify(
|
||||
auth,
|
||||
["auth:admin", "auth:org:admin"],
|
||||
match=permutil.has_any,
|
||||
host=request.headers.get("host"),
|
||||
max_age="5m",
|
||||
)
|
||||
if not can_manage_org(ctx, role.org_uuid):
|
||||
raise authz.AuthException(
|
||||
status_code=403, detail="Insufficient permissions", mode="forbidden"
|
||||
)
|
||||
|
||||
# Sanity check: prevent admin from deleting their own role
|
||||
if ctx.role.uuid == role_uuid:
|
||||
raise ValueError("Cannot delete your own role")
|
||||
|
||||
db.delete_role(role_uuid, ctx=ctx)
|
||||
return {"status": "ok"}
|
||||
@@ -0,0 +1,85 @@
|
||||
from fastapi import Body, FastAPI, HTTPException, Request
|
||||
|
||||
from paskia import db
|
||||
from paskia.db.structs import Config
|
||||
from paskia.fastapi import authz
|
||||
from paskia.fastapi.admin.errors import install_error_handlers
|
||||
from paskia.fastapi.session import AUTH_COOKIE
|
||||
from paskia.globals import passkey
|
||||
from paskia.sansio import Passkey
|
||||
from paskia.util import hostutil
|
||||
from paskia.util.runtime import update_runtime_config
|
||||
|
||||
app = FastAPI(docs_url=None, redoc_url=None, openapi_url=None)
|
||||
|
||||
install_error_handlers(app)
|
||||
|
||||
|
||||
@app.get("/")
|
||||
async def admin_get_server_config(
|
||||
request: Request,
|
||||
auth=AUTH_COOKIE,
|
||||
):
|
||||
"""Get current server configuration (master admin only)."""
|
||||
await authz.verify(auth, ["auth:admin"], host=request.headers.get("host"))
|
||||
pk = passkey
|
||||
config = db.data().config
|
||||
return {
|
||||
"rp_name": pk.rp_name,
|
||||
"auth_host": config.auth_host or "",
|
||||
"origins": list(pk.allowed_origins) if pk.allowed_origins else [],
|
||||
}
|
||||
|
||||
|
||||
@app.patch("/")
|
||||
async def admin_update_server_config(
|
||||
request: Request,
|
||||
payload: dict = Body(...),
|
||||
auth=AUTH_COOKIE,
|
||||
):
|
||||
"""Update server configuration (master admin only).
|
||||
|
||||
Updates rp_name, auth_host, and origins in both the runtime Passkey
|
||||
instance and the persisted database config.
|
||||
"""
|
||||
await authz.verify(
|
||||
auth, ["auth:admin"], host=request.headers.get("host"), max_age="5m"
|
||||
)
|
||||
config = db.data().config
|
||||
pk = passkey
|
||||
|
||||
rp_name = payload.get("rp_name", "").strip() or None
|
||||
auth_host = payload.get("auth_host", "").strip() or None
|
||||
raw_origins = payload.get("origins", [])
|
||||
origins = [
|
||||
hostutil.normalize_origin(o.strip()) for o in raw_origins if o.strip()
|
||||
] or None
|
||||
|
||||
# Normalize auth_host and origins (matching CLI startup behavior)
|
||||
if auth_host:
|
||||
try:
|
||||
hostutil.validate_auth_host(auth_host, config.rp_id)
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
auth_host, origins = hostutil.normalize_auth_host_and_origins(auth_host, origins)
|
||||
|
||||
# Validate origins against the current rp_id
|
||||
if origins:
|
||||
for o in origins:
|
||||
Passkey(rp_id=config.rp_id, origins=[o]) # validates or raises
|
||||
|
||||
# Update runtime Passkey instance
|
||||
pk.rp_name = rp_name or config.rp_id
|
||||
pk.allowed_origins = set(origins) if origins else None
|
||||
|
||||
# Persist to database
|
||||
new_config = Config(
|
||||
rp_id=config.rp_id,
|
||||
rp_name=rp_name,
|
||||
auth_host=auth_host,
|
||||
origins=origins,
|
||||
listen=config.listen,
|
||||
)
|
||||
db.update_config(new_config)
|
||||
update_runtime_config(new_config)
|
||||
return {"status": "ok"}
|
||||
@@ -0,0 +1,318 @@
|
||||
from uuid import UUID
|
||||
|
||||
from fastapi import Body, FastAPI, HTTPException, Request
|
||||
|
||||
from paskia import aaguid as aaguid_mod
|
||||
from paskia import db
|
||||
from paskia.authsession import reset_expires
|
||||
from paskia.fastapi import authz
|
||||
from paskia.fastapi.admin.errors import install_error_handlers
|
||||
from paskia.fastapi.response import MsgspecResponse
|
||||
from paskia.fastapi.session import AUTH_COOKIE
|
||||
from paskia.util import avatar, hostutil, permutil
|
||||
from paskia.util.apistructs import (
|
||||
ApiAaguidInfo,
|
||||
ApiCreateLinkResponse,
|
||||
ApiOrg,
|
||||
ApiRole,
|
||||
ApiUser,
|
||||
ApiUserDetail,
|
||||
ApiUserSession,
|
||||
)
|
||||
|
||||
app = FastAPI(docs_url=None, redoc_url=None, openapi_url=None)
|
||||
|
||||
install_error_handlers(app)
|
||||
|
||||
|
||||
def master_admin(ctx) -> bool:
|
||||
return any(p.scope == "auth:admin" for p in ctx.permissions)
|
||||
|
||||
|
||||
def org_admin(ctx, org_uuid: UUID) -> bool:
|
||||
return ctx.org.uuid == org_uuid and any(
|
||||
p.scope == "auth:org:admin" for p in ctx.permissions
|
||||
)
|
||||
|
||||
|
||||
def can_manage_org(ctx, org_uuid: UUID) -> bool:
|
||||
return master_admin(ctx) or org_admin(ctx, org_uuid)
|
||||
|
||||
|
||||
@app.patch("/{user_uuid}/role")
|
||||
async def admin_update_user_role(
|
||||
user_uuid: UUID,
|
||||
request: Request,
|
||||
payload: dict = Body(...),
|
||||
auth=AUTH_COOKIE,
|
||||
):
|
||||
try:
|
||||
user = db.data().users[user_uuid]
|
||||
except KeyError:
|
||||
raise HTTPException(status_code=404, detail="User not found")
|
||||
ctx = await authz.verify(
|
||||
auth,
|
||||
["auth:admin", "auth:org:admin"],
|
||||
match=permutil.has_any,
|
||||
host=request.headers.get("host"),
|
||||
)
|
||||
if not can_manage_org(ctx, user.org.uuid):
|
||||
raise authz.AuthException(
|
||||
status_code=403, detail="Insufficient permissions", mode="forbidden"
|
||||
)
|
||||
role_uuid_str = payload.get("role_uuid")
|
||||
if not role_uuid_str:
|
||||
raise ValueError("role_uuid is required")
|
||||
try:
|
||||
new_role_uuid = UUID(role_uuid_str)
|
||||
except (ValueError, TypeError):
|
||||
raise ValueError("Invalid role UUID")
|
||||
new_role = db.data().roles.get(new_role_uuid)
|
||||
if not new_role or new_role.org_uuid != user.org.uuid:
|
||||
raise ValueError("Role not found in organization")
|
||||
|
||||
# Sanity check: prevent admin from removing their own access
|
||||
if ctx.user.uuid == user_uuid:
|
||||
# Check if any permission in the new role is an admin permission
|
||||
has_admin_access = False
|
||||
for perm_uuid in new_role.permissions:
|
||||
perm = db.data().permissions.get(perm_uuid)
|
||||
if perm and perm.scope in ["auth:admin", "auth:org:admin"]:
|
||||
has_admin_access = True
|
||||
break
|
||||
if not has_admin_access:
|
||||
raise ValueError(
|
||||
"Cannot change your own role to one without admin permissions"
|
||||
)
|
||||
|
||||
db.update_user_role(user_uuid, new_role_uuid, ctx=ctx)
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
@app.post("/{user_uuid}/create-link")
|
||||
async def admin_create_user_registration_link(
|
||||
user_uuid: UUID,
|
||||
request: Request,
|
||||
auth=AUTH_COOKIE,
|
||||
):
|
||||
try:
|
||||
user = db.data().users[user_uuid]
|
||||
except KeyError:
|
||||
raise HTTPException(status_code=404, detail="User not found")
|
||||
ctx = await authz.verify(
|
||||
auth,
|
||||
["auth:admin", "auth:org:admin"],
|
||||
match=permutil.has_any,
|
||||
host=request.headers.get("host"),
|
||||
max_age="5m",
|
||||
)
|
||||
if not can_manage_org(ctx, user.org.uuid):
|
||||
raise authz.AuthException(
|
||||
status_code=403, detail="Insufficient permissions", mode="forbidden"
|
||||
)
|
||||
|
||||
# Check if user has existing credentials
|
||||
has_credentials = db.data().users[user_uuid].credential_ids
|
||||
token_type = "user registration" if not has_credentials else "account recovery"
|
||||
|
||||
expiry = reset_expires()
|
||||
token = db.create_reset_token(
|
||||
user_uuid=user_uuid,
|
||||
expiry=expiry,
|
||||
token_type=token_type,
|
||||
ctx=ctx,
|
||||
)
|
||||
url = hostutil.reset_link_url(token)
|
||||
return MsgspecResponse(
|
||||
ApiCreateLinkResponse(
|
||||
url=url,
|
||||
expires=expiry,
|
||||
token_type=token_type,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@app.get("/{user_uuid}")
|
||||
async def admin_get_user_detail(
|
||||
user_uuid: UUID,
|
||||
request: Request,
|
||||
auth=AUTH_COOKIE,
|
||||
):
|
||||
try:
|
||||
user = db.data().users[user_uuid]
|
||||
except KeyError:
|
||||
raise HTTPException(status_code=404, detail="User not found")
|
||||
ctx = await authz.verify(
|
||||
auth,
|
||||
["auth:admin", "auth:org:admin"],
|
||||
match=permutil.has_any,
|
||||
host=request.headers.get("host"),
|
||||
)
|
||||
if not can_manage_org(ctx, user.org.uuid):
|
||||
raise authz.AuthException(
|
||||
status_code=403, detail="Insufficient permissions", mode="forbidden"
|
||||
)
|
||||
normalized_host = hostutil.normalize_host(request.headers.get("host"))
|
||||
|
||||
sessions = {
|
||||
s.key: ApiUserSession.from_db(
|
||||
s,
|
||||
current_key=ctx.session.key,
|
||||
normalized_host=normalized_host,
|
||||
)
|
||||
for s in user.sessions
|
||||
}
|
||||
|
||||
return MsgspecResponse(
|
||||
ApiUserDetail(
|
||||
user=ApiUser.from_db(user, avatar_url=avatar.avatar_browser_url(user.uuid)),
|
||||
credentials={c.uuid: c for c in user.credentials},
|
||||
aaguid_info={
|
||||
k: ApiAaguidInfo(**v)
|
||||
for k, v in aaguid_mod.filter(
|
||||
c.aaguid for c in user.credentials
|
||||
).items()
|
||||
},
|
||||
sessions=sessions,
|
||||
org=ApiOrg.from_db(user.org),
|
||||
role=ApiRole.from_db(user.role),
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@app.patch("/{user_uuid}/info")
|
||||
async def admin_update_user_info(
|
||||
user_uuid: UUID,
|
||||
request: Request,
|
||||
payload: dict = Body(...),
|
||||
auth=AUTH_COOKIE,
|
||||
):
|
||||
"""Update user profile info (display_name, email, preferred_username, telephone).
|
||||
|
||||
Pass only the fields you want to update. Use null to clear optional fields.
|
||||
"""
|
||||
try:
|
||||
user = db.data().users[user_uuid]
|
||||
except KeyError:
|
||||
raise HTTPException(status_code=404, detail="User not found")
|
||||
ctx = await authz.verify(
|
||||
auth,
|
||||
["auth:admin", "auth:org:admin"],
|
||||
match=permutil.has_any,
|
||||
host=request.headers.get("host"),
|
||||
)
|
||||
if not can_manage_org(ctx, user.org.uuid):
|
||||
raise authz.AuthException(
|
||||
status_code=403, detail="Insufficient permissions", mode="forbidden"
|
||||
)
|
||||
|
||||
kwargs = {}
|
||||
if "display_name" in payload:
|
||||
name = (payload["display_name"] or "").strip()
|
||||
if not name:
|
||||
raise HTTPException(status_code=400, detail="display_name cannot be empty")
|
||||
if len(name) > 64:
|
||||
raise HTTPException(status_code=400, detail="display_name too long")
|
||||
kwargs["display_name"] = name
|
||||
if "email" in payload:
|
||||
kwargs["email"] = payload["email"]
|
||||
if "preferred_username" in payload:
|
||||
kwargs["preferred_username"] = payload["preferred_username"]
|
||||
if "telephone" in payload:
|
||||
kwargs["telephone"] = payload["telephone"]
|
||||
|
||||
if not kwargs:
|
||||
raise HTTPException(status_code=400, detail="No fields to update")
|
||||
|
||||
db.update_user_info(user_uuid, **kwargs, ctx=ctx)
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
@app.delete("/{user_uuid}")
|
||||
async def admin_delete_user(
|
||||
user_uuid: UUID,
|
||||
request: Request,
|
||||
auth=AUTH_COOKIE,
|
||||
):
|
||||
"""Delete a user and all their credentials/sessions."""
|
||||
try:
|
||||
user = db.data().users[user_uuid]
|
||||
except KeyError:
|
||||
raise HTTPException(status_code=404, detail="User not found")
|
||||
ctx = await authz.verify(
|
||||
auth,
|
||||
["auth:admin", "auth:org:admin"],
|
||||
match=permutil.has_any,
|
||||
host=request.headers.get("host"),
|
||||
max_age="5m",
|
||||
)
|
||||
if not can_manage_org(ctx, user.org.uuid):
|
||||
raise authz.AuthException(
|
||||
status_code=403, detail="Insufficient permissions", mode="forbidden"
|
||||
)
|
||||
# Prevent admin from deleting themselves
|
||||
if ctx.user.uuid == user_uuid:
|
||||
raise ValueError("Cannot delete your own account")
|
||||
db.delete_user(user_uuid, ctx=ctx)
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
@app.delete("/{user_uuid}/credentials/{credential_uuid}")
|
||||
async def admin_delete_user_credential(
|
||||
user_uuid: UUID,
|
||||
credential_uuid: UUID,
|
||||
request: Request,
|
||||
auth=AUTH_COOKIE,
|
||||
):
|
||||
try:
|
||||
user = db.data().users[user_uuid]
|
||||
except KeyError:
|
||||
raise HTTPException(status_code=404, detail="User not found")
|
||||
ctx = await authz.verify(
|
||||
auth,
|
||||
["auth:admin", "auth:org:admin"],
|
||||
match=permutil.has_any,
|
||||
host=request.headers.get("host"),
|
||||
max_age="5m",
|
||||
)
|
||||
if not can_manage_org(ctx, user.org.uuid):
|
||||
raise authz.AuthException(
|
||||
status_code=403, detail="Insufficient permissions", mode="forbidden"
|
||||
)
|
||||
db.delete_credential(credential_uuid, user_uuid, ctx=ctx)
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
@app.delete("/{user_uuid}/sessions/{session_id}")
|
||||
async def admin_delete_user_session(
|
||||
user_uuid: UUID,
|
||||
session_id: str,
|
||||
request: Request,
|
||||
auth=AUTH_COOKIE,
|
||||
):
|
||||
try:
|
||||
user = db.data().users[user_uuid]
|
||||
except KeyError:
|
||||
raise HTTPException(status_code=404, detail="User not found")
|
||||
ctx = await authz.verify(
|
||||
auth,
|
||||
["auth:admin", "auth:org:admin"],
|
||||
match=permutil.has_any,
|
||||
host=request.headers.get("host"),
|
||||
)
|
||||
if not can_manage_org(ctx, user.org.uuid):
|
||||
raise authz.AuthException(
|
||||
status_code=403, detail="Insufficient permissions", mode="forbidden"
|
||||
)
|
||||
|
||||
session_key = session_id
|
||||
|
||||
target_session = db.data().sessions.get(session_key)
|
||||
if not target_session or target_session.user_uuid != user_uuid:
|
||||
raise HTTPException(status_code=404, detail="Session not found")
|
||||
|
||||
db.delete_session(session_key, ctx=ctx, action="admin:delete_session")
|
||||
|
||||
# Check if admin terminated their own session
|
||||
current_terminated = session_key == ctx.session.key
|
||||
return {"status": "ok", "current_session_terminated": current_terminated}
|
||||
+109
-27
@@ -1,6 +1,7 @@
|
||||
import logging
|
||||
from contextlib import suppress
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from uuid import UUID
|
||||
|
||||
from fastapi import (
|
||||
Depends,
|
||||
@@ -15,13 +16,23 @@ from fastapi.security import HTTPBearer
|
||||
|
||||
from paskia import authcode, db
|
||||
from paskia._version import __version__
|
||||
from paskia.authsession import EXPIRES, get_reset
|
||||
from paskia.authsession import EXPIRES, get_reset, session_ctx
|
||||
from paskia.fastapi import authz, session, user
|
||||
from paskia.fastapi.response import MsgspecResponse
|
||||
from paskia.fastapi.session import AUTH_COOKIE, AUTH_COOKIE_NAME, get_client_ip
|
||||
from paskia.globals import passkey as global_passkey
|
||||
from paskia.util import hostutil, htmlutil, passphrase, userinfo, vitedev
|
||||
from paskia.util.apistructs import ApiSettings, ApiTokenInfo, ApiValidateResponse
|
||||
from paskia.util import hostutil, htmlutil, passphrase, permutil, userinfo
|
||||
from paskia.util.apistructs import (
|
||||
ApiCheckUserResponse,
|
||||
ApiOrgContext,
|
||||
ApiRoleContext,
|
||||
ApiSessionContext,
|
||||
ApiSettings,
|
||||
ApiTokenInfo,
|
||||
ApiUserContext,
|
||||
ApiValidateResponse,
|
||||
)
|
||||
from paskia.util.crypto import hash_secret
|
||||
|
||||
bearer_auth = HTTPBearer(auto_error=False)
|
||||
|
||||
@@ -46,6 +57,12 @@ async def http_exception_handler(_request: Request, exc: HTTPException):
|
||||
_REFRESH_INTERVAL = timedelta(minutes=5)
|
||||
|
||||
|
||||
def _set_log_extra(request: Request, *parts: str) -> None:
|
||||
values = [part for part in parts if part]
|
||||
if values:
|
||||
request.state.log_extra = " ".join(values)
|
||||
|
||||
|
||||
@app.exception_handler(ValueError)
|
||||
async def value_error_handler(_request: Request, exc: ValueError):
|
||||
return JSONResponse(status_code=400, content={"detail": str(exc)})
|
||||
@@ -100,6 +117,7 @@ async def validate_token(
|
||||
)
|
||||
session.set_session_cookie(response, auth)
|
||||
renewed = True
|
||||
_set_log_extra(request, ctx.session.key)
|
||||
return MsgspecResponse(
|
||||
ApiValidateResponse(
|
||||
valid=True,
|
||||
@@ -109,6 +127,60 @@ async def validate_token(
|
||||
)
|
||||
|
||||
|
||||
@app.get("/check")
|
||||
async def check_user(
|
||||
request: Request,
|
||||
user_uuid: UUID = Query(..., alias="user"),
|
||||
perm: list[str] = Query([]),
|
||||
):
|
||||
"""Check permissions for a user by UUID without requiring a session.
|
||||
|
||||
Query Params:
|
||||
- user: UUID of the user to check.
|
||||
- perm: repeated permission scope the user must possess (ALL required).
|
||||
|
||||
Returns 200 with valid=True/False and the user's effective permissions,
|
||||
scoped to the requesting host (domain-restricted permissions are filtered).
|
||||
Returns 404 if the user UUID does not exist.
|
||||
|
||||
No session cookie is read or written. Caller authentication is not required.
|
||||
"""
|
||||
data = db.data()
|
||||
try:
|
||||
u = data.users[user_uuid]
|
||||
role = u.role
|
||||
org = role.org
|
||||
except KeyError:
|
||||
raise HTTPException(status_code=404, detail="User not found")
|
||||
|
||||
host = hostutil.normalize_host(request.headers.get("host"))
|
||||
org_perm_uuids = {p.uuid for p in org.permissions}
|
||||
|
||||
effective_perms = []
|
||||
for perm_uuid in role.permission_set:
|
||||
if perm_uuid not in org_perm_uuids:
|
||||
continue
|
||||
try:
|
||||
p = data.permissions[perm_uuid]
|
||||
except KeyError:
|
||||
continue
|
||||
if p.domain is not None and p.domain != host:
|
||||
continue
|
||||
effective_perms.append(p)
|
||||
|
||||
required = " ".join(perm).split()
|
||||
effective_scopes = {p.scope for p in effective_perms}
|
||||
valid = permutil.has_all_scopes(effective_scopes, required)
|
||||
|
||||
ctx = ApiSessionContext(
|
||||
user=ApiUserContext(uuid=u.uuid, display_name=u.display_name, theme=u.theme),
|
||||
org=ApiOrgContext(uuid=org.uuid, display_name=org.display_name),
|
||||
role=ApiRoleContext(uuid=role.uuid, display_name=role.display_name),
|
||||
permissions=sorted(effective_scopes),
|
||||
)
|
||||
return MsgspecResponse(ApiCheckUserResponse(valid=valid, ctx=ctx))
|
||||
|
||||
|
||||
@app.get("/forward")
|
||||
async def forward_authentication(
|
||||
request: Request,
|
||||
@@ -131,6 +203,15 @@ async def forward_authentication(
|
||||
- Otherwise: JSON response with error details and an `iframe` field
|
||||
pointing to /auth/restricted/iframe#mode=... for iframe-based authentication.
|
||||
"""
|
||||
forwarded_method = request.headers.get("x-forwarded-method", "").strip()
|
||||
forwarded_uri = request.headers.get("x-forwarded-uri", "").strip()
|
||||
forwarded = (
|
||||
f"{forwarded_method} {forwarded_uri}"
|
||||
if forwarded_method and forwarded_uri
|
||||
else ""
|
||||
)
|
||||
_set_log_extra(request, forwarded)
|
||||
|
||||
try:
|
||||
ctx = await authz.verify(
|
||||
auth,
|
||||
@@ -138,6 +219,7 @@ async def forward_authentication(
|
||||
host=request.headers.get("host"),
|
||||
max_age=max_age,
|
||||
)
|
||||
_set_log_extra(request, forwarded, ctx.session.key)
|
||||
# Build permission scopes for Remote-Groups header
|
||||
role_permissions = (
|
||||
{p.scope for p in ctx.permissions} if ctx.permissions else set()
|
||||
@@ -161,30 +243,20 @@ async def forward_authentication(
|
||||
# Clear cookie only if session is invalid (not for reauth)
|
||||
if e.clear_session:
|
||||
session.clear_session_cookie(response)
|
||||
|
||||
# Check Accept header to decide response format
|
||||
accept = request.headers.get("accept", "")
|
||||
wants_html = "text/html" in accept
|
||||
|
||||
if wants_html:
|
||||
# Browser request - return full-page HTML with metadata
|
||||
data_attrs = {"mode": e.mode, **e.metadata}
|
||||
html = (await vitedev.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),
|
||||
# Browser request? - return full-page HTML with metadata patched into data attrs
|
||||
if "text/html" in request.headers.get("accept", ""):
|
||||
return await htmlutil.patched_html_response(
|
||||
request, "/int/forward/", e.status_code, mode=e.mode, **e.metadata
|
||||
)
|
||||
# API request - return JSON with iframe srcdoc HTML
|
||||
return JSONResponse(
|
||||
status_code=e.status_code, content=await authz.auth_error_content(e)
|
||||
)
|
||||
|
||||
|
||||
@app.get("/settings")
|
||||
async def get_settings():
|
||||
pk = global_passkey.instance
|
||||
pk = global_passkey
|
||||
base_path = hostutil.ui_base_path()
|
||||
return MsgspecResponse(
|
||||
ApiSettings(
|
||||
@@ -195,7 +267,8 @@ async def get_settings():
|
||||
auth_site_url=hostutil.auth_site_url(),
|
||||
session_cookie=AUTH_COOKIE_NAME,
|
||||
version=__version__,
|
||||
)
|
||||
),
|
||||
headers={"Access-Control-Allow-Origin": "*", "Vary": "Origin"},
|
||||
)
|
||||
|
||||
|
||||
@@ -212,9 +285,16 @@ async def api_user_info(
|
||||
detail="Authentication required",
|
||||
mode="login",
|
||||
)
|
||||
ctx = db.data().session_ctx(auth, request.headers.get("host"))
|
||||
ctx = session_ctx(auth, request.headers.get("host"))
|
||||
if not ctx:
|
||||
raise HTTPException(401, "Session expired")
|
||||
raise authz.AuthException(
|
||||
status_code=401,
|
||||
detail="Session expired",
|
||||
mode="login",
|
||||
clear_session=True,
|
||||
)
|
||||
|
||||
_set_log_extra(request, ctx.session.key)
|
||||
|
||||
return MsgspecResponse(
|
||||
await userinfo.build_user_info(
|
||||
@@ -244,6 +324,7 @@ async def token_info(credentials=Depends(bearer_auth)):
|
||||
ApiTokenInfo(
|
||||
token_type=reset_token.token_type,
|
||||
display_name=u.display_name,
|
||||
theme=u.theme,
|
||||
)
|
||||
)
|
||||
|
||||
@@ -253,7 +334,7 @@ async def api_logout(request: Request, response: Response, auth=AUTH_COOKIE):
|
||||
if not auth:
|
||||
return {"message": "Already logged out"}
|
||||
host = request.headers.get("host")
|
||||
ctx = db.data().session_ctx(auth, host)
|
||||
ctx = session_ctx(auth, host)
|
||||
if not ctx:
|
||||
return {"message": "Already logged out"}
|
||||
with suppress(Exception):
|
||||
@@ -286,9 +367,10 @@ async def api_set_session(
|
||||
secret = a.session_key
|
||||
|
||||
# Verify the session exists
|
||||
ctx = db.data().session_ctx(secret, host)
|
||||
ctx = session_ctx(secret, host)
|
||||
if not ctx:
|
||||
raise HTTPException(401, f"Session not found on {host}")
|
||||
|
||||
_set_log_extra(request, hash_secret("cookie", secret))
|
||||
session.set_session_cookie(response, secret)
|
||||
return {"status": "ok", "user": str(ctx.user.uuid)}
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi_vue import Frontend
|
||||
|
||||
# Vue Frontend static files
|
||||
frontend = Frontend(
|
||||
Path(__file__).parent.parent / "frontend-build",
|
||||
cached=["/auth/assets/"],
|
||||
favicon="/paskia.webp",
|
||||
)
|
||||
+43
-50
@@ -26,8 +26,8 @@ _METHOD_WRITE = "\033[1;94m" # POST, PUT, DELETE, PATCH (bold bright blue)
|
||||
_HOST = "\033[38;5;242m" # hostname (dark grey)
|
||||
_PATH = "\033[38;5;250m" # path (white)
|
||||
_TIMING = "\033[38;5;242m" # timing/devmode (dark grey)
|
||||
_WS_OPEN = "\033[1;93m" # WebSocket connect (bold bright yellow)
|
||||
_WS_CLOSE = "\033[33m" # WebSocket disconnect (yellow)
|
||||
_WS_OPEN = "\033[38;5;226m" # WebSocket connect (brightest yellow from 6x6x6 cube)
|
||||
_WS_CLOSE = "\033[38;5;142m" # WebSocket disconnect (significantly dimmer yellow)
|
||||
_WS_STATUS = "\033[38;5;242m" # WebSocket close status (dark grey)
|
||||
_AUTHZ_DENIED = "\033[0;31m" # Permission denied (red)
|
||||
_AUTHZ_USER = "\033[1;34m" # User info (light blue)
|
||||
@@ -112,31 +112,31 @@ def method_color(method: str) -> str:
|
||||
|
||||
|
||||
def format_access_log(
|
||||
client: str, status: int, method: str, host: str, path: str, duration_ms: float
|
||||
client: str,
|
||||
status: int,
|
||||
method: str,
|
||||
host: str,
|
||||
path: str,
|
||||
duration_ms: float,
|
||||
extra: str = "",
|
||||
) -> str:
|
||||
"""Format access log line with colors and aligned fields."""
|
||||
use_color = sys.stderr.isatty()
|
||||
|
||||
# Format components with fixed widths for alignment
|
||||
ip = format_client_ip(client).ljust(19) # IPv6 network max 19 chars
|
||||
timing = f"{duration_ms:.0f}ms"
|
||||
method_padded = method.ljust(7) # Longest method is OPTIONS (7)
|
||||
|
||||
if use_color:
|
||||
status_str = f"{status_color(status)}{status}{_RESET}"
|
||||
timing_str = f"{_TIMING}{timing}{_RESET}"
|
||||
method_str = f"{method_color(method)}{method_padded}{_RESET}"
|
||||
host_str = f"{_HOST}{host}{_RESET}"
|
||||
path_str = f"{_PATH}{path}{_RESET}"
|
||||
else:
|
||||
status_str = str(status)
|
||||
timing_str = timing
|
||||
method_str = method_padded
|
||||
host_str = host
|
||||
path_str = path
|
||||
status_str = f"{status_color(status)}{status}{_RESET}"
|
||||
timing_str = f"{_TIMING}{timing}{_RESET}"
|
||||
method_str = f"{method_color(method)}{method_padded}{_RESET}"
|
||||
host_str = f"{_HOST}{host}{_RESET}"
|
||||
path_str = f"{_PATH}{path}{_RESET}"
|
||||
|
||||
# Format: "IP STATUS METHOD host path TIMING"
|
||||
return f"{ip} {status_str} {method_str} {host_str}{path_str} {timing_str}"
|
||||
# Format: "IP STATUS METHOD host path [extra] TIMING"
|
||||
extra_str = f" {_TIMING}{extra}{_RESET}" if extra else ""
|
||||
return (
|
||||
f"{ip} {status_str} {method_str} {host_str}{path_str}{extra_str} {timing_str}"
|
||||
)
|
||||
|
||||
|
||||
# WebSocket connection counter (mod 100)
|
||||
@@ -153,7 +153,6 @@ def _next_ws_id() -> int:
|
||||
|
||||
def log_ws_open(ws) -> int:
|
||||
"""Log WebSocket connection open. Returns connection ID for use in close."""
|
||||
use_color = sys.stderr.isatty()
|
||||
ws_id = _next_ws_id()
|
||||
|
||||
client = ws.client.host if ws.client else "-"
|
||||
@@ -162,28 +161,21 @@ def log_ws_open(ws) -> int:
|
||||
origin = ws.headers.get("origin")
|
||||
|
||||
ip = format_client_ip(client).ljust(19)
|
||||
id_str = f"{ws_id:02d}".ljust(7) # Align with method field (7 chars)
|
||||
# ID right-aligned like status codes (3 chars), emoji formatted like method
|
||||
id_str = f"{_WS_OPEN}{str(ws_id).rjust(3)}{_RESET}"
|
||||
# Emoji (2 display width) + 6 spaces = 8 display chars, but within color for alignment
|
||||
emoji_str = f"{_METHOD_READ}🔌 {_RESET}"
|
||||
|
||||
# Determine if origin should be shown (omit when same as host)
|
||||
# Origin header includes scheme (e.g., "https://example.com"), compare host part
|
||||
origin_host = origin.split("://", 1)[-1] if origin else None
|
||||
show_origin = origin_host and origin_host != host
|
||||
|
||||
if use_color:
|
||||
# 🔌 aligned with status (takes ~2 char width), ID aligned with method
|
||||
prefix = f"🔌 {_WS_OPEN}{id_str}{_RESET}"
|
||||
host_str = f"{_HOST}{host}{_RESET}"
|
||||
path_str = f"{_PATH}{path}{_RESET}"
|
||||
origin_str = (
|
||||
f" {_RESET}from {_HOST}{origin_host}{_RESET}" if show_origin else ""
|
||||
)
|
||||
else:
|
||||
prefix = f"WS+ {id_str}"
|
||||
host_str = host
|
||||
path_str = path
|
||||
origin_str = f" from {origin_host}" if show_origin else ""
|
||||
host_str = f"{_HOST}{host}{_RESET}"
|
||||
path_str = f"{_PATH}{path}{_RESET}"
|
||||
origin_str = f" {_RESET}from {_HOST}{origin_host}{_RESET}" if show_origin else ""
|
||||
|
||||
logger.info(f"{ip} {prefix} {host_str}{path_str}{origin_str}")
|
||||
logger.info(f"{ip} {id_str} {emoji_str}{host_str}{path_str}{origin_str}")
|
||||
return ws_id
|
||||
|
||||
|
||||
@@ -209,28 +201,25 @@ WS_CLOSE_CODES = {
|
||||
|
||||
def log_ws_close(ws_id: int, close_code: int | None, duration: float) -> None:
|
||||
"""Log WebSocket connection close with duration and status."""
|
||||
use_color = sys.stderr.isatty()
|
||||
|
||||
id_str = f"{ws_id:02d}".ljust(7) # Align with method field (7 chars)
|
||||
# ID right-aligned like status codes (3 chars), "closed" formatted like method
|
||||
id_str = f"{_WS_CLOSE}{str(ws_id).rjust(3)}{_RESET}"
|
||||
# Pad within the dim color to keep full width in color (8 display chars)
|
||||
closed_str = f"{_TIMING}closed {_RESET}"
|
||||
timing = f"{duration * 1000:.0f}ms"
|
||||
|
||||
# Convert close code to status text
|
||||
if close_code is None:
|
||||
status = "closed"
|
||||
code = "----"
|
||||
status = "unknown"
|
||||
else:
|
||||
code = str(close_code)
|
||||
status = WS_CLOSE_CODES.get(close_code, f"code {close_code}")
|
||||
|
||||
if use_color:
|
||||
# 🔌 aligned with status, ID aligned with method
|
||||
prefix = f"🔌 {_WS_CLOSE}{id_str}{_RESET}"
|
||||
status_str = f"{_WS_STATUS}{status}{_RESET}"
|
||||
timing_str = f"{_TIMING}{timing}{_RESET}"
|
||||
else:
|
||||
prefix = f"WS- {id_str}"
|
||||
status_str = status
|
||||
timing_str = timing
|
||||
# Status code and text in normal color, not dim
|
||||
status_str = f"{code} {status}"
|
||||
timing_str = f"{_TIMING}{timing}{_RESET}"
|
||||
|
||||
logger.info(f"{' ' * 19} {prefix} {status_str} {timing_str}")
|
||||
logger.info(f"{' ' * 19} {id_str} {closed_str}{status_str} {timing_str}")
|
||||
|
||||
|
||||
def log_permission_denied(
|
||||
@@ -269,7 +258,11 @@ class AccessLogMiddleware(BaseHTTPMiddleware):
|
||||
path = f"{path}?{request.url.query}"
|
||||
status = response.status_code
|
||||
|
||||
line = format_access_log(client, status, method, host, path, duration_ms)
|
||||
extra = getattr(request.state, "log_extra", "")
|
||||
|
||||
line = format_access_log(
|
||||
client, status, method, host, path, duration_ms, extra=extra
|
||||
)
|
||||
logger.info(line)
|
||||
|
||||
return response
|
||||
|
||||
+48
-45
@@ -1,36 +1,35 @@
|
||||
import json
|
||||
import asyncio
|
||||
import logging
|
||||
import os
|
||||
from contextlib import asynccontextmanager
|
||||
from pathlib import Path
|
||||
|
||||
import msgspec
|
||||
from fastapi import FastAPI, HTTPException, Request, Response
|
||||
from fastapi.responses import FileResponse, RedirectResponse
|
||||
from fastapi_vue import Frontend
|
||||
from kanta.logging import configure_logging as configure_kanta_logging
|
||||
|
||||
from paskia import authcode, globals
|
||||
from paskia.__main__ import DEVMODE
|
||||
from paskia.db import start_background, stop_background
|
||||
from paskia.db.logging import configure_db_logging
|
||||
from paskia import authcode, db, remoteauth
|
||||
from paskia.bootstrap import bootstrap_if_needed
|
||||
from paskia.db.background import start_background, stop_background
|
||||
from paskia.db.lifecycle import kanta
|
||||
from paskia.fastapi import admin, api, auth_host, oid, ws
|
||||
from paskia.fastapi.admin.adminapp import adminapp
|
||||
|
||||
# Import frontend instance
|
||||
from paskia.fastapi.front import frontend
|
||||
from paskia.fastapi.logging import AccessLogMiddleware, configure_access_logging
|
||||
from paskia.fastapi.session import AUTH_COOKIE
|
||||
from paskia.util import hostutil, passphrase, vitedev
|
||||
from paskia.util.constants import DEVMODE
|
||||
from paskia.util.runtime import RuntimeConfig
|
||||
|
||||
# Configure custom logging
|
||||
configure_access_logging()
|
||||
configure_db_logging()
|
||||
configure_kanta_logging()
|
||||
|
||||
_access_logger = logging.getLogger("paskia.access")
|
||||
|
||||
# Vue Frontend static files
|
||||
frontend = Frontend(
|
||||
Path(__file__).parent.parent / "frontend-build",
|
||||
cached=["/auth/assets/"],
|
||||
favicon="/paskia.webp",
|
||||
)
|
||||
|
||||
|
||||
# Path to examples/index.html when running from source tree
|
||||
_EXAMPLES_DIR = Path(__file__).parent.parent.parent / "examples"
|
||||
|
||||
@@ -43,31 +42,35 @@ async def lifespan(app: FastAPI): # pragma: no cover - startup path
|
||||
so that uvicorn reload / multiprocess workers inherit the settings.
|
||||
All keys are guaranteed to exist; values are already normalized by __main__.py.
|
||||
"""
|
||||
config = json.loads(os.environ["PASKIA_CONFIG"])
|
||||
runtime = msgspec.json.decode(os.environ["PASKIA_CONFIG"], type=RuntimeConfig)
|
||||
|
||||
try:
|
||||
# CLI (__main__) performs bootstrap once; here we skip to avoid duplicate work
|
||||
await globals.init(
|
||||
rp_id=config["rp_id"],
|
||||
rp_name=config["rp_name"],
|
||||
origins=config["origins"],
|
||||
bootstrap=False,
|
||||
)
|
||||
except ValueError as e:
|
||||
logging.error(f"⚠️ {e}")
|
||||
# Re-raise to fail fast
|
||||
raise
|
||||
await asyncio.to_thread(
|
||||
Path(kanta.filename).parent.mkdir, parents=True, exist_ok=True
|
||||
)
|
||||
async with kanta:
|
||||
try:
|
||||
await remoteauth.init()
|
||||
await authcode.start()
|
||||
except ValueError as e:
|
||||
logging.error(f"⚠️ {e}")
|
||||
# Re-raise to fail fast
|
||||
raise
|
||||
|
||||
# Restore uvicorn info logging (suppressed during startup in dev mode)
|
||||
# Keep uvicorn.error at WARNING to suppress WebSocket "connection open/closed" messages
|
||||
if app.debug:
|
||||
logging.getLogger("uvicorn").setLevel(logging.INFO)
|
||||
logging.getLogger("uvicorn.error").setLevel(logging.WARNING)
|
||||
await frontend.load()
|
||||
await start_background()
|
||||
yield
|
||||
await stop_background()
|
||||
await authcode.stop()
|
||||
# Bootstrap and persist config now that the full DB is loaded
|
||||
await bootstrap_if_needed(config=runtime.config)
|
||||
if runtime.save:
|
||||
db.update_config(runtime.config)
|
||||
|
||||
# Restore uvicorn info logging (suppressed during startup in dev mode)
|
||||
# Keep uvicorn.error at WARNING to suppress WebSocket "connection open/closed" messages
|
||||
if app.debug:
|
||||
logging.getLogger("uvicorn").setLevel(logging.INFO)
|
||||
logging.getLogger("uvicorn.error").setLevel(logging.WARNING)
|
||||
await frontend.load()
|
||||
await start_background()
|
||||
yield
|
||||
await stop_background()
|
||||
await authcode.stop()
|
||||
|
||||
|
||||
app = FastAPI(
|
||||
@@ -123,6 +126,7 @@ async def openid_configuration(request: Request):
|
||||
"name",
|
||||
"preferred_username",
|
||||
"email",
|
||||
"picture",
|
||||
"groups",
|
||||
"sid",
|
||||
],
|
||||
@@ -131,9 +135,9 @@ async def openid_configuration(request: Request):
|
||||
|
||||
@app.get("/auth/restricted/iframe")
|
||||
@app.get("/auth/restricted/oidc")
|
||||
async def restricted_view():
|
||||
async def restricted_view(request: Request):
|
||||
"""Serve the restricted/authentication UI for iframe or OpenID Connect."""
|
||||
return Response(*await vitedev.read("/auth/restricted/index.html"))
|
||||
return await vitedev.handle(request, frontend, "/auth/restricted/")
|
||||
|
||||
|
||||
# Navigable URLs are defined here. We support both / and /auth/ as the base path
|
||||
@@ -148,7 +152,7 @@ async def frontapp(request: Request, response: Response, auth=AUTH_COOKIE):
|
||||
The frontend handles mode detection (host mode vs full profile) based on settings.
|
||||
Access control is handled via APIs.
|
||||
"""
|
||||
return Response(*await vitedev.read("/auth/index.html"))
|
||||
return await vitedev.handle(request, frontend, "/auth/")
|
||||
|
||||
|
||||
@app.get("/admin", include_in_schema=False)
|
||||
@@ -160,7 +164,7 @@ async def admin_root_redirect():
|
||||
@app.get("/admin/", include_in_schema=False)
|
||||
@app.get("/auth/admin/", include_in_schema=False)
|
||||
async def admin_root(request: Request, auth=AUTH_COOKIE):
|
||||
return await admin.adminapp(request, auth) # Delegated to admin app
|
||||
return await adminapp(request, auth) # Delegated to admin app
|
||||
|
||||
|
||||
@app.get("/auth/examples/", include_in_schema=False)
|
||||
@@ -180,14 +184,13 @@ async def examples_page():
|
||||
|
||||
|
||||
# Frontend static files - must be before /{token} catch-all routes
|
||||
# (actual routes registered during lifespan after frontend.load())
|
||||
frontend.route(app, "/")
|
||||
|
||||
|
||||
# Note: this catch-all handler must be the last route defined
|
||||
@app.get("/{token}")
|
||||
@app.get("/auth/{token}")
|
||||
async def token_link(token: str):
|
||||
async def token_link(request: Request, token: str):
|
||||
"""Serve the reset app for reset tokens (password reset / device addition).
|
||||
|
||||
The frontend will validate the token via /auth/api/token-info.
|
||||
@@ -195,4 +198,4 @@ async def token_link(token: str):
|
||||
if not passphrase.is_well_formed(token):
|
||||
raise HTTPException(status_code=404)
|
||||
|
||||
return Response(*await vitedev.read("/int/reset/index.html"))
|
||||
return await vitedev.handle(request, frontend, "/int/reset/")
|
||||
|
||||
+13
-11
@@ -22,7 +22,7 @@ from fastapi.security import HTTPBearer
|
||||
|
||||
from paskia import authcode, db
|
||||
from paskia.db.structs import Session
|
||||
from paskia.util import oidjwt
|
||||
from paskia.util import avatar, oidjwt
|
||||
from paskia.util.crypto import hash_secret
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
@@ -40,7 +40,7 @@ def _oidc_session_by_token(
|
||||
token: str, client_uuid: UUID | None = None
|
||||
) -> Session | None:
|
||||
"""Look up an OIDC session by token (refresh token value)."""
|
||||
key = base64url.enc(hash_secret("oidc", token))
|
||||
key = hash_secret("oidc", token)
|
||||
s = db.data().sessions.get(key)
|
||||
if not s or s.client_uuid is None:
|
||||
return None
|
||||
@@ -259,8 +259,10 @@ async def _handle_refresh_token(
|
||||
The refresh_token is the session secret. On refresh:
|
||||
- Validates session exists and belongs to client
|
||||
- Extends session expiry (24h sliding window)
|
||||
- Records current IP and user_agent
|
||||
- Issues new access_token and id_token
|
||||
|
||||
Note: ip and user_agent are NOT updated because the refresh request
|
||||
comes from the OIDC client's backend, not the end user's browser.
|
||||
"""
|
||||
if not refresh_token_value:
|
||||
return JSONResponse(
|
||||
@@ -287,17 +289,13 @@ async def _handle_refresh_token(
|
||||
status_code=400,
|
||||
)
|
||||
|
||||
# Refresh the session - extend expiry and record IP/user_agent
|
||||
# Refresh the session - extend expiry only
|
||||
# Don't update ip/user_agent: the refresh request comes from the OIDC
|
||||
# client's backend, not the end user's browser.
|
||||
now = datetime.now(UTC)
|
||||
ip = request.headers.get("x-forwarded-for", "").split(",")[0].strip()
|
||||
if not ip:
|
||||
ip = request.client.host if request.client else ""
|
||||
user_agent = request.headers.get("user-agent", "")
|
||||
|
||||
db.update_session(
|
||||
session.key,
|
||||
ip=ip,
|
||||
user_agent=user_agent,
|
||||
validated=now,
|
||||
)
|
||||
|
||||
@@ -363,6 +361,7 @@ def _build_token_response(
|
||||
name=user.display_name,
|
||||
preferred_username=user.preferred_username,
|
||||
email=user.email,
|
||||
picture=avatar.current_avatar_url(user.uuid),
|
||||
groups=groups or None,
|
||||
auth_time=auth_time,
|
||||
)
|
||||
@@ -444,12 +443,15 @@ async def userinfo(
|
||||
|
||||
# Build userinfo response based on scope
|
||||
scope = payload.get("scope", "openid").split()
|
||||
response = {"sub": str(user.uuid)}
|
||||
response: dict[str, object] = {"sub": str(user.uuid)}
|
||||
|
||||
if "profile" in scope:
|
||||
response["name"] = user.display_name
|
||||
if user.preferred_username:
|
||||
response["preferred_username"] = user.preferred_username
|
||||
picture = avatar.current_avatar_url(user.uuid)
|
||||
if picture:
|
||||
response["picture"] = picture
|
||||
|
||||
if "email" in scope and user.email:
|
||||
response["email"] = user.email
|
||||
|
||||
@@ -312,7 +312,13 @@ async def websocket_remote_auth_permit(ws: WebSocket, auth=AUTH_COOKIE):
|
||||
|
||||
# Handle authenticate request (no PoW needed - already validated during lookup)
|
||||
if msg.get("authenticate") and request is not None:
|
||||
ctx, secret = await authenticate_and_login(ws, auth)
|
||||
ctx, secret = await authenticate_and_login(
|
||||
ws,
|
||||
auth,
|
||||
session_host=request.host,
|
||||
session_ip=request.ip,
|
||||
session_user_agent=request.user_agent,
|
||||
)
|
||||
|
||||
reset_token = None
|
||||
|
||||
|
||||
+95
-7
@@ -3,26 +3,65 @@ from uuid import UUID
|
||||
from fastapi import (
|
||||
Body,
|
||||
FastAPI,
|
||||
File,
|
||||
HTTPException,
|
||||
Request,
|
||||
Response,
|
||||
UploadFile,
|
||||
)
|
||||
from fastapi.responses import JSONResponse
|
||||
from fastapi.responses import FileResponse, JSONResponse
|
||||
|
||||
from paskia import db
|
||||
from paskia.authsession import (
|
||||
delete_credential,
|
||||
expires,
|
||||
session_ctx,
|
||||
)
|
||||
from paskia.fastapi import authz, session
|
||||
from paskia.fastapi.response import MsgspecResponse
|
||||
from paskia.fastapi.session import AUTH_COOKIE
|
||||
from paskia.util import hostutil
|
||||
from paskia.util import avatar, hostutil
|
||||
from paskia.util.apistructs import ApiCreateLinkResponse
|
||||
|
||||
app = FastAPI(docs_url=None, redoc_url=None, openapi_url=None)
|
||||
|
||||
|
||||
def _can_manage_avatar(ctx, target_user) -> bool:
|
||||
if ctx.user.uuid == target_user.uuid:
|
||||
return True
|
||||
|
||||
if any(p.scope == "auth:admin" for p in ctx.permissions):
|
||||
return True
|
||||
|
||||
return ctx.org.uuid == target_user.org.uuid and any(
|
||||
p.scope == "auth:org:admin" for p in ctx.permissions
|
||||
)
|
||||
|
||||
|
||||
def _avatar_write_ctx(request: Request, user_uuid: UUID, auth):
|
||||
if not auth:
|
||||
raise authz.AuthException(
|
||||
status_code=401, detail="Authentication Required", mode="login"
|
||||
)
|
||||
|
||||
ctx = session_ctx(auth, request.headers.get("host"))
|
||||
if not ctx:
|
||||
raise authz.AuthException(
|
||||
status_code=401, detail="Session expired", mode="login"
|
||||
)
|
||||
|
||||
user = db.data().users.get(user_uuid)
|
||||
if not user:
|
||||
raise HTTPException(status_code=404, detail="Avatar not found")
|
||||
|
||||
if not _can_manage_avatar(ctx, user):
|
||||
raise authz.AuthException(
|
||||
status_code=403, detail="Insufficient permissions", mode="forbidden"
|
||||
)
|
||||
|
||||
return ctx, user
|
||||
|
||||
|
||||
@app.exception_handler(authz.AuthException)
|
||||
async def auth_exception_handler(_request, exc: authz.AuthException):
|
||||
"""Handle AuthException with auth info for UI."""
|
||||
@@ -45,7 +84,7 @@ async def user_update_display_name(
|
||||
status_code=401, detail="Authentication Required", mode="login"
|
||||
)
|
||||
host = request.headers.get("host")
|
||||
ctx = db.data().session_ctx(auth, host)
|
||||
ctx = session_ctx(auth, host)
|
||||
if not ctx:
|
||||
raise authz.AuthException(
|
||||
status_code=401, detail="Session expired", mode="login"
|
||||
@@ -74,7 +113,7 @@ async def user_update_info(
|
||||
raise authz.AuthException(
|
||||
status_code=401, detail="Authentication Required", mode="login"
|
||||
)
|
||||
ctx = db.data().session_ctx(auth, request.headers.get("host"))
|
||||
ctx = session_ctx(auth, request.headers.get("host"))
|
||||
if not ctx:
|
||||
raise authz.AuthException(
|
||||
status_code=401, detail="Session expired", mode="login"
|
||||
@@ -102,6 +141,55 @@ async def user_update_info(
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
@app.get("/{user_uuid}/profile.webp")
|
||||
async def serve_avatar(request: Request, user_uuid: UUID):
|
||||
"""Serve a user's current avatar with short-lived caching and ETag."""
|
||||
user = db.data().users.get(user_uuid)
|
||||
if not user:
|
||||
raise HTTPException(status_code=404, detail="Avatar not found")
|
||||
|
||||
path = avatar.avatar_path(user_uuid)
|
||||
if not path.is_file():
|
||||
raise HTTPException(status_code=404, detail="Avatar not found")
|
||||
|
||||
data = avatar.read_avatar_bytes(user_uuid)
|
||||
if data is None:
|
||||
raise HTTPException(status_code=404, detail="Avatar not found")
|
||||
|
||||
etag = avatar.avatar_etag(data)
|
||||
if request.headers.get("if-none-match") == f'"{etag}"':
|
||||
return Response(status_code=304, headers={"ETag": f'"{etag}"'})
|
||||
|
||||
headers = {
|
||||
"ETag": f'"{etag}"',
|
||||
"Cache-Control": "public, max-age=300",
|
||||
}
|
||||
|
||||
return FileResponse(path, media_type="image/webp", headers=headers)
|
||||
|
||||
|
||||
@app.put("/{user_uuid}/profile.webp")
|
||||
async def upload_avatar(
|
||||
request: Request,
|
||||
user_uuid: UUID,
|
||||
file: UploadFile = File(...),
|
||||
auth=AUTH_COOKIE,
|
||||
):
|
||||
"""Upload a user's browser-prepared WebP avatar on the same URL it is served from."""
|
||||
_ctx, _user = _avatar_write_ctx(request, user_uuid, auth)
|
||||
data = await avatar.read_upload(file)
|
||||
avatar.store_avatar(user_uuid, data)
|
||||
return {"status": "ok", "avatar_url": avatar.avatar_browser_url(user_uuid)}
|
||||
|
||||
|
||||
@app.delete("/{user_uuid}/profile.webp")
|
||||
async def delete_avatar(request: Request, user_uuid: UUID, auth=AUTH_COOKIE):
|
||||
"""Delete a user's avatar image on the same URL it is served from."""
|
||||
_ctx, _user = _avatar_write_ctx(request, user_uuid, auth)
|
||||
avatar.remove_avatar_file(user_uuid)
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
@app.patch("/theme")
|
||||
async def user_update_theme(
|
||||
request: Request,
|
||||
@@ -112,7 +200,7 @@ async def user_update_theme(
|
||||
raise authz.AuthException(
|
||||
status_code=401, detail="Authentication Required", mode="login"
|
||||
)
|
||||
ctx = db.data().session_ctx(auth, request.headers.get("host"))
|
||||
ctx = session_ctx(auth, request.headers.get("host"))
|
||||
if not ctx:
|
||||
raise authz.AuthException(
|
||||
status_code=401, detail="Session expired", mode="login"
|
||||
@@ -129,7 +217,7 @@ async def api_logout_all(request: Request, response: Response, auth=AUTH_COOKIE)
|
||||
if not auth:
|
||||
return {"message": "Already logged out"}
|
||||
host = request.headers.get("host")
|
||||
ctx = db.data().session_ctx(auth, host)
|
||||
ctx = session_ctx(auth, host)
|
||||
if not ctx:
|
||||
raise authz.AuthException(
|
||||
status_code=401, detail="Session expired", mode="login"
|
||||
@@ -151,7 +239,7 @@ async def api_delete_session(
|
||||
status_code=401, detail="Authentication Required", mode="login"
|
||||
)
|
||||
host = request.headers.get("host")
|
||||
ctx = db.data().session_ctx(auth, host)
|
||||
ctx = session_ctx(auth, host)
|
||||
if not ctx:
|
||||
raise authz.AuthException(
|
||||
status_code=401, detail="Session expired", mode="login"
|
||||
|
||||
@@ -3,12 +3,11 @@ from datetime import UTC, datetime
|
||||
from urllib.parse import urlencode
|
||||
from uuid import UUID
|
||||
|
||||
import base64url
|
||||
from fastapi import FastAPI, WebSocket
|
||||
|
||||
from paskia import authcode, db
|
||||
from paskia.authcode import CookieCode, OIDCCode
|
||||
from paskia.authsession import get_reset
|
||||
from paskia.authsession import get_reset, session_ctx
|
||||
from paskia.db.structs import Session
|
||||
from paskia.fastapi import authz, remote
|
||||
from paskia.fastapi.session import AUTH_COOKIE, infodict
|
||||
@@ -59,7 +58,7 @@ async def websocket_register_add(
|
||||
if reset is not None:
|
||||
if not passphrase.is_well_formed(reset):
|
||||
raise ValueError(
|
||||
f"The reset link for {passkey.instance.rp_name} is invalid or has expired"
|
||||
f"The reset link for {passkey.rp_name} is invalid or has expired"
|
||||
)
|
||||
s = get_reset(reset)
|
||||
user_uuid = s.user_uuid
|
||||
@@ -196,7 +195,7 @@ async def websocket_authenticate(
|
||||
# If there's an existing session, restrict to that user's credentials (reauth)
|
||||
session_user_uuid = None
|
||||
if auth:
|
||||
existing_ctx = db.data().session_ctx(auth, host)
|
||||
existing_ctx = session_ctx(auth, host)
|
||||
if existing_ctx:
|
||||
session_user_uuid = existing_ctx.user.uuid
|
||||
|
||||
@@ -218,7 +217,7 @@ async def websocket_authenticate(
|
||||
session = Session.create(
|
||||
user=cred.user_uuid,
|
||||
credential=cred.uuid,
|
||||
key=base64url.enc(hash_secret("oidc", token)),
|
||||
key=hash_secret("oidc", token),
|
||||
host=normalized_host,
|
||||
ip=metadata["ip"],
|
||||
user_agent=metadata["user_agent"],
|
||||
|
||||
+38
-17
@@ -7,6 +7,7 @@ from uuid import UUID
|
||||
from fastapi import WebSocket
|
||||
|
||||
from paskia import db
|
||||
from paskia.authsession import session_ctx
|
||||
from paskia.db import Credential, SessionContext
|
||||
from paskia.fastapi.session import infodict
|
||||
from paskia.fastapi.wsutil import validate_origin
|
||||
@@ -22,14 +23,14 @@ async def register_chat(
|
||||
credential_ids: list[bytes] | None = None,
|
||||
):
|
||||
"""Run WebAuthn registration flow and return the verified credential."""
|
||||
options, challenge = passkey.instance.reg_generate_options(
|
||||
options, challenge = passkey.reg_generate_options(
|
||||
user_id=user_uuid,
|
||||
user_name=user_name,
|
||||
credential_ids=credential_ids,
|
||||
)
|
||||
await ws.send_json({"optionsJSON": options})
|
||||
response = await ws.receive_json()
|
||||
return passkey.instance.reg_verify(response, challenge, user_uuid, origin=origin)
|
||||
return passkey.reg_verify(response, challenge, user_uuid, origin=origin)
|
||||
|
||||
|
||||
async def authenticate_chat(
|
||||
@@ -42,11 +43,9 @@ async def authenticate_chat(
|
||||
tuple of (credential, new_sign_count) where new_sign_count comes from WebAuthn verification
|
||||
"""
|
||||
origin = validate_origin(ws)
|
||||
options, challenge = passkey.instance.auth_generate_options(
|
||||
credential_ids=credential_ids
|
||||
)
|
||||
options, challenge = passkey.auth_generate_options(credential_ids=credential_ids)
|
||||
await ws.send_json({"optionsJSON": options})
|
||||
authcred = passkey.instance.auth_parse(await ws.receive_json())
|
||||
authcred = passkey.auth_parse(await ws.receive_json())
|
||||
|
||||
cred = next(
|
||||
(
|
||||
@@ -57,22 +56,31 @@ async def authenticate_chat(
|
||||
None,
|
||||
)
|
||||
if not cred:
|
||||
raise ValueError(
|
||||
f"This passkey is no longer registered with {passkey.instance.rp_name}"
|
||||
)
|
||||
raise ValueError(f"This passkey is no longer registered with {passkey.rp_name}")
|
||||
|
||||
verification = passkey.instance.auth_verify(authcred, challenge, cred, origin)
|
||||
verification = passkey.auth_verify(authcred, challenge, cred, origin)
|
||||
return cred, verification.new_sign_count
|
||||
|
||||
|
||||
async def authenticate_and_login(
|
||||
ws: WebSocket,
|
||||
auth: str | None = None,
|
||||
*,
|
||||
session_host: str | None = None,
|
||||
session_ip: str | None = None,
|
||||
session_user_agent: str | None = None,
|
||||
) -> tuple[SessionContext, str]:
|
||||
"""Run WebAuthn authentication flow, create session, and return the session context.
|
||||
|
||||
If auth is provided, restrict authentication to credentials of that session's user.
|
||||
|
||||
Args:
|
||||
ws: The WebSocket connection (used for WebAuthn and origin validation)
|
||||
auth: Existing session cookie for re-auth credential restriction
|
||||
session_host: Override host for the new session (defaults to ws origin)
|
||||
session_ip: Override IP for the new session (defaults to ws client IP)
|
||||
session_user_agent: Override user-agent for the new session (defaults to ws headers)
|
||||
|
||||
Returns:
|
||||
Tuple of (SessionContext for the authenticated session, session secret)
|
||||
"""
|
||||
@@ -82,7 +90,7 @@ async def authenticate_and_login(
|
||||
if not normalized_host:
|
||||
raise ValueError("Host required for session creation")
|
||||
hostname = normalized_host.split(":")[0]
|
||||
rp_id = passkey.instance.rp_id
|
||||
rp_id = passkey.rp_id
|
||||
if not (hostname == rp_id or hostname.endswith(f".{rp_id}")):
|
||||
raise ValueError(f"Host must be the same as or a subdomain of {rp_id}")
|
||||
metadata = infodict(ws, "auth")
|
||||
@@ -90,24 +98,37 @@ async def authenticate_and_login(
|
||||
# Get credential IDs if restricting to a user's credentials
|
||||
credential_ids = None
|
||||
if auth:
|
||||
existing_ctx = db.data().session_ctx(auth, host)
|
||||
existing_ctx = session_ctx(auth, host)
|
||||
if existing_ctx:
|
||||
credential_ids = existing_ctx.user.credential_ids or None
|
||||
|
||||
cred, new_sign_count = await authenticate_chat(ws, credential_ids)
|
||||
|
||||
# Use overrides if provided, otherwise use websocket metadata
|
||||
login_host = (
|
||||
hostutil.normalize_host(session_host)
|
||||
if session_host is not None
|
||||
else normalized_host
|
||||
)
|
||||
if not login_host:
|
||||
raise ValueError("Host required for session creation")
|
||||
login_ip = session_ip if session_ip is not None else metadata["ip"]
|
||||
login_user_agent = (
|
||||
session_user_agent if session_user_agent is not None else metadata["user_agent"]
|
||||
)
|
||||
|
||||
# Create session and update user/credential
|
||||
secret = db.login(
|
||||
user_uuid=cred.user_uuid,
|
||||
credential_uuid=cred.uuid,
|
||||
sign_count=new_sign_count,
|
||||
host=normalized_host,
|
||||
ip=metadata["ip"],
|
||||
user_agent=metadata["user_agent"],
|
||||
host=login_host,
|
||||
ip=login_ip,
|
||||
user_agent=login_user_agent,
|
||||
)
|
||||
|
||||
# Fetch and return the full session context
|
||||
ctx = db.data().session_ctx(secret, normalized_host)
|
||||
# Fetch and return the full session context (using the same host the session was created with)
|
||||
ctx = session_ctx(secret, login_host)
|
||||
if not ctx:
|
||||
raise ValueError("Failed to create session context")
|
||||
return ctx, secret
|
||||
|
||||
@@ -96,4 +96,4 @@ def validate_origin(ws: WebSocket) -> str:
|
||||
origin = ws.headers.get("origin")
|
||||
if not origin:
|
||||
raise ValueError("Origin header is required for WebSocket connections")
|
||||
return passkey.instance.validate_origin(origin)
|
||||
return passkey.validate_origin(origin)
|
||||
|
||||
+16
-67
@@ -1,71 +1,20 @@
|
||||
from typing import Generic, TypeVar
|
||||
"""Global Passkey instance configured from PASKIA_CONFIG.
|
||||
|
||||
The Passkey instance is created at import time using the runtime configuration
|
||||
passed via the ``PASKIA_CONFIG`` environment variable. Other runtime setup
|
||||
(remote auth, auth codes, bootstrap checks) is performed explicitly by the
|
||||
FastAPI lifespan once the database is open.
|
||||
"""
|
||||
|
||||
from paskia import authcode, db, remoteauth
|
||||
from paskia.bootstrap import bootstrap_if_needed
|
||||
from paskia.sansio import Passkey
|
||||
from paskia.util import runtime
|
||||
|
||||
T = TypeVar("T")
|
||||
runtime = runtime.config()
|
||||
if runtime is None:
|
||||
raise RuntimeError("PASKIA_CONFIG must be defined before importing paskia.globals")
|
||||
|
||||
|
||||
class Manager(Generic[T]):
|
||||
"""Generic manager for global instances."""
|
||||
|
||||
def __init__(self, name: str):
|
||||
self._instance: T | None = None
|
||||
self._name = name
|
||||
|
||||
@property
|
||||
def instance(self) -> T:
|
||||
if self._instance is None:
|
||||
raise RuntimeError(
|
||||
f"{self._name} not initialized. Call globals.init() first."
|
||||
)
|
||||
return self._instance
|
||||
|
||||
@instance.setter
|
||||
def instance(self, instance: T) -> None:
|
||||
self._instance = instance
|
||||
|
||||
|
||||
async def init(
|
||||
rp_id: str = "localhost",
|
||||
rp_name: str | None = None,
|
||||
origins: list[str] | None = None,
|
||||
*,
|
||||
bootstrap: bool = True,
|
||||
) -> None:
|
||||
"""Initialize global passkey + database.
|
||||
|
||||
If bootstrap=True (default) the system bootstrap_if_needed() will be invoked.
|
||||
In FastAPI lifespan we call with bootstrap=False to avoid duplicate bootstrapping
|
||||
since the CLI performs it once before servers start.
|
||||
|
||||
Database configuration:
|
||||
Set PASKIA_DB environment variable to specify the JSONL database file path.
|
||||
Default: {rp_id}.paskiadb
|
||||
"""
|
||||
|
||||
# Initialize passkey instance with provided parameters
|
||||
passkey.instance = Passkey(
|
||||
rp_id=rp_id,
|
||||
rp_name=rp_name or rp_id,
|
||||
origins=origins,
|
||||
)
|
||||
|
||||
# Initialize database
|
||||
await db.init(rp_id=rp_id)
|
||||
|
||||
# Initialize remote auth manager
|
||||
await remoteauth.init()
|
||||
|
||||
# Initialize auth code manager
|
||||
await authcode.start()
|
||||
|
||||
if bootstrap:
|
||||
# Bootstrap system if needed
|
||||
|
||||
await bootstrap_if_needed()
|
||||
|
||||
|
||||
# Global instances
|
||||
passkey = Manager[Passkey]("Passkey")
|
||||
passkey = Passkey(
|
||||
rp_id=runtime.config.rp_id,
|
||||
rp_name=runtime.config.rp_name,
|
||||
origins=runtime.config.origins,
|
||||
)
|
||||
|
||||
@@ -13,7 +13,7 @@ import httpx
|
||||
|
||||
from paskia import db
|
||||
from paskia.util import oidjwt
|
||||
from paskia.util.hostutil import _load_config
|
||||
from paskia.util.runtime import config as runtime_config
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -23,8 +23,8 @@ _TIMEOUT = httpx.Timeout(10.0, connect=5.0)
|
||||
|
||||
def _issuer() -> str:
|
||||
"""Derive issuer URL from config (same base as discovery document)."""
|
||||
cfg = _load_config()
|
||||
return cfg.get("site_url", "https://localhost")
|
||||
cfg = runtime_config()
|
||||
return cfg.site_url if cfg else "https://localhost"
|
||||
|
||||
|
||||
def _collect_oidc_sessions(
|
||||
|
||||
@@ -24,10 +24,11 @@ class ApiUser(User, kw_only=True):
|
||||
"""User with uuid serialized."""
|
||||
|
||||
uuid: UUID
|
||||
avatar_url: str | None = None
|
||||
|
||||
@classmethod
|
||||
def from_db(cls, u: User) -> ApiUser:
|
||||
return cls(uuid=u.uuid, **msgspec.structs.asdict(u))
|
||||
def from_db(cls, u: User, *, avatar_url: str | None = None) -> ApiUser:
|
||||
return cls(uuid=u.uuid, avatar_url=avatar_url, **msgspec.structs.asdict(u))
|
||||
|
||||
|
||||
class ApiOrg(Org, kw_only=True):
|
||||
@@ -139,7 +140,7 @@ class ApiUserDetail(msgspec.Struct, kw_only=True):
|
||||
user: ApiUser
|
||||
credentials: dict[UUID, Credential]
|
||||
aaguid_info: dict[str, ApiAaguidInfo]
|
||||
sessions: dict[bytes, ApiUserSession]
|
||||
sessions: dict[str, ApiUserSession]
|
||||
permissions: dict[UUID, ApiPermission] = {}
|
||||
org: ApiOrg | None = None
|
||||
role: ApiRole | None = None
|
||||
@@ -156,7 +157,7 @@ class ApiOrgResponse(msgspec.Struct, kw_only=True):
|
||||
org: ApiOrg
|
||||
permissions: dict[UUID, Permission]
|
||||
roles: dict[UUID, Role]
|
||||
users: dict[UUID, User]
|
||||
users: dict[UUID, ApiUser]
|
||||
|
||||
|
||||
class ApiSettings(msgspec.Struct):
|
||||
@@ -171,11 +172,12 @@ class ApiSettings(msgspec.Struct):
|
||||
version: str
|
||||
|
||||
|
||||
class ApiTokenInfo(msgspec.Struct):
|
||||
class ApiTokenInfo(msgspec.Struct, omit_defaults=True):
|
||||
"""Token info response struct."""
|
||||
|
||||
token_type: str
|
||||
display_name: str
|
||||
theme: str = ""
|
||||
|
||||
|
||||
class ApiUuidResponse(msgspec.Struct):
|
||||
@@ -232,6 +234,13 @@ class ApiValidateResponse(msgspec.Struct):
|
||||
ctx: ApiSessionContext
|
||||
|
||||
|
||||
class ApiCheckUserResponse(msgspec.Struct):
|
||||
"""Response struct for check-user endpoint."""
|
||||
|
||||
valid: bool
|
||||
ctx: ApiSessionContext
|
||||
|
||||
|
||||
class ApiAdminInfo(msgspec.Struct, kw_only=True):
|
||||
"""Combined admin info response."""
|
||||
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
"""Avatar storage and URL helpers."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import contextlib
|
||||
import hashlib
|
||||
from pathlib import Path
|
||||
from uuid import UUID
|
||||
|
||||
from fastapi import HTTPException, UploadFile
|
||||
|
||||
from paskia.db.paths import users_root_path
|
||||
from paskia.util import hostutil
|
||||
|
||||
MAX_UPLOAD_BYTES = 10 * 1024 * 1024
|
||||
|
||||
|
||||
def media_root() -> Path:
|
||||
"""Return the filesystem root for auxiliary media files."""
|
||||
return users_root_path(create_root=True)
|
||||
|
||||
|
||||
def avatars_root() -> Path:
|
||||
"""Return the filesystem root for stored avatar images."""
|
||||
return media_root()
|
||||
|
||||
|
||||
def avatar_path(user_uuid: UUID) -> Path:
|
||||
"""Return the avatar file path for a user."""
|
||||
return avatars_root() / str(user_uuid) / "profile.webp"
|
||||
|
||||
|
||||
def avatar_public_path(user_uuid: UUID) -> str:
|
||||
"""Return the public relative path for a user's avatar."""
|
||||
return f"/auth/api/user/{user_uuid}/profile.webp"
|
||||
|
||||
|
||||
def avatar_browser_url(user_uuid: UUID) -> str | None:
|
||||
"""Return the browser-facing avatar URL."""
|
||||
if not avatar_path(user_uuid).is_file():
|
||||
return None
|
||||
return avatar_public_path(user_uuid)
|
||||
|
||||
|
||||
def avatar_url(user_uuid: UUID) -> str | None:
|
||||
"""Return the absolute public avatar URL for a user, or None."""
|
||||
if not avatar_path(user_uuid).is_file():
|
||||
return None
|
||||
return hostutil.api_url(f"user/{user_uuid}/profile.webp")
|
||||
|
||||
|
||||
def current_avatar_url(user_uuid: UUID) -> str | None:
|
||||
"""Return the current absolute avatar URL for a user UUID."""
|
||||
return avatar_url(user_uuid)
|
||||
|
||||
|
||||
def remove_avatar_file(user_uuid: UUID) -> None:
|
||||
"""Delete a stored avatar file if it exists."""
|
||||
with contextlib.suppress(FileNotFoundError):
|
||||
avatar_path(user_uuid).unlink()
|
||||
|
||||
|
||||
def read_avatar_bytes(user_uuid: UUID) -> bytes | None:
|
||||
"""Read the stored avatar file for a user, if present."""
|
||||
path = avatar_path(user_uuid)
|
||||
if not path.is_file():
|
||||
return None
|
||||
return path.read_bytes()
|
||||
|
||||
|
||||
def _is_webp(data: bytes) -> bool:
|
||||
"""Return True when bytes look like a RIFF WebP file."""
|
||||
return len(data) >= 12 and data[:4] == b"RIFF" and data[8:12] == b"WEBP"
|
||||
|
||||
|
||||
async def read_upload(upload: UploadFile) -> bytes:
|
||||
"""Read an uploaded avatar and require it to already be WebP."""
|
||||
data = await upload.read(MAX_UPLOAD_BYTES + 1)
|
||||
if not data:
|
||||
raise HTTPException(status_code=400, detail="No avatar file uploaded")
|
||||
if len(data) > MAX_UPLOAD_BYTES:
|
||||
raise HTTPException(status_code=413, detail="Avatar upload too large")
|
||||
|
||||
if not _is_webp(data):
|
||||
raise HTTPException(status_code=400, detail="Avatar upload must be WebP")
|
||||
|
||||
return data
|
||||
|
||||
|
||||
def store_avatar(user_uuid: UUID, data: bytes) -> None:
|
||||
"""Store avatar bytes."""
|
||||
path = avatar_path(user_uuid)
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_bytes(data)
|
||||
|
||||
|
||||
def avatar_etag(data: bytes) -> str:
|
||||
"""Return a stable ETag value for avatar bytes."""
|
||||
return hashlib.sha256(data).hexdigest()[:16]
|
||||
@@ -0,0 +1,6 @@
|
||||
"""Small, dependency-free constants shared by CLI and server modules."""
|
||||
|
||||
import os
|
||||
|
||||
DEFAULT_PORT = 4401
|
||||
DEVMODE = os.getenv("PASKIA_DEV") == "1"
|
||||
@@ -1,17 +1,15 @@
|
||||
import hashlib
|
||||
|
||||
import base64url
|
||||
from cryptography.hazmat.primitives import serialization
|
||||
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
|
||||
|
||||
|
||||
def hash_secret(*data) -> bytes:
|
||||
"""A custom HMAC that securily combines and hashes the given data (context, secrets). The first argument should be a namespacing string."""
|
||||
inner = bytearray(len(data).to_bytes(8, "big"))
|
||||
for d in data:
|
||||
if isinstance(d, str):
|
||||
d = d.encode()
|
||||
inner += hashlib.sha256(d).digest()
|
||||
return hashlib.sha256(inner).digest()[:12]
|
||||
def hash_secret(*data: str | bytes, length=12) -> str:
|
||||
"""A custom HMAC that securily combines and hashes the given data. The first argument should be a namespacing string."""
|
||||
p = [d.encode() if hasattr(d, "encode") else d for d in data]
|
||||
p += [len(x).to_bytes(8, "little") for x in [p, *p]]
|
||||
return base64url.enc(hashlib.sha256(b"".join(p)).digest()[:length])
|
||||
|
||||
|
||||
def secret_key() -> bytes:
|
||||
|
||||
@@ -11,7 +11,7 @@ __all__ = ["path", "file", "read", "is_dev_mode"]
|
||||
|
||||
def _get_dev_server() -> str | None:
|
||||
"""Get the dev server URL from environment, or None if not in dev mode."""
|
||||
return os.environ.get("FASTAPI_VUE_FRONTEND_URL") or None
|
||||
return os.environ.get("PASKIA_VITE_URL") or None
|
||||
|
||||
|
||||
def _resolve_static_dir() -> Path:
|
||||
|
||||
+83
-17
@@ -1,27 +1,24 @@
|
||||
"""Utilities for determining the auth UI host and base URLs."""
|
||||
|
||||
import json
|
||||
import os
|
||||
from functools import lru_cache
|
||||
from urllib.parse import urlparse, urlsplit
|
||||
|
||||
from paskia.util.runtime import clear_config_cache
|
||||
from paskia.util.runtime import config as runtime_config
|
||||
|
||||
@lru_cache(maxsize=1)
|
||||
def _load_config() -> dict:
|
||||
"""Load PASKIA_CONFIG JSON."""
|
||||
config_json = os.getenv("PASKIA_CONFIG")
|
||||
if not config_json:
|
||||
return {}
|
||||
return json.loads(config_json)
|
||||
|
||||
def _cfg():
|
||||
return runtime_config()
|
||||
|
||||
|
||||
def is_root_mode() -> bool:
|
||||
return _load_config().get("auth_host") is not None
|
||||
cfg = _cfg()
|
||||
return cfg is not None and cfg.config.auth_host is not None
|
||||
|
||||
|
||||
def dedicated_auth_host() -> str | None:
|
||||
"""Return configured auth_host netloc, or None."""
|
||||
auth_host = _load_config().get("auth_host")
|
||||
cfg = _cfg()
|
||||
auth_host = cfg.config.auth_host if cfg else None
|
||||
if not auth_host:
|
||||
return None
|
||||
|
||||
@@ -33,10 +30,22 @@ def ui_base_path() -> str:
|
||||
return "/" if is_root_mode() else "/auth/"
|
||||
|
||||
|
||||
def api_url(path: str = "") -> str:
|
||||
"""Return an absolute URL under the canonical /auth/api/ prefix."""
|
||||
cfg = _cfg()
|
||||
base = cfg.site_url if cfg else "https://localhost"
|
||||
if not path:
|
||||
return f"{base}/auth/api/"
|
||||
normalized = path.lstrip("/")
|
||||
return f"{base}/auth/api/{normalized}"
|
||||
|
||||
|
||||
def auth_site_url() -> str:
|
||||
"""Return the base URL for the auth site UI (computed at startup)."""
|
||||
cfg = _load_config()
|
||||
return cfg.get("site_url", "https://localhost") + cfg.get("site_path", "/auth/")
|
||||
cfg = _cfg()
|
||||
if cfg:
|
||||
return cfg.site_url + cfg.site_path
|
||||
return "https://localhost/auth/"
|
||||
|
||||
|
||||
def reset_link_url(token: str) -> str:
|
||||
@@ -45,14 +54,59 @@ def reset_link_url(token: str) -> str:
|
||||
|
||||
|
||||
def normalize_origin(origin: str) -> str:
|
||||
"""Normalize an origin URL by adding https:// if no scheme is present."""
|
||||
"""Normalize an origin URL by adding https:// if no scheme is present, removing trailing slashes."""
|
||||
if "://" not in origin:
|
||||
return f"https://{origin}"
|
||||
return origin
|
||||
return origin.rstrip("/")
|
||||
|
||||
|
||||
def is_subdomain(sub: str, domain: str) -> bool:
|
||||
"""Check if sub is a subdomain of domain (or equal)."""
|
||||
sub_parts = sub.lower().split(".")
|
||||
domain_parts = domain.lower().split(".")
|
||||
if len(sub_parts) < len(domain_parts):
|
||||
return False
|
||||
return sub_parts[-len(domain_parts) :] == domain_parts
|
||||
|
||||
|
||||
def validate_auth_host(auth_host: str, rp_id: str) -> None:
|
||||
"""Validate that auth_host is a subdomain of rp_id.
|
||||
|
||||
Raises ValueError on invalid auth_host.
|
||||
"""
|
||||
parsed = urlparse(auth_host if "://" in auth_host else f"//{auth_host}")
|
||||
host = parsed.hostname or parsed.path
|
||||
if not host:
|
||||
raise ValueError(f"Invalid auth-host: '{auth_host}'")
|
||||
if not is_subdomain(host, rp_id):
|
||||
raise ValueError(
|
||||
f"auth-host '{auth_host}' is not a subdomain of rp-id '{rp_id}'"
|
||||
)
|
||||
|
||||
|
||||
def normalize_auth_host_and_origins(
|
||||
auth_host: str | None, origins: list[str] | None
|
||||
) -> tuple[str | None, list[str] | None]:
|
||||
"""Normalize auth_host and origins, matching CLI startup behavior.
|
||||
|
||||
- Adds https:// to auth_host if no scheme present, strips trailing slashes
|
||||
- Validates auth_host is a well-formed subdomain (caller provides rp_id via validate_auth_host)
|
||||
- Inserts auth_host as first origin if both are specified and not already present
|
||||
- Deduplicates origins while preserving order
|
||||
"""
|
||||
if auth_host:
|
||||
if "://" not in auth_host:
|
||||
auth_host = f"https://{auth_host}"
|
||||
auth_host = auth_host.rstrip("/")
|
||||
if origins is not None and auth_host not in origins:
|
||||
origins.insert(0, auth_host)
|
||||
if origins:
|
||||
origins = list(dict.fromkeys(origins))
|
||||
return auth_host, origins
|
||||
|
||||
|
||||
def reload_config() -> None:
|
||||
_load_config.cache_clear()
|
||||
clear_config_cache()
|
||||
|
||||
|
||||
def normalize_host(raw_host: str | None) -> str | None:
|
||||
@@ -74,3 +128,15 @@ def normalize_host(raw_host: str | None) -> str | None:
|
||||
# Strip port from host:port
|
||||
netloc = netloc.rsplit(":", 1)[0]
|
||||
return netloc.lower() or None
|
||||
|
||||
|
||||
def format_endpoint(ep: dict) -> str:
|
||||
"""Format an endpoint dict to a listen string (e.g. 'unix:/path' or 'host:port')."""
|
||||
if uds := ep.get("uds"):
|
||||
return f"unix:{uds}"
|
||||
host = ep["host"]
|
||||
port = ep["port"]
|
||||
# Bracket IPv6 addresses
|
||||
if ":" in host:
|
||||
host = f"[{host}]"
|
||||
return f"{host}:{port}"
|
||||
|
||||
@@ -2,6 +2,39 @@
|
||||
|
||||
import re
|
||||
|
||||
from paskia.fastapi.front import frontend
|
||||
from paskia.util import vitedev
|
||||
|
||||
|
||||
async def patched_html_response(request, filepath: str, status_code: int, **data_attrs):
|
||||
"""Fetch HTML from vitedev and patch with data attributes.
|
||||
|
||||
Strips caching/compression headers from request to get raw content,
|
||||
patches the HTML body with data attributes, and strips caching headers
|
||||
from response.
|
||||
|
||||
Args:
|
||||
request: The FastAPI Request object
|
||||
filepath: Path to HTML file, e.g. "/int/forward/"
|
||||
status_code: HTTP status code for the response
|
||||
**data_attrs: Key-value pairs for data attributes
|
||||
|
||||
Returns:
|
||||
Patched Response object, or original response if not 200.
|
||||
"""
|
||||
resp = await vitedev.handle(request, frontend, filepath)
|
||||
# Pass through non-200 responses
|
||||
if resp.status_code != 200:
|
||||
return resp
|
||||
# Patch HTML with data attrs and strip caching headers from response
|
||||
resp.body = patch_html_data_attrs(resp.body, **data_attrs)
|
||||
resp.status_code = status_code
|
||||
strip_headers = {b"etag", b"last-modified", b"content-length"}
|
||||
resp.raw_headers = [
|
||||
(k, v) for k, v in resp.raw_headers if k.lower() not in strip_headers
|
||||
]
|
||||
return resp
|
||||
|
||||
|
||||
def patch_html_data_attrs(html: bytes, **data_attrs: str) -> bytes:
|
||||
"""Patch HTML by adding data attributes to the <html> tag.
|
||||
|
||||
+29
-15
@@ -29,11 +29,14 @@ def _load_or_generate_key() -> None:
|
||||
global _private_key, _public_key, _kid
|
||||
|
||||
data = db.data()
|
||||
store = data._store
|
||||
if store is None:
|
||||
raise RuntimeError("Kanta store is not initialized")
|
||||
if data.oidc.key is not None:
|
||||
_private_key = public_key_from_secret(data.oidc.key)
|
||||
else:
|
||||
raw_key = secret_key()
|
||||
with data.transaction("oidc_key"):
|
||||
with store.transaction("oidc_key"):
|
||||
data.oidc.key = raw_key
|
||||
_private_key = public_key_from_secret(raw_key)
|
||||
|
||||
@@ -78,6 +81,7 @@ def create_id_token(
|
||||
name: str | None = None,
|
||||
preferred_username: str | None = None,
|
||||
email: str | None = None,
|
||||
picture: str | None = None,
|
||||
groups: list[str] | None = None,
|
||||
auth_time: datetime | None = None,
|
||||
expires_in: int = 3600,
|
||||
@@ -93,6 +97,7 @@ def create_id_token(
|
||||
name: User's display name
|
||||
preferred_username: User's preferred username
|
||||
email: User's email address
|
||||
picture: User avatar URL
|
||||
groups: List of permission scopes (groups claim)
|
||||
auth_time: When the user authenticated (last credential use time)
|
||||
expires_in: Token lifetime in seconds
|
||||
@@ -101,8 +106,9 @@ def create_id_token(
|
||||
Signed JWT string
|
||||
"""
|
||||
_ensure_key()
|
||||
assert _private_key is not None
|
||||
now = datetime.now(UTC)
|
||||
payload = {
|
||||
payload: dict[str, object] = {
|
||||
"iss": issuer,
|
||||
"sub": str(subject),
|
||||
"aud": audience,
|
||||
@@ -119,6 +125,8 @@ def create_id_token(
|
||||
payload["preferred_username"] = preferred_username
|
||||
if email:
|
||||
payload["email"] = email
|
||||
if picture:
|
||||
payload["picture"] = picture
|
||||
if groups:
|
||||
payload["groups"] = groups
|
||||
if auth_time:
|
||||
@@ -147,8 +155,9 @@ def create_access_token(
|
||||
Signed JWT string
|
||||
"""
|
||||
_ensure_key()
|
||||
assert _private_key is not None
|
||||
now = datetime.now(UTC)
|
||||
payload = {
|
||||
payload: dict[str, object] = {
|
||||
"iss": issuer,
|
||||
"sub": str(subject),
|
||||
"aud": audience,
|
||||
@@ -173,20 +182,24 @@ def decode_access_token(
|
||||
Decoded payload or None if invalid
|
||||
"""
|
||||
_ensure_key()
|
||||
assert _public_key is not None
|
||||
try:
|
||||
# PyJWT requires audience parameter when token has aud claim.
|
||||
# When audience is None, we skip PyJWT's audience validation and validate manually.
|
||||
options = {}
|
||||
decode_kwargs = {
|
||||
"algorithms": ["EdDSA"],
|
||||
"issuer": issuer,
|
||||
}
|
||||
if audience is not None:
|
||||
decode_kwargs["audience"] = audience
|
||||
else:
|
||||
options["verify_aud"] = False
|
||||
return jwt.decode(
|
||||
token,
|
||||
_public_key,
|
||||
algorithms=["EdDSA"],
|
||||
issuer=issuer,
|
||||
audience=audience,
|
||||
)
|
||||
|
||||
return jwt.decode(token, _public_key, options=options, **decode_kwargs)
|
||||
return jwt.decode(
|
||||
token,
|
||||
_public_key,
|
||||
algorithms=["EdDSA"],
|
||||
issuer=issuer,
|
||||
options={"verify_aud": False},
|
||||
)
|
||||
except jwt.PyJWTError:
|
||||
return None
|
||||
|
||||
@@ -212,8 +225,9 @@ def create_logout_token(
|
||||
Signed JWT string
|
||||
"""
|
||||
_ensure_key()
|
||||
assert _private_key is not None
|
||||
now = datetime.now(UTC)
|
||||
payload = {
|
||||
payload: dict[str, object] = {
|
||||
"iss": issuer,
|
||||
"aud": audience,
|
||||
"iat": int(now.timestamp()),
|
||||
|
||||
@@ -3,10 +3,10 @@
|
||||
from collections.abc import Sequence
|
||||
from fnmatch import fnmatchcase
|
||||
|
||||
from paskia import db
|
||||
from paskia.authsession import session_ctx
|
||||
from paskia.util.hostutil import normalize_host
|
||||
|
||||
__all__ = ["has_any", "has_all", "session_context"]
|
||||
__all__ = ["has_any", "has_all", "has_all_scopes", "session_context"]
|
||||
|
||||
|
||||
def _match(perms: set[str], patterns: Sequence[str]):
|
||||
@@ -36,8 +36,13 @@ def has_all(ctx, patterns: Sequence[str]) -> bool:
|
||||
return all(_match(_get_effective_scopes(ctx), patterns)) if ctx else False
|
||||
|
||||
|
||||
def has_all_scopes(scopes: set[str], patterns: Sequence[str]) -> bool:
|
||||
"""Check that a pre-computed scope set satisfies all required patterns."""
|
||||
return all(_match(scopes, patterns)) if patterns else True
|
||||
|
||||
|
||||
async def session_context(auth: str | None, host: str | None = None):
|
||||
if not auth:
|
||||
return None
|
||||
normalized_host = normalize_host(host) if host else None
|
||||
return db.data().session_ctx(auth, normalized_host)
|
||||
return session_ctx(auth, normalized_host)
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
"""Runtime configuration utilities."""
|
||||
|
||||
import os
|
||||
from functools import lru_cache
|
||||
|
||||
import msgspec
|
||||
|
||||
from paskia.db.structs import Config
|
||||
|
||||
|
||||
class RuntimeConfig(msgspec.Struct):
|
||||
"""Runtime configuration for the Paskia authentication server.
|
||||
|
||||
Wraps the db Config (CLI/stored settings) with computed runtime fields.
|
||||
Serialized to PASKIA_CONFIG env var as JSON via msgspec.
|
||||
"""
|
||||
|
||||
config: Config # CLI/stored configuration to persist
|
||||
site_url: str # Base URL without trailing path (e.g. https://example.com)
|
||||
site_path: str # Path to auth UI: "/" if auth_host, else "/auth/"
|
||||
save: bool = False # Whether to persist config to database
|
||||
|
||||
|
||||
@lru_cache(maxsize=1)
|
||||
def _load_config() -> "RuntimeConfig | None":
|
||||
"""Load RuntimeConfig from PASKIA_CONFIG env var."""
|
||||
config_json = os.getenv("PASKIA_CONFIG")
|
||||
if not config_json:
|
||||
return None
|
||||
|
||||
return msgspec.json.decode(config_json.encode(), type=RuntimeConfig)
|
||||
|
||||
|
||||
def config() -> "RuntimeConfig | None":
|
||||
"""Return cached runtime config loaded from PASKIA_CONFIG."""
|
||||
return _load_config()
|
||||
|
||||
|
||||
def clear_config_cache() -> None:
|
||||
"""Clear cached runtime config; next config() call reloads from env."""
|
||||
_load_config.cache_clear()
|
||||
|
||||
|
||||
def update_runtime_config(new_config: Config) -> None:
|
||||
"""Update the runtime configuration with a new Config and refresh the cache."""
|
||||
current_runtime = config()
|
||||
if not current_runtime:
|
||||
return # No runtime config to update
|
||||
|
||||
# Recompute site_url and site_path based on new config
|
||||
old_auth_host = current_runtime.config.auth_host
|
||||
if new_config.auth_host:
|
||||
site_url, site_path = new_config.auth_host, "/"
|
||||
else:
|
||||
site_path = "/auth/"
|
||||
# Never derive site_url from a just-removed auth host
|
||||
origins = [o for o in (new_config.origins or []) if o != old_auth_host]
|
||||
if origins:
|
||||
site_url = origins[0]
|
||||
elif current_runtime.site_url != old_auth_host:
|
||||
# Keep current site_url if it wasn't derived from the removed auth host
|
||||
site_url = current_runtime.site_url
|
||||
else:
|
||||
site_url = f"https://{new_config.rp_id}"
|
||||
|
||||
new_runtime = RuntimeConfig(
|
||||
config=new_config,
|
||||
site_url=site_url,
|
||||
site_path=site_path,
|
||||
save=current_runtime.save,
|
||||
)
|
||||
os.environ["PASKIA_CONFIG"] = msgspec.json.encode(new_runtime).decode()
|
||||
|
||||
# Clear the cache so next access loads the updated config
|
||||
clear_config_cache()
|
||||
+30
-28
@@ -1,21 +1,27 @@
|
||||
"""Startup configuration box formatting utilities."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import re
|
||||
from sys import stderr
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from fastapi_vue.hostutil import parse_endpoints
|
||||
|
||||
from paskia._version import __version__
|
||||
from paskia.util.constants import DEFAULT_PORT, DEVMODE
|
||||
from paskia.util.hostutil import format_endpoint
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from paskia.config import PaskiaConfig
|
||||
from paskia.util.runtime import RuntimeConfig
|
||||
|
||||
BOX_WIDTH = 60 # Inner width (excluding box chars)
|
||||
|
||||
# ANSI color codes
|
||||
RESET = "\033[0m"
|
||||
YELLOW = "\033[33m" # Dark yellow
|
||||
BRIGHT_YELLOW = "\033[93m" # Bright yellow
|
||||
YELLOW = "\033[38;5;184m" # Bright yellow (6x6x6 cube, r=4 g=4)
|
||||
BRIGHT_YELLOW = "\033[38;5;226m" # Brightest yellow (6x6x6 cube)
|
||||
BRIGHT_WHITE = "\033[1;37m" # Bold bright white
|
||||
|
||||
|
||||
@@ -42,11 +48,11 @@ def bottom() -> str:
|
||||
return "┗" + "━" * (BOX_WIDTH + 2) + "┛\n"
|
||||
|
||||
|
||||
def print_startup_config(config: "PaskiaConfig") -> None:
|
||||
def print_startup_config(runtime: RuntimeConfig) -> None:
|
||||
"""Print server configuration on startup."""
|
||||
# Key graphic with yellow shading (bright for highlights, dark for body)
|
||||
y = YELLOW # Dark yellow for main body
|
||||
b = BRIGHT_YELLOW # Bright yellow for highlights/edges
|
||||
y = YELLOW # Bright golden yellow for main body
|
||||
b = BRIGHT_YELLOW # Brightest yellow for highlights/edges
|
||||
w = BRIGHT_WHITE # Bold white for URL
|
||||
r = RESET
|
||||
|
||||
@@ -57,41 +63,37 @@ def print_startup_config(config: "PaskiaConfig") -> None:
|
||||
lines.append(
|
||||
line(
|
||||
f"{b}█{y} {b}█{y}▀▀▀▀{b}█{y}▀▀{b}█{y}▀▀{b}█{r} {w}"
|
||||
+ config.site_url
|
||||
+ config.site_path
|
||||
+ runtime.site_url
|
||||
+ runtime.site_path
|
||||
+ r
|
||||
)
|
||||
)
|
||||
lines.append(line(f" {y}▀▀▀▀▀{r}"))
|
||||
|
||||
# Format auth host section
|
||||
if config.auth_host:
|
||||
lines.append(line(f"Auth Host: {config.auth_host}"))
|
||||
if runtime.config.auth_host:
|
||||
lines.append(line(f"Auth Host: {runtime.config.auth_host}"))
|
||||
|
||||
# Show frontend URL if in dev mode
|
||||
devmode = os.environ.get("FASTAPI_VUE_FRONTEND_URL")
|
||||
if devmode:
|
||||
lines.append(line(f"Dev Frontend: {devmode}"))
|
||||
if DEVMODE:
|
||||
lines.append(line(f"Dev Frontend: {os.environ.get('PASKIA_VITE_URL')}"))
|
||||
|
||||
# Format listen address with scheme
|
||||
if config.uds:
|
||||
listen = f"unix:{config.uds}"
|
||||
elif config.host:
|
||||
listen = f"http://{config.host}:{config.port}"
|
||||
else:
|
||||
listen = f"http://0.0.0.0:{config.port} + [::]:{config.port}"
|
||||
lines.append(line(f"Backend: {listen}"))
|
||||
# Format listen endpoints (dev mode only uses the first endpoint)
|
||||
|
||||
endpoints = list(parse_endpoints(runtime.config.listen, DEFAULT_PORT))
|
||||
if DEVMODE:
|
||||
endpoints = endpoints[:1] # server.run reload=True uses only one
|
||||
parts = [format_endpoint(ep) for ep in endpoints]
|
||||
lines.append(line(f"Backend: {' '.join(parts)}"))
|
||||
|
||||
# Relying Party line (omit name if same as id)
|
||||
rp_id = config.rp_id
|
||||
rp_name = config.rp_name
|
||||
if rp_name and rp_name != rp_id:
|
||||
lines.append(line(f"Relying Party: {rp_id} ({rp_name})"))
|
||||
else:
|
||||
lines.append(line(f"Relying Party: {rp_id}"))
|
||||
rp_id = runtime.config.rp_id
|
||||
rp_name = runtime.config.rp_name
|
||||
suffix = f" ({rp_name})" if rp_name and rp_name != rp_id else ""
|
||||
lines.append(line(f"Relying Party: {rp_id}{suffix}"))
|
||||
|
||||
# Format origins section
|
||||
allowed = config.origins
|
||||
allowed = runtime.config.origins
|
||||
if allowed:
|
||||
lines.append(line("Permitted Origins:"))
|
||||
for origin in sorted(allowed):
|
||||
|
||||
@@ -2,11 +2,13 @@
|
||||
|
||||
from paskia import aaguid, db
|
||||
from paskia.db import SessionContext
|
||||
from paskia.util import hostutil
|
||||
from paskia.util import avatar, hostutil
|
||||
from paskia.util.apistructs import (
|
||||
ApiAaguidInfo,
|
||||
ApiOrg,
|
||||
ApiOrgContext,
|
||||
ApiPermission,
|
||||
ApiRole,
|
||||
ApiRoleContext,
|
||||
ApiSessionContext,
|
||||
ApiUser,
|
||||
@@ -54,7 +56,7 @@ async def build_user_info(
|
||||
}
|
||||
|
||||
return ApiUserDetail(
|
||||
user=ApiUser.from_db(user),
|
||||
user=ApiUser.from_db(user, avatar_url=avatar.avatar_browser_url(user.uuid)),
|
||||
credentials={c.uuid: c for c in user.credentials},
|
||||
aaguid_info={
|
||||
k: ApiAaguidInfo(**v)
|
||||
@@ -64,4 +66,6 @@ async def build_user_info(
|
||||
permissions={p.uuid: ApiPermission.from_db(p) for p in ctx.permissions}
|
||||
if ctx
|
||||
else {},
|
||||
org=ApiOrg.from_db(ctx.org) if ctx else None,
|
||||
role=ApiRole.from_db(ctx.role) if ctx else None,
|
||||
)
|
||||
|
||||
+35
-31
@@ -1,39 +1,29 @@
|
||||
"""Vite dev server proxy for fetching frontend files during development.
|
||||
|
||||
In dev mode (FASTAPI_VUE_FRONTEND_URL set), fetches files from Vite.
|
||||
In dev mode (PASKIA_VITE_URL set), fetches files from Vite.
|
||||
In production, reads from the static build directory.
|
||||
|
||||
This complements fastapi_vue.Frontend which handles static file serving
|
||||
but doesn't provide server-side fetching of HTML content.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import mimetypes
|
||||
import os
|
||||
from importlib import resources
|
||||
from pathlib import Path
|
||||
|
||||
import httpx
|
||||
from fastapi import Response
|
||||
|
||||
__all__ = ["read"]
|
||||
|
||||
|
||||
def _get_dev_server() -> str | None:
|
||||
"""Get the dev server URL from environment, or None if not in dev mode."""
|
||||
return os.environ.get("FASTAPI_VUE_FRONTEND_URL") or None
|
||||
__all__ = ["handle"]
|
||||
|
||||
|
||||
def _resolve_static_dir() -> Path:
|
||||
"""Resolve the static files directory."""
|
||||
|
||||
# Try packaged path via importlib.resources (works for wheel/installed).
|
||||
try: # pragma: no cover - trivial path resolution
|
||||
pkg_dir = resources.files("paskia") / "frontend-build"
|
||||
fs_path = Path(str(pkg_dir))
|
||||
if fs_path.is_dir():
|
||||
return fs_path
|
||||
except Exception: # pragma: no cover - defensive
|
||||
pass
|
||||
pkg_dir = resources.files("paskia") / "frontend-build"
|
||||
fs_path = Path(str(pkg_dir))
|
||||
if fs_path.is_dir():
|
||||
return fs_path
|
||||
# Fallback for editable/development before build.
|
||||
return Path(__file__).parent.parent / "frontend-build"
|
||||
|
||||
@@ -41,31 +31,45 @@ def _resolve_static_dir() -> Path:
|
||||
_static_dir: Path = _resolve_static_dir()
|
||||
|
||||
|
||||
async def read(filepath: str) -> tuple[bytes, int, dict[str, str]]:
|
||||
"""Read file content and return response tuple.
|
||||
async def handle(request, frontend, filepath: str):
|
||||
"""Read file content and return Response.
|
||||
|
||||
In dev mode, fetches from the Vite dev server.
|
||||
In production, reads from the static build directory.
|
||||
In production, uses frontend.handle.
|
||||
|
||||
Args:
|
||||
request: The FastAPI Request object
|
||||
frontend: The fastapi_vue.Frontend instance
|
||||
filepath: Path relative to frontend root, e.g. "/auth/index.html"
|
||||
|
||||
Returns:
|
||||
Tuple of (content, status_code, headers) suitable for
|
||||
FastAPI Response(*args).
|
||||
FastAPI Response object.
|
||||
"""
|
||||
dev_server = _get_dev_server()
|
||||
if dev_server:
|
||||
if dev_server := os.environ.get("PASKIA_VITE_URL"):
|
||||
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 = _static_dir / filepath.lstrip("/")
|
||||
content = await asyncio.to_thread(file_path.read_bytes)
|
||||
mime, _ = mimetypes.guess_type(str(file_path))
|
||||
return content, 200, {"content-type": mime or "application/octet-stream"}
|
||||
return Response(resp.content, resp.status_code, {"content-type": mime})
|
||||
|
||||
# Read from frontend cache directly to bypass any compression/processing
|
||||
cached_content = getattr(frontend, "_files", {}).get(filepath)
|
||||
if cached_content is not None:
|
||||
mime, _ = mimetypes.guess_type(filepath)
|
||||
return Response(
|
||||
cached_content, 200, {"content-type": mime or "application/octet-stream"}
|
||||
)
|
||||
|
||||
# Fallback to frontend.handle for cache negotiation
|
||||
# Strip accept-encoding to get uncompressed content (needed for HTML patching)
|
||||
strip_headers = {b"accept-encoding", b"if-none-match", b"if-modified-since"}
|
||||
request.scope["headers"] = [
|
||||
(k, v) for k, v in request.scope["headers"] if k.lower() not in strip_headers
|
||||
]
|
||||
# Invalidate cached Headers object (it doesn't re-read scope after first access)
|
||||
if hasattr(request, "_headers"):
|
||||
del request._headers
|
||||
|
||||
return frontend.handle(request, filepath)
|
||||
|
||||
+18
-28
@@ -11,20 +11,29 @@ keywords = [ "forward_auth", "auth_request", "FastAPI" ]
|
||||
authors = [
|
||||
{name = "Leo Vasanko"},
|
||||
]
|
||||
requires-python = ">=3.11"
|
||||
dependencies = [
|
||||
"fastapi[standard]>=0.104.1",
|
||||
"websockets>=12.0",
|
||||
"webauthn>=1.11.1",
|
||||
"base64url>=1.0.0",
|
||||
"uuid7-standard>=1.0.0",
|
||||
"pyjwt[crypto]>=2.8.0",
|
||||
"fastapi[standard]>=0.129.0",
|
||||
"websockets>=16.0",
|
||||
"webauthn>=2.7.1",
|
||||
"base64url>=1.1.1",
|
||||
"uuid7-standard>=1.1.0",
|
||||
"pyjwt[crypto]>=2.11.0",
|
||||
"jsondiff>=2.2.1",
|
||||
"msgspec>=0.20.0",
|
||||
"aiofiles>=25.1.0",
|
||||
"fastapi-vue>=0.3.0",
|
||||
"fastapi-vue>=1.1.0",
|
||||
"ua-parser[regex]>=1.0.1",
|
||||
"kanta>=0.7.0",
|
||||
]
|
||||
[dependency-groups]
|
||||
dev = [
|
||||
"coverage>=7.13.4",
|
||||
"httpx>=0.28.1",
|
||||
"pytest>=9.0.2",
|
||||
"pytest-asyncio>=1.3.0",
|
||||
"pytest-cov>=7.0.0",
|
||||
"ruff>=0.15.1",
|
||||
]
|
||||
requires-python = ">=3.11"
|
||||
|
||||
[project.urls]
|
||||
Homepage = "https://git.zi.fi/LeoVasanko/paskia"
|
||||
@@ -36,15 +45,6 @@ source = "vcs"
|
||||
[tool.hatch.build.hooks.vcs]
|
||||
version-file = "paskia/_version.py"
|
||||
|
||||
[project.optional-dependencies]
|
||||
dev = [
|
||||
"ruff>=0.1.0",
|
||||
"coverage[toml]>=7.0.0",
|
||||
"pytest>=8.0.0",
|
||||
"pytest-asyncio>=0.24.0",
|
||||
"httpx>=0.27.0",
|
||||
]
|
||||
|
||||
[tool.coverage.run]
|
||||
source = ["paskia"]
|
||||
branch = true
|
||||
@@ -75,16 +75,6 @@ select = ["E", "F", "I", "N", "W", "UP", "PLC0415"]
|
||||
ignore = ["E501"] # Line too long
|
||||
isort.known-first-party = ["paskia"]
|
||||
|
||||
[dependency-groups]
|
||||
dev = [
|
||||
"coverage>=7.12.0",
|
||||
"httpx>=0.28.1",
|
||||
"pytest>=9.0.1",
|
||||
"pytest-asyncio>=1.3.0",
|
||||
"pytest-cov>=7.0.0",
|
||||
"ruff>=0.14.8",
|
||||
]
|
||||
|
||||
[project.scripts]
|
||||
paskia = "paskia.__main__:main"
|
||||
|
||||
|
||||
+19
-21
@@ -153,29 +153,9 @@ async def run_devserver(args: argparse.Namespace, remaining: list[str]) -> None:
|
||||
paskia.extend(["--origin", origin])
|
||||
paskia.extend(remaining)
|
||||
|
||||
# Compute origins for Caddy
|
||||
caddy_origins = []
|
||||
if args.auth_host:
|
||||
auth_host = args.auth_host
|
||||
if "://" not in auth_host:
|
||||
auth_host = f"https://{auth_host}"
|
||||
caddy_origins.append(auth_host)
|
||||
caddy_origins.append(f"https://{args.rp_id}")
|
||||
if args.origins:
|
||||
for origin in args.origins:
|
||||
if "://" not in origin:
|
||||
origin = f"https://{origin}"
|
||||
caddy_origins.append(origin)
|
||||
if not args.auth_host and not args.origins:
|
||||
caddy_origins.append(f"https://{args.rp_id}")
|
||||
# Remove duplicates while preserving order
|
||||
seen = set()
|
||||
caddy_origins = [x for x in caddy_origins if not (x in seen or seen.add(x))]
|
||||
|
||||
# Set environment for subprocesses
|
||||
os.environ["PASKIA_VITE_URL"] = viteurl
|
||||
os.environ["PASKIA_BACKEND_URL"] = backurl
|
||||
os.environ["PASKIA_SITE_URL"] = caddy_origins[0] if args.caddy else viteurl
|
||||
os.environ["PASKIA_DEV"] = "1"
|
||||
if args.auth_host:
|
||||
os.environ["PASKIA_AUTH_HOST"] = args.auth_host
|
||||
@@ -183,6 +163,22 @@ async def run_devserver(args: argparse.Namespace, remaining: list[str]) -> None:
|
||||
async with ProcessGroup() as pg:
|
||||
# Start Caddy first if requested (needs to bind ports)
|
||||
if args.caddy:
|
||||
caddy_origins = []
|
||||
if args.auth_host:
|
||||
auth_host = args.auth_host
|
||||
if "://" not in auth_host:
|
||||
auth_host = f"https://{auth_host}"
|
||||
caddy_origins.append(auth_host)
|
||||
caddy_origins.append(f"https://{args.rp_id}")
|
||||
if args.origins:
|
||||
for origin in args.origins:
|
||||
if "://" not in origin:
|
||||
origin = f"https://{origin}"
|
||||
caddy_origins.append(origin)
|
||||
if not caddy_origins:
|
||||
caddy_origins.append(f"https://{args.rp_id}")
|
||||
seen: set = set()
|
||||
caddy_origins = [x for x in caddy_origins if not (x in seen or seen.add(x))]
|
||||
caddy_proc = await run_caddy(caddy_origins, viteurl, backurl)
|
||||
pg._procs.append(caddy_proc)
|
||||
pg._cmds[caddy_proc.pid] = "caddy"
|
||||
@@ -190,7 +186,9 @@ async def run_devserver(args: argparse.Namespace, remaining: list[str]) -> None:
|
||||
npm_proc = await pg.spawn(*npm_install, cwd=frontend_path)
|
||||
await check_ports_free(viteurl, backurl)
|
||||
await pg.spawn(*paskia)
|
||||
await pg.wait(npm_proc, ready(backurl, path="/api/health?from=devserver.py"))
|
||||
await pg.wait(
|
||||
npm_proc, ready(backurl, path="/auth/api/settings?from=devserver.py")
|
||||
)
|
||||
await pg.spawn(*vite, cwd=frontend_path)
|
||||
|
||||
|
||||
|
||||
+81
-23
@@ -12,6 +12,7 @@ in the database to test authenticated endpoints.
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import os
|
||||
import secrets
|
||||
import tempfile
|
||||
@@ -19,29 +20,41 @@ from collections.abc import AsyncGenerator
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from uuid import UUID
|
||||
|
||||
import base64url
|
||||
import httpx
|
||||
import pytest
|
||||
import pytest_asyncio
|
||||
from kanta import Kanta
|
||||
|
||||
# Keep runtime initialization invariant aligned with production:
|
||||
# db.lifecycle requires PASKIA_CONFIG at import time.
|
||||
os.environ.setdefault(
|
||||
"PASKIA_CONFIG",
|
||||
json.dumps(
|
||||
{
|
||||
"config": {"rp_id": "localhost", "rp_name": "localhost"},
|
||||
"site_url": "http://localhost:4401",
|
||||
"site_path": "/auth/",
|
||||
"save": False,
|
||||
}
|
||||
),
|
||||
)
|
||||
|
||||
import paskia.db.operations as ops_db
|
||||
from paskia import globals as paskia_globals
|
||||
from paskia.authsession import reset_expires
|
||||
from paskia.config import SESSION_LIFETIME
|
||||
from paskia.db import (
|
||||
Config,
|
||||
Credential,
|
||||
Org,
|
||||
Permission,
|
||||
Role,
|
||||
User,
|
||||
bootstrap,
|
||||
create_credential,
|
||||
create_reset_token,
|
||||
create_role,
|
||||
create_user,
|
||||
)
|
||||
from paskia.db.jsonl import JsonlStore
|
||||
from paskia.db.bootstrap import bootstrap
|
||||
from paskia.db.operations import DB
|
||||
from paskia.db.structs import Session
|
||||
from paskia.fastapi.mainapp import app
|
||||
@@ -60,41 +73,59 @@ def event_loop():
|
||||
|
||||
@pytest_asyncio.fixture(scope="function")
|
||||
async def test_db() -> AsyncGenerator[DB, None]:
|
||||
"""Create an in-memory JSON database for testing.
|
||||
"""Create a temporary JSONL database for testing using kanta.
|
||||
|
||||
Uses bootstrap() to properly initialize the database with:
|
||||
Uses a kanta bootstrap callback to properly initialize the database with:
|
||||
- auth:admin and auth:org:admin permissions
|
||||
- A default organization with Administration role
|
||||
- An admin user with the Administration role
|
||||
"""
|
||||
with tempfile.NamedTemporaryFile(suffix=".jsonl", delete=True) as f:
|
||||
db = DB(config=Config(rp_id="test.example.com"))
|
||||
store = JsonlStore(db, f.name)
|
||||
db._store = store
|
||||
await store.load()
|
||||
ops_db._db = db
|
||||
ops_db._store = store
|
||||
# Bootstrap creates the initial permissions, org, role, and admin user
|
||||
bootstrap(
|
||||
org_name="Test Organization",
|
||||
admin_name="Test Admin",
|
||||
db = DB()
|
||||
kanta = Kanta(
|
||||
f.name,
|
||||
db,
|
||||
migrations="paskia.db.migrations",
|
||||
)
|
||||
yield db
|
||||
kanta.ctx.rp_id = "test.example.com"
|
||||
|
||||
# Register bootstrap callback so kanta seeds the empty DB during open()
|
||||
@kanta.bootstrap(action="bootstrap")
|
||||
def bootstrap_test_db(data: DB) -> None:
|
||||
bootstrap(
|
||||
data,
|
||||
org_name="Test Organization",
|
||||
admin_name="Test Admin",
|
||||
)
|
||||
|
||||
await kanta.open()
|
||||
ops_db._db = db
|
||||
ops_db._db._store = kanta
|
||||
yield ops_db._db
|
||||
await kanta.close()
|
||||
ops_db._db = None
|
||||
ops_db._store = None
|
||||
|
||||
|
||||
@pytest_asyncio.fixture(scope="function")
|
||||
async def passkey_instance() -> Passkey:
|
||||
"""Initialize a passkey instance for testing."""
|
||||
"""Override the module-level passkey instance for testing."""
|
||||
pk = Passkey(
|
||||
rp_id="localhost",
|
||||
rp_name="Test RP",
|
||||
origins=["http://localhost:4401"],
|
||||
)
|
||||
paskia_globals.passkey._instance = pk
|
||||
original = {
|
||||
"rp_id": paskia_globals.passkey.rp_id,
|
||||
"rp_name": paskia_globals.passkey.rp_name,
|
||||
"allowed_origins": paskia_globals.passkey.allowed_origins,
|
||||
}
|
||||
paskia_globals.passkey.rp_id = pk.rp_id
|
||||
paskia_globals.passkey.rp_name = pk.rp_name
|
||||
paskia_globals.passkey.allowed_origins = pk.allowed_origins
|
||||
yield pk
|
||||
paskia_globals.passkey._instance = None
|
||||
paskia_globals.passkey.rp_id = original["rp_id"]
|
||||
paskia_globals.passkey.rp_name = original["rp_name"]
|
||||
paskia_globals.passkey.allowed_origins = original["allowed_origins"]
|
||||
|
||||
|
||||
@pytest_asyncio.fixture(scope="function")
|
||||
@@ -268,7 +299,7 @@ def create_test_session(
|
||||
|
||||
# Generate token and derive key
|
||||
token = secrets.token_urlsafe(12)
|
||||
key = base64url.enc(hash_secret("cookie", token))
|
||||
key = hash_secret("cookie", token)
|
||||
|
||||
session = Session.create(
|
||||
user=user_uuid,
|
||||
@@ -281,6 +312,33 @@ def create_test_session(
|
||||
)
|
||||
if session.key in ops_db._db.sessions:
|
||||
raise ValueError("Session already exists")
|
||||
with ops_db._db.transaction("create_test_session"):
|
||||
store = ops_db._db._store
|
||||
if store is None:
|
||||
raise RuntimeError("Test DB store is not initialized")
|
||||
with store.transaction("create_test_session"):
|
||||
session.store(now)
|
||||
return session.key, token
|
||||
|
||||
|
||||
def create_test_image_bytes(
|
||||
*,
|
||||
image_format: str = "WEBP",
|
||||
) -> bytes:
|
||||
"""Return deterministic test upload bytes without image-library dependencies."""
|
||||
fixtures = {
|
||||
"WEBP": (
|
||||
b"RIFF\x1a\x00\x00\x00WEBPVP8 "
|
||||
b"\x0e\x00\x00\x000\x01\x00\x9d\x01*\x01\x00\x01\x00\x01\x00"
|
||||
),
|
||||
"PNG": (
|
||||
b"\x89PNG\r\n\x1a\n"
|
||||
b"\x00\x00\x00\rIHDR"
|
||||
b"\x00\x00\x00\x01\x00\x00\x00\x01\x08\x02\x00\x00\x00"
|
||||
b"\x90wS\xde"
|
||||
),
|
||||
}
|
||||
|
||||
try:
|
||||
return fixtures[image_format.upper()]
|
||||
except KeyError as exc:
|
||||
raise ValueError(f"Unsupported test image format: {image_format}") from exc
|
||||
|
||||
+193
-37
@@ -14,9 +14,9 @@ These tests cover:
|
||||
import os
|
||||
import secrets
|
||||
from datetime import UTC, datetime
|
||||
from urllib.parse import urlsplit
|
||||
from uuid import UUID
|
||||
|
||||
import base64url
|
||||
import httpx
|
||||
import pytest
|
||||
import pytest_asyncio
|
||||
@@ -37,8 +37,11 @@ from paskia.db import (
|
||||
create_user,
|
||||
)
|
||||
from paskia.db.operations import DB
|
||||
from paskia.util import hostutil
|
||||
from paskia.util.crypto import hash_secret
|
||||
from tests.conftest import auth_headers, create_test_session
|
||||
from paskia.util.runtime import clear_config_cache
|
||||
from paskia.util.runtime import config as runtime_config
|
||||
from tests.conftest import auth_headers, create_test_image_bytes, create_test_session
|
||||
|
||||
# -------------------- Additional Fixtures --------------------
|
||||
|
||||
@@ -188,25 +191,6 @@ class TestExceptionHandlers:
|
||||
assert "iframe" in data["auth"]
|
||||
|
||||
|
||||
# -------------------- Admin App Root --------------------
|
||||
|
||||
|
||||
class TestAdminAppRoot:
|
||||
"""Tests for the admin app root endpoint"""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_admin_app_root_with_auth(
|
||||
self, client: httpx.AsyncClient, session_token: str
|
||||
):
|
||||
"""Admin app root returns HTML when authenticated."""
|
||||
response = await client.get(
|
||||
"/auth/api/admin/",
|
||||
headers={**auth_headers(session_token), "Host": "localhost:4401"},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert "text/html" in response.headers.get("content-type", "")
|
||||
|
||||
|
||||
# -------------------- Organization Tests --------------------
|
||||
|
||||
|
||||
@@ -258,6 +242,38 @@ class TestAdminOrganizations:
|
||||
assert "roles" in org_data
|
||||
assert "users" in org_data
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_orgs_includes_user_avatar_urls(
|
||||
self,
|
||||
client: httpx.AsyncClient,
|
||||
session_token: str,
|
||||
test_org,
|
||||
test_user,
|
||||
tmp_path,
|
||||
monkeypatch,
|
||||
):
|
||||
"""Admin org payload should include canonical avatar URLs for listed users."""
|
||||
monkeypatch.setenv("PASKIA_DB", str(tmp_path / "test-avatar-db.paskiadb"))
|
||||
|
||||
upload = await client.put(
|
||||
f"/auth/api/user/{test_user.uuid}/profile.webp",
|
||||
files={"file": ("avatar.webp", create_test_image_bytes(), "image/webp")},
|
||||
headers={**auth_headers(session_token), "Host": "localhost:4401"},
|
||||
)
|
||||
assert upload.status_code == 200
|
||||
|
||||
response = await client.get(
|
||||
"/auth/api/admin/info",
|
||||
headers={**auth_headers(session_token), "Host": "localhost:4401"},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
|
||||
data = response.json()
|
||||
listed_user = data["orgs"][str(test_org.uuid)]["users"][str(test_user.uuid)]
|
||||
parts = urlsplit(listed_user["avatar_url"])
|
||||
assert parts.path.endswith(f"/auth/api/user/{test_user.uuid}/profile.webp")
|
||||
assert parts.query == ""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_orgs_with_org_admin(
|
||||
self,
|
||||
@@ -283,7 +299,7 @@ class TestAdminOrganizations:
|
||||
):
|
||||
"""Creating org without admin permission should fail."""
|
||||
response = await client.post(
|
||||
"/auth/api/admin/orgs",
|
||||
"/auth/api/admin/orgs/",
|
||||
json={"display_name": "New Org"},
|
||||
headers={
|
||||
**auth_headers(regular_session_token),
|
||||
@@ -298,7 +314,7 @@ class TestAdminOrganizations:
|
||||
):
|
||||
"""Admin should be able to create a new organization."""
|
||||
response = await client.post(
|
||||
"/auth/api/admin/orgs",
|
||||
"/auth/api/admin/orgs/",
|
||||
json={"display_name": "New Test Org", "permissions": []},
|
||||
headers={**auth_headers(session_token), "Host": "localhost:4401"},
|
||||
)
|
||||
@@ -312,7 +328,7 @@ class TestAdminOrganizations:
|
||||
):
|
||||
"""Admin should be able to create org with default values."""
|
||||
response = await client.post(
|
||||
"/auth/api/admin/orgs",
|
||||
"/auth/api/admin/orgs/",
|
||||
json={}, # No display_name or permissions
|
||||
headers={**auth_headers(session_token), "Host": "localhost:4401"},
|
||||
)
|
||||
@@ -922,6 +938,34 @@ class TestAdminUsersInOrg:
|
||||
data = response.json()
|
||||
assert "display_name too long" in data["detail"]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_admin_can_upload_user_avatar(
|
||||
self,
|
||||
client: httpx.AsyncClient,
|
||||
session_token: str,
|
||||
test_user: User,
|
||||
tmp_path,
|
||||
monkeypatch,
|
||||
):
|
||||
"""Admin should be able to upload avatar for a managed user."""
|
||||
monkeypatch.setenv("PASKIA_DB", str(tmp_path / "test-admin-avatar-db.paskiadb"))
|
||||
|
||||
response = await client.put(
|
||||
f"/auth/api/user/{test_user.uuid}/profile.webp",
|
||||
files={"file": ("avatar.webp", create_test_image_bytes(), "image/webp")},
|
||||
headers={**auth_headers(session_token), "Host": "localhost:4401"},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
|
||||
detail = await client.get(
|
||||
f"/auth/api/admin/users/{test_user.uuid}",
|
||||
headers={**auth_headers(session_token), "Host": "localhost:4401"},
|
||||
)
|
||||
assert detail.status_code == 200
|
||||
avatar_url = detail.json()["user"]["avatar_url"]
|
||||
parts = urlsplit(avatar_url)
|
||||
assert parts.path.endswith(f"/auth/api/user/{test_user.uuid}/profile.webp")
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_user_role_in_org(
|
||||
self,
|
||||
@@ -1320,7 +1364,7 @@ class TestAdminSessions:
|
||||
test_user,
|
||||
):
|
||||
"""Admin can delete their own current session."""
|
||||
session_db_key = base64url.enc(hash_secret("cookie", session_token))
|
||||
session_db_key = hash_secret("cookie", session_token)
|
||||
response = await client.delete(
|
||||
f"/auth/api/admin/users/{test_user.uuid}/sessions/{session_db_key}",
|
||||
headers={**auth_headers(session_token), "Host": "localhost:4401"},
|
||||
@@ -1441,7 +1485,7 @@ class TestAdminPermissions:
|
||||
):
|
||||
"""Admin should be able to create new permissions."""
|
||||
response = await client.post(
|
||||
"/auth/api/admin/permissions",
|
||||
"/auth/api/admin/permissions/",
|
||||
json={"scope": "test:create:permission", "display_name": "Test Permission"},
|
||||
headers={**auth_headers(session_token), "Host": "localhost:4401"},
|
||||
)
|
||||
@@ -1455,7 +1499,7 @@ class TestAdminPermissions:
|
||||
):
|
||||
"""Creating permission without required fields should fail."""
|
||||
response = await client.post(
|
||||
"/auth/api/admin/permissions",
|
||||
"/auth/api/admin/permissions/",
|
||||
json={},
|
||||
headers={**auth_headers(session_token), "Host": "localhost:4401"},
|
||||
)
|
||||
@@ -1469,7 +1513,7 @@ class TestAdminPermissions:
|
||||
):
|
||||
"""Creating permission without admin should fail."""
|
||||
response = await client.post(
|
||||
"/auth/api/admin/permissions",
|
||||
"/auth/api/admin/permissions/",
|
||||
json={"scope": "test:forbidden", "display_name": "Forbidden"},
|
||||
headers={
|
||||
**auth_headers(regular_session_token),
|
||||
@@ -1488,7 +1532,7 @@ class TestAdminPermissions:
|
||||
create_permission(perm)
|
||||
|
||||
response = await client.patch(
|
||||
f"/auth/api/admin/permission?permission_uuid={perm.uuid}&display_name=Updated%20Name",
|
||||
f"/auth/api/admin/permissions/{perm.uuid}?display_name=Updated%20Name",
|
||||
headers={**auth_headers(session_token), "Host": "localhost:4401"},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
@@ -1505,7 +1549,7 @@ class TestAdminPermissions:
|
||||
create_permission(perm)
|
||||
|
||||
response = await client.patch(
|
||||
f"/auth/api/admin/permission?permission_uuid={perm.uuid}&display_name=",
|
||||
f"/auth/api/admin/permissions/{perm.uuid}?display_name=",
|
||||
headers={**auth_headers(session_token), "Host": "localhost:4401"},
|
||||
)
|
||||
assert response.status_code == 400
|
||||
@@ -1522,7 +1566,7 @@ class TestAdminPermissions:
|
||||
create_permission(perm)
|
||||
|
||||
response = await client.patch(
|
||||
f"/auth/api/admin/permission?permission_uuid={perm.uuid}&scope=test:renamed2",
|
||||
f"/auth/api/admin/permissions/{perm.uuid}?scope=test:renamed2",
|
||||
headers={**auth_headers(session_token), "Host": "localhost:4401"},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
@@ -1538,7 +1582,7 @@ class TestAdminPermissions:
|
||||
admin_perm = next(p for p in perms if p.scope == "auth:admin")
|
||||
|
||||
response = await client.patch(
|
||||
f"/auth/api/admin/permission?permission_uuid={admin_perm.uuid}&scope=auth:superadmin",
|
||||
f"/auth/api/admin/permissions/{admin_perm.uuid}?scope=auth:superadmin",
|
||||
headers={**auth_headers(session_token), "Host": "localhost:4401"},
|
||||
)
|
||||
assert response.status_code == 400
|
||||
@@ -1554,7 +1598,7 @@ class TestAdminPermissions:
|
||||
create_permission(perm)
|
||||
|
||||
response = await client.patch(
|
||||
f"/auth/api/admin/permission?permission_uuid={perm.uuid}&scope=test:renamed:withname&display_name=New%20Display%20Name",
|
||||
f"/auth/api/admin/permissions/{perm.uuid}?scope=test:renamed:withname&display_name=New%20Display%20Name",
|
||||
headers={**auth_headers(session_token), "Host": "localhost:4401"},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
@@ -1569,7 +1613,7 @@ class TestAdminPermissions:
|
||||
create_permission(perm)
|
||||
|
||||
response = await client.delete(
|
||||
f"/auth/api/admin/permission?permission_uuid={perm.uuid}",
|
||||
f"/auth/api/admin/permissions/{perm.uuid}",
|
||||
headers={**auth_headers(session_token), "Host": "localhost:4401"},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
@@ -1587,7 +1631,7 @@ class TestAdminPermissions:
|
||||
admin_perm = next(p for p in perms if p.scope == "auth:admin")
|
||||
|
||||
response = await client.delete(
|
||||
f"/auth/api/admin/permission?permission_uuid={admin_perm.uuid}",
|
||||
f"/auth/api/admin/permissions/{admin_perm.uuid}",
|
||||
headers={**auth_headers(session_token), "Host": "localhost:4401"},
|
||||
)
|
||||
assert response.status_code == 400
|
||||
@@ -1613,7 +1657,7 @@ class TestAdminPermissions:
|
||||
|
||||
# Now we can delete the original one
|
||||
response = await client.delete(
|
||||
f"/auth/api/admin/permission?permission_uuid={original_admin_perm.uuid}",
|
||||
f"/auth/api/admin/permissions/{original_admin_perm.uuid}",
|
||||
headers={**auth_headers(session_token), "Host": "localhost:4401"},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
@@ -1642,7 +1686,7 @@ class TestAdminPermissions:
|
||||
original_admin_perm = admin_perms[0] # The one without domain
|
||||
|
||||
response = await client.delete(
|
||||
f"/auth/api/admin/permission?permission_uuid={original_admin_perm.uuid}",
|
||||
f"/auth/api/admin/permissions/{original_admin_perm.uuid}",
|
||||
headers={**auth_headers(session_token), "Host": "localhost:4401"},
|
||||
)
|
||||
assert response.status_code == 400
|
||||
@@ -1748,3 +1792,115 @@ class TestOrgAdminAuthExceptions:
|
||||
headers={**auth_headers(regular_session_token), "Host": "localhost:4401"},
|
||||
)
|
||||
assert response.status_code == 403
|
||||
|
||||
|
||||
class TestServerConfig:
|
||||
"""Tests for GET/PATCH /auth/api/admin/server-config/ runtime updates."""
|
||||
|
||||
@pytest.fixture(scope="function")
|
||||
def restore_runtime_config(self):
|
||||
"""Restore PASKIA_CONFIG env and cache after a test mutates runtime."""
|
||||
original = os.environ["PASKIA_CONFIG"]
|
||||
yield
|
||||
os.environ["PASKIA_CONFIG"] = original
|
||||
clear_config_cache()
|
||||
|
||||
async def _set_auth_host(self, client, session_token, test_user, test_credential):
|
||||
"""Configure an auth host via PATCH, as the admin UI would."""
|
||||
r = await client.patch(
|
||||
"/auth/api/admin/server-config/",
|
||||
json={
|
||||
"rp_name": "",
|
||||
"auth_host": "auth.localhost",
|
||||
"origins": ["auth.localhost", "localhost"],
|
||||
},
|
||||
headers={**auth_headers(session_token), "Host": "localhost:4401"},
|
||||
)
|
||||
assert r.status_code == 200, r.text
|
||||
assert db.data().config.auth_host == "https://auth.localhost"
|
||||
assert hostutil.dedicated_auth_host() == "auth.localhost"
|
||||
assert hostutil.auth_site_url() == "https://auth.localhost/"
|
||||
# Session for requests coming from the auth host (sessions are host-bound)
|
||||
_, token = create_test_session(
|
||||
test_user.uuid, test_credential.uuid, host="auth.localhost"
|
||||
)
|
||||
return {**auth_headers(token), "Host": "auth.localhost"}
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_remove_auth_host_updates_runtime(
|
||||
self,
|
||||
client: httpx.AsyncClient,
|
||||
session_token: str,
|
||||
test_user,
|
||||
test_credential,
|
||||
restore_runtime_config,
|
||||
):
|
||||
"""Removing auth_host must clear it from runtime config and URLs."""
|
||||
headers = await self._set_auth_host(
|
||||
client, session_token, test_user, test_credential
|
||||
)
|
||||
|
||||
# The dialog still lists the old auth host among origins, so it is sent back
|
||||
r = await client.patch(
|
||||
"/auth/api/admin/server-config/",
|
||||
json={
|
||||
"rp_name": "",
|
||||
"auth_host": "",
|
||||
"origins": ["auth.localhost", "localhost"],
|
||||
},
|
||||
headers=headers,
|
||||
)
|
||||
assert r.status_code == 200, r.text
|
||||
assert db.data().config.auth_host is None
|
||||
|
||||
rt = runtime_config()
|
||||
assert rt.config.auth_host is None
|
||||
assert rt.site_path == "/auth/"
|
||||
assert "auth.localhost" not in rt.site_url
|
||||
assert hostutil.dedicated_auth_host() is None
|
||||
assert "auth.localhost" not in hostutil.auth_site_url()
|
||||
|
||||
# GET and settings reflect the cleared state
|
||||
r = await client.get(
|
||||
"/auth/api/admin/server-config/",
|
||||
headers={**auth_headers(session_token), "Host": "localhost:4401"},
|
||||
)
|
||||
assert r.json()["auth_host"] == ""
|
||||
r = await client.get("/auth/api/settings")
|
||||
assert r.json()["auth_host"] is None
|
||||
assert r.json()["ui_base_path"] == "/auth/"
|
||||
|
||||
# Middleware no longer redirects to the removed auth host
|
||||
r = await client.get(
|
||||
"/auth/admin",
|
||||
headers={**auth_headers(session_token), "Host": "localhost:4401"},
|
||||
follow_redirects=False,
|
||||
)
|
||||
assert "auth.localhost" not in r.headers.get("location", "")
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_remove_auth_host_without_origins_falls_back_to_rp_id(
|
||||
self,
|
||||
client: httpx.AsyncClient,
|
||||
session_token: str,
|
||||
test_user,
|
||||
test_credential,
|
||||
restore_runtime_config,
|
||||
):
|
||||
"""With no origins left, site_url must not keep the removed auth host."""
|
||||
headers = await self._set_auth_host(
|
||||
client, session_token, test_user, test_credential
|
||||
)
|
||||
|
||||
r = await client.patch(
|
||||
"/auth/api/admin/server-config/",
|
||||
json={"rp_name": "", "auth_host": "", "origins": []},
|
||||
headers=headers,
|
||||
)
|
||||
assert r.status_code == 200, r.text
|
||||
|
||||
rt = runtime_config()
|
||||
assert rt.config.auth_host is None
|
||||
assert rt.site_path == "/auth/"
|
||||
assert "auth.localhost" not in rt.site_url
|
||||
assert "auth.localhost" not in hostutil.auth_site_url()
|
||||
|
||||
+158
-29
@@ -12,6 +12,8 @@ These tests cover:
|
||||
|
||||
import secrets
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from urllib.parse import urlsplit
|
||||
from uuid import UUID
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
@@ -19,8 +21,10 @@ import pytest
|
||||
from paskia import authcode
|
||||
from paskia.authsession import EXPIRES
|
||||
from paskia.db import delete_session
|
||||
from paskia.db.structs import Client
|
||||
from paskia.util import avatar, hostutil, oidjwt
|
||||
from paskia.util.passphrase import generate
|
||||
from tests.conftest import auth_headers, create_test_session
|
||||
from tests.conftest import auth_headers, create_test_image_bytes, create_test_session
|
||||
|
||||
|
||||
class TestSettingsEndpoint:
|
||||
@@ -46,6 +50,41 @@ class TestSettingsEndpoint:
|
||||
data = response.json()
|
||||
assert "ui_base_path" in data
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_openid_configuration_includes_picture_claim(
|
||||
self, client: httpx.AsyncClient
|
||||
):
|
||||
"""Discovery document should advertise picture claim support."""
|
||||
response = await client.get("/.well-known/openid-configuration")
|
||||
assert response.status_code == 200
|
||||
assert "picture" in response.json()["claims_supported"]
|
||||
|
||||
|
||||
class TestAvatarUrls:
|
||||
"""Tests for avatar URL helpers."""
|
||||
|
||||
def test_avatar_url_uses_canonical_public_path_in_auth_host_mode(
|
||||
self, tmp_path, monkeypatch
|
||||
):
|
||||
"""Absolute avatar URLs should preserve /auth/api even with an auth host."""
|
||||
db_root = tmp_path / "test-avatar-db.paskiadb"
|
||||
monkeypatch.setenv("PASKIA_DB", str(db_root))
|
||||
monkeypatch.setattr(
|
||||
hostutil,
|
||||
"api_url",
|
||||
lambda path="": f"https://auth.zi.fi/auth/api/{path.lstrip('/')}",
|
||||
)
|
||||
|
||||
user_uuid = test_uuid = UUID("019c6831-84cf-7b88-b66c-c8165890b7c5")
|
||||
path = db_root / "users" / str(test_uuid) / "profile.webp"
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_bytes(b"RIFF1234WEBP")
|
||||
|
||||
assert avatar.avatar_url(user_uuid) == (
|
||||
"https://auth.zi.fi/auth/api/user/"
|
||||
"019c6831-84cf-7b88-b66c-c8165890b7c5/profile.webp"
|
||||
)
|
||||
|
||||
|
||||
class TestValidateEndpoint:
|
||||
"""Tests for POST /auth/api/validate"""
|
||||
@@ -294,6 +333,124 @@ class TestUserInfoEndpoint:
|
||||
data = response.json()
|
||||
assert "permissions" in data
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_user_info_includes_avatar_url(
|
||||
self,
|
||||
client: httpx.AsyncClient,
|
||||
session_token: str,
|
||||
test_user,
|
||||
tmp_path,
|
||||
monkeypatch,
|
||||
):
|
||||
"""User info should include the canonical avatar URL when present."""
|
||||
monkeypatch.setenv("PASKIA_DB", str(tmp_path / "test-avatar-db.paskiadb"))
|
||||
|
||||
upload = await client.put(
|
||||
f"/auth/api/user/{test_user.uuid}/profile.webp",
|
||||
files={"file": ("avatar.webp", create_test_image_bytes(), "image/webp")},
|
||||
headers={**auth_headers(session_token), "Host": "localhost:4401"},
|
||||
)
|
||||
assert upload.status_code == 200
|
||||
|
||||
response = await client.get(
|
||||
"/auth/api/user-info",
|
||||
headers={**auth_headers(session_token), "Host": "localhost:4401"},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
avatar_url = data["user"]["avatar_url"]
|
||||
parts = urlsplit(avatar_url)
|
||||
assert parts.path.endswith(f"/auth/api/user/{test_user.uuid}/profile.webp")
|
||||
assert parts.query == ""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_avatar_route_returns_304_for_matching_etag(
|
||||
self,
|
||||
client: httpx.AsyncClient,
|
||||
session_token: str,
|
||||
test_user,
|
||||
tmp_path,
|
||||
monkeypatch,
|
||||
):
|
||||
"""Avatar route should honor If-None-Match for unchanged avatars."""
|
||||
monkeypatch.setenv("PASKIA_DB", str(tmp_path / "test-avatar-db.paskiadb"))
|
||||
|
||||
upload = await client.put(
|
||||
f"/auth/api/user/{test_user.uuid}/profile.webp",
|
||||
files={"file": ("avatar.webp", create_test_image_bytes(), "image/webp")},
|
||||
headers={**auth_headers(session_token), "Host": "localhost:4401"},
|
||||
)
|
||||
assert upload.status_code == 200
|
||||
parts = urlsplit(upload.json()["avatar_url"])
|
||||
|
||||
first = await client.get(parts.path, headers={"Host": "localhost:4401"})
|
||||
assert first.status_code == 200
|
||||
|
||||
response = await client.get(
|
||||
f"/auth/api/user/{test_user.uuid}/profile.webp",
|
||||
headers={
|
||||
"Host": "localhost:4401",
|
||||
"If-None-Match": first.headers["etag"],
|
||||
},
|
||||
)
|
||||
assert response.status_code == 304
|
||||
assert response.headers["etag"] == first.headers["etag"]
|
||||
|
||||
|
||||
class TestOidcUserInfoEndpoint:
|
||||
"""Tests for OIDC userinfo metadata relevant to avatars."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_userinfo_includes_picture_claim(
|
||||
self,
|
||||
client: httpx.AsyncClient,
|
||||
test_db,
|
||||
session_token: str,
|
||||
test_user,
|
||||
tmp_path,
|
||||
monkeypatch,
|
||||
):
|
||||
"""OIDC userinfo should expose picture when profile scope is granted."""
|
||||
monkeypatch.setenv("PASKIA_DB", str(tmp_path / "test-avatar-db.paskiadb"))
|
||||
|
||||
upload = await client.put(
|
||||
f"/auth/api/user/{test_user.uuid}/profile.webp",
|
||||
files={"file": ("avatar.webp", create_test_image_bytes(), "image/webp")},
|
||||
headers={**auth_headers(session_token), "Host": "localhost:4401"},
|
||||
)
|
||||
assert upload.status_code == 200
|
||||
avatar_url = upload.json()["avatar_url"]
|
||||
|
||||
oidc_client, _secret = Client.create(
|
||||
name="Test Client",
|
||||
redirect_uris=["https://client.example/callback"],
|
||||
client_secret="topsecret",
|
||||
)
|
||||
store = test_db._store
|
||||
if store is None:
|
||||
raise RuntimeError("Test DB store is not initialized")
|
||||
with store.transaction("create_test_oidc_client"):
|
||||
test_db.oidc.clients[oidc_client.uuid] = oidc_client
|
||||
|
||||
access_token = oidjwt.create_access_token(
|
||||
issuer="http://localhost:4401",
|
||||
subject=test_user.uuid,
|
||||
audience=str(oidc_client.uuid),
|
||||
scope="openid profile",
|
||||
)
|
||||
|
||||
response = await client.get(
|
||||
"/auth/oidc/userinfo",
|
||||
headers={
|
||||
"Authorization": f"Bearer {access_token}",
|
||||
"Host": "localhost:4401",
|
||||
},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert urlsplit(data["picture"]).path == urlsplit(avatar_url).path
|
||||
assert data["picture"].startswith("http")
|
||||
|
||||
|
||||
class TestSetSessionEndpoint:
|
||||
"""Tests for POST /auth/api/set-session"""
|
||||
@@ -355,34 +512,6 @@ class TestErrorHandling:
|
||||
class TestForwardAuthHtmlResponse:
|
||||
"""Tests for forward auth HTML responses"""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_forward_401_html_response(self, client: httpx.AsyncClient):
|
||||
"""Forward auth 401 should return HTML page for browser requests."""
|
||||
response = await client.get(
|
||||
"/auth/api/forward",
|
||||
headers={"Accept": "text/html"},
|
||||
)
|
||||
assert response.status_code == 401
|
||||
assert "text/html" in response.headers.get("content-type", "")
|
||||
# HTML response should contain the mode data attribute
|
||||
assert b"data-mode" in response.content or b"mode" in response.content
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_forward_403_html_response(
|
||||
self, client: httpx.AsyncClient, regular_session_token: str
|
||||
):
|
||||
"""Forward auth 403 should return HTML page for browser requests."""
|
||||
response = await client.get(
|
||||
"/auth/api/forward?perm=auth:admin",
|
||||
headers={
|
||||
**auth_headers(regular_session_token),
|
||||
"Host": "localhost:4401",
|
||||
"Accept": "text/html",
|
||||
},
|
||||
)
|
||||
assert response.status_code == 403
|
||||
assert "text/html" in response.headers.get("content-type", "")
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_forward_with_expired_session_clears_cookie(
|
||||
self, client: httpx.AsyncClient
|
||||
|
||||
@@ -0,0 +1,154 @@
|
||||
"""Tests for the CLI entry point in paskia/__main__.py."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
from kanta import Kanta
|
||||
|
||||
from paskia.__main__ import main
|
||||
from paskia.db.structs import DB, Config
|
||||
from paskia.util.runtime import clear_config_cache
|
||||
from paskia.util.runtime import config as runtime_config
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def cli_run(monkeypatch):
|
||||
"""Run the CLI main() with the given args and return the RuntimeConfig."""
|
||||
|
||||
def _run(*args: str, db_root: str | None = None) -> Any:
|
||||
env = os.environ.copy()
|
||||
if db_root is not None:
|
||||
env["PASKIA_DB"] = db_root
|
||||
monkeypatch.setattr(os, "environ", env)
|
||||
|
||||
monkeypatch.setattr(sys, "argv", ["paskia", *args])
|
||||
monkeypatch.setattr("fastapi_vue.server.run", lambda *_args, **_kw: None)
|
||||
monkeypatch.setattr(
|
||||
"paskia.util.startupbox.print_startup_config", lambda _rt: None
|
||||
)
|
||||
monkeypatch.setattr("logging.basicConfig", lambda **_kw: None)
|
||||
|
||||
clear_config_cache()
|
||||
main()
|
||||
runtime = runtime_config()
|
||||
clear_config_cache()
|
||||
return runtime
|
||||
|
||||
return _run
|
||||
|
||||
|
||||
async def _write_config(db_path: Path, config: Config) -> None:
|
||||
"""Write a Config into a JSONL database file using Kanta.
|
||||
|
||||
The initial root uses a different rp_id so the stored diff includes the
|
||||
target rp_id (required because Config omits defaults when diffing).
|
||||
"""
|
||||
kanta = Kanta(
|
||||
str(db_path),
|
||||
DB(config=Config(rp_id="uninitialized.invalid")),
|
||||
migrations="paskia.db.migrations",
|
||||
)
|
||||
kanta.ctx.rp_id = config.rp_id
|
||||
await kanta.open()
|
||||
with kanta.transaction("test:write_config"):
|
||||
kanta.data.config = config
|
||||
await kanta.close()
|
||||
|
||||
|
||||
def write_config(db_path: Path, config: Config) -> None:
|
||||
"""Synchronous wrapper for _write_config."""
|
||||
asyncio.run(_write_config(db_path, config))
|
||||
|
||||
|
||||
def test_cli_defaults(cli_run):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
runtime = cli_run("--rp-id", "localhost", db_root=tmp)
|
||||
|
||||
assert runtime.config.rp_id == "localhost"
|
||||
assert runtime.config.rp_name is None
|
||||
assert runtime.config.auth_host is None
|
||||
assert runtime.config.origins is None
|
||||
assert runtime.site_url == "http://localhost:4401"
|
||||
assert runtime.site_path == "/auth/"
|
||||
assert runtime.save is False
|
||||
|
||||
|
||||
def test_cli_explicit_options(cli_run):
|
||||
runtime = cli_run(
|
||||
"--rp-id",
|
||||
"example.com",
|
||||
"--rp-name",
|
||||
"Example Corp",
|
||||
"--auth-host",
|
||||
"auth.example.com",
|
||||
"--origin",
|
||||
"https://app.example.com",
|
||||
)
|
||||
|
||||
assert runtime.config.rp_id == "example.com"
|
||||
assert runtime.config.rp_name == "Example Corp"
|
||||
assert runtime.config.auth_host == "https://auth.example.com"
|
||||
assert runtime.config.origins == [
|
||||
"https://auth.example.com",
|
||||
"https://app.example.com",
|
||||
]
|
||||
assert runtime.site_url == "https://auth.example.com"
|
||||
assert runtime.site_path == "/"
|
||||
|
||||
|
||||
def test_cli_loads_stored_config(cli_run):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
db_path = Path(tmp) / "main.db"
|
||||
write_config(
|
||||
db_path,
|
||||
Config(
|
||||
rp_id="example.com",
|
||||
rp_name="Stored Name",
|
||||
origins=["https://stored.example.com"],
|
||||
),
|
||||
)
|
||||
runtime = cli_run("--rp-id", "example.com", db_root=tmp)
|
||||
|
||||
assert runtime.config.rp_name == "Stored Name"
|
||||
assert runtime.config.origins == ["https://stored.example.com"]
|
||||
assert runtime.site_url == "https://stored.example.com"
|
||||
|
||||
|
||||
def test_cli_overrides_stored_config(cli_run):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
db_path = Path(tmp) / "main.db"
|
||||
write_config(db_path, Config(rp_id="example.com", rp_name="Stored Name"))
|
||||
runtime = cli_run(
|
||||
"--rp-id", "example.com", "--rp-name", "Overridden", db_root=tmp
|
||||
)
|
||||
|
||||
assert runtime.config.rp_name == "Overridden"
|
||||
|
||||
|
||||
def test_cli_save_flag(cli_run):
|
||||
runtime = cli_run("--save")
|
||||
assert runtime.save is True
|
||||
|
||||
|
||||
def test_cli_invalid_auth_host(cli_run):
|
||||
with pytest.raises(SystemExit):
|
||||
cli_run("--rp-id", "example.com", "--auth-host", "notsub.example.org")
|
||||
|
||||
|
||||
def test_cli_help():
|
||||
result = subprocess.run(
|
||||
[sys.executable, "-m", "paskia", "--help"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
)
|
||||
assert result.returncode == 0
|
||||
assert "Paskia authentication server" in result.stdout
|
||||
+161
-1
@@ -3,16 +3,20 @@ Tests for the user API endpoints (/auth/api/user/).
|
||||
|
||||
These tests cover user self-service operations:
|
||||
- Display name update
|
||||
- Avatar upload/delete
|
||||
- Logout all sessions
|
||||
- Session management (delete specific session)
|
||||
- Credential management (delete credential)
|
||||
- Device addition link creation
|
||||
"""
|
||||
|
||||
from urllib.parse import urlsplit
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
from tests.conftest import auth_headers
|
||||
from paskia.db.paths import db_file_path, users_root_path
|
||||
from tests.conftest import auth_headers, create_test_image_bytes
|
||||
|
||||
|
||||
class TestUserDisplayName:
|
||||
@@ -67,6 +71,162 @@ class TestUserDisplayName:
|
||||
assert response.status_code == 400
|
||||
|
||||
|
||||
class TestUserAvatar:
|
||||
"""Tests for PUT/DELETE /auth/api/user/{user_uuid}/profile.webp"""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_upload_avatar_requires_auth(self, client: httpx.AsyncClient):
|
||||
"""Uploading avatar without auth should return 401."""
|
||||
response = await client.put(
|
||||
"/auth/api/user/00000000-0000-0000-0000-000000000000/profile.webp",
|
||||
files={"file": ("avatar.webp", create_test_image_bytes(), "image/webp")},
|
||||
)
|
||||
assert response.status_code in (401, 404)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_upload_avatar_success(
|
||||
self,
|
||||
client: httpx.AsyncClient,
|
||||
session_token: str,
|
||||
test_user,
|
||||
tmp_path,
|
||||
monkeypatch,
|
||||
):
|
||||
"""Uploading a WebP avatar should store and expose the canonical URL."""
|
||||
monkeypatch.setenv("PASKIA_DB", str(tmp_path / "test-avatar-db.paskiadb"))
|
||||
|
||||
upload_bytes = create_test_image_bytes()
|
||||
|
||||
response = await client.put(
|
||||
f"/auth/api/user/{test_user.uuid}/profile.webp",
|
||||
files={"file": ("avatar.webp", upload_bytes, "image/webp")},
|
||||
headers={**auth_headers(session_token), "Host": "localhost:4401"},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
avatar_url = data["avatar_url"]
|
||||
parts = urlsplit(avatar_url)
|
||||
assert parts.query == ""
|
||||
|
||||
avatar_response = await client.get(
|
||||
parts.path,
|
||||
headers={"Host": "localhost:4401"},
|
||||
)
|
||||
assert avatar_response.status_code == 200
|
||||
assert avatar_response.headers["cache-control"] == "public, max-age=300"
|
||||
assert avatar_response.headers["content-type"] == "image/webp"
|
||||
assert "etag" in avatar_response.headers
|
||||
assert avatar_response.content == upload_bytes
|
||||
|
||||
not_modified = await client.get(
|
||||
parts.path,
|
||||
headers={
|
||||
"Host": "localhost:4401",
|
||||
"If-None-Match": avatar_response.headers["etag"],
|
||||
},
|
||||
)
|
||||
assert not_modified.status_code == 304
|
||||
assert not_modified.headers["etag"] == avatar_response.headers["etag"]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_upload_avatar_rejects_non_webp(
|
||||
self,
|
||||
client: httpx.AsyncClient,
|
||||
session_token: str,
|
||||
test_user,
|
||||
tmp_path,
|
||||
monkeypatch,
|
||||
):
|
||||
"""Avatar uploads must already be browser-prepared WebP."""
|
||||
monkeypatch.setenv("PASKIA_DB", str(tmp_path / "test-avatar-db.paskiadb"))
|
||||
|
||||
response = await client.put(
|
||||
f"/auth/api/user/{test_user.uuid}/profile.webp",
|
||||
files={
|
||||
"file": (
|
||||
"avatar.png",
|
||||
create_test_image_bytes(image_format="PNG"),
|
||||
"image/png",
|
||||
)
|
||||
},
|
||||
headers={**auth_headers(session_token), "Host": "localhost:4401"},
|
||||
)
|
||||
|
||||
assert response.status_code == 400
|
||||
assert response.json()["detail"] == "Avatar upload must be WebP"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delete_avatar_success(
|
||||
self,
|
||||
client: httpx.AsyncClient,
|
||||
session_token: str,
|
||||
test_user,
|
||||
tmp_path,
|
||||
monkeypatch,
|
||||
):
|
||||
"""Deleting avatar should clear the user avatar URL."""
|
||||
monkeypatch.setenv("PASKIA_DB", str(tmp_path / "test-avatar-db.paskiadb"))
|
||||
|
||||
await client.put(
|
||||
f"/auth/api/user/{test_user.uuid}/profile.webp",
|
||||
files={"file": ("avatar.webp", create_test_image_bytes(), "image/webp")},
|
||||
headers={**auth_headers(session_token), "Host": "localhost:4401"},
|
||||
)
|
||||
|
||||
response = await client.delete(
|
||||
f"/auth/api/user/{test_user.uuid}/profile.webp",
|
||||
headers={**auth_headers(session_token), "Host": "localhost:4401"},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
|
||||
info = await client.get(
|
||||
"/auth/api/user-info",
|
||||
headers={**auth_headers(session_token), "Host": "localhost:4401"},
|
||||
)
|
||||
assert info.status_code == 200
|
||||
assert info.json()["user"].get("avatar_url") is None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_regular_user_cannot_upload_another_users_avatar(
|
||||
self,
|
||||
client: httpx.AsyncClient,
|
||||
regular_session_token: str,
|
||||
session_token: str,
|
||||
test_user,
|
||||
):
|
||||
"""A non-admin user should not be able to upload another user's avatar."""
|
||||
response = await client.put(
|
||||
f"/auth/api/user/{test_user.uuid}/profile.webp",
|
||||
files={"file": ("avatar.webp", create_test_image_bytes(), "image/webp")},
|
||||
headers={**auth_headers(regular_session_token), "Host": "localhost:4401"},
|
||||
)
|
||||
assert response.status_code == 403
|
||||
|
||||
|
||||
def test_paskia_db_legacy_file_is_migrated_to_root_dir(tmp_path, monkeypatch):
|
||||
legacy_path = tmp_path / "legacy.paskiadb"
|
||||
legacy_bytes = b'{"v":0}\n'
|
||||
legacy_path.write_bytes(legacy_bytes)
|
||||
|
||||
monkeypatch.setenv("PASKIA_DB", str(legacy_path))
|
||||
|
||||
db_path = db_file_path(create_root=True)
|
||||
|
||||
assert legacy_path.is_dir()
|
||||
assert db_path == legacy_path / "main.db"
|
||||
assert db_path.read_bytes() == legacy_bytes
|
||||
|
||||
|
||||
def test_paskia_db_root_uses_users_directory(tmp_path, monkeypatch):
|
||||
root_path = tmp_path / "instance-root"
|
||||
monkeypatch.setenv("PASKIA_DB", str(root_path))
|
||||
|
||||
users_path = users_root_path(create_root=True)
|
||||
|
||||
assert users_path == root_path / "users"
|
||||
assert users_path.parent == root_path
|
||||
|
||||
|
||||
class TestUserLogoutAll:
|
||||
"""Tests for POST /auth/api/user/logout-all"""
|
||||
|
||||
|
||||
Reference in New Issue
Block a user