Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 |
@@ -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:
|
Install [UV](https://docs.astral.sh/uv/getting-started/installation/) and run:
|
||||||
|
|
||||||
```fish
|
```sh
|
||||||
uvx paskia --rp-id example.com
|
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`:
|
For a permanent install of `paskia` CLI command, not needing `uvx`:
|
||||||
|
|
||||||
```fish
|
```sh
|
||||||
uv tool install paskia
|
uv tool install paskia
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -72,27 +72,17 @@ To clear a stored setting, pass an empty value like `--auth-host=`. The database
|
|||||||
|
|
||||||
This section walks you through a complete example, from running Paskia locally to protecting a real site in production.
|
This section walks you through a complete example, from running Paskia locally to protecting a real site in production.
|
||||||
|
|
||||||
### Step 1: Local Testing
|
### Step 1: Production Configuration
|
||||||
|
|
||||||
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
|
|
||||||
|
|
||||||
For a real deployment, configure Paskia with your domain name (rp-id). This enables SSO setup for that domain and any subdomains.
|
For a real deployment, configure Paskia with your domain name (rp-id). This enables SSO setup for that domain and any subdomains.
|
||||||
|
|
||||||
```fish
|
```sh
|
||||||
paskia --rp-id=example.com --rp-name="Example Corp" --save
|
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:
|
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.
|
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).
|
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):
|
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:
|
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
|
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
|
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
|
sudo systemctl edit --force --full paskia@.service
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -190,28 +190,20 @@ Description=Paskia for %i
|
|||||||
Type=simple
|
Type=simple
|
||||||
User=paskia
|
User=paskia
|
||||||
WorkingDirectory=/srv/paskia
|
WorkingDirectory=/srv/paskia
|
||||||
ExecStart=uvx paskia --rp-id=%i
|
ExecStart=uvx paskia@latest --rp-id=%i
|
||||||
|
|
||||||
[Install]
|
[Install]
|
||||||
WantedBy=multi-user.target
|
WantedBy=multi-user.target
|
||||||
```
|
```
|
||||||
|
|
||||||
Then enable and start, view output for registration link:
|
Run the service and view log:
|
||||||
|
|
||||||
```fish
|
```sh
|
||||||
sudo systemctl enable --now paskia@example.com && sudo journalctl -u paskia@example.com -f -n 30 -o cat
|
sudo systemctl enable --now paskia@example.com && sudo journalctl -n30 -ocat -fu paskia@example.com
|
||||||
```
|
```
|
||||||
|
|
||||||
### Optional: Dedicated Authentication Site
|
### 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:
|
Add a Caddy configuration for the authentication domain:
|
||||||
|
|
||||||
```caddyfile
|
```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
|
## Further Documentation
|
||||||
|
|||||||
+32
@@ -40,6 +40,38 @@ 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.
|
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 |
|
||||||
|
|
||||||
### WebSockets: `/auth/ws/*`
|
### WebSockets: `/auth/ws/*`
|
||||||
|
|
||||||
| Path | Used for | Notes |
|
| Path | Used for | Notes |
|
||||||
|
|||||||
@@ -216,7 +216,7 @@ test.describe('API Mode - 401 Login Flow', () => {
|
|||||||
await clearSessionCookie(page)
|
await clearSessionCookie(page)
|
||||||
|
|
||||||
// Make API call that triggers 401 (don't await - it blocks until iframe resolves)
|
// 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')
|
console.log('✓ Auth iframe appeared on 401')
|
||||||
|
|
||||||
// Verify it's in login mode (not reauth)
|
// Verify it's in login mode (not reauth)
|
||||||
@@ -268,7 +268,7 @@ test.describe('API Mode - 401 Login Flow', () => {
|
|||||||
await setupTestHarness(page)
|
await setupTestHarness(page)
|
||||||
|
|
||||||
// Make API call that triggers 401
|
// 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
|
// Wait for auth iframe to appear
|
||||||
await waitForAuthIframe(page)
|
await waitForAuthIframe(page)
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { spawn } from 'child_process'
|
import { execSync, spawn } from 'child_process'
|
||||||
import { join, dirname } from 'path'
|
import { join, dirname } from 'path'
|
||||||
import { existsSync, mkdirSync, writeFileSync } from 'fs'
|
import { existsSync, mkdirSync, writeFileSync } from 'fs'
|
||||||
import { fileURLToPath } from 'url'
|
import { fileURLToPath } from 'url'
|
||||||
@@ -31,6 +31,11 @@ export default async function globalSetup() {
|
|||||||
mkdirSync(testDataDir, { recursive: true })
|
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...')
|
console.log(' Starting server with in-memory database...')
|
||||||
if (COLLECT_COVERAGE) {
|
if (COLLECT_COVERAGE) {
|
||||||
console.log(' 📊 Coverage collection enabled for Python backend')
|
console.log(' 📊 Coverage collection enabled for Python backend')
|
||||||
|
|||||||
+21
-72
@@ -13,8 +13,8 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { computed, onMounted, onUnmounted, ref } from 'vue'
|
import { computed, onMounted, onUnmounted, ref } from 'vue'
|
||||||
import { useAuthStore } from '@/stores/auth'
|
import { useAuthStore } from '@/stores/auth'
|
||||||
import { apiJson, SessionValidator, createAuthIframe, removeAuthIframe } from 'paskia'
|
import { apiJson, SessionValidator } from 'paskia'
|
||||||
import { getAuthIframeUrl } from '@/utils/api'
|
import { updateThemeFromSession } from '@/utils/theme'
|
||||||
import StatusMessage from '@/components/StatusMessage.vue'
|
import StatusMessage from '@/components/StatusMessage.vue'
|
||||||
import ProfileView from '@/components/ProfileView.vue'
|
import ProfileView from '@/components/ProfileView.vue'
|
||||||
import HostProfileView from '@/components/HostProfileView.vue'
|
import HostProfileView from '@/components/HostProfileView.vue'
|
||||||
@@ -48,90 +48,49 @@ const isHostMode = computed(() => {
|
|||||||
return currentHost !== configuredHost
|
return currentHost !== configuredHost
|
||||||
})
|
})
|
||||||
|
|
||||||
function terminateSession() {
|
function onSessionLost(e) {
|
||||||
store.userInfo = null
|
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 userUuidGetter = () => store.ctx?.user.uuid
|
||||||
const sessionValidator = new SessionValidator(userUuidGetter, terminateSession)
|
const sessionValidator = new SessionValidator(userUuidGetter, onSessionLost)
|
||||||
|
|
||||||
onMounted(() => sessionValidator.start())
|
onMounted(() => sessionValidator.start())
|
||||||
onUnmounted(() => sessionValidator.stop())
|
onUnmounted(() => sessionValidator.stop())
|
||||||
|
|
||||||
async function loadUserInfo() {
|
async function loadUserInfo() {
|
||||||
|
viewState.value = 'loading'
|
||||||
|
loadingMessage.value = 'Loading...'
|
||||||
try {
|
try {
|
||||||
|
// apiJson handles 401/403 with auth.iframe automatically:
|
||||||
|
// shows overlay iframe, waits for auth, retries the request.
|
||||||
const [validateData, userInfoData] = await Promise.all([
|
const [validateData, userInfoData] = await Promise.all([
|
||||||
apiJson('/auth/api/validate', { method: 'POST' }),
|
apiJson('/auth/api/validate', { method: 'POST' }),
|
||||||
apiJson('/auth/api/user-info', { method: 'GET' })
|
apiJson('/auth/api/user-info', { method: 'GET' })
|
||||||
])
|
])
|
||||||
store.userInfo = userInfoData
|
store.userInfo = userInfoData
|
||||||
store.ctx = validateData.ctx
|
store.ctx = validateData.ctx
|
||||||
|
updateThemeFromSession(store.userInfo)
|
||||||
// Verify that the user UUIDs match between user-info and validate responses
|
// Verify that the user UUIDs match between user-info and validate responses
|
||||||
if (store.userInfo.user.uuid !== store.ctx.user.uuid) {
|
if (store.userInfo.user.uuid !== store.ctx.user.uuid) {
|
||||||
console.error('User UUID mismatch between user-info and validate responses')
|
console.error('User UUID mismatch between user-info and validate responses')
|
||||||
window.location.reload()
|
window.location.reload()
|
||||||
return false
|
return
|
||||||
}
|
}
|
||||||
viewState.value = 'profile'
|
viewState.value = 'profile'
|
||||||
return true
|
} catch (e) {
|
||||||
} catch {
|
onSessionLost(e)
|
||||||
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
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
// Listen for postMessage from auth iframe
|
|
||||||
window.addEventListener('message', handleAuthMessage)
|
|
||||||
|
|
||||||
// Load settings
|
// Load settings
|
||||||
await store.loadSettings()
|
await store.loadSettings()
|
||||||
|
|
||||||
@@ -145,17 +104,7 @@ onMounted(async () => {
|
|||||||
document.title = inHostMode ? `${rpName} · Account summary` : rpName
|
document.title = inHostMode ? `${rpName} · Account summary` : rpName
|
||||||
}
|
}
|
||||||
|
|
||||||
// Try to load user info
|
// Load user info (apiJson handles auth iframe if needed)
|
||||||
const success = await loadUserInfo()
|
await loadUserInfo()
|
||||||
|
|
||||||
if (!success) {
|
|
||||||
// Need authentication - show login iframe
|
|
||||||
showAuthIframe()
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
onUnmounted(() => {
|
|
||||||
window.removeEventListener('message', handleAuthMessage)
|
|
||||||
removeAuthIframe()
|
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import AdminDialogs from '@/admin/AdminDialogs.vue'
|
|||||||
import { useAuthStore } from '@/stores/auth'
|
import { useAuthStore } from '@/stores/auth'
|
||||||
import { adminUiPath, makeUiHref } from '@/utils/settings'
|
import { adminUiPath, makeUiHref } from '@/utils/settings'
|
||||||
import { apiJson, SessionValidator } from 'paskia'
|
import { apiJson, SessionValidator } from 'paskia'
|
||||||
|
import { updateThemeFromSession } from '@/utils/theme'
|
||||||
import { uuidv7 } from 'uuidv7'
|
import { uuidv7 } from 'uuidv7'
|
||||||
import { getDirection } from '@/utils/keynav'
|
import { getDirection } from '@/utils/keynav'
|
||||||
import { goBack } from '@/utils/helpers'
|
import { goBack } from '@/utils/helpers'
|
||||||
@@ -197,6 +198,7 @@ function orgUserCount(org) {
|
|||||||
async function loadUserInfo() {
|
async function loadUserInfo() {
|
||||||
const data = await apiJson('/auth/api/validate', { method: 'POST' })
|
const data = await apiJson('/auth/api/validate', { method: 'POST' })
|
||||||
info.value = data
|
info.value = data
|
||||||
|
updateThemeFromSession(data.ctx)
|
||||||
authenticated.value = true
|
authenticated.value = true
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -337,24 +339,9 @@ async function moveUserToRole(userUuid, user, targetRoleUuid) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function onUserDragStart(e, userUuid, org) {
|
function moveUserToRoleFromDrag(userUuid, newRoleUuid) {
|
||||||
e.dataTransfer.effectAllowed = 'move'
|
const user = selectedOrg.value?.users?.[userUuid]
|
||||||
e.dataTransfer.setData('text/plain', JSON.stringify({ user_uuid: userUuid, org }))
|
if (user) moveUserToRole(userUuid, user, newRoleUuid)
|
||||||
}
|
|
||||||
|
|
||||||
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 */ }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Role actions
|
// Role actions
|
||||||
@@ -475,6 +462,23 @@ function createPermissionForClient(clientId) {
|
|||||||
openDialog('perm-create', { display_name: '', scope: '', domain: 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) {
|
function deleteOidcClient(client) {
|
||||||
openDialog('confirm', {
|
openDialog('confirm', {
|
||||||
message: `Delete OIDC client "${client.name}"? This will break any applications using this client.`,
|
message: `Delete OIDC client "${client.name}"? This will break any applications using this client.`,
|
||||||
@@ -899,6 +903,27 @@ async function submitDialog() {
|
|||||||
authStore.showMessage(e.message || `Failed to ${isNew ? 'create' : 'update'} OIDC client`, 'error')
|
authStore.showMessage(e.message || `Failed to ${isNew ? 'create' : 'update'} OIDC client`, 'error')
|
||||||
})
|
})
|
||||||
return // Don't call closeDialog() again
|
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') {
|
} else if (t === 'confirm') {
|
||||||
const action = dialog.value.data.action
|
const action = dialog.value.data.action
|
||||||
// Close dialog first, then perform action (errors shown via showMessage)
|
// Close dialog first, then perform action (errors shown via showMessage)
|
||||||
@@ -964,6 +989,7 @@ async function submitDialog() {
|
|||||||
@create-oidc-client="createOidcClient"
|
@create-oidc-client="createOidcClient"
|
||||||
@open-oidc-client="openOidcClient"
|
@open-oidc-client="openOidcClient"
|
||||||
@delete-oidc-client="deleteOidcClient"
|
@delete-oidc-client="deleteOidcClient"
|
||||||
|
@open-server-config="openServerConfig"
|
||||||
@navigate-out="handlePanelNavigateOut"
|
@navigate-out="handlePanelNavigateOut"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
@@ -999,10 +1025,8 @@ async function submitDialog() {
|
|||||||
@create-user-in-role="createUserInRole"
|
@create-user-in-role="createUserInRole"
|
||||||
@open-user="openUser"
|
@open-user="openUser"
|
||||||
@toggle-role-permission="toggleRolePermission"
|
@toggle-role-permission="toggleRolePermission"
|
||||||
@on-role-drag-over="onRoleDragOver"
|
@move-user-to-role="moveUserToRoleFromDrag"
|
||||||
@navigate-out="handlePanelNavigateOut"
|
@navigate-out="handlePanelNavigateOut"
|
||||||
@on-role-drop="onRoleDrop"
|
|
||||||
@on-user-drag-start="onUserDragStart"
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<AdminOidcDetail
|
<AdminOidcDetail
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<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">
|
<link rel="stylesheet" href="/src/assets/style.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<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'
|
import { applyTheme, getCachedTheme } from '@/utils/theme.js'
|
||||||
|
|
||||||
function getTheme() {
|
function getTheme() {
|
||||||
const params = new URLSearchParams(location.hash.slice(1))
|
const params = new URLSearchParams(location.hash.slice(1))
|
||||||
return params.get('theme') || getCachedTheme() || ''
|
return getCachedTheme() || params.get('theme') || ''
|
||||||
}
|
}
|
||||||
|
|
||||||
// Apply theme class to document root
|
// Apply theme class to document root
|
||||||
|
|||||||
@@ -8,7 +8,7 @@
|
|||||||
|
|
||||||
<main class="view-root">
|
<main class="view-root">
|
||||||
<div class="surface surface--tight reset-container">
|
<div class="surface surface--tight reset-container">
|
||||||
<header class="view-header reset-header">
|
<header class="view-header center">
|
||||||
<h1>🔑 Registration</h1>
|
<h1>🔑 Registration</h1>
|
||||||
<p class="view-lede">
|
<p class="view-lede">
|
||||||
{{ subtitleMessage }}
|
{{ subtitleMessage }}
|
||||||
@@ -60,6 +60,7 @@ import { computed, onMounted, reactive, ref } from 'vue'
|
|||||||
import passkey from '@/utils/passkey'
|
import passkey from '@/utils/passkey'
|
||||||
import { getSettings, uiBasePath } from '@/utils/settings'
|
import { getSettings, uiBasePath } from '@/utils/settings'
|
||||||
import { apiJson, ApiError, getUserFriendlyErrorMessage } from 'paskia'
|
import { apiJson, ApiError, getUserFriendlyErrorMessage } from 'paskia'
|
||||||
|
import { updateThemeFromSession } from '@/utils/theme'
|
||||||
|
|
||||||
const status = reactive({
|
const status = reactive({
|
||||||
show: false,
|
show: false,
|
||||||
@@ -80,7 +81,7 @@ const sessionDescriptor = computed(() => tokenInfo.value?.token_type || 'your en
|
|||||||
const subtitleMessage = computed(() => {
|
const subtitleMessage = computed(() => {
|
||||||
if (initializing.value) return 'Preparing your secure enrollment…'
|
if (initializing.value) return 'Preparing your secure enrollment…'
|
||||||
if (!canRegister.value) return 'This authentication link is no longer valid.'
|
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())
|
const basePath = computed(() => uiBasePath())
|
||||||
@@ -117,6 +118,7 @@ async function fetchTokenInfo() {
|
|||||||
headers: { 'Authorization': `Bearer ${token.value}` },
|
headers: { 'Authorization': `Bearer ${token.value}` },
|
||||||
})
|
})
|
||||||
displayName.value = tokenInfo.value.display_name
|
displayName.value = tokenInfo.value.display_name
|
||||||
|
if (tokenInfo.value.theme) updateThemeFromSession({ user: { theme: tokenInfo.value.theme } })
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to load token info', error)
|
console.error('Failed to load token info', error)
|
||||||
const message = error instanceof ApiError
|
const message = error instanceof ApiError
|
||||||
@@ -201,14 +203,14 @@ onMounted(async () => {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
main.view-root { min-height: 100vh; align-items: center; justify-content: center; padding: 2rem 1rem; }
|
||||||
.reset-container {
|
.reset-container {
|
||||||
max-width: 560px;
|
max-width: 520px;
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
.reset-header {
|
gap: 1.75rem;
|
||||||
text-align: center;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.section-body {
|
.section-body {
|
||||||
|
|||||||
@@ -15,7 +15,8 @@
|
|||||||
"qrcode": "^1.5.4",
|
"qrcode": "^1.5.4",
|
||||||
"sirv": "^3.0.2",
|
"sirv": "^3.0.2",
|
||||||
"uuidv7": "^1.1.0",
|
"uuidv7": "^1.1.0",
|
||||||
"vue": "^3.5.17"
|
"vue": "^3.5.17",
|
||||||
|
"vuedraggable": "^4.1.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@vitejs/plugin-vue": "^6.0.0",
|
"@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 rpId = computed(() => props.settings?.rp_id || 'the configured domain')
|
||||||
const discoveryUrl = computed(() => `${window.location.origin}/.well-known/openid-configuration`)
|
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
|
// Copy-to-clipboard helper
|
||||||
const authStore = useAuthStore()
|
const authStore = useAuthStore()
|
||||||
function copyText(value, label) {
|
function copyText(value, label) {
|
||||||
@@ -24,6 +39,135 @@ function copyText(value, label) {
|
|||||||
authStore.showMessage(`${label} copied to clipboard`, 'success', 1500)
|
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>
|
</script>
|
||||||
|
|
||||||
<template>
|
<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==='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==='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==='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>
|
<template v-else-if="dialog.type==='confirm'">Confirm</template>
|
||||||
</h3>
|
</h3>
|
||||||
<form @submit.prevent="$emit('submitDialog')" class="modal-form">
|
<form @submit.prevent="$emit('submitDialog')" class="modal-form">
|
||||||
@@ -96,6 +241,38 @@ function copyText(value, label) {
|
|||||||
</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>
|
<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>
|
||||||
|
<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'">
|
<template v-else-if="dialog.type==='confirm'">
|
||||||
<p>{{ dialog.data.message }}</p>
|
<p>{{ dialog.data.message }}</p>
|
||||||
</template>
|
</template>
|
||||||
@@ -112,7 +289,7 @@ function copyText(value, label) {
|
|||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
class="btn-primary"
|
class="btn-primary"
|
||||||
:disabled="dialog.busy"
|
:disabled="dialog.busy || isValidationInvalid"
|
||||||
>
|
>
|
||||||
{{ dialog.type==='confirm' ? 'OK' : 'Save' }}
|
{{ dialog.type==='confirm' ? 'OK' : 'Save' }}
|
||||||
</button>
|
</button>
|
||||||
@@ -141,4 +318,17 @@ function copyText(value, label) {
|
|||||||
.oidc-groups { cursor: default; }
|
.oidc-groups { cursor: default; }
|
||||||
.oidc-group { cursor: pointer; }
|
.oidc-group { cursor: pointer; }
|
||||||
.oidc-group output { white-space: normal; word-break: break-all; }
|
.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>
|
</style>
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { computed, ref } from 'vue'
|
import { computed, ref } from 'vue'
|
||||||
|
import draggable from 'vuedraggable'
|
||||||
import { getDirection, navigateButtonRow, focusPreferred } from '@/utils/keynav'
|
import { getDirection, navigateButtonRow, focusPreferred } from '@/utils/keynav'
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
@@ -8,7 +9,7 @@ const props = defineProps({
|
|||||||
navigationDisabled: { type: Boolean, default: false }
|
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
|
// Template refs for navigation
|
||||||
const orgTitleRef = ref(null)
|
const orgTitleRef = ref(null)
|
||||||
@@ -50,6 +51,14 @@ function roleUserCount(roleUuid) {
|
|||||||
return Object.values(props.selectedOrg.users).filter(u => u.role === roleUuid).length
|
return Object.values(props.selectedOrg.users).filter(u => u.role === roleUuid).length
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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 permissionDisplayName(scope) {
|
function permissionDisplayName(scope) {
|
||||||
return props.permissions.find(p => p.scope === scope)?.display_name || scope
|
return props.permissions.find(p => p.scope === scope)?.display_name || scope
|
||||||
}
|
}
|
||||||
@@ -350,8 +359,6 @@ defineExpose({ focusFirstElement })
|
|||||||
v-for="(r, roleIndex) in sortedRoles"
|
v-for="(r, roleIndex) in sortedRoles"
|
||||||
:key="r.uuid"
|
:key="r.uuid"
|
||||||
class="role-column"
|
class="role-column"
|
||||||
@dragover="$emit('onRoleDragOver', $event)"
|
|
||||||
@drop="e => $emit('onRoleDrop', e, selectedOrg, r)"
|
|
||||||
>
|
>
|
||||||
<div class="role-header" @keydown="e => handleRoleHeaderKeydown(e, roleIndex)">
|
<div class="role-header" @keydown="e => handleRoleHeaderKeydown(e, roleIndex)">
|
||||||
<strong class="role-name" :title="r.uuid">
|
<strong class="role-name" :title="r.uuid">
|
||||||
@@ -363,26 +370,32 @@ defineExpose({ focusFirstElement })
|
|||||||
<button @click="$emit('createUserInRole', selectedOrg, r)" class="plus-btn" aria-label="Add user" title="Add user">➕</button>
|
<button @click="$emit('createUserInRole', selectedOrg, r)" class="plus-btn" aria-label="Add user" title="Add user">➕</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<template v-if="roleUserCount(r.uuid) > 0">
|
<div class="user-list-wrapper">
|
||||||
<ul class="user-list" @keydown="handleUserListKeydown">
|
<draggable
|
||||||
<li
|
:list="roleUsers(r.uuid)"
|
||||||
v-for="u in roleUsers(r.uuid)"
|
group="users"
|
||||||
:key="u.uuid"
|
item-key="uuid"
|
||||||
class="user-chip"
|
tag="ul"
|
||||||
tabindex="0"
|
class="user-list"
|
||||||
draggable="true"
|
@change="evt => onUserChange(evt, r.uuid)"
|
||||||
@dragstart="e => $emit('onUserDragStart', e, u.uuid, selectedOrg.uuid)"
|
@keydown="handleUserListKeydown"
|
||||||
@click="$emit('openUser', u)"
|
>
|
||||||
@keydown.enter="$emit('openUser', u)"
|
<template #item="{ element: u }">
|
||||||
:title="u.uuid"
|
<li
|
||||||
>
|
class="user-chip"
|
||||||
<span class="name">{{ u.display_name }}</span>
|
tabindex="0"
|
||||||
<span class="meta">{{ u.last_seen ? new Date(u.last_seen).toLocaleDateString() : '—' }}</span>
|
@click="$emit('openUser', u)"
|
||||||
</li>
|
@keydown.enter="$emit('openUser', u)"
|
||||||
</ul>
|
:title="u.uuid"
|
||||||
</template>
|
>
|
||||||
<div v-else class="empty-role">
|
<span class="name">{{ u.display_name }}</span>
|
||||||
<p class="empty-text muted">No members</p>
|
<span class="meta">{{ u.last_seen ? new Date(u.last_seen).toLocaleDateString() : '—' }}</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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -391,23 +404,27 @@ defineExpose({ focusFirstElement })
|
|||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.card.surface { padding: var(--space-lg); }
|
.card.surface { padding: var(--space-lg); }
|
||||||
.org-title { display: flex; align-items: center; gap: var(--space-sm); margin-bottom: var(--space-lg); }
|
.org-title { display: flex; align-items: center; gap: var(--space-sm); margin-bottom: var(--space-lg); font-size: 1.65rem; }
|
||||||
.org-name { font-size: 1.5rem; font-weight: 600; color: var(--color-heading); }
|
.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 { 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 .role-head span { writing-mode: vertical-rl; transform: rotate(180deg); font-size: 0.65rem; }
|
||||||
.perm-matrix-grid .add-role-head { cursor: pointer; }
|
.perm-matrix-grid .add-role-head { cursor: pointer; }
|
||||||
.roles-grid { display: flex; gap: var(--space-lg); margin-top: var(--space-lg); }
|
.roles-grid { display: flex; flex-wrap: wrap; gap: var(--space-lg); margin-top: var(--space-lg); justify-content: flex-start; align-items: stretch; }
|
||||||
.role-column { flex: 1; min-width: 200px; border-radius: var(--radius-md); padding: var(--space-md); }
|
.role-column { flex: 0 0 240px; 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-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-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); }
|
.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 { 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); }
|
.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-list-wrapper { position: relative; flex: 1; display: flex; flex-direction: column; min-height: 5.5rem; }
|
||||||
.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 { 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: white; border: none; border-radius: var(--radius-md); padding: 0.45rem 0.6rem; display: flex; justify-content: space-between; gap: var(--space-sm); cursor: grab; }
|
||||||
.user-chip:focus { outline: 2px solid var(--color-accent); outline-offset: 1px; }
|
.user-chip:focus { outline: 2px solid var(--color-accent); outline-offset: 1px; }
|
||||||
.user-chip .meta { font-size: 0.7rem; color: var(--color-text-muted); }
|
.user-chip .meta { font-size: 0.7rem; color: rgba(255, 255, 255, 0.8); }
|
||||||
.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.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; }
|
.empty-text { margin: 0; }
|
||||||
|
|
||||||
@media (max-width: 720px) {
|
@media (max-width: 720px) {
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ const props = defineProps({
|
|||||||
navigationDisabled: { type: Boolean, default: false }
|
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
|
// Template refs for navigation
|
||||||
const orgSection = ref(null)
|
const orgSection = ref(null)
|
||||||
@@ -431,6 +431,16 @@ defineExpose({ focusFirstElement })
|
|||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</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>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
@@ -455,4 +465,8 @@ defineExpose({ focusFirstElement })
|
|||||||
.oidc-clients-section { margin-bottom: var(--space-xl); margin-top: var(--space-2xl); }
|
.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); }
|
.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); }
|
.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>
|
</style>
|
||||||
|
|||||||
@@ -823,9 +823,6 @@ th {
|
|||||||
|
|
||||||
.user-info {
|
.user-info {
|
||||||
display: grid;
|
display: grid;
|
||||||
border-radius: var(--radius-md);
|
|
||||||
background: var(--color-surface);
|
|
||||||
padding: 1rem;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.user-details {
|
.user-details {
|
||||||
|
|||||||
@@ -10,9 +10,9 @@
|
|||||||
<UserBasicInfo
|
<UserBasicInfo
|
||||||
v-if="ctx"
|
v-if="ctx"
|
||||||
:name="ctx.user.display_name"
|
:name="ctx.user.display_name"
|
||||||
:visits="authStore.userInfo?.visits || 0"
|
:visits="authStore.userInfo.user.visits"
|
||||||
:created-at="authStore.userInfo?.created_at"
|
:created-at="authStore.userInfo.user.created_at"
|
||||||
:last-seen="authStore.userInfo?.last_seen"
|
:last-seen="authStore.userInfo.user.last_seen"
|
||||||
:email="ctx.user.email"
|
:email="ctx.user.email"
|
||||||
:telephone="ctx.user.telephone"
|
:telephone="ctx.user.telephone"
|
||||||
:org-display-name="orgDisplayName"
|
:org-display-name="orgDisplayName"
|
||||||
|
|||||||
@@ -22,8 +22,8 @@
|
|||||||
:created-at="authStore.userInfo.user.created_at"
|
:created-at="authStore.userInfo.user.created_at"
|
||||||
:last-seen="authStore.userInfo.user.last_seen"
|
:last-seen="authStore.userInfo.user.last_seen"
|
||||||
:loading="authStore.isLoading"
|
:loading="authStore.isLoading"
|
||||||
:org-display-name="authStore.ctx?.org.display_name"
|
:org-display-name="authStore.userInfo.org.display_name"
|
||||||
:role-name="authStore.ctx?.role.display_name"
|
:role-name="authStore.userInfo.role.display_name"
|
||||||
update-endpoint="/auth/api/user/info"
|
update-endpoint="/auth/api/user/info"
|
||||||
@saved="authStore.loadUserInfo()"
|
@saved="authStore.loadUserInfo()"
|
||||||
@edit="openEditDialog"
|
@edit="openEditDialog"
|
||||||
@@ -53,7 +53,7 @@
|
|||||||
<CredentialList
|
<CredentialList
|
||||||
ref="credentialList"
|
ref="credentialList"
|
||||||
:credentials="credentials"
|
:credentials="credentials"
|
||||||
:aaguid-info="authStore.userInfo?.aaguid_info || {}"
|
:aaguid-info="authStore.userInfo.aaguid_info"
|
||||||
:loading="authStore.isLoading"
|
:loading="authStore.isLoading"
|
||||||
:hovered-credential-uuid="hoveredCredentialUuid"
|
:hovered-credential-uuid="hoveredCredentialUuid"
|
||||||
:hovered-session-credential-uuid="hoveredSession?.credential"
|
:hovered-session-credential-uuid="hoveredSession?.credential"
|
||||||
@@ -184,11 +184,11 @@ const hasActiveModal = computed(() => showEditDialog.value || showRegLink.value)
|
|||||||
|
|
||||||
watch(showEditDialog, (open) => {
|
watch(showEditDialog, (open) => {
|
||||||
if (!open) return
|
if (!open) return
|
||||||
const user = authStore.userInfo?.user
|
const user = authStore.userInfo.user
|
||||||
editName.value = user?.display_name ?? ''
|
editName.value = user.display_name ?? ''
|
||||||
editEmail.value = user?.email ?? ''
|
editEmail.value = user.email ?? ''
|
||||||
editUsername.value = user?.preferred_username ?? ''
|
editUsername.value = user.preferred_username ?? ''
|
||||||
editTelephone.value = user?.telephone ?? ''
|
editTelephone.value = user.telephone ?? ''
|
||||||
editError.value = ''
|
editError.value = ''
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -341,7 +341,7 @@ const handleDelete = async (credential) => {
|
|||||||
|
|
||||||
const rpName = computed(() => authStore.settings?.rp_name || 'this service')
|
const rpName = computed(() => authStore.settings?.rp_name || 'this service')
|
||||||
const paskiaVersion = computed(() => authStore.settings?.version || '')
|
const paskiaVersion = computed(() => authStore.settings?.version || '')
|
||||||
const sessions = computed(() => authStore.userInfo?.sessions || {})
|
const sessions = computed(() => authStore.userInfo.sessions)
|
||||||
const currentSessionHost = computed(() => {
|
const currentSessionHost = computed(() => {
|
||||||
const currentSession = Object.values(sessions.value).find(session => session.is_current)
|
const currentSession = Object.values(sessions.value).find(session => session.is_current)
|
||||||
return currentSession?.host || 'this host'
|
return currentSession?.host || 'this host'
|
||||||
@@ -365,12 +365,12 @@ const logoutEverywhere = async () => { await authStore.logoutEverywhere() }
|
|||||||
const logout = async () => { await authStore.logout() }
|
const logout = async () => { await authStore.logout() }
|
||||||
const openEditDialog = () => { showEditDialog.value = true }
|
const openEditDialog = () => { showEditDialog.value = true }
|
||||||
const isAdmin = computed(() => {
|
const isAdmin = computed(() => {
|
||||||
const perms = authStore.ctx?.permissions
|
const perms = Object.values(authStore.userInfo.permissions).map(p => p.scope)
|
||||||
return perms?.includes('auth:admin') || perms?.includes('auth:org:admin')
|
return perms.includes('auth:admin') || perms.includes('auth:org:admin')
|
||||||
})
|
})
|
||||||
const hasMultipleSessions = computed(() => Object.keys(sessions.value).length > 1)
|
const hasMultipleSessions = computed(() => Object.keys(sessions.value).length > 1)
|
||||||
const credentials = computed(() =>
|
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(() => {
|
const useWideLayout = computed(() => {
|
||||||
// Check if any single site has more than 8 sessions
|
// Check if any single site has more than 8 sessions
|
||||||
|
|||||||
@@ -37,7 +37,7 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { ref, computed, onMounted, onUnmounted, nextTick } from 'vue'
|
import { ref, computed, onMounted, onUnmounted, nextTick } from 'vue'
|
||||||
import QRCodeDisplay from '@/components/QRCodeDisplay.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 { formatDate } from '@/utils/helpers'
|
||||||
import { getDirection } from '@/utils/keynav'
|
import { getDirection } from '@/utils/keynav'
|
||||||
import { useAuthStore } from '@/stores/auth'
|
import { useAuthStore } from '@/stores/auth'
|
||||||
@@ -90,7 +90,9 @@ async function generateLink() {
|
|||||||
emit('close')
|
emit('close')
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} 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')
|
emit('close')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -163,6 +163,9 @@ async function startRemoteAuth() {
|
|||||||
|
|
||||||
// PoW challenge
|
// PoW challenge
|
||||||
const powChallenge = await ws.receive_json()
|
const powChallenge = await ws.receive_json()
|
||||||
|
if (powChallenge.status) {
|
||||||
|
throw new Error(powChallenge.detail || `Failed to connect: ${powChallenge.status}`)
|
||||||
|
}
|
||||||
if (powChallenge.pow) {
|
if (powChallenge.pow) {
|
||||||
const challenge = b64dec(powChallenge.pow.challenge)
|
const challenge = b64dec(powChallenge.pow.challenge)
|
||||||
const nonces = await solvePoW(challenge, powChallenge.pow.work)
|
const nonces = await solvePoW(challenge, powChallenge.pow.work)
|
||||||
|
|||||||
@@ -61,6 +61,7 @@ import { getSettings, uiBasePath } from '@/utils/settings'
|
|||||||
import { fetchJson, getUserFriendlyErrorMessage } from 'paskia'
|
import { fetchJson, getUserFriendlyErrorMessage } from 'paskia'
|
||||||
import RemoteAuthRequest from '@/components/RemoteAuthRequest.vue'
|
import RemoteAuthRequest from '@/components/RemoteAuthRequest.vue'
|
||||||
import { focusDialogButton } from '@/utils/keynav'
|
import { focusDialogButton } from '@/utils/keynav'
|
||||||
|
import { updateThemeFromSession } from '@/utils/theme'
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
mode: {
|
mode: {
|
||||||
@@ -147,6 +148,7 @@ async function fetchSettings() {
|
|||||||
async function validateSession() {
|
async function validateSession() {
|
||||||
try {
|
try {
|
||||||
session.value = await fetchJson('/auth/api/validate', { method: 'POST' })
|
session.value = await fetchJson('/auth/api/validate', { method: 'POST' })
|
||||||
|
updateThemeFromSession(session.value?.ctx)
|
||||||
if (isAuthenticated.value && props.mode !== 'reauth') {
|
if (isAuthenticated.value && props.mode !== 'reauth') {
|
||||||
currentView.value = 'forbidden'
|
currentView.value = 'forbidden'
|
||||||
emit('forbidden', session.value)
|
emit('forbidden', session.value)
|
||||||
|
|||||||
@@ -118,7 +118,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-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; }
|
.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; }
|
.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:hover:not(:disabled) { background: var(--color-accent-soft); color: var(--color-accent); }
|
||||||
.mini-btn:active:not(:disabled) { transform: translateY(1px); }
|
.mini-btn:active:not(:disabled) { transform: translateY(1px); }
|
||||||
.mini-btn:disabled { opacity: 0.5; cursor: not-allowed; }
|
.mini-btn:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||||
|
|||||||
@@ -88,7 +88,7 @@ export const useAuthStore = defineStore('auth', {
|
|||||||
async loadUserInfo() {
|
async loadUserInfo() {
|
||||||
try {
|
try {
|
||||||
this.userInfo = await apiJson('/auth/api/user-info', { method: 'GET' })
|
this.userInfo = await apiJson('/auth/api/user-info', { method: 'GET' })
|
||||||
updateThemeFromSession(this.ctx)
|
updateThemeFromSession(this.userInfo)
|
||||||
console.log('User info loaded:', this.userInfo)
|
console.log('User info loaded:', this.userInfo)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// Suppress toast for 401/403 errors - the auth iframe will handle these
|
// Suppress toast for 401/403 errors - the auth iframe will handle these
|
||||||
|
|||||||
@@ -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')
|
|
||||||
}
|
|
||||||
@@ -18,8 +18,6 @@ export {
|
|||||||
isAuthIframeOpen,
|
isAuthIframeOpen,
|
||||||
hideAuthIframe,
|
hideAuthIframe,
|
||||||
showAuthIframe,
|
showAuthIframe,
|
||||||
createAuthIframe,
|
|
||||||
removeAuthIframe,
|
|
||||||
} from './overlay'
|
} from './overlay'
|
||||||
|
|
||||||
export { SessionValidator } from './validate'
|
export { SessionValidator } from './validate'
|
||||||
|
|||||||
+53
-150
@@ -1,21 +1,19 @@
|
|||||||
import argparse
|
import argparse
|
||||||
import asyncio
|
|
||||||
import json
|
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
from urllib.parse import urlparse
|
|
||||||
|
|
||||||
|
import msgspec
|
||||||
from fastapi_vue import server
|
from fastapi_vue import server
|
||||||
from fastapi_vue.hostutil import parse_endpoint
|
from fastapi_vue.hostutil import parse_endpoints
|
||||||
|
|
||||||
from paskia import db
|
from paskia.db.jsonl import load_readonly
|
||||||
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.util import startupbox
|
from paskia.util import startupbox
|
||||||
from paskia.util.hostutil import normalize_origin
|
from paskia.util.hostutil import (
|
||||||
|
normalize_auth_host_and_origins,
|
||||||
|
normalize_origin,
|
||||||
|
validate_auth_host,
|
||||||
|
)
|
||||||
|
from paskia.util.runtime import RuntimeConfig
|
||||||
|
|
||||||
DEFAULT_PORT = 4401
|
DEFAULT_PORT = 4401
|
||||||
DEVMODE = os.getenv("PASKIA_DEV") == "1"
|
DEVMODE = os.getenv("PASKIA_DEV") == "1"
|
||||||
@@ -26,27 +24,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:
|
def add_common_options(p: argparse.ArgumentParser) -> None:
|
||||||
p.add_argument(
|
p.add_argument(
|
||||||
"--rp-id", default="localhost", help="Relying Party ID (default: localhost)"
|
"--rp-id", default="localhost", help="Relying Party ID (default: localhost)"
|
||||||
@@ -95,138 +72,64 @@ def main():
|
|||||||
|
|
||||||
args = parser.parse_args()
|
args = parser.parse_args()
|
||||||
|
|
||||||
# Handle clearing options
|
# Load stored config (read-only, no writes, no global state)
|
||||||
if getattr(args, "auth_host", None) == "":
|
db_path = os.environ.get("PASKIA_DB", f"{args.rp_id}.paskiadb")
|
||||||
args.auth_host = None
|
config = load_readonly(db_path, rp_id=args.rp_id).config
|
||||||
if getattr(args, "rp_name", None) == "":
|
|
||||||
args.rp_name = None
|
|
||||||
if getattr(args, "listen", None) == "":
|
|
||||||
args.listen = None
|
|
||||||
|
|
||||||
# Init db and load stored config
|
# Override stored config with CLI args, or clear with empty string
|
||||||
asyncio.run(db.init(rp_id=args.rp_id))
|
if args.rp_name is not None:
|
||||||
stored_config = db.data().config
|
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
|
# Process and normalize auth_host and origins
|
||||||
if args.rp_name is None and stored_config.rp_name is not None:
|
try:
|
||||||
args.rp_name = stored_config.rp_name
|
validate_auth_host(config.auth_host, config.rp_id) if config.auth_host else None
|
||||||
if args.origins is None and stored_config.origins is not None:
|
except ValueError as e:
|
||||||
args.origins = stored_config.origins
|
raise SystemExit(str(e))
|
||||||
if args.auth_host is None and stored_config.auth_host is not None:
|
if config.origins:
|
||||||
args.auth_host = stored_config.auth_host
|
config.origins = [normalize_origin(o) for o in config.origins]
|
||||||
if args.listen is None and stored_config.listen is not None:
|
config.auth_host, config.origins = normalize_auth_host_and_origins(
|
||||||
args.listen = stored_config.listen
|
config.auth_host, config.origins
|
||||||
|
)
|
||||||
|
|
||||||
# Parse first endpoint for config display and site_url
|
# Parse first endpoint for site_url fallback
|
||||||
first_listen = args.listen[0] if isinstance(args.listen, list) else args.listen
|
ep = next(iter(parse_endpoints(config.listen, DEFAULT_PORT)), {})
|
||||||
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")
|
|
||||||
port = ep.get("port")
|
port = ep.get("port")
|
||||||
uds = ep.get("uds")
|
|
||||||
|
|
||||||
# Collect and normalize origins, handle auth_host
|
# Compute site_url and site_path
|
||||||
origins = [normalize_origin(o) for o in (getattr(args, "origins", None) or [])]
|
# Priority: auth_host > origins[0] > PASKIA_VITE_URL > http://localhost:port > https://rp_id
|
||||||
if args.auth_host:
|
site_path = "/auth/"
|
||||||
# Normalize auth_host with scheme
|
if config.auth_host:
|
||||||
if "://" not in args.auth_host:
|
site_url, site_path = config.auth_host, "/"
|
||||||
args.auth_host = f"https://{args.auth_host}"
|
elif config.origins:
|
||||||
|
site_url = config.origins[0]
|
||||||
validate_auth_host(args.auth_host, args.rp_id)
|
elif vite_url := os.environ.get("PASKIA_VITE_URL"):
|
||||||
|
site_url = vite_url.rstrip("/") # Devserver
|
||||||
# If origins are configured, ensure auth_host is included at top
|
elif config.rp_id == "localhost" and port:
|
||||||
if origins:
|
site_url = f"http://localhost:{port}" # Backend directly if we can
|
||||||
# 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/"
|
|
||||||
else:
|
else:
|
||||||
site_url = f"https://{args.rp_id}"
|
site_url = f"https://{config.rp_id}" # Assume external reverse proxy
|
||||||
site_path = "/auth/"
|
|
||||||
|
|
||||||
# Build runtime configuration
|
# Build runtime configuration for the server
|
||||||
config = PaskiaConfig(
|
runtime = RuntimeConfig(
|
||||||
rp_id=args.rp_id,
|
config=config,
|
||||||
rp_name=args.rp_name or None,
|
|
||||||
origins=origins or None,
|
|
||||||
auth_host=args.auth_host or None,
|
|
||||||
site_url=site_url,
|
site_url=site_url,
|
||||||
site_path=site_path,
|
site_path=site_path,
|
||||||
host=host,
|
save=args.save,
|
||||||
port=port,
|
|
||||||
uds=uds,
|
|
||||||
)
|
)
|
||||||
|
startupbox.print_startup_config(runtime)
|
||||||
|
os.environ["PASKIA_CONFIG"] = msgspec.json.encode(runtime).decode()
|
||||||
|
|
||||||
# Export configuration via single JSON env variable for worker processes
|
# Run the server (spawns processes in dev mode)
|
||||||
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())
|
|
||||||
|
|
||||||
dev = {"reload": True, "reload_dirs": ["paskia"]} if DEVMODE else {}
|
dev = {"reload": True, "reload_dirs": ["paskia"]} if DEVMODE else {}
|
||||||
server.run(
|
server.run(
|
||||||
"paskia.fastapi.mainapp:app",
|
"paskia.fastapi.mainapp:app",
|
||||||
listen=args.listen,
|
listen=config.listen,
|
||||||
default_port=DEFAULT_PORT,
|
default_port=DEFAULT_PORT,
|
||||||
log_level="warning",
|
log_level="warning",
|
||||||
access_log=False,
|
access_log=False,
|
||||||
|
|||||||
@@ -23,6 +23,11 @@ if TYPE_CHECKING:
|
|||||||
EXPIRES = SESSION_LIFETIME
|
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:
|
def expires() -> datetime:
|
||||||
return datetime.now(UTC) + EXPIRES
|
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):
|
def delete_credential(credential_uuid: UUID, auth: str, host: str | None = None):
|
||||||
"""Delete a specific credential for the current user."""
|
"""Delete a specific credential for the current user."""
|
||||||
ctx = db.data().session_ctx(auth, hostutil.normalize_host(host))
|
ctx = session_ctx(auth, host)
|
||||||
if not ctx:
|
if not ctx:
|
||||||
raise ValueError("Session expired")
|
raise ValueError("Session expired")
|
||||||
db.delete_credential(credential_uuid, ctx.user.uuid)
|
db.delete_credential(credential_uuid, ctx.user.uuid)
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
from dataclasses import dataclass
|
|
||||||
from datetime import timedelta
|
from datetime import timedelta
|
||||||
|
|
||||||
# Shared configuration constants for session management.
|
# Shared configuration constants for session management.
|
||||||
@@ -6,19 +5,3 @@ SESSION_LIFETIME = timedelta(hours=24)
|
|||||||
|
|
||||||
# Lifetime for reset links created by admins
|
# Lifetime for reset links created by admins
|
||||||
RESET_LIFETIME = timedelta(days=14)
|
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,7 +1,7 @@
|
|||||||
"""
|
"""
|
||||||
Database module for WebAuthn passkey authentication.
|
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.
|
CTX: data().session_ctx(key) returns SessionContext with effective permissions.
|
||||||
Write: Functions validate and commit, or raise ValueError.
|
Write: Functions validate and commit, or raise ValueError.
|
||||||
|
|
||||||
@@ -10,7 +10,6 @@ Usage:
|
|||||||
|
|
||||||
# Read (after init)
|
# Read (after init)
|
||||||
user_data = db.data().users[user_uuid]
|
user_data = db.data().users[user_uuid]
|
||||||
user = db.build_user(user_uuid)
|
|
||||||
|
|
||||||
# Context
|
# Context
|
||||||
ctx = db.data().session_ctx(session_key)
|
ctx = db.data().session_ctx(session_key)
|
||||||
@@ -27,6 +26,7 @@ from paskia.db.background import (
|
|||||||
stop_cleanup,
|
stop_cleanup,
|
||||||
)
|
)
|
||||||
from paskia.db.bootstrap import bootstrap
|
from paskia.db.bootstrap import bootstrap
|
||||||
|
from paskia.db.jsonl import load_readonly
|
||||||
from paskia.db.lifecycle import cleanup_expired, init
|
from paskia.db.lifecycle import cleanup_expired, init
|
||||||
from paskia.db.operations import (
|
from paskia.db.operations import (
|
||||||
add_permission_to_org,
|
add_permission_to_org,
|
||||||
@@ -102,18 +102,12 @@ __all__ = [
|
|||||||
# Instance
|
# Instance
|
||||||
"data",
|
"data",
|
||||||
"init",
|
"init",
|
||||||
|
"load_readonly",
|
||||||
# Background
|
# Background
|
||||||
"start_background",
|
"start_background",
|
||||||
"stop_background",
|
"stop_background",
|
||||||
"start_cleanup",
|
"start_cleanup",
|
||||||
"stop_cleanup",
|
"stop_cleanup",
|
||||||
# Builders
|
|
||||||
"build_credential",
|
|
||||||
"build_permission",
|
|
||||||
"build_reset_token",
|
|
||||||
"build_role",
|
|
||||||
"build_session",
|
|
||||||
"build_user",
|
|
||||||
# Read ops
|
# Read ops
|
||||||
# Write ops
|
# Write ops
|
||||||
"add_permission_to_org",
|
"add_permission_to_org",
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ _background_task: asyncio.Task | None = None
|
|||||||
|
|
||||||
async def flush() -> None:
|
async def flush() -> None:
|
||||||
"""Write all pending database changes to disk."""
|
"""Write all pending database changes to disk."""
|
||||||
store = _ops._store
|
store = _ops._db._store
|
||||||
if store is None:
|
if store is None:
|
||||||
_logger.warning("flush() called but _store is None")
|
_logger.warning("flush() called but _store is None")
|
||||||
return
|
return
|
||||||
@@ -48,6 +48,10 @@ async def _background_loop():
|
|||||||
cleanup_expired()
|
cleanup_expired()
|
||||||
await flush() # Flush cleanup changes
|
await flush() # Flush cleanup changes
|
||||||
last_cleanup = now
|
last_cleanup = now
|
||||||
|
|
||||||
|
# Conditionally write a snapshot to speed up future startups
|
||||||
|
if _ops._db._store is not None:
|
||||||
|
_ops._db._store.maybe_snapshot()
|
||||||
except asyncio.CancelledError:
|
except asyncio.CancelledError:
|
||||||
# Final flush before exit
|
# Final flush before exit
|
||||||
await flush()
|
await flush()
|
||||||
@@ -90,7 +94,7 @@ async def start_background():
|
|||||||
|
|
||||||
|
|
||||||
async def stop_background():
|
async def stop_background():
|
||||||
"""Stop the background task and flush any pending changes."""
|
"""Stop the background task, flush pending changes, and release the file lock."""
|
||||||
global _background_task
|
global _background_task
|
||||||
if _background_task:
|
if _background_task:
|
||||||
_background_task.cancel()
|
_background_task.cancel()
|
||||||
@@ -99,6 +103,7 @@ async def stop_background():
|
|||||||
except asyncio.CancelledError:
|
except asyncio.CancelledError:
|
||||||
pass
|
pass
|
||||||
_background_task = None
|
_background_task = None
|
||||||
|
_ops._db._store.close()
|
||||||
|
|
||||||
|
|
||||||
# Aliases for backwards compatibility
|
# Aliases for backwards compatibility
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ from datetime import UTC, datetime
|
|||||||
import uuid7
|
import uuid7
|
||||||
|
|
||||||
import paskia.db.operations as _ops
|
import paskia.db.operations as _ops
|
||||||
|
from paskia.authsession import reset_expires
|
||||||
from paskia.db.structs import Config, Org, Permission, ResetToken, Role, User
|
from paskia.db.structs import Config, Org, Permission, ResetToken, Role, User
|
||||||
from paskia.util.crypto import secret_key
|
from paskia.util.crypto import secret_key
|
||||||
|
|
||||||
@@ -59,8 +60,6 @@ def bootstrap(
|
|||||||
|
|
||||||
# Set reset token expiry (passphrase generated by ResetToken.create)
|
# Set reset token expiry (passphrase generated by ResetToken.create)
|
||||||
if reset_expiry is None:
|
if reset_expiry is None:
|
||||||
from paskia.authsession import reset_expires # noqa: PLC0415
|
|
||||||
|
|
||||||
reset_expiry = reset_expires()
|
reset_expiry = reset_expires()
|
||||||
|
|
||||||
with _ops._db.transaction("bootstrap"):
|
with _ops._db.transaction("bootstrap"):
|
||||||
|
|||||||
@@ -0,0 +1,247 @@
|
|||||||
|
"""Cross-platform locked file for the database (no separate .lock files).
|
||||||
|
|
||||||
|
Unix: open() + fcntl.flock (advisory, cooperative among processes that flock).
|
||||||
|
Windows: CreateFileW with FILE_SHARE_READ (OS-enforced, allows readers, blocks writers).
|
||||||
|
|
||||||
|
A single file descriptor is opened once for both reading and writing.
|
||||||
|
The lock is acquired atomically (on Windows) or immediately after open (on Unix),
|
||||||
|
and the same descriptor is used for the lifetime of the process: first to read
|
||||||
|
the existing content, then to append new writes.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
_logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def _fatal(msg: str) -> None:
|
||||||
|
"""Log a fatal error and exit immediately, bypassing exception handlers."""
|
||||||
|
_logger.critical(msg)
|
||||||
|
os._exit(1)
|
||||||
|
|
||||||
|
|
||||||
|
if sys.platform == "win32":
|
||||||
|
import ctypes
|
||||||
|
from ctypes import wintypes
|
||||||
|
|
||||||
|
_kernel32 = ctypes.WinDLL("kernel32", use_last_error=True)
|
||||||
|
|
||||||
|
_GENERIC_READ = 0x80000000
|
||||||
|
_GENERIC_WRITE = 0x40000000
|
||||||
|
_FILE_SHARE_READ = 0x00000001
|
||||||
|
_OPEN_EXISTING = 3
|
||||||
|
_OPEN_ALWAYS = 4
|
||||||
|
_FILE_ATTRIBUTE_NORMAL = 0x80
|
||||||
|
_FILE_BEGIN = 0
|
||||||
|
_FILE_END = 2
|
||||||
|
_ERROR_SHARING_VIOLATION = 32
|
||||||
|
_INVALID_FILE_SIZE = 0xFFFFFFFF
|
||||||
|
|
||||||
|
_kernel32.CreateFileW.restype = wintypes.HANDLE
|
||||||
|
_kernel32.CreateFileW.argtypes = [
|
||||||
|
wintypes.LPCWSTR,
|
||||||
|
wintypes.DWORD,
|
||||||
|
wintypes.DWORD,
|
||||||
|
ctypes.c_void_p,
|
||||||
|
wintypes.DWORD,
|
||||||
|
wintypes.DWORD,
|
||||||
|
wintypes.HANDLE,
|
||||||
|
]
|
||||||
|
_kernel32.ReadFile.restype = wintypes.BOOL
|
||||||
|
_kernel32.ReadFile.argtypes = [
|
||||||
|
wintypes.HANDLE,
|
||||||
|
ctypes.c_void_p,
|
||||||
|
wintypes.DWORD,
|
||||||
|
ctypes.POINTER(wintypes.DWORD),
|
||||||
|
ctypes.c_void_p,
|
||||||
|
]
|
||||||
|
_kernel32.WriteFile.restype = wintypes.BOOL
|
||||||
|
_kernel32.WriteFile.argtypes = [
|
||||||
|
wintypes.HANDLE,
|
||||||
|
ctypes.c_void_p,
|
||||||
|
wintypes.DWORD,
|
||||||
|
ctypes.POINTER(wintypes.DWORD),
|
||||||
|
ctypes.c_void_p,
|
||||||
|
]
|
||||||
|
_kernel32.GetFileSize.restype = wintypes.DWORD
|
||||||
|
_kernel32.GetFileSize.argtypes = [
|
||||||
|
wintypes.HANDLE,
|
||||||
|
ctypes.POINTER(wintypes.DWORD),
|
||||||
|
]
|
||||||
|
_kernel32.SetFilePointer.restype = wintypes.DWORD
|
||||||
|
_kernel32.SetFilePointer.argtypes = [
|
||||||
|
wintypes.HANDLE,
|
||||||
|
wintypes.LONG,
|
||||||
|
ctypes.POINTER(wintypes.LONG),
|
||||||
|
wintypes.DWORD,
|
||||||
|
]
|
||||||
|
_kernel32.CloseHandle.restype = wintypes.BOOL
|
||||||
|
_kernel32.CloseHandle.argtypes = [wintypes.HANDLE]
|
||||||
|
|
||||||
|
def _is_invalid_handle(handle) -> bool:
|
||||||
|
return ctypes.c_void_p(handle).value == ctypes.c_void_p(-1).value
|
||||||
|
|
||||||
|
else:
|
||||||
|
import fcntl
|
||||||
|
|
||||||
|
|
||||||
|
class LockedFile:
|
||||||
|
"""A file opened with an exclusive write lock.
|
||||||
|
|
||||||
|
Usage::
|
||||||
|
|
||||||
|
f = LockedFile()
|
||||||
|
f.open(path) # open + lock (read+write)
|
||||||
|
content = f.read() # read entire content
|
||||||
|
f.write(data) # append data (seeks to end first)
|
||||||
|
f.close() # release lock + close fd
|
||||||
|
|
||||||
|
Unix: fcntl.flock (advisory) — read-only callers that don't flock are unaffected.
|
||||||
|
Windows: CreateFileW with FILE_SHARE_READ — OS blocks other writers.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self._fd: int | None = None # Unix fd or Windows HANDLE
|
||||||
|
|
||||||
|
def open(self, path: Path, *, create: bool = False) -> None:
|
||||||
|
"""Open *path* for read+write with an exclusive lock.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
path: File to open and lock.
|
||||||
|
create: If True, create the file if it doesn't exist (bootstrap).
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
SystemExit: If the file is locked by another process or not found.
|
||||||
|
"""
|
||||||
|
if self._fd is not None:
|
||||||
|
return # Already open (idempotent)
|
||||||
|
|
||||||
|
if sys.platform == "win32":
|
||||||
|
self._open_win32(path, create)
|
||||||
|
else:
|
||||||
|
self._open_unix(path, create)
|
||||||
|
|
||||||
|
def open_and_read(self, path: Path) -> bytes:
|
||||||
|
"""Open *path* with exclusive lock and read all content.
|
||||||
|
|
||||||
|
Combined operation for efficient use with asyncio.to_thread().
|
||||||
|
"""
|
||||||
|
self.open(path)
|
||||||
|
return self.read()
|
||||||
|
|
||||||
|
def read(self) -> bytes:
|
||||||
|
"""Read the entire file content from the beginning."""
|
||||||
|
if self._fd is None:
|
||||||
|
raise RuntimeError("LockedFile.read() called on a closed file")
|
||||||
|
|
||||||
|
if sys.platform == "win32":
|
||||||
|
return self._read_win32()
|
||||||
|
else:
|
||||||
|
return self._read_unix()
|
||||||
|
|
||||||
|
def write(self, data: bytes) -> None:
|
||||||
|
"""Append *data* to the end of the file."""
|
||||||
|
if self._fd is None:
|
||||||
|
raise RuntimeError("LockedFile.write() called on a closed file")
|
||||||
|
|
||||||
|
if sys.platform == "win32":
|
||||||
|
self._write_win32(data)
|
||||||
|
else:
|
||||||
|
self._write_unix(data)
|
||||||
|
|
||||||
|
def close(self) -> None:
|
||||||
|
"""Release the lock and close the file."""
|
||||||
|
if self._fd is None:
|
||||||
|
return
|
||||||
|
if sys.platform == "win32":
|
||||||
|
_kernel32.CloseHandle(self._fd)
|
||||||
|
else:
|
||||||
|
os.close(self._fd)
|
||||||
|
self._fd = None
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_open(self) -> bool:
|
||||||
|
return self._fd is not None
|
||||||
|
|
||||||
|
# -- Unix ----------------------------------------------------------------
|
||||||
|
|
||||||
|
def _open_unix(self, path: Path, create: bool) -> None:
|
||||||
|
flags = os.O_RDWR | (os.O_CREAT if create else 0)
|
||||||
|
try:
|
||||||
|
fd = os.open(path, flags, 0o666)
|
||||||
|
except FileNotFoundError:
|
||||||
|
_fatal(f"Database file not found: {path.resolve()}")
|
||||||
|
try:
|
||||||
|
fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
||||||
|
except OSError:
|
||||||
|
os.close(fd)
|
||||||
|
_fatal(f"🛑 {path.resolve()}: database already locked by another instance")
|
||||||
|
self._fd = fd
|
||||||
|
|
||||||
|
def _read_unix(self) -> bytes:
|
||||||
|
os.lseek(self._fd, 0, os.SEEK_SET)
|
||||||
|
chunks = []
|
||||||
|
while True:
|
||||||
|
chunk = os.read(self._fd, 1 << 20) # 1 MiB
|
||||||
|
if not chunk:
|
||||||
|
break
|
||||||
|
chunks.append(chunk)
|
||||||
|
return b"".join(chunks)
|
||||||
|
|
||||||
|
def _write_unix(self, data: bytes) -> None:
|
||||||
|
os.lseek(self._fd, 0, os.SEEK_END)
|
||||||
|
os.write(self._fd, data)
|
||||||
|
|
||||||
|
# -- Windows -------------------------------------------------------------
|
||||||
|
|
||||||
|
def _open_win32(self, path: Path, create: bool) -> None:
|
||||||
|
disposition = _OPEN_ALWAYS if create else _OPEN_EXISTING
|
||||||
|
handle = _kernel32.CreateFileW(
|
||||||
|
str(path),
|
||||||
|
_GENERIC_READ | _GENERIC_WRITE,
|
||||||
|
_FILE_SHARE_READ,
|
||||||
|
None,
|
||||||
|
disposition,
|
||||||
|
_FILE_ATTRIBUTE_NORMAL,
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
if _is_invalid_handle(handle):
|
||||||
|
err = ctypes.get_last_error()
|
||||||
|
if err == _ERROR_SHARING_VIOLATION:
|
||||||
|
_fatal(
|
||||||
|
f"🛑 {path.resolve()}: database already locked by another instance"
|
||||||
|
)
|
||||||
|
_fatal(f"Failed to open database {path.resolve()}: Windows error {err}")
|
||||||
|
self._fd = handle
|
||||||
|
|
||||||
|
def _read_win32(self) -> bytes:
|
||||||
|
_kernel32.SetFilePointer(self._fd, 0, None, _FILE_BEGIN)
|
||||||
|
size = _kernel32.GetFileSize(self._fd, None)
|
||||||
|
if size == _INVALID_FILE_SIZE:
|
||||||
|
raise OSError(
|
||||||
|
f"GetFileSize failed: Windows error {ctypes.get_last_error()}"
|
||||||
|
)
|
||||||
|
if size == 0:
|
||||||
|
return b""
|
||||||
|
buf = ctypes.create_string_buffer(size)
|
||||||
|
bytes_read = wintypes.DWORD()
|
||||||
|
ok = _kernel32.ReadFile(self._fd, buf, size, ctypes.byref(bytes_read), None)
|
||||||
|
if not ok:
|
||||||
|
raise OSError(f"ReadFile failed: Windows error {ctypes.get_last_error()}")
|
||||||
|
return buf.raw[: bytes_read.value]
|
||||||
|
|
||||||
|
def _write_win32(self, data: bytes) -> None:
|
||||||
|
_kernel32.SetFilePointer(self._fd, 0, None, _FILE_END)
|
||||||
|
written = wintypes.DWORD()
|
||||||
|
ok = _kernel32.WriteFile(
|
||||||
|
self._fd,
|
||||||
|
data,
|
||||||
|
len(data),
|
||||||
|
ctypes.byref(written),
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
if not ok:
|
||||||
|
raise OSError(f"WriteFile failed: Windows error {ctypes.get_last_error()}")
|
||||||
+185
-127
@@ -2,6 +2,7 @@
|
|||||||
JSONL persistence layer for the database.
|
JSONL persistence layer for the database.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
import copy
|
import copy
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
@@ -13,126 +14,137 @@ from pathlib import Path
|
|||||||
from typing import Any
|
from typing import Any
|
||||||
from uuid import UUID
|
from uuid import UUID
|
||||||
|
|
||||||
import aiofiles
|
|
||||||
import jsondiff
|
import jsondiff
|
||||||
import msgspec
|
import msgspec
|
||||||
|
|
||||||
|
from paskia.db.filelock import LockedFile
|
||||||
from paskia.db.logging import log_change
|
from paskia.db.logging import log_change
|
||||||
from paskia.db.migrations import DBVER, apply_all_migrations
|
from paskia.db.migrations import (
|
||||||
from paskia.db.structs import DB, SessionContext
|
DBVER,
|
||||||
|
MigrationCtx,
|
||||||
|
apply_all_migrations,
|
||||||
|
apply_migrations_readonly,
|
||||||
|
)
|
||||||
|
from paskia.db.snapshot import SnapshotState
|
||||||
|
from paskia.db.structs import DB, Config, SessionContext
|
||||||
|
|
||||||
_logger = logging.getLogger(__name__)
|
_logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
# Default database path
|
|
||||||
DB_PATH_DEFAULT = "paskia.jsonl"
|
class ReplayResult(msgspec.Struct, frozen=False):
|
||||||
|
"""Return value of _replay_from_data"""
|
||||||
|
|
||||||
|
state: dict
|
||||||
|
v: int = 0
|
||||||
|
ts: datetime | None = None
|
||||||
|
snapts: datetime | None = None
|
||||||
|
changes: int = 0
|
||||||
|
|
||||||
|
|
||||||
class _ChangeRecord(msgspec.Struct, omit_defaults=True):
|
class DatabaseError(Exception):
|
||||||
"""A single change record in the JSONL file."""
|
"""Exception raised for database loading errors."""
|
||||||
|
|
||||||
ts: datetime
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def _replay_from_data(data: bytes, db_path: str) -> ReplayResult:
|
||||||
|
"""Replay database state from file data, using the last snapshot if available."""
|
||||||
|
resolved_path = str(Path(db_path).resolve())
|
||||||
|
result = ReplayResult(state={})
|
||||||
|
|
||||||
|
# Find and apply the last snapshot
|
||||||
|
snap, start_offset = SnapshotState.load(data)
|
||||||
|
if snap:
|
||||||
|
result.state = snap.state
|
||||||
|
result.v = snap.v
|
||||||
|
result.snapts = snap.ts
|
||||||
|
|
||||||
|
# Replay change records after the snapshot
|
||||||
|
lines = data[start_offset:].split(b"\n")
|
||||||
|
for line_num, raw in enumerate(lines, start=1): # 1-based line numbering
|
||||||
|
line = raw.strip()
|
||||||
|
if not line:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
change = msgspec.json.decode(line, type=ChangeRecord)
|
||||||
|
except msgspec.DecodeError as e:
|
||||||
|
raise DatabaseError(f"{resolved_path}:{line_num}: {e}")
|
||||||
|
result.state = jsondiff.patch(result.state, change.diff, marshal=True)
|
||||||
|
result.v = change.v
|
||||||
|
result.ts = change.ts
|
||||||
|
result.changes += 1
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def load_readonly(db_path: str, *, rp_id: str = "localhost") -> DB:
|
||||||
|
"""Replay JSONL and apply migrations to produce a DB, without writing anything.
|
||||||
|
|
||||||
|
This is suitable for reading settings before the server starts.
|
||||||
|
Migrations are applied in-memory only; nothing is queued or flushed.
|
||||||
|
"""
|
||||||
|
path = Path(db_path)
|
||||||
|
if not path.exists():
|
||||||
|
return DB(config=Config(rp_id=rp_id))
|
||||||
|
|
||||||
|
try:
|
||||||
|
with open(path, "rb") as f:
|
||||||
|
content = f.read()
|
||||||
|
r = _replay_from_data(content, str(path.resolve()))
|
||||||
|
data_dict = r.state
|
||||||
|
version = r.v
|
||||||
|
except OSError as e:
|
||||||
|
_logger.exception("Failed to load database")
|
||||||
|
raise SystemExit(f"{e}")
|
||||||
|
except (ValueError, msgspec.DecodeError, DatabaseError) as e:
|
||||||
|
raise SystemExit(f"{e}")
|
||||||
|
except Exception as e:
|
||||||
|
_logger.exception("Unexpected error loading database")
|
||||||
|
raise SystemExit(f"{e}")
|
||||||
|
|
||||||
|
if not data_dict:
|
||||||
|
return DB(config=Config(rp_id=rp_id))
|
||||||
|
|
||||||
|
# Apply migrations in-memory (no persistence)
|
||||||
|
apply_migrations_readonly(data_dict, version, MigrationCtx(rp_id=rp_id))
|
||||||
|
|
||||||
|
# Decode to msgspec struct
|
||||||
|
db = msgspec.json.decode(msgspec.json.encode(data_dict), type=DB)
|
||||||
|
return db
|
||||||
|
|
||||||
|
|
||||||
|
class ChangeRecord(msgspec.Struct, omit_defaults=True, kw_only=True):
|
||||||
|
ts: datetime = msgspec.field(default_factory=lambda: datetime.now(UTC))
|
||||||
a: str # action - describes the operation (e.g., "migrate", "login", "create_user")
|
a: str # action - describes the operation (e.g., "migrate", "login", "create_user")
|
||||||
v: int # schema version after this change
|
v: int = 0 # schema version after this change
|
||||||
u: str | None = None # user UUID who performed the action (None for system)
|
u: str | None = None # user UUID who performed the action (None for system)
|
||||||
diff: dict = {}
|
diff: dict
|
||||||
|
|
||||||
|
|
||||||
# msgspec encoder for change records
|
|
||||||
_change_encoder = msgspec.json.Encoder()
|
|
||||||
|
|
||||||
|
|
||||||
def compute_diff(previous: dict, current: dict) -> dict | None:
|
def compute_diff(previous: dict, current: dict) -> dict | None:
|
||||||
"""Compute JSON diff between two states.
|
return jsondiff.diff(previous, current, marshal=True) or None
|
||||||
|
|
||||||
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
|
# Actions that are allowed to create a new database file
|
||||||
_BOOTSTRAP_ACTIONS = frozenset({"bootstrap"})
|
_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:
|
class JsonlStore:
|
||||||
"""JSONL persistence layer for a DB instance."""
|
"""JSONL persistence layer for a DB instance."""
|
||||||
|
|
||||||
def __init__(self, db: DB, db_path: str = DB_PATH_DEFAULT):
|
def __init__(self, db: DB, db_path: str):
|
||||||
self.db: DB = db
|
self.db: DB = db
|
||||||
self.db_path = Path(db_path)
|
self.db_path = Path(db_path)
|
||||||
self._previous_builtins: dict[str, Any] = {}
|
self._file = LockedFile()
|
||||||
self._pending_changes: deque[_ChangeRecord] = deque()
|
self._flush_failed = False
|
||||||
|
self._statedict: dict[str, Any] = {}
|
||||||
|
self._pending_changes: deque[ChangeRecord] = deque()
|
||||||
self._current_action: str = "system"
|
self._current_action: str = "system"
|
||||||
self._current_user: str | None = None
|
self._current_user: str | None = None
|
||||||
self._in_transaction: bool = False
|
self._in_transaction: bool = False
|
||||||
self._transaction_snapshot: dict[str, Any] | None = None
|
self._transaction_snapshot: dict[str, Any] | None = None
|
||||||
self._current_version: int = DBVER # Schema version for new databases
|
self._v: int = DBVER # Schema version for new databases
|
||||||
|
self._snapshot = SnapshotState()
|
||||||
|
|
||||||
async def load(
|
async def load(
|
||||||
self, db_path: str | None = None, *, rp_id: str = "localhost"
|
self, db_path: str | None = None, *, rp_id: str = "localhost"
|
||||||
@@ -144,55 +156,52 @@ class JsonlStore:
|
|||||||
if not self.db_path.exists():
|
if not self.db_path.exists():
|
||||||
return
|
return
|
||||||
|
|
||||||
# Replay change log to reconstruct state
|
# Open with exclusive write lock and read contents — single threadpool call
|
||||||
data_dict: dict = {}
|
content = await asyncio.to_thread(self._file.open_and_read, self.db_path)
|
||||||
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:
|
# Replay change log to reconstruct state (snapshot-accelerated)
|
||||||
|
try:
|
||||||
|
r = _replay_from_data(content, str(self.db_path.resolve()))
|
||||||
|
statedict = r.state
|
||||||
|
self._v = r.v
|
||||||
|
self._snapshot.ts = r.snapts
|
||||||
|
self._snapshot.changes = r.changes
|
||||||
|
except (OSError, ValueError, msgspec.DecodeError, DatabaseError) as e:
|
||||||
|
raise SystemExit(f"{e}")
|
||||||
|
except Exception as e:
|
||||||
|
_logger.exception("Unexpected error loading database")
|
||||||
|
raise SystemExit(f"{e}")
|
||||||
|
|
||||||
|
if not statedict:
|
||||||
return
|
return
|
||||||
|
|
||||||
# Set previous state for diffing (will be updated by _queue_change)
|
# Set previous state for diffing (will be updated by _queue_change)
|
||||||
self._previous_builtins = copy.deepcopy(data_dict)
|
self._statedict = copy.deepcopy(statedict)
|
||||||
|
|
||||||
# Callback to persist each migration
|
# Callback to persist each migration
|
||||||
async def persist_migration(
|
async def persist_migration(
|
||||||
action: str, new_version: int, current: dict
|
action: str, new_version: int, current: dict
|
||||||
) -> None:
|
) -> None:
|
||||||
self._current_version = new_version
|
self._v = new_version
|
||||||
self._queue_change(action, new_version, current)
|
self._queue_change(action, new_version, current)
|
||||||
|
|
||||||
# Apply schema migrations one at a time
|
# Apply schema migrations one at a time
|
||||||
await apply_all_migrations(
|
await apply_all_migrations(
|
||||||
data_dict, self._current_version, persist_migration, rp_id=rp_id
|
statedict,
|
||||||
|
self._v,
|
||||||
|
persist_migration,
|
||||||
|
MigrationCtx(rp_id=rp_id),
|
||||||
)
|
)
|
||||||
|
|
||||||
# Decode to msgspec struct
|
# Decode to msgspec struct
|
||||||
decoder = msgspec.json.Decoder(DB)
|
decoder = msgspec.json.Decoder(DB)
|
||||||
self.db = decoder.decode(msgspec.json.encode(data_dict))
|
self.db = decoder.decode(msgspec.json.encode(statedict))
|
||||||
self.db._store = self
|
self.db._store = self
|
||||||
|
|
||||||
# Normalize via msgspec round-trip (handles omit_defaults etc.)
|
# Normalize via msgspec round-trip (handles omit_defaults etc.)
|
||||||
# This ensures _previous_builtins matches what msgspec would produce
|
# This ensures _previous_builtins matches what msgspec would produce
|
||||||
normalized_dict = msgspec.to_builtins(self.db)
|
normalized_dict = msgspec.to_builtins(self.db)
|
||||||
await persist_migration(
|
await persist_migration("migrate:msgspec", self._v, normalized_dict)
|
||||||
"migrate:msgspec", self._current_version, normalized_dict
|
|
||||||
)
|
|
||||||
|
|
||||||
def _queue_change(
|
def _queue_change(
|
||||||
self, action: str, version: int, current: dict, user: str | None = None
|
self, action: str, version: int, current: dict, user: str | None = None
|
||||||
@@ -205,10 +214,17 @@ class JsonlStore:
|
|||||||
current: The current state as a plain dict
|
current: The current state as a plain dict
|
||||||
user: Optional user UUID who performed the action
|
user: Optional user UUID who performed the action
|
||||||
"""
|
"""
|
||||||
diff = compute_diff(self._previous_builtins, current)
|
diff = compute_diff(self._statedict, current)
|
||||||
if not diff:
|
if not diff:
|
||||||
return
|
return
|
||||||
self._pending_changes.append(create_change_record(action, version, diff, user))
|
self._pending_changes.append(
|
||||||
|
ChangeRecord(
|
||||||
|
a=action,
|
||||||
|
v=version,
|
||||||
|
u=user,
|
||||||
|
diff=diff,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
# Log the change with user display name if available
|
# Log the change with user display name if available
|
||||||
user_display = None
|
user_display = None
|
||||||
@@ -220,8 +236,8 @@ class JsonlStore:
|
|||||||
except (ValueError, KeyError):
|
except (ValueError, KeyError):
|
||||||
user_display = user
|
user_display = user
|
||||||
|
|
||||||
log_change(action, diff, user_display, self._previous_builtins, self.db)
|
log_change(action, diff, user_display, self._statedict, self.db)
|
||||||
self._previous_builtins = copy.deepcopy(current)
|
self._statedict = copy.deepcopy(current)
|
||||||
|
|
||||||
@contextmanager
|
@contextmanager
|
||||||
def transaction(
|
def transaction(
|
||||||
@@ -243,13 +259,13 @@ class JsonlStore:
|
|||||||
|
|
||||||
# Check for out-of-transaction modifications
|
# Check for out-of-transaction modifications
|
||||||
current_state = msgspec.to_builtins(self.db)
|
current_state = msgspec.to_builtins(self.db)
|
||||||
if current_state != self._previous_builtins:
|
if current_state != self._statedict:
|
||||||
# Allow bootstrap to create a new database from empty state
|
# Allow bootstrap to create a new database from empty state
|
||||||
is_bootstrap = action in _BOOTSTRAP_ACTIONS
|
is_bootstrap = action in _BOOTSTRAP_ACTIONS
|
||||||
if is_bootstrap and not self._previous_builtins:
|
if is_bootstrap and not self._statedict:
|
||||||
pass # Expected: creating database from scratch
|
pass # Expected: creating database from scratch
|
||||||
else:
|
else:
|
||||||
diff = compute_diff(self._previous_builtins, current_state)
|
diff = compute_diff(self._statedict, current_state)
|
||||||
diff_json = msgspec.json.encode(diff).decode()
|
diff_json = msgspec.json.encode(diff).decode()
|
||||||
_logger.critical(
|
_logger.critical(
|
||||||
"Database state modified outside of transaction! "
|
"Database state modified outside of transaction! "
|
||||||
@@ -270,7 +286,7 @@ class JsonlStore:
|
|||||||
yield
|
yield
|
||||||
current = msgspec.to_builtins(self.db)
|
current = msgspec.to_builtins(self.db)
|
||||||
self._queue_change(
|
self._queue_change(
|
||||||
self._current_action, self._current_version, current, self._current_user
|
self._current_action, self._v, current, self._current_user
|
||||||
)
|
)
|
||||||
except Exception:
|
except Exception:
|
||||||
# Rollback on error: restore from snapshot
|
# Rollback on error: restore from snapshot
|
||||||
@@ -289,5 +305,47 @@ class JsonlStore:
|
|||||||
self._transaction_snapshot = None
|
self._transaction_snapshot = None
|
||||||
|
|
||||||
async def flush(self) -> None:
|
async def flush(self) -> None:
|
||||||
"""Write all pending changes to disk."""
|
"""Write all pending changes to disk.
|
||||||
await flush_changes(self.db_path, self._pending_changes)
|
|
||||||
|
On failure, logs an error and sends SIGTERM to trigger graceful shutdown.
|
||||||
|
"""
|
||||||
|
if self._flush_failed or not self._pending_changes:
|
||||||
|
return
|
||||||
|
|
||||||
|
if not self._file.is_open:
|
||||||
|
first_action = self._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,
|
||||||
|
)
|
||||||
|
self._flush_failed = True
|
||||||
|
os.kill(os.getpid(), signal.SIGTERM)
|
||||||
|
return
|
||||||
|
# Bootstrap: create and open the file with lock
|
||||||
|
await asyncio.to_thread(self._file.open, self.db_path, create=True)
|
||||||
|
|
||||||
|
changes_to_write = list(self._pending_changes)
|
||||||
|
|
||||||
|
try:
|
||||||
|
lines = [msgspec.json.encode(change) for change in changes_to_write]
|
||||||
|
if not lines:
|
||||||
|
self._pending_changes.clear()
|
||||||
|
return
|
||||||
|
|
||||||
|
await asyncio.to_thread(self._file.write, b"\n".join(lines) + b"\n")
|
||||||
|
self._snapshot.record_lines(len(lines))
|
||||||
|
self._pending_changes.clear()
|
||||||
|
except OSError as e:
|
||||||
|
_logger.error("Failed to flush database: %s", e)
|
||||||
|
self._flush_failed = True
|
||||||
|
os.kill(os.getpid(), signal.SIGTERM)
|
||||||
|
|
||||||
|
def maybe_snapshot(self) -> None:
|
||||||
|
"""Write a snapshot if conditions are met."""
|
||||||
|
self._snapshot.maybe_write(self._file, self._v, self._statedict)
|
||||||
|
|
||||||
|
def close(self) -> None:
|
||||||
|
"""Release the file lock and close the file."""
|
||||||
|
self._file.close()
|
||||||
|
|||||||
+11
-9
@@ -7,21 +7,25 @@ import os
|
|||||||
from datetime import UTC, datetime
|
from datetime import UTC, datetime
|
||||||
|
|
||||||
import paskia.db.operations as _ops
|
import paskia.db.operations as _ops
|
||||||
|
from paskia import oidc_notify
|
||||||
from paskia.authsession import EXPIRES
|
from paskia.authsession import EXPIRES
|
||||||
|
from paskia.db.jsonl import JsonlStore
|
||||||
|
|
||||||
_logger = logging.getLogger(__name__)
|
_logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
async def init(rp_id: str = "localhost", *args, **kwargs):
|
async def init(rp_id: str, *args, **kwargs):
|
||||||
"""Load database from JSONL file."""
|
"""Load database from JSONL file."""
|
||||||
if _ops._initialized:
|
if _ops._db._store:
|
||||||
_logger.debug("Database already initialized, skipping reload")
|
_logger.debug("Database already initialized, skipping reload")
|
||||||
return
|
return
|
||||||
default_path = f"{rp_id}.paskiadb"
|
db_path = os.environ.get("PASKIA_DB", f"{rp_id}.paskiadb")
|
||||||
db_path = os.environ.get("PASKIA_DB", default_path)
|
store = JsonlStore(_ops._db, db_path)
|
||||||
await _ops._store.load(db_path, rp_id=rp_id)
|
await store.load(db_path, rp_id=rp_id)
|
||||||
_ops._db = _ops._store.db
|
_ops._db = store.db
|
||||||
_ops._initialized = True
|
_ops._db._store = store
|
||||||
|
# Request a snapshot after successful startup
|
||||||
|
store._snapshot.request_force()
|
||||||
|
|
||||||
|
|
||||||
def cleanup_expired() -> int:
|
def cleanup_expired() -> int:
|
||||||
@@ -31,8 +35,6 @@ def cleanup_expired() -> int:
|
|||||||
limit = now - EXPIRES
|
limit = now - EXPIRES
|
||||||
expired_sessions = [k for k, s in _ops._db.sessions.items() if s.validated < limit]
|
expired_sessions = [k for k, s in _ops._db.sessions.items() if s.validated < limit]
|
||||||
if expired_sessions:
|
if expired_sessions:
|
||||||
from paskia import oidc_notify # noqa: PLC0415
|
|
||||||
|
|
||||||
oidc_notify.schedule_notifications(expired_sessions)
|
oidc_notify.schedule_notifications(expired_sessions)
|
||||||
with _ops._db.transaction("expiry"):
|
with _ops._db.transaction("expiry"):
|
||||||
for k in expired_sessions:
|
for k in expired_sessions:
|
||||||
|
|||||||
+10
-10
@@ -35,9 +35,9 @@ _UNSAFE_CHARS = re.compile(
|
|||||||
|
|
||||||
# ANSI color codes (matching FastAPI logging style)
|
# ANSI color codes (matching FastAPI logging style)
|
||||||
_RESET = "\033[0m"
|
_RESET = "\033[0m"
|
||||||
_DIM = "\033[2m"
|
_SEP = "\033[38;5;242m" # Dark grey for separators (like host/timing in access log)
|
||||||
_PATH_PREFIX = "\033[1;30m" # Dark grey for path prefix (like host in access log)
|
_PATH_PREFIX = "\033[38;5;242m" # Dark grey for path prefix (like host in access log)
|
||||||
_PATH_FINAL = "\033[0m" # Default for final element (like path in access log)
|
_PATH_FINAL = "\033[38;5;250m" # Default for final element (like path in access log)
|
||||||
_DELETE = "\033[1;31m" # Red for deletions
|
_DELETE = "\033[1;31m" # Red for deletions
|
||||||
_ADD = "\033[0;32m" # Green for additions
|
_ADD = "\033[0;32m" # Green for additions
|
||||||
_ACTION = "\033[1;34m" # Bold blue for action name
|
_ACTION = "\033[1;34m" # Bold blue for action name
|
||||||
@@ -317,7 +317,7 @@ def _format_change_lines(
|
|||||||
# Helper to format a value, checking for censored paths
|
# Helper to format a value, checking for censored paths
|
||||||
def fmt_value(v: Any, child_path: list[str]) -> str:
|
def fmt_value(v: Any, child_path: list[str]) -> str:
|
||||||
if child_path[-2:] == ["oidc", "key"]:
|
if child_path[-2:] == ["oidc", "key"]:
|
||||||
return f"{_DIM}<hidden>{_RESET}"
|
return f"{_SEP}<hidden>{_RESET}"
|
||||||
return _format_value(v, resolver=resolver)
|
return _format_value(v, resolver=resolver)
|
||||||
|
|
||||||
# Helper to format path with UUID replacement
|
# Helper to format path with UUID replacement
|
||||||
@@ -342,12 +342,12 @@ def _format_change_lines(
|
|||||||
lines = []
|
lines = []
|
||||||
# First line: path with green final element and grey =
|
# First line: path with green final element and grey =
|
||||||
if len(formatted_path) == 1:
|
if len(formatted_path) == 1:
|
||||||
lines.append(f" {_ADD}{formatted_path[0]}{_RESET} {_DIM}={_RESET}")
|
lines.append(f" {_ADD}{formatted_path[0]}{_RESET} {_SEP}={_RESET}")
|
||||||
else:
|
else:
|
||||||
prefix = ".".join(formatted_path[:-1])
|
prefix = ".".join(formatted_path[:-1])
|
||||||
final = formatted_path[-1]
|
final = formatted_path[-1]
|
||||||
lines.append(
|
lines.append(
|
||||||
f" {_PATH_PREFIX}{prefix}.{_RESET}{_ADD}{final}{_RESET} {_DIM}={_RESET}"
|
f" {_PATH_PREFIX}{prefix}.{_RESET}{_ADD}{final}{_RESET} {_SEP}={_RESET}"
|
||||||
)
|
)
|
||||||
# Child lines: indented key: value, with aligned values
|
# Child lines: indented key: value, with aligned values
|
||||||
# Format keys (may contain UUIDs)
|
# Format keys (may contain UUIDs)
|
||||||
@@ -360,24 +360,24 @@ def _format_change_lines(
|
|||||||
field_width = max(max_key_len, 12) # minimum 12 chars
|
field_width = max(max_key_len, 12) # minimum 12 chars
|
||||||
for k_display, v_str in formatted_items:
|
for k_display, v_str in formatted_items:
|
||||||
padding = " " * (field_width - len(k_display))
|
padding = " " * (field_width - len(k_display))
|
||||||
lines.append(f" {k_display}{_DIM}:{_RESET}{padding} {v_str}")
|
lines.append(f" {k_display}{_SEP}:{_RESET}{padding} {v_str}")
|
||||||
return lines
|
return lines
|
||||||
else:
|
else:
|
||||||
value_str = fmt_value(value, path)
|
value_str = fmt_value(value, path)
|
||||||
if len(formatted_path) == 1:
|
if len(formatted_path) == 1:
|
||||||
return [
|
return [
|
||||||
f" {_ADD}{formatted_path[0]}{_RESET} {_DIM}={_RESET} {value_str}"
|
f" {_ADD}{formatted_path[0]}{_RESET} {_SEP}={_RESET} {value_str}"
|
||||||
]
|
]
|
||||||
prefix = ".".join(formatted_path[:-1])
|
prefix = ".".join(formatted_path[:-1])
|
||||||
final = formatted_path[-1]
|
final = formatted_path[-1]
|
||||||
return [
|
return [
|
||||||
f" {_PATH_PREFIX}{prefix}.{_RESET}{_ADD}{final}{_RESET} {_DIM}={_RESET} {value_str}"
|
f" {_PATH_PREFIX}{prefix}.{_RESET}{_ADD}{final}{_RESET} {_SEP}={_RESET} {value_str}"
|
||||||
]
|
]
|
||||||
|
|
||||||
# update: Existing item being updated - normal path colors
|
# update: Existing item being updated - normal path colors
|
||||||
value_str = fmt_value(value, path)
|
value_str = fmt_value(value, path)
|
||||||
path_str = _format_path(path, resolver=resolver)
|
path_str = _format_path(path, resolver=resolver)
|
||||||
return [f" {path_str} {_DIM}={_RESET} {value_str}"]
|
return [f" {path_str} {_SEP}={_RESET} {value_str}"]
|
||||||
|
|
||||||
|
|
||||||
def format_diff(
|
def format_diff(
|
||||||
|
|||||||
+30
-8
@@ -8,28 +8,36 @@ Each migration should be idempotent and only run when needed.
|
|||||||
import base64
|
import base64
|
||||||
from collections.abc import Awaitable, Callable
|
from collections.abc import Awaitable, Callable
|
||||||
|
|
||||||
|
import msgspec
|
||||||
|
|
||||||
from paskia.util.crypto import secret_key
|
from paskia.util.crypto import secret_key
|
||||||
|
|
||||||
|
|
||||||
def migrate_v1(d: dict, **kwargs) -> None:
|
class MigrationCtx(msgspec.Struct):
|
||||||
|
"""Context passed to each migration function."""
|
||||||
|
|
||||||
|
rp_id: str
|
||||||
|
|
||||||
|
|
||||||
|
def migrate_v1(d: dict, ctx: MigrationCtx) -> None:
|
||||||
"""Remove Org.created_at fields."""
|
"""Remove Org.created_at fields."""
|
||||||
for org_data in d["orgs"].values():
|
for org_data in d["orgs"].values():
|
||||||
org_data.pop("created_at", None)
|
org_data.pop("created_at", None)
|
||||||
|
|
||||||
|
|
||||||
def migrate_v2(d: dict, *, rp_id: str = "localhost") -> None:
|
def migrate_v2(d: dict, ctx: MigrationCtx) -> None:
|
||||||
"""Add config field if missing."""
|
"""Add config field if missing."""
|
||||||
if "config" not in d:
|
if "config" not in d:
|
||||||
d["config"] = {"rp_id": rp_id}
|
d["config"] = {"rp_id": ctx.rp_id}
|
||||||
|
|
||||||
|
|
||||||
def migrate_v3(d: dict, **kwargs) -> None:
|
def migrate_v3(d: dict, ctx: MigrationCtx) -> None:
|
||||||
"""Ensure all users have visits field."""
|
"""Ensure all users have visits field."""
|
||||||
for user_data in d["users"].values():
|
for user_data in d["users"].values():
|
||||||
user_data.setdefault("visits", 0)
|
user_data.setdefault("visits", 0)
|
||||||
|
|
||||||
|
|
||||||
def migrate_v4(d: dict, **kwargs) -> None:
|
def migrate_v4(d: dict, ctx: MigrationCtx) -> None:
|
||||||
"""OpenID Connect support and hardened session keys."""
|
"""OpenID Connect support and hardened session keys."""
|
||||||
# Session keys changed to hashes, drop old sessions
|
# Session keys changed to hashes, drop old sessions
|
||||||
d["sessions"] = {}
|
d["sessions"] = {}
|
||||||
@@ -45,14 +53,28 @@ migrations = sorted(
|
|||||||
DBVER = len(migrations) # Used by bootstrap to set initial version
|
DBVER = len(migrations) # Used by bootstrap to set initial version
|
||||||
|
|
||||||
|
|
||||||
|
def apply_migrations_readonly(
|
||||||
|
data_dict: dict,
|
||||||
|
current_version: int,
|
||||||
|
ctx: MigrationCtx,
|
||||||
|
) -> int:
|
||||||
|
"""Apply migration functions in-place without persistence.
|
||||||
|
|
||||||
|
Returns the new version after all migrations.
|
||||||
|
"""
|
||||||
|
while current_version < DBVER:
|
||||||
|
migrations[current_version](data_dict, ctx)
|
||||||
|
current_version += 1
|
||||||
|
return current_version
|
||||||
|
|
||||||
|
|
||||||
async def apply_all_migrations(
|
async def apply_all_migrations(
|
||||||
data_dict: dict,
|
data_dict: dict,
|
||||||
current_version: int,
|
current_version: int,
|
||||||
persist: Callable[[str, int, dict], Awaitable[None]],
|
persist: Callable[[str, int, dict], Awaitable[None]],
|
||||||
*,
|
ctx: MigrationCtx,
|
||||||
rp_id: str = "localhost",
|
|
||||||
) -> None:
|
) -> None:
|
||||||
while current_version < DBVER:
|
while current_version < DBVER:
|
||||||
migrations[current_version](data_dict, rp_id=rp_id)
|
migrations[current_version](data_dict, ctx)
|
||||||
current_version += 1
|
current_version += 1
|
||||||
await persist(f"migrate:v{current_version}", current_version, data_dict)
|
await persist(f"migrate:v{current_version}", current_version, data_dict)
|
||||||
|
|||||||
+4
-12
@@ -11,13 +11,10 @@ import secrets
|
|||||||
from datetime import UTC, datetime, timedelta
|
from datetime import UTC, datetime, timedelta
|
||||||
from uuid import UUID
|
from uuid import UUID
|
||||||
|
|
||||||
import base64url
|
|
||||||
import uuid7
|
import uuid7
|
||||||
|
|
||||||
|
from paskia import oidc_notify
|
||||||
from paskia.config import SESSION_LIFETIME
|
from paskia.config import SESSION_LIFETIME
|
||||||
from paskia.db.jsonl import (
|
|
||||||
JsonlStore,
|
|
||||||
)
|
|
||||||
from paskia.db.structs import (
|
from paskia.db.structs import (
|
||||||
DB,
|
DB,
|
||||||
Client,
|
Client,
|
||||||
@@ -41,9 +38,6 @@ _UNSET = object()
|
|||||||
|
|
||||||
# Global database instance (empty until init() loads data)
|
# Global database instance (empty until init() loads data)
|
||||||
_db = DB(config=Config(rp_id="uninitialized.invalid"))
|
_db = DB(config=Config(rp_id="uninitialized.invalid"))
|
||||||
_store = JsonlStore(_db)
|
|
||||||
_db._store = _store
|
|
||||||
_initialized = False
|
|
||||||
|
|
||||||
|
|
||||||
def is_username_taken(username: str, exclude_uuid: UUID | None = None) -> bool:
|
def is_username_taken(username: str, exclude_uuid: UUID | None = None) -> bool:
|
||||||
@@ -62,7 +56,7 @@ 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."""
|
"""Update the stored configuration."""
|
||||||
with _db.transaction("update_config"):
|
with _db.transaction("update_config"):
|
||||||
_db.config = config
|
_db.config = config
|
||||||
@@ -484,7 +478,6 @@ def delete_session(
|
|||||||
"""
|
"""
|
||||||
if key not in _db.sessions:
|
if key not in _db.sessions:
|
||||||
raise ValueError("Session not found")
|
raise ValueError("Session not found")
|
||||||
from paskia import oidc_notify # noqa: PLC0415
|
|
||||||
|
|
||||||
oidc_notify.schedule_notifications([key])
|
oidc_notify.schedule_notifications([key])
|
||||||
with _db.transaction(action, ctx):
|
with _db.transaction(action, ctx):
|
||||||
@@ -503,7 +496,6 @@ def delete_sessions_for_user(
|
|||||||
user = _db.users.get(user_uuid)
|
user = _db.users.get(user_uuid)
|
||||||
if not user:
|
if not user:
|
||||||
return
|
return
|
||||||
from paskia import oidc_notify # noqa: PLC0415
|
|
||||||
|
|
||||||
keys = [s.key for s in user.sessions]
|
keys = [s.key for s in user.sessions]
|
||||||
oidc_notify.schedule_notifications(keys)
|
oidc_notify.schedule_notifications(keys)
|
||||||
@@ -589,7 +581,7 @@ def login(
|
|||||||
session = Session.create(
|
session = Session.create(
|
||||||
user=user_uuid,
|
user=user_uuid,
|
||||||
credential=credential_uuid,
|
credential=credential_uuid,
|
||||||
key=base64url.enc(hash_secret("cookie", token)),
|
key=hash_secret("cookie", token),
|
||||||
host=host,
|
host=host,
|
||||||
ip=ip,
|
ip=ip,
|
||||||
user_agent=user_agent,
|
user_agent=user_agent,
|
||||||
@@ -657,7 +649,7 @@ def create_credential_session(
|
|||||||
|
|
||||||
# Generate token and derive key
|
# Generate token and derive key
|
||||||
token = secrets.token_urlsafe(12)
|
token = secrets.token_urlsafe(12)
|
||||||
key = base64url.enc(hash_secret("cookie", token))
|
key = hash_secret("cookie", token)
|
||||||
|
|
||||||
session = Session.create(
|
session = Session.create(
|
||||||
user=user_uuid,
|
user=user_uuid,
|
||||||
|
|||||||
@@ -0,0 +1,88 @@
|
|||||||
|
"""
|
||||||
|
Snapshot handling for JSONL database persistence.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from datetime import UTC, datetime
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import msgspec
|
||||||
|
|
||||||
|
_logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
LINEPREFIX = b"SNAPSHOT "
|
||||||
|
MINDIFFS = 100
|
||||||
|
|
||||||
|
|
||||||
|
class Snapshot(msgspec.Struct):
|
||||||
|
"""Snapshot data structure for database persistence."""
|
||||||
|
|
||||||
|
ts: datetime
|
||||||
|
v: int
|
||||||
|
state: dict[str, Any]
|
||||||
|
|
||||||
|
|
||||||
|
class SnapshotState:
|
||||||
|
"""Tracks snapshot timing and line counts for a database file."""
|
||||||
|
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self.ts: datetime | None = None
|
||||||
|
self.changes: int = 0
|
||||||
|
self._force_pending: bool = False
|
||||||
|
|
||||||
|
def request_force(self) -> None:
|
||||||
|
"""Request a forced snapshot on the next maybe_write call."""
|
||||||
|
self._force_pending = True
|
||||||
|
|
||||||
|
def record_lines(self, count: int) -> None:
|
||||||
|
self.changes += count
|
||||||
|
|
||||||
|
def maybe_write(self, file, version: int, state: dict) -> None:
|
||||||
|
"""Write a snapshot if conditions are met (enough changes, and Sunday UTC or forced)."""
|
||||||
|
if self.changes < MINDIFFS:
|
||||||
|
return
|
||||||
|
force = self._force_pending
|
||||||
|
now = datetime.now(UTC)
|
||||||
|
if not force and now.weekday() != 6: # 6 = Sunday
|
||||||
|
return
|
||||||
|
sunday_midnight = now.replace(hour=0, minute=0, second=0, microsecond=0)
|
||||||
|
if not force and self.ts is not None and self.ts >= sunday_midnight:
|
||||||
|
return
|
||||||
|
if not file.is_open:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
self._write(file, version, state, now)
|
||||||
|
self._force_pending = False
|
||||||
|
except Exception as exc:
|
||||||
|
_logger.error("snapshot: failed to write snapshot: %r", exc)
|
||||||
|
|
||||||
|
def _write(self, file, version: int, state: dict, now: datetime) -> None:
|
||||||
|
"""Write a snapshot and update internal state."""
|
||||||
|
data = msgspec.json.encode(Snapshot(ts=now, v=version, state=state))
|
||||||
|
file.write(LINEPREFIX + data + b"\n")
|
||||||
|
self.changes = 0
|
||||||
|
self.ts = now
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def load(data: bytes) -> tuple[Snapshot | None, int]:
|
||||||
|
"""Find and parse the last snapshot in file data.
|
||||||
|
|
||||||
|
Returns (snapshot, replay_offset) where replay_offset is the byte
|
||||||
|
position to start replaying change records from. If no valid snapshot
|
||||||
|
is found, returns (None, 0).
|
||||||
|
"""
|
||||||
|
marker = b"\n" + LINEPREFIX
|
||||||
|
pos = data.rfind(marker)
|
||||||
|
if pos != -1:
|
||||||
|
pos += 1 # skip the newline
|
||||||
|
elif data.startswith(LINEPREFIX):
|
||||||
|
pos = 0
|
||||||
|
else:
|
||||||
|
return None, 0
|
||||||
|
|
||||||
|
end = data.find(b"\n", pos)
|
||||||
|
if end == -1:
|
||||||
|
raise ValueError("Incomplete snapshot line at end of file")
|
||||||
|
|
||||||
|
snap = msgspec.json.decode(data[pos + len(LINEPREFIX) : end], type=Snapshot)
|
||||||
|
return snap, end + 1
|
||||||
+11
-15
@@ -5,12 +5,10 @@ import secrets
|
|||||||
from datetime import UTC, datetime
|
from datetime import UTC, datetime
|
||||||
from uuid import UUID
|
from uuid import UUID
|
||||||
|
|
||||||
import base64url
|
|
||||||
import msgspec
|
import msgspec
|
||||||
import uuid7
|
import uuid7
|
||||||
|
|
||||||
from paskia import db
|
from paskia import db
|
||||||
from paskia.util import hostutil
|
|
||||||
from paskia.util import passphrase as passphrase_util
|
from paskia.util import passphrase as passphrase_util
|
||||||
from paskia.util.crypto import hash_secret
|
from paskia.util.crypto import hash_secret
|
||||||
|
|
||||||
@@ -434,7 +432,7 @@ class Session(msgspec.Struct, dict=True, omit_defaults=True):
|
|||||||
"""Create a new Session with the provided key.
|
"""Create a new Session with the provided key.
|
||||||
|
|
||||||
Args:
|
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:
|
Returns:
|
||||||
Session object with key set
|
Session object with key set
|
||||||
@@ -471,7 +469,7 @@ class ResetToken(msgspec.Struct, dict=True):
|
|||||||
|
|
||||||
def __post_init__(self):
|
def __post_init__(self):
|
||||||
if not hasattr(self, "key"):
|
if not hasattr(self, "key"):
|
||||||
self.key: bytes = b""
|
self.key: str = ""
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def user(self) -> User:
|
def user(self) -> User:
|
||||||
@@ -487,15 +485,15 @@ class ResetToken(msgspec.Struct, dict=True):
|
|||||||
del db.data().reset_tokens[self.key]
|
del db.data().reset_tokens[self.key]
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def hash(passphrase: str) -> bytes:
|
def hash(passphrase: str) -> str:
|
||||||
"""Hash a passphrase to bytes for reset token storage."""
|
"""Hash a passphrase to string for reset token storage."""
|
||||||
if not passphrase_util.is_well_formed(passphrase):
|
if not passphrase_util.is_well_formed(passphrase):
|
||||||
raise ValueError(
|
raise ValueError(
|
||||||
"Trying to reset with a session token in place of a passphrase"
|
"Trying to reset with a session token in place of a passphrase"
|
||||||
if len(passphrase) == 16
|
if len(passphrase) == 16
|
||||||
else "Invalid passphrase format"
|
else "Invalid passphrase format"
|
||||||
)
|
)
|
||||||
return hashlib.sha512(passphrase.encode()).digest()[:9]
|
return hash_secret("reset", passphrase)
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def by_passphrase(cls, passphrase: str) -> ResetToken | None:
|
def by_passphrase(cls, passphrase: str) -> ResetToken | None:
|
||||||
@@ -602,14 +600,14 @@ class OIDC(msgspec.Struct, dict=True):
|
|||||||
key: bytes | None = None
|
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."""
|
"""Stored configuration for the instance."""
|
||||||
|
|
||||||
rp_id: str
|
rp_id: str
|
||||||
rp_name: str | None = None
|
rp_name: str | None = None
|
||||||
origins: list[str] | None = None
|
|
||||||
auth_host: str | None = None
|
auth_host: str | None = None
|
||||||
listen: str | None = None
|
origins: list[str] | None = None
|
||||||
|
listen: list[str] | None = None
|
||||||
|
|
||||||
|
|
||||||
# -------------------------------------------------------------------------
|
# -------------------------------------------------------------------------
|
||||||
@@ -627,7 +625,7 @@ class DB(msgspec.Struct, dict=True, omit_defaults=False):
|
|||||||
users: dict[UUID, User] = {}
|
users: dict[UUID, User] = {}
|
||||||
credentials: dict[UUID, Credential] = {}
|
credentials: dict[UUID, Credential] = {}
|
||||||
sessions: dict[str, Session] = {}
|
sessions: dict[str, Session] = {}
|
||||||
reset_tokens: dict[bytes, ResetToken] = {}
|
reset_tokens: dict[str, ResetToken] = {}
|
||||||
# OIDC provider data
|
# OIDC provider data
|
||||||
oidc: OIDC = msgspec.field(default_factory=lambda: OIDC())
|
oidc: OIDC = msgspec.field(default_factory=lambda: OIDC())
|
||||||
|
|
||||||
@@ -670,7 +668,7 @@ class DB(msgspec.Struct, dict=True, omit_defaults=False):
|
|||||||
SessionContext if valid, None if session not found, expired, or host mismatch
|
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:
|
try:
|
||||||
s = self.sessions[key]
|
s = self.sessions[key]
|
||||||
except KeyError:
|
except KeyError:
|
||||||
@@ -680,10 +678,8 @@ class DB(msgspec.Struct, dict=True, omit_defaults=False):
|
|||||||
if s.client_uuid is not None:
|
if s.client_uuid is not None:
|
||||||
return 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)
|
# Validate host matches (sessions are always created with a host)
|
||||||
|
normalized_input = host
|
||||||
if s.host != normalized_input:
|
if s.host != normalized_input:
|
||||||
# Session bound to different host
|
# Session bound to different host
|
||||||
return None
|
return None
|
||||||
|
|||||||
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,116 @@
|
|||||||
|
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 (
|
||||||
|
permutil,
|
||||||
|
vitedev,
|
||||||
|
)
|
||||||
|
from paskia.util.apistructs import (
|
||||||
|
ApiAdminInfo,
|
||||||
|
ApiOidcClient,
|
||||||
|
ApiOrg,
|
||||||
|
ApiOrgResponse,
|
||||||
|
ApiPermission,
|
||||||
|
)
|
||||||
|
|
||||||
|
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: u 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.instance.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.instance
|
||||||
|
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.instance
|
||||||
|
|
||||||
|
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 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),
|
||||||
|
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}
|
||||||
+22
-25
@@ -15,12 +15,12 @@ from fastapi.security import HTTPBearer
|
|||||||
|
|
||||||
from paskia import authcode, db
|
from paskia import authcode, db
|
||||||
from paskia._version import __version__
|
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 import authz, session, user
|
||||||
from paskia.fastapi.response import MsgspecResponse
|
from paskia.fastapi.response import MsgspecResponse
|
||||||
from paskia.fastapi.session import AUTH_COOKIE, AUTH_COOKIE_NAME, get_client_ip
|
from paskia.fastapi.session import AUTH_COOKIE, AUTH_COOKIE_NAME, get_client_ip
|
||||||
from paskia.globals import passkey as global_passkey
|
from paskia.globals import passkey as global_passkey
|
||||||
from paskia.util import hostutil, htmlutil, passphrase, userinfo, vitedev
|
from paskia.util import hostutil, htmlutil, passphrase, userinfo
|
||||||
from paskia.util.apistructs import ApiSettings, ApiTokenInfo, ApiValidateResponse
|
from paskia.util.apistructs import ApiSettings, ApiTokenInfo, ApiValidateResponse
|
||||||
|
|
||||||
bearer_auth = HTTPBearer(auto_error=False)
|
bearer_auth = HTTPBearer(auto_error=False)
|
||||||
@@ -161,25 +161,15 @@ async def forward_authentication(
|
|||||||
# Clear cookie only if session is invalid (not for reauth)
|
# Clear cookie only if session is invalid (not for reauth)
|
||||||
if e.clear_session:
|
if e.clear_session:
|
||||||
session.clear_session_cookie(response)
|
session.clear_session_cookie(response)
|
||||||
|
# Browser request? - return full-page HTML with metadata patched into data attrs
|
||||||
# Check Accept header to decide response format
|
if "text/html" in request.headers.get("accept", ""):
|
||||||
accept = request.headers.get("accept", "")
|
return await htmlutil.patched_html_response(
|
||||||
wants_html = "text/html" in accept
|
request, "/int/forward/", e.status_code, mode=e.mode, **e.metadata
|
||||||
|
|
||||||
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),
|
|
||||||
)
|
)
|
||||||
|
# API request - return JSON with iframe srcdoc HTML
|
||||||
|
return JSONResponse(
|
||||||
|
status_code=e.status_code, content=await authz.auth_error_content(e)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@app.get("/settings")
|
@app.get("/settings")
|
||||||
@@ -195,7 +185,8 @@ async def get_settings():
|
|||||||
auth_site_url=hostutil.auth_site_url(),
|
auth_site_url=hostutil.auth_site_url(),
|
||||||
session_cookie=AUTH_COOKIE_NAME,
|
session_cookie=AUTH_COOKIE_NAME,
|
||||||
version=__version__,
|
version=__version__,
|
||||||
)
|
),
|
||||||
|
headers={"Access-Control-Allow-Origin": "*", "Vary": "Origin"},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -212,9 +203,14 @@ async def api_user_info(
|
|||||||
detail="Authentication required",
|
detail="Authentication required",
|
||||||
mode="login",
|
mode="login",
|
||||||
)
|
)
|
||||||
ctx = db.data().session_ctx(auth, request.headers.get("host"))
|
ctx = session_ctx(auth, request.headers.get("host"))
|
||||||
if not ctx:
|
if not ctx:
|
||||||
raise HTTPException(401, "Session expired")
|
raise authz.AuthException(
|
||||||
|
status_code=401,
|
||||||
|
detail="Session expired",
|
||||||
|
mode="login",
|
||||||
|
clear_session=True,
|
||||||
|
)
|
||||||
|
|
||||||
return MsgspecResponse(
|
return MsgspecResponse(
|
||||||
await userinfo.build_user_info(
|
await userinfo.build_user_info(
|
||||||
@@ -244,6 +240,7 @@ async def token_info(credentials=Depends(bearer_auth)):
|
|||||||
ApiTokenInfo(
|
ApiTokenInfo(
|
||||||
token_type=reset_token.token_type,
|
token_type=reset_token.token_type,
|
||||||
display_name=u.display_name,
|
display_name=u.display_name,
|
||||||
|
theme=u.theme,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -253,7 +250,7 @@ async def api_logout(request: Request, response: Response, auth=AUTH_COOKIE):
|
|||||||
if not auth:
|
if not auth:
|
||||||
return {"message": "Already logged out"}
|
return {"message": "Already logged out"}
|
||||||
host = request.headers.get("host")
|
host = request.headers.get("host")
|
||||||
ctx = db.data().session_ctx(auth, host)
|
ctx = session_ctx(auth, host)
|
||||||
if not ctx:
|
if not ctx:
|
||||||
return {"message": "Already logged out"}
|
return {"message": "Already logged out"}
|
||||||
with suppress(Exception):
|
with suppress(Exception):
|
||||||
@@ -286,7 +283,7 @@ async def api_set_session(
|
|||||||
secret = a.session_key
|
secret = a.session_key
|
||||||
|
|
||||||
# Verify the session exists
|
# Verify the session exists
|
||||||
ctx = db.data().session_ctx(secret, host)
|
ctx = session_ctx(secret, host)
|
||||||
if not ctx:
|
if not ctx:
|
||||||
raise HTTPException(401, f"Session not found on {host}")
|
raise HTTPException(401, f"Session not found on {host}")
|
||||||
|
|
||||||
|
|||||||
@@ -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",
|
||||||
|
)
|
||||||
+14
-39
@@ -115,25 +115,16 @@ 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
|
||||||
) -> str:
|
) -> str:
|
||||||
"""Format access log line with colors and aligned fields."""
|
"""Format access log line with colors and aligned fields."""
|
||||||
use_color = sys.stderr.isatty()
|
|
||||||
|
|
||||||
# Format components with fixed widths for alignment
|
# Format components with fixed widths for alignment
|
||||||
ip = format_client_ip(client).ljust(19) # IPv6 network max 19 chars
|
ip = format_client_ip(client).ljust(19) # IPv6 network max 19 chars
|
||||||
timing = f"{duration_ms:.0f}ms"
|
timing = f"{duration_ms:.0f}ms"
|
||||||
method_padded = method.ljust(7) # Longest method is OPTIONS (7)
|
method_padded = method.ljust(7) # Longest method is OPTIONS (7)
|
||||||
|
|
||||||
if use_color:
|
status_str = f"{status_color(status)}{status}{_RESET}"
|
||||||
status_str = f"{status_color(status)}{status}{_RESET}"
|
timing_str = f"{_TIMING}{timing}{_RESET}"
|
||||||
timing_str = f"{_TIMING}{timing}{_RESET}"
|
method_str = f"{method_color(method)}{method_padded}{_RESET}"
|
||||||
method_str = f"{method_color(method)}{method_padded}{_RESET}"
|
host_str = f"{_HOST}{host}{_RESET}"
|
||||||
host_str = f"{_HOST}{host}{_RESET}"
|
path_str = f"{_PATH}{path}{_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
|
|
||||||
|
|
||||||
# Format: "IP STATUS METHOD host path TIMING"
|
# Format: "IP STATUS METHOD host path TIMING"
|
||||||
return f"{ip} {status_str} {method_str} {host_str}{path_str} {timing_str}"
|
return f"{ip} {status_str} {method_str} {host_str}{path_str} {timing_str}"
|
||||||
@@ -153,7 +144,6 @@ def _next_ws_id() -> int:
|
|||||||
|
|
||||||
def log_ws_open(ws) -> int:
|
def log_ws_open(ws) -> int:
|
||||||
"""Log WebSocket connection open. Returns connection ID for use in close."""
|
"""Log WebSocket connection open. Returns connection ID for use in close."""
|
||||||
use_color = sys.stderr.isatty()
|
|
||||||
ws_id = _next_ws_id()
|
ws_id = _next_ws_id()
|
||||||
|
|
||||||
client = ws.client.host if ws.client else "-"
|
client = ws.client.host if ws.client else "-"
|
||||||
@@ -169,19 +159,11 @@ def log_ws_open(ws) -> int:
|
|||||||
origin_host = origin.split("://", 1)[-1] if origin else None
|
origin_host = origin.split("://", 1)[-1] if origin else None
|
||||||
show_origin = origin_host and origin_host != host
|
show_origin = origin_host and origin_host != host
|
||||||
|
|
||||||
if use_color:
|
# 🔌 aligned with status (takes ~2 char width), ID aligned with method
|
||||||
# 🔌 aligned with status (takes ~2 char width), ID aligned with method
|
prefix = f"🔌 {_WS_OPEN}{id_str}{_RESET}"
|
||||||
prefix = f"🔌 {_WS_OPEN}{id_str}{_RESET}"
|
host_str = f"{_HOST}{host}{_RESET}"
|
||||||
host_str = f"{_HOST}{host}{_RESET}"
|
path_str = f"{_PATH}{path}{_RESET}"
|
||||||
path_str = f"{_PATH}{path}{_RESET}"
|
origin_str = f" {_RESET}from {_HOST}{origin_host}{_RESET}" if show_origin else ""
|
||||||
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 ""
|
|
||||||
|
|
||||||
logger.info(f"{ip} {prefix} {host_str}{path_str}{origin_str}")
|
logger.info(f"{ip} {prefix} {host_str}{path_str}{origin_str}")
|
||||||
return ws_id
|
return ws_id
|
||||||
@@ -209,8 +191,6 @@ WS_CLOSE_CODES = {
|
|||||||
|
|
||||||
def log_ws_close(ws_id: int, close_code: int | None, duration: float) -> None:
|
def log_ws_close(ws_id: int, close_code: int | None, duration: float) -> None:
|
||||||
"""Log WebSocket connection close with duration and status."""
|
"""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_str = f"{ws_id:02d}".ljust(7) # Align with method field (7 chars)
|
||||||
timing = f"{duration * 1000:.0f}ms"
|
timing = f"{duration * 1000:.0f}ms"
|
||||||
|
|
||||||
@@ -220,15 +200,10 @@ def log_ws_close(ws_id: int, close_code: int | None, duration: float) -> None:
|
|||||||
else:
|
else:
|
||||||
status = WS_CLOSE_CODES.get(close_code, f"code {close_code}")
|
status = WS_CLOSE_CODES.get(close_code, f"code {close_code}")
|
||||||
|
|
||||||
if use_color:
|
# 🔌 aligned with status, ID aligned with method
|
||||||
# 🔌 aligned with status, ID aligned with method
|
prefix = f"🔌 {_WS_CLOSE}{id_str}{_RESET}"
|
||||||
prefix = f"🔌 {_WS_CLOSE}{id_str}{_RESET}"
|
status_str = f"{_WS_STATUS}{status}{_RESET}"
|
||||||
status_str = f"{_WS_STATUS}{status}{_RESET}"
|
timing_str = f"{_TIMING}{timing}{_RESET}"
|
||||||
timing_str = f"{_TIMING}{timing}{_RESET}"
|
|
||||||
else:
|
|
||||||
prefix = f"WS- {id_str}"
|
|
||||||
status_str = status
|
|
||||||
timing_str = timing
|
|
||||||
|
|
||||||
logger.info(f"{' ' * 19} {prefix} {status_str} {timing_str}")
|
logger.info(f"{' ' * 19} {prefix} {status_str} {timing_str}")
|
||||||
|
|
||||||
|
|||||||
+25
-23
@@ -1,21 +1,27 @@
|
|||||||
import json
|
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
from contextlib import asynccontextmanager
|
from contextlib import asynccontextmanager
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
|
import msgspec
|
||||||
from fastapi import FastAPI, HTTPException, Request, Response
|
from fastapi import FastAPI, HTTPException, Request, Response
|
||||||
from fastapi.responses import FileResponse, RedirectResponse
|
from fastapi.responses import FileResponse, RedirectResponse
|
||||||
from fastapi_vue import Frontend
|
|
||||||
|
|
||||||
from paskia import authcode, globals
|
from paskia import authcode, db, globals
|
||||||
from paskia.__main__ import DEVMODE
|
from paskia.__main__ import DEVMODE
|
||||||
|
from paskia.bootstrap import bootstrap_if_needed
|
||||||
from paskia.db import start_background, stop_background
|
from paskia.db import start_background, stop_background
|
||||||
|
from paskia.db.background import flush
|
||||||
from paskia.db.logging import configure_db_logging
|
from paskia.db.logging import configure_db_logging
|
||||||
from paskia.fastapi import admin, api, auth_host, oid, ws
|
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.logging import AccessLogMiddleware, configure_access_logging
|
||||||
from paskia.fastapi.session import AUTH_COOKIE
|
from paskia.fastapi.session import AUTH_COOKIE
|
||||||
from paskia.util import hostutil, passphrase, vitedev
|
from paskia.util import hostutil, passphrase, vitedev
|
||||||
|
from paskia.util.runtime import RuntimeConfig
|
||||||
|
|
||||||
# Configure custom logging
|
# Configure custom logging
|
||||||
configure_access_logging()
|
configure_access_logging()
|
||||||
@@ -23,14 +29,6 @@ configure_db_logging()
|
|||||||
|
|
||||||
_access_logger = logging.getLogger("paskia.access")
|
_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
|
# Path to examples/index.html when running from source tree
|
||||||
_EXAMPLES_DIR = Path(__file__).parent.parent.parent / "examples"
|
_EXAMPLES_DIR = Path(__file__).parent.parent.parent / "examples"
|
||||||
|
|
||||||
@@ -43,14 +41,13 @@ async def lifespan(app: FastAPI): # pragma: no cover - startup path
|
|||||||
so that uvicorn reload / multiprocess workers inherit the settings.
|
so that uvicorn reload / multiprocess workers inherit the settings.
|
||||||
All keys are guaranteed to exist; values are already normalized by __main__.py.
|
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:
|
try:
|
||||||
# CLI (__main__) performs bootstrap once; here we skip to avoid duplicate work
|
|
||||||
await globals.init(
|
await globals.init(
|
||||||
rp_id=config["rp_id"],
|
rp_id=runtime.config.rp_id,
|
||||||
rp_name=config["rp_name"],
|
rp_name=runtime.config.rp_name,
|
||||||
origins=config["origins"],
|
origins=runtime.config.origins,
|
||||||
bootstrap=False,
|
bootstrap=False,
|
||||||
)
|
)
|
||||||
except ValueError as e:
|
except ValueError as e:
|
||||||
@@ -58,6 +55,12 @@ async def lifespan(app: FastAPI): # pragma: no cover - startup path
|
|||||||
# Re-raise to fail fast
|
# Re-raise to fail fast
|
||||||
raise
|
raise
|
||||||
|
|
||||||
|
# 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)
|
||||||
|
await flush()
|
||||||
|
|
||||||
# Restore uvicorn info logging (suppressed during startup in dev mode)
|
# Restore uvicorn info logging (suppressed during startup in dev mode)
|
||||||
# Keep uvicorn.error at WARNING to suppress WebSocket "connection open/closed" messages
|
# Keep uvicorn.error at WARNING to suppress WebSocket "connection open/closed" messages
|
||||||
if app.debug:
|
if app.debug:
|
||||||
@@ -131,9 +134,9 @@ async def openid_configuration(request: Request):
|
|||||||
|
|
||||||
@app.get("/auth/restricted/iframe")
|
@app.get("/auth/restricted/iframe")
|
||||||
@app.get("/auth/restricted/oidc")
|
@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."""
|
"""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
|
# Navigable URLs are defined here. We support both / and /auth/ as the base path
|
||||||
@@ -148,7 +151,7 @@ async def frontapp(request: Request, response: Response, auth=AUTH_COOKIE):
|
|||||||
The frontend handles mode detection (host mode vs full profile) based on settings.
|
The frontend handles mode detection (host mode vs full profile) based on settings.
|
||||||
Access control is handled via APIs.
|
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)
|
@app.get("/admin", include_in_schema=False)
|
||||||
@@ -160,7 +163,7 @@ async def admin_root_redirect():
|
|||||||
@app.get("/admin/", include_in_schema=False)
|
@app.get("/admin/", include_in_schema=False)
|
||||||
@app.get("/auth/admin/", include_in_schema=False)
|
@app.get("/auth/admin/", include_in_schema=False)
|
||||||
async def admin_root(request: Request, auth=AUTH_COOKIE):
|
async def admin_root(request: Request, auth=AUTH_COOKIE):
|
||||||
return await admin.adminapp(request, auth) # Delegated to admin app
|
return await adminapp(request, auth) # Delegated to admin app
|
||||||
|
|
||||||
|
|
||||||
@app.get("/auth/examples/", include_in_schema=False)
|
@app.get("/auth/examples/", include_in_schema=False)
|
||||||
@@ -180,14 +183,13 @@ async def examples_page():
|
|||||||
|
|
||||||
|
|
||||||
# Frontend static files - must be before /{token} catch-all routes
|
# Frontend static files - must be before /{token} catch-all routes
|
||||||
# (actual routes registered during lifespan after frontend.load())
|
|
||||||
frontend.route(app, "/")
|
frontend.route(app, "/")
|
||||||
|
|
||||||
|
|
||||||
# Note: this catch-all handler must be the last route defined
|
# Note: this catch-all handler must be the last route defined
|
||||||
@app.get("/{token}")
|
@app.get("/{token}")
|
||||||
@app.get("/auth/{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).
|
"""Serve the reset app for reset tokens (password reset / device addition).
|
||||||
|
|
||||||
The frontend will validate the token via /auth/api/token-info.
|
The frontend will validate the token via /auth/api/token-info.
|
||||||
@@ -195,4 +197,4 @@ async def token_link(token: str):
|
|||||||
if not passphrase.is_well_formed(token):
|
if not passphrase.is_well_formed(token):
|
||||||
raise HTTPException(status_code=404)
|
raise HTTPException(status_code=404)
|
||||||
|
|
||||||
return Response(*await vitedev.read("/int/reset/index.html"))
|
return await vitedev.handle(request, frontend, "/int/reset/")
|
||||||
|
|||||||
@@ -40,7 +40,7 @@ def _oidc_session_by_token(
|
|||||||
token: str, client_uuid: UUID | None = None
|
token: str, client_uuid: UUID | None = None
|
||||||
) -> Session | None:
|
) -> Session | None:
|
||||||
"""Look up an OIDC session by token (refresh token value)."""
|
"""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)
|
s = db.data().sessions.get(key)
|
||||||
if not s or s.client_uuid is None:
|
if not s or s.client_uuid is None:
|
||||||
return None
|
return None
|
||||||
@@ -259,8 +259,10 @@ async def _handle_refresh_token(
|
|||||||
The refresh_token is the session secret. On refresh:
|
The refresh_token is the session secret. On refresh:
|
||||||
- Validates session exists and belongs to client
|
- Validates session exists and belongs to client
|
||||||
- Extends session expiry (24h sliding window)
|
- Extends session expiry (24h sliding window)
|
||||||
- Records current IP and user_agent
|
|
||||||
- Issues new access_token and id_token
|
- 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:
|
if not refresh_token_value:
|
||||||
return JSONResponse(
|
return JSONResponse(
|
||||||
@@ -287,17 +289,13 @@ async def _handle_refresh_token(
|
|||||||
status_code=400,
|
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)
|
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(
|
db.update_session(
|
||||||
session.key,
|
session.key,
|
||||||
ip=ip,
|
|
||||||
user_agent=user_agent,
|
|
||||||
validated=now,
|
validated=now,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ from paskia import db
|
|||||||
from paskia.authsession import (
|
from paskia.authsession import (
|
||||||
delete_credential,
|
delete_credential,
|
||||||
expires,
|
expires,
|
||||||
|
session_ctx,
|
||||||
)
|
)
|
||||||
from paskia.fastapi import authz, session
|
from paskia.fastapi import authz, session
|
||||||
from paskia.fastapi.response import MsgspecResponse
|
from paskia.fastapi.response import MsgspecResponse
|
||||||
@@ -45,7 +46,7 @@ async def user_update_display_name(
|
|||||||
status_code=401, detail="Authentication Required", mode="login"
|
status_code=401, detail="Authentication Required", mode="login"
|
||||||
)
|
)
|
||||||
host = request.headers.get("host")
|
host = request.headers.get("host")
|
||||||
ctx = db.data().session_ctx(auth, host)
|
ctx = session_ctx(auth, host)
|
||||||
if not ctx:
|
if not ctx:
|
||||||
raise authz.AuthException(
|
raise authz.AuthException(
|
||||||
status_code=401, detail="Session expired", mode="login"
|
status_code=401, detail="Session expired", mode="login"
|
||||||
@@ -74,7 +75,7 @@ async def user_update_info(
|
|||||||
raise authz.AuthException(
|
raise authz.AuthException(
|
||||||
status_code=401, detail="Authentication Required", mode="login"
|
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:
|
if not ctx:
|
||||||
raise authz.AuthException(
|
raise authz.AuthException(
|
||||||
status_code=401, detail="Session expired", mode="login"
|
status_code=401, detail="Session expired", mode="login"
|
||||||
@@ -112,7 +113,7 @@ async def user_update_theme(
|
|||||||
raise authz.AuthException(
|
raise authz.AuthException(
|
||||||
status_code=401, detail="Authentication Required", mode="login"
|
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:
|
if not ctx:
|
||||||
raise authz.AuthException(
|
raise authz.AuthException(
|
||||||
status_code=401, detail="Session expired", mode="login"
|
status_code=401, detail="Session expired", mode="login"
|
||||||
@@ -129,7 +130,7 @@ async def api_logout_all(request: Request, response: Response, auth=AUTH_COOKIE)
|
|||||||
if not auth:
|
if not auth:
|
||||||
return {"message": "Already logged out"}
|
return {"message": "Already logged out"}
|
||||||
host = request.headers.get("host")
|
host = request.headers.get("host")
|
||||||
ctx = db.data().session_ctx(auth, host)
|
ctx = session_ctx(auth, host)
|
||||||
if not ctx:
|
if not ctx:
|
||||||
raise authz.AuthException(
|
raise authz.AuthException(
|
||||||
status_code=401, detail="Session expired", mode="login"
|
status_code=401, detail="Session expired", mode="login"
|
||||||
@@ -151,7 +152,7 @@ async def api_delete_session(
|
|||||||
status_code=401, detail="Authentication Required", mode="login"
|
status_code=401, detail="Authentication Required", mode="login"
|
||||||
)
|
)
|
||||||
host = request.headers.get("host")
|
host = request.headers.get("host")
|
||||||
ctx = db.data().session_ctx(auth, host)
|
ctx = session_ctx(auth, host)
|
||||||
if not ctx:
|
if not ctx:
|
||||||
raise authz.AuthException(
|
raise authz.AuthException(
|
||||||
status_code=401, detail="Session expired", mode="login"
|
status_code=401, detail="Session expired", mode="login"
|
||||||
|
|||||||
@@ -3,12 +3,11 @@ from datetime import UTC, datetime
|
|||||||
from urllib.parse import urlencode
|
from urllib.parse import urlencode
|
||||||
from uuid import UUID
|
from uuid import UUID
|
||||||
|
|
||||||
import base64url
|
|
||||||
from fastapi import FastAPI, WebSocket
|
from fastapi import FastAPI, WebSocket
|
||||||
|
|
||||||
from paskia import authcode, db
|
from paskia import authcode, db
|
||||||
from paskia.authcode import CookieCode, OIDCCode
|
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.db.structs import Session
|
||||||
from paskia.fastapi import authz, remote
|
from paskia.fastapi import authz, remote
|
||||||
from paskia.fastapi.session import AUTH_COOKIE, infodict
|
from paskia.fastapi.session import AUTH_COOKIE, infodict
|
||||||
@@ -196,7 +195,7 @@ async def websocket_authenticate(
|
|||||||
# If there's an existing session, restrict to that user's credentials (reauth)
|
# If there's an existing session, restrict to that user's credentials (reauth)
|
||||||
session_user_uuid = None
|
session_user_uuid = None
|
||||||
if auth:
|
if auth:
|
||||||
existing_ctx = db.data().session_ctx(auth, host)
|
existing_ctx = session_ctx(auth, host)
|
||||||
if existing_ctx:
|
if existing_ctx:
|
||||||
session_user_uuid = existing_ctx.user.uuid
|
session_user_uuid = existing_ctx.user.uuid
|
||||||
|
|
||||||
@@ -218,7 +217,7 @@ async def websocket_authenticate(
|
|||||||
session = Session.create(
|
session = Session.create(
|
||||||
user=cred.user_uuid,
|
user=cred.user_uuid,
|
||||||
credential=cred.uuid,
|
credential=cred.uuid,
|
||||||
key=base64url.enc(hash_secret("oidc", token)),
|
key=hash_secret("oidc", token),
|
||||||
host=normalized_host,
|
host=normalized_host,
|
||||||
ip=metadata["ip"],
|
ip=metadata["ip"],
|
||||||
user_agent=metadata["user_agent"],
|
user_agent=metadata["user_agent"],
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ from uuid import UUID
|
|||||||
from fastapi import WebSocket
|
from fastapi import WebSocket
|
||||||
|
|
||||||
from paskia import db
|
from paskia import db
|
||||||
|
from paskia.authsession import session_ctx
|
||||||
from paskia.db import Credential, SessionContext
|
from paskia.db import Credential, SessionContext
|
||||||
from paskia.fastapi.session import infodict
|
from paskia.fastapi.session import infodict
|
||||||
from paskia.fastapi.wsutil import validate_origin
|
from paskia.fastapi.wsutil import validate_origin
|
||||||
@@ -90,7 +91,7 @@ async def authenticate_and_login(
|
|||||||
# Get credential IDs if restricting to a user's credentials
|
# Get credential IDs if restricting to a user's credentials
|
||||||
credential_ids = None
|
credential_ids = None
|
||||||
if auth:
|
if auth:
|
||||||
existing_ctx = db.data().session_ctx(auth, host)
|
existing_ctx = session_ctx(auth, host)
|
||||||
if existing_ctx:
|
if existing_ctx:
|
||||||
credential_ids = existing_ctx.user.credential_ids or None
|
credential_ids = existing_ctx.user.credential_ids or None
|
||||||
|
|
||||||
@@ -107,7 +108,7 @@ async def authenticate_and_login(
|
|||||||
)
|
)
|
||||||
|
|
||||||
# Fetch and return the full session context
|
# Fetch and return the full session context
|
||||||
ctx = db.data().session_ctx(secret, normalized_host)
|
ctx = session_ctx(secret, host)
|
||||||
if not ctx:
|
if not ctx:
|
||||||
raise ValueError("Failed to create session context")
|
raise ValueError("Failed to create session context")
|
||||||
return ctx, secret
|
return ctx, secret
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ import httpx
|
|||||||
|
|
||||||
from paskia import db
|
from paskia import db
|
||||||
from paskia.util import oidjwt
|
from paskia.util import oidjwt
|
||||||
from paskia.util.hostutil import _load_config
|
from paskia.util.runtime import _load_config
|
||||||
|
|
||||||
_logger = logging.getLogger(__name__)
|
_logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|||||||
@@ -171,11 +171,12 @@ class ApiSettings(msgspec.Struct):
|
|||||||
version: str
|
version: str
|
||||||
|
|
||||||
|
|
||||||
class ApiTokenInfo(msgspec.Struct):
|
class ApiTokenInfo(msgspec.Struct, omit_defaults=True):
|
||||||
"""Token info response struct."""
|
"""Token info response struct."""
|
||||||
|
|
||||||
token_type: str
|
token_type: str
|
||||||
display_name: str
|
display_name: str
|
||||||
|
theme: str = ""
|
||||||
|
|
||||||
|
|
||||||
class ApiUuidResponse(msgspec.Struct):
|
class ApiUuidResponse(msgspec.Struct):
|
||||||
|
|||||||
@@ -1,17 +1,15 @@
|
|||||||
import hashlib
|
import hashlib
|
||||||
|
|
||||||
|
import base64url
|
||||||
from cryptography.hazmat.primitives import serialization
|
from cryptography.hazmat.primitives import serialization
|
||||||
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
|
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
|
||||||
|
|
||||||
|
|
||||||
def hash_secret(*data) -> bytes:
|
def hash_secret(*data: str | bytes, length=12) -> str:
|
||||||
"""A custom HMAC that securily combines and hashes the given data (context, secrets). The first argument should be a namespacing string."""
|
"""A custom HMAC that securily combines and hashes the given data. The first argument should be a namespacing string."""
|
||||||
inner = bytearray(len(data).to_bytes(8, "big"))
|
p = [d.encode() if hasattr(d, "encode") else d for d in data]
|
||||||
for d in data:
|
p += [len(x).to_bytes(8, "little") for x in [p, *p]]
|
||||||
if isinstance(d, str):
|
return base64url.enc(hashlib.sha256(b"".join(p)).digest()[:length])
|
||||||
d = d.encode()
|
|
||||||
inner += hashlib.sha256(d).digest()
|
|
||||||
return hashlib.sha256(inner).digest()[:12]
|
|
||||||
|
|
||||||
|
|
||||||
def secret_key() -> bytes:
|
def secret_key() -> bytes:
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ __all__ = ["path", "file", "read", "is_dev_mode"]
|
|||||||
|
|
||||||
def _get_dev_server() -> str | None:
|
def _get_dev_server() -> str | None:
|
||||||
"""Get the dev server URL from environment, or None if not in dev mode."""
|
"""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:
|
def _resolve_static_dir() -> Path:
|
||||||
|
|||||||
+71
-16
@@ -1,27 +1,23 @@
|
|||||||
"""Utilities for determining the auth UI host and base URLs."""
|
"""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 urllib.parse import urlparse, urlsplit
|
||||||
|
|
||||||
|
from paskia.util.runtime import _load_config
|
||||||
|
|
||||||
@lru_cache(maxsize=1)
|
|
||||||
def _load_config() -> dict:
|
def _cfg():
|
||||||
"""Load PASKIA_CONFIG JSON."""
|
return _load_config()
|
||||||
config_json = os.getenv("PASKIA_CONFIG")
|
|
||||||
if not config_json:
|
|
||||||
return {}
|
|
||||||
return json.loads(config_json)
|
|
||||||
|
|
||||||
|
|
||||||
def is_root_mode() -> bool:
|
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:
|
def dedicated_auth_host() -> str | None:
|
||||||
"""Return configured auth_host netloc, or 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:
|
if not auth_host:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
@@ -35,8 +31,10 @@ def ui_base_path() -> str:
|
|||||||
|
|
||||||
def auth_site_url() -> str:
|
def auth_site_url() -> str:
|
||||||
"""Return the base URL for the auth site UI (computed at startup)."""
|
"""Return the base URL for the auth site UI (computed at startup)."""
|
||||||
cfg = _load_config()
|
cfg = _cfg()
|
||||||
return cfg.get("site_url", "https://localhost") + cfg.get("site_path", "/auth/")
|
if cfg:
|
||||||
|
return cfg.site_url + cfg.site_path
|
||||||
|
return "https://localhost/auth/"
|
||||||
|
|
||||||
|
|
||||||
def reset_link_url(token: str) -> str:
|
def reset_link_url(token: str) -> str:
|
||||||
@@ -45,10 +43,55 @@ def reset_link_url(token: str) -> str:
|
|||||||
|
|
||||||
|
|
||||||
def normalize_origin(origin: 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:
|
if "://" not in origin:
|
||||||
return f"https://{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:
|
def reload_config() -> None:
|
||||||
@@ -74,3 +117,15 @@ def normalize_host(raw_host: str | None) -> str | None:
|
|||||||
# Strip port from host:port
|
# Strip port from host:port
|
||||||
netloc = netloc.rsplit(":", 1)[0]
|
netloc = netloc.rsplit(":", 1)[0]
|
||||||
return netloc.lower() or None
|
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
|
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:
|
def patch_html_data_attrs(html: bytes, **data_attrs: str) -> bytes:
|
||||||
"""Patch HTML by adding data attributes to the <html> tag.
|
"""Patch HTML by adding data attributes to the <html> tag.
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
from collections.abc import Sequence
|
from collections.abc import Sequence
|
||||||
from fnmatch import fnmatchcase
|
from fnmatch import fnmatchcase
|
||||||
|
|
||||||
from paskia import db
|
from paskia.authsession import session_ctx
|
||||||
from paskia.util.hostutil import normalize_host
|
from paskia.util.hostutil import normalize_host
|
||||||
|
|
||||||
__all__ = ["has_any", "has_all", "session_context"]
|
__all__ = ["has_any", "has_all", "session_context"]
|
||||||
@@ -40,4 +40,4 @@ async def session_context(auth: str | None, host: str | None = None):
|
|||||||
if not auth:
|
if not auth:
|
||||||
return None
|
return None
|
||||||
normalized_host = normalize_host(host) if host else 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,59 @@
|
|||||||
|
"""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 update_runtime_config(new_config: Config) -> None:
|
||||||
|
"""Update the runtime configuration with a new Config and refresh the cache."""
|
||||||
|
current_runtime = _load_config()
|
||||||
|
if not current_runtime:
|
||||||
|
return # No runtime config to update
|
||||||
|
|
||||||
|
# Recompute site_url and site_path based on new config
|
||||||
|
site_path = "/" if new_config.auth_host else "/auth/"
|
||||||
|
if new_config.auth_host:
|
||||||
|
site_url = new_config.auth_host
|
||||||
|
elif new_config.origins:
|
||||||
|
site_url = new_config.origins[0]
|
||||||
|
else:
|
||||||
|
# Keep current site_url if no auth_host and no origins
|
||||||
|
site_url = current_runtime.site_url
|
||||||
|
|
||||||
|
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
|
||||||
|
_load_config.cache_clear()
|
||||||
+28
-24
@@ -1,14 +1,19 @@
|
|||||||
"""Startup configuration box formatting utilities."""
|
"""Startup configuration box formatting utilities."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
import os
|
import os
|
||||||
import re
|
import re
|
||||||
from sys import stderr
|
from sys import stderr
|
||||||
from typing import TYPE_CHECKING
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
|
from fastapi_vue.hostutil import parse_endpoints
|
||||||
|
|
||||||
from paskia._version import __version__
|
from paskia._version import __version__
|
||||||
|
from paskia.util.hostutil import format_endpoint
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from paskia.config import PaskiaConfig
|
from paskia.util.runtime import RuntimeConfig
|
||||||
|
|
||||||
BOX_WIDTH = 60 # Inner width (excluding box chars)
|
BOX_WIDTH = 60 # Inner width (excluding box chars)
|
||||||
|
|
||||||
@@ -42,7 +47,7 @@ def bottom() -> str:
|
|||||||
return "┗" + "━" * (BOX_WIDTH + 2) + "┛\n"
|
return "┗" + "━" * (BOX_WIDTH + 2) + "┛\n"
|
||||||
|
|
||||||
|
|
||||||
def print_startup_config(config: "PaskiaConfig") -> None:
|
def print_startup_config(runtime: RuntimeConfig) -> None:
|
||||||
"""Print server configuration on startup."""
|
"""Print server configuration on startup."""
|
||||||
# Key graphic with yellow shading (bright for highlights, dark for body)
|
# Key graphic with yellow shading (bright for highlights, dark for body)
|
||||||
y = YELLOW # Dark yellow for main body
|
y = YELLOW # Dark yellow for main body
|
||||||
@@ -57,41 +62,40 @@ def print_startup_config(config: "PaskiaConfig") -> None:
|
|||||||
lines.append(
|
lines.append(
|
||||||
line(
|
line(
|
||||||
f"{b}█{y} {b}█{y}▀▀▀▀{b}█{y}▀▀{b}█{y}▀▀{b}█{r} {w}"
|
f"{b}█{y} {b}█{y}▀▀▀▀{b}█{y}▀▀{b}█{y}▀▀{b}█{r} {w}"
|
||||||
+ config.site_url
|
+ runtime.site_url
|
||||||
+ config.site_path
|
+ runtime.site_path
|
||||||
+ r
|
+ r
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
lines.append(line(f" {y}▀▀▀▀▀{r}"))
|
lines.append(line(f" {y}▀▀▀▀▀{r}"))
|
||||||
|
|
||||||
# Format auth host section
|
# Format auth host section
|
||||||
if config.auth_host:
|
if runtime.config.auth_host:
|
||||||
lines.append(line(f"Auth Host: {config.auth_host}"))
|
lines.append(line(f"Auth Host: {runtime.config.auth_host}"))
|
||||||
|
|
||||||
|
from paskia.__main__ import DEFAULT_PORT as P # noqa: PLC0415 - circular
|
||||||
|
from paskia.__main__ import DEVMODE # noqa: PLC0415 - circular
|
||||||
|
|
||||||
# Show frontend URL if in dev mode
|
# Show frontend URL if in dev mode
|
||||||
devmode = os.environ.get("FASTAPI_VUE_FRONTEND_URL")
|
if DEVMODE:
|
||||||
if devmode:
|
lines.append(line(f"Dev Frontend: {os.environ.get('PASKIA_VITE_URL')}"))
|
||||||
lines.append(line(f"Dev Frontend: {devmode}"))
|
|
||||||
|
|
||||||
# Format listen address with scheme
|
# Format listen endpoints (dev mode only uses the first endpoint)
|
||||||
if config.uds:
|
|
||||||
listen = f"unix:{config.uds}"
|
endpoints = list(parse_endpoints(runtime.config.listen, P))
|
||||||
elif config.host:
|
if DEVMODE:
|
||||||
listen = f"http://{config.host}:{config.port}"
|
endpoints = endpoints[:1] # server.run reload=True uses only one
|
||||||
else:
|
parts = [format_endpoint(ep) for ep in endpoints]
|
||||||
listen = f"http://0.0.0.0:{config.port} + [::]:{config.port}"
|
lines.append(line(f"Backend: {' '.join(parts)}"))
|
||||||
lines.append(line(f"Backend: {listen}"))
|
|
||||||
|
|
||||||
# Relying Party line (omit name if same as id)
|
# Relying Party line (omit name if same as id)
|
||||||
rp_id = config.rp_id
|
rp_id = runtime.config.rp_id
|
||||||
rp_name = config.rp_name
|
rp_name = runtime.config.rp_name
|
||||||
if rp_name and rp_name != rp_id:
|
suffix = f" ({rp_name})" if rp_name and rp_name != rp_id else ""
|
||||||
lines.append(line(f"Relying Party: {rp_id} ({rp_name})"))
|
lines.append(line(f"Relying Party: {rp_id}{suffix}"))
|
||||||
else:
|
|
||||||
lines.append(line(f"Relying Party: {rp_id}"))
|
|
||||||
|
|
||||||
# Format origins section
|
# Format origins section
|
||||||
allowed = config.origins
|
allowed = runtime.config.origins
|
||||||
if allowed:
|
if allowed:
|
||||||
lines.append(line("Permitted Origins:"))
|
lines.append(line("Permitted Origins:"))
|
||||||
for origin in sorted(allowed):
|
for origin in sorted(allowed):
|
||||||
|
|||||||
@@ -5,8 +5,10 @@ from paskia.db import SessionContext
|
|||||||
from paskia.util import hostutil
|
from paskia.util import hostutil
|
||||||
from paskia.util.apistructs import (
|
from paskia.util.apistructs import (
|
||||||
ApiAaguidInfo,
|
ApiAaguidInfo,
|
||||||
|
ApiOrg,
|
||||||
ApiOrgContext,
|
ApiOrgContext,
|
||||||
ApiPermission,
|
ApiPermission,
|
||||||
|
ApiRole,
|
||||||
ApiRoleContext,
|
ApiRoleContext,
|
||||||
ApiSessionContext,
|
ApiSessionContext,
|
||||||
ApiUser,
|
ApiUser,
|
||||||
@@ -64,4 +66,6 @@ async def build_user_info(
|
|||||||
permissions={p.uuid: ApiPermission.from_db(p) for p in ctx.permissions}
|
permissions={p.uuid: ApiPermission.from_db(p) for p in ctx.permissions}
|
||||||
if ctx
|
if ctx
|
||||||
else {},
|
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.
|
"""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.
|
In production, reads from the static build directory.
|
||||||
|
|
||||||
This complements fastapi_vue.Frontend which handles static file serving
|
This complements fastapi_vue.Frontend which handles static file serving
|
||||||
but doesn't provide server-side fetching of HTML content.
|
but doesn't provide server-side fetching of HTML content.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import asyncio
|
|
||||||
import mimetypes
|
import mimetypes
|
||||||
import os
|
import os
|
||||||
from importlib import resources
|
from importlib import resources
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
|
from fastapi import Response
|
||||||
|
|
||||||
__all__ = ["read"]
|
__all__ = ["handle"]
|
||||||
|
|
||||||
|
|
||||||
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
|
|
||||||
|
|
||||||
|
|
||||||
def _resolve_static_dir() -> Path:
|
def _resolve_static_dir() -> Path:
|
||||||
"""Resolve the static files directory."""
|
|
||||||
|
|
||||||
# Try packaged path via importlib.resources (works for wheel/installed).
|
# Try packaged path via importlib.resources (works for wheel/installed).
|
||||||
try: # pragma: no cover - trivial path resolution
|
pkg_dir = resources.files("paskia") / "frontend-build"
|
||||||
pkg_dir = resources.files("paskia") / "frontend-build"
|
fs_path = Path(str(pkg_dir))
|
||||||
fs_path = Path(str(pkg_dir))
|
if fs_path.is_dir():
|
||||||
if fs_path.is_dir():
|
return fs_path
|
||||||
return fs_path
|
|
||||||
except Exception: # pragma: no cover - defensive
|
|
||||||
pass
|
|
||||||
# Fallback for editable/development before build.
|
# Fallback for editable/development before build.
|
||||||
return Path(__file__).parent.parent / "frontend-build"
|
return Path(__file__).parent.parent / "frontend-build"
|
||||||
|
|
||||||
@@ -41,31 +31,45 @@ def _resolve_static_dir() -> Path:
|
|||||||
_static_dir: Path = _resolve_static_dir()
|
_static_dir: Path = _resolve_static_dir()
|
||||||
|
|
||||||
|
|
||||||
async def read(filepath: str) -> tuple[bytes, int, dict[str, str]]:
|
async def handle(request, frontend, filepath: str):
|
||||||
"""Read file content and return response tuple.
|
"""Read file content and return Response.
|
||||||
|
|
||||||
In dev mode, fetches from the Vite dev server.
|
In dev mode, fetches from the Vite dev server.
|
||||||
In production, reads from the static build directory.
|
In production, uses frontend.handle.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
|
request: The FastAPI Request object
|
||||||
|
frontend: The fastapi_vue.Frontend instance
|
||||||
filepath: Path relative to frontend root, e.g. "/auth/index.html"
|
filepath: Path relative to frontend root, e.g. "/auth/index.html"
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Tuple of (content, status_code, headers) suitable for
|
FastAPI Response object.
|
||||||
FastAPI Response(*args).
|
|
||||||
"""
|
"""
|
||||||
dev_server = _get_dev_server()
|
if dev_server := os.environ.get("PASKIA_VITE_URL"):
|
||||||
if dev_server:
|
|
||||||
async with httpx.AsyncClient() as client:
|
async with httpx.AsyncClient() as client:
|
||||||
resp = await client.get(f"{dev_server}{filepath}")
|
resp = await client.get(f"{dev_server}{filepath}")
|
||||||
resp.raise_for_status()
|
resp.raise_for_status()
|
||||||
mime = resp.headers.get("content-type", "application/octet-stream")
|
mime = resp.headers.get("content-type", "application/octet-stream")
|
||||||
# Strip charset suffix if present
|
# Strip charset suffix if present
|
||||||
mime = mime.split(";")[0].strip()
|
mime = mime.split(";")[0].strip()
|
||||||
return resp.content, resp.status_code, {"content-type": mime}
|
return Response(resp.content, resp.status_code, {"content-type": mime})
|
||||||
else:
|
|
||||||
# Production: read from static build
|
# Read from frontend cache directly to bypass any compression/processing
|
||||||
file_path = _static_dir / filepath.lstrip("/")
|
cached_content = getattr(frontend, "_files", {}).get(filepath)
|
||||||
content = await asyncio.to_thread(file_path.read_bytes)
|
if cached_content is not None:
|
||||||
mime, _ = mimetypes.guess_type(str(file_path))
|
mime, _ = mimetypes.guess_type(filepath)
|
||||||
return content, 200, {"content-type": mime or "application/octet-stream"}
|
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)
|
||||||
|
|||||||
+17
-28
@@ -11,20 +11,28 @@ keywords = [ "forward_auth", "auth_request", "FastAPI" ]
|
|||||||
authors = [
|
authors = [
|
||||||
{name = "Leo Vasanko"},
|
{name = "Leo Vasanko"},
|
||||||
]
|
]
|
||||||
|
requires-python = ">=3.11"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"fastapi[standard]>=0.104.1",
|
"fastapi[standard]>=0.129.0",
|
||||||
"websockets>=12.0",
|
"websockets>=16.0",
|
||||||
"webauthn>=1.11.1",
|
"webauthn>=2.7.1",
|
||||||
"base64url>=1.0.0",
|
"base64url>=1.1.1",
|
||||||
"uuid7-standard>=1.0.0",
|
"uuid7-standard>=1.1.0",
|
||||||
"pyjwt[crypto]>=2.8.0",
|
"pyjwt[crypto]>=2.11.0",
|
||||||
"jsondiff>=2.2.1",
|
"jsondiff>=2.2.1",
|
||||||
"msgspec>=0.20.0",
|
"msgspec>=0.20.0",
|
||||||
"aiofiles>=25.1.0",
|
"fastapi-vue>=1.1.0",
|
||||||
"fastapi-vue>=0.3.0",
|
|
||||||
"ua-parser[regex]>=1.0.1",
|
"ua-parser[regex]>=1.0.1",
|
||||||
]
|
]
|
||||||
requires-python = ">=3.11"
|
[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",
|
||||||
|
]
|
||||||
|
|
||||||
[project.urls]
|
[project.urls]
|
||||||
Homepage = "https://git.zi.fi/LeoVasanko/paskia"
|
Homepage = "https://git.zi.fi/LeoVasanko/paskia"
|
||||||
@@ -36,15 +44,6 @@ source = "vcs"
|
|||||||
[tool.hatch.build.hooks.vcs]
|
[tool.hatch.build.hooks.vcs]
|
||||||
version-file = "paskia/_version.py"
|
version-file = "paskia/_version.py"
|
||||||
|
|
||||||
[project.optional-dependencies]
|
|
||||||
dev = [
|
|
||||||
"ruff>=0.1.0",
|
|
||||||
"coverage[toml]>=7.0.0",
|
|
||||||
"pytest>=8.0.0",
|
|
||||||
"pytest-asyncio>=0.24.0",
|
|
||||||
"httpx>=0.27.0",
|
|
||||||
]
|
|
||||||
|
|
||||||
[tool.coverage.run]
|
[tool.coverage.run]
|
||||||
source = ["paskia"]
|
source = ["paskia"]
|
||||||
branch = true
|
branch = true
|
||||||
@@ -75,16 +74,6 @@ select = ["E", "F", "I", "N", "W", "UP", "PLC0415"]
|
|||||||
ignore = ["E501"] # Line too long
|
ignore = ["E501"] # Line too long
|
||||||
isort.known-first-party = ["paskia"]
|
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]
|
[project.scripts]
|
||||||
paskia = "paskia.__main__:main"
|
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(["--origin", origin])
|
||||||
paskia.extend(remaining)
|
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
|
# Set environment for subprocesses
|
||||||
os.environ["PASKIA_VITE_URL"] = viteurl
|
os.environ["PASKIA_VITE_URL"] = viteurl
|
||||||
os.environ["PASKIA_BACKEND_URL"] = backurl
|
os.environ["PASKIA_BACKEND_URL"] = backurl
|
||||||
os.environ["PASKIA_SITE_URL"] = caddy_origins[0] if args.caddy else viteurl
|
|
||||||
os.environ["PASKIA_DEV"] = "1"
|
os.environ["PASKIA_DEV"] = "1"
|
||||||
if args.auth_host:
|
if args.auth_host:
|
||||||
os.environ["PASKIA_AUTH_HOST"] = 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:
|
async with ProcessGroup() as pg:
|
||||||
# Start Caddy first if requested (needs to bind ports)
|
# Start Caddy first if requested (needs to bind ports)
|
||||||
if args.caddy:
|
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)
|
caddy_proc = await run_caddy(caddy_origins, viteurl, backurl)
|
||||||
pg._procs.append(caddy_proc)
|
pg._procs.append(caddy_proc)
|
||||||
pg._cmds[caddy_proc.pid] = "caddy"
|
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)
|
npm_proc = await pg.spawn(*npm_install, cwd=frontend_path)
|
||||||
await check_ports_free(viteurl, backurl)
|
await check_ports_free(viteurl, backurl)
|
||||||
await pg.spawn(*paskia)
|
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)
|
await pg.spawn(*vite, cwd=frontend_path)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+1
-2
@@ -19,7 +19,6 @@ from collections.abc import AsyncGenerator
|
|||||||
from datetime import UTC, datetime, timedelta
|
from datetime import UTC, datetime, timedelta
|
||||||
from uuid import UUID
|
from uuid import UUID
|
||||||
|
|
||||||
import base64url
|
|
||||||
import httpx
|
import httpx
|
||||||
import pytest
|
import pytest
|
||||||
import pytest_asyncio
|
import pytest_asyncio
|
||||||
@@ -268,7 +267,7 @@ def create_test_session(
|
|||||||
|
|
||||||
# Generate token and derive key
|
# Generate token and derive key
|
||||||
token = secrets.token_urlsafe(12)
|
token = secrets.token_urlsafe(12)
|
||||||
key = base64url.enc(hash_secret("cookie", token))
|
key = hash_secret("cookie", token)
|
||||||
|
|
||||||
session = Session.create(
|
session = Session.create(
|
||||||
user=user_uuid,
|
user=user_uuid,
|
||||||
|
|||||||
+16
-36
@@ -16,7 +16,6 @@ import secrets
|
|||||||
from datetime import UTC, datetime
|
from datetime import UTC, datetime
|
||||||
from uuid import UUID
|
from uuid import UUID
|
||||||
|
|
||||||
import base64url
|
|
||||||
import httpx
|
import httpx
|
||||||
import pytest
|
import pytest
|
||||||
import pytest_asyncio
|
import pytest_asyncio
|
||||||
@@ -188,25 +187,6 @@ class TestExceptionHandlers:
|
|||||||
assert "iframe" in data["auth"]
|
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 --------------------
|
# -------------------- Organization Tests --------------------
|
||||||
|
|
||||||
|
|
||||||
@@ -283,7 +263,7 @@ class TestAdminOrganizations:
|
|||||||
):
|
):
|
||||||
"""Creating org without admin permission should fail."""
|
"""Creating org without admin permission should fail."""
|
||||||
response = await client.post(
|
response = await client.post(
|
||||||
"/auth/api/admin/orgs",
|
"/auth/api/admin/orgs/",
|
||||||
json={"display_name": "New Org"},
|
json={"display_name": "New Org"},
|
||||||
headers={
|
headers={
|
||||||
**auth_headers(regular_session_token),
|
**auth_headers(regular_session_token),
|
||||||
@@ -298,7 +278,7 @@ class TestAdminOrganizations:
|
|||||||
):
|
):
|
||||||
"""Admin should be able to create a new organization."""
|
"""Admin should be able to create a new organization."""
|
||||||
response = await client.post(
|
response = await client.post(
|
||||||
"/auth/api/admin/orgs",
|
"/auth/api/admin/orgs/",
|
||||||
json={"display_name": "New Test Org", "permissions": []},
|
json={"display_name": "New Test Org", "permissions": []},
|
||||||
headers={**auth_headers(session_token), "Host": "localhost:4401"},
|
headers={**auth_headers(session_token), "Host": "localhost:4401"},
|
||||||
)
|
)
|
||||||
@@ -312,7 +292,7 @@ class TestAdminOrganizations:
|
|||||||
):
|
):
|
||||||
"""Admin should be able to create org with default values."""
|
"""Admin should be able to create org with default values."""
|
||||||
response = await client.post(
|
response = await client.post(
|
||||||
"/auth/api/admin/orgs",
|
"/auth/api/admin/orgs/",
|
||||||
json={}, # No display_name or permissions
|
json={}, # No display_name or permissions
|
||||||
headers={**auth_headers(session_token), "Host": "localhost:4401"},
|
headers={**auth_headers(session_token), "Host": "localhost:4401"},
|
||||||
)
|
)
|
||||||
@@ -1320,7 +1300,7 @@ class TestAdminSessions:
|
|||||||
test_user,
|
test_user,
|
||||||
):
|
):
|
||||||
"""Admin can delete their own current session."""
|
"""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(
|
response = await client.delete(
|
||||||
f"/auth/api/admin/users/{test_user.uuid}/sessions/{session_db_key}",
|
f"/auth/api/admin/users/{test_user.uuid}/sessions/{session_db_key}",
|
||||||
headers={**auth_headers(session_token), "Host": "localhost:4401"},
|
headers={**auth_headers(session_token), "Host": "localhost:4401"},
|
||||||
@@ -1441,7 +1421,7 @@ class TestAdminPermissions:
|
|||||||
):
|
):
|
||||||
"""Admin should be able to create new permissions."""
|
"""Admin should be able to create new permissions."""
|
||||||
response = await client.post(
|
response = await client.post(
|
||||||
"/auth/api/admin/permissions",
|
"/auth/api/admin/permissions/",
|
||||||
json={"scope": "test:create:permission", "display_name": "Test Permission"},
|
json={"scope": "test:create:permission", "display_name": "Test Permission"},
|
||||||
headers={**auth_headers(session_token), "Host": "localhost:4401"},
|
headers={**auth_headers(session_token), "Host": "localhost:4401"},
|
||||||
)
|
)
|
||||||
@@ -1455,7 +1435,7 @@ class TestAdminPermissions:
|
|||||||
):
|
):
|
||||||
"""Creating permission without required fields should fail."""
|
"""Creating permission without required fields should fail."""
|
||||||
response = await client.post(
|
response = await client.post(
|
||||||
"/auth/api/admin/permissions",
|
"/auth/api/admin/permissions/",
|
||||||
json={},
|
json={},
|
||||||
headers={**auth_headers(session_token), "Host": "localhost:4401"},
|
headers={**auth_headers(session_token), "Host": "localhost:4401"},
|
||||||
)
|
)
|
||||||
@@ -1469,7 +1449,7 @@ class TestAdminPermissions:
|
|||||||
):
|
):
|
||||||
"""Creating permission without admin should fail."""
|
"""Creating permission without admin should fail."""
|
||||||
response = await client.post(
|
response = await client.post(
|
||||||
"/auth/api/admin/permissions",
|
"/auth/api/admin/permissions/",
|
||||||
json={"scope": "test:forbidden", "display_name": "Forbidden"},
|
json={"scope": "test:forbidden", "display_name": "Forbidden"},
|
||||||
headers={
|
headers={
|
||||||
**auth_headers(regular_session_token),
|
**auth_headers(regular_session_token),
|
||||||
@@ -1488,7 +1468,7 @@ class TestAdminPermissions:
|
|||||||
create_permission(perm)
|
create_permission(perm)
|
||||||
|
|
||||||
response = await client.patch(
|
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"},
|
headers={**auth_headers(session_token), "Host": "localhost:4401"},
|
||||||
)
|
)
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
@@ -1505,7 +1485,7 @@ class TestAdminPermissions:
|
|||||||
create_permission(perm)
|
create_permission(perm)
|
||||||
|
|
||||||
response = await client.patch(
|
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"},
|
headers={**auth_headers(session_token), "Host": "localhost:4401"},
|
||||||
)
|
)
|
||||||
assert response.status_code == 400
|
assert response.status_code == 400
|
||||||
@@ -1522,7 +1502,7 @@ class TestAdminPermissions:
|
|||||||
create_permission(perm)
|
create_permission(perm)
|
||||||
|
|
||||||
response = await client.patch(
|
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"},
|
headers={**auth_headers(session_token), "Host": "localhost:4401"},
|
||||||
)
|
)
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
@@ -1538,7 +1518,7 @@ class TestAdminPermissions:
|
|||||||
admin_perm = next(p for p in perms if p.scope == "auth:admin")
|
admin_perm = next(p for p in perms if p.scope == "auth:admin")
|
||||||
|
|
||||||
response = await client.patch(
|
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"},
|
headers={**auth_headers(session_token), "Host": "localhost:4401"},
|
||||||
)
|
)
|
||||||
assert response.status_code == 400
|
assert response.status_code == 400
|
||||||
@@ -1554,7 +1534,7 @@ class TestAdminPermissions:
|
|||||||
create_permission(perm)
|
create_permission(perm)
|
||||||
|
|
||||||
response = await client.patch(
|
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"},
|
headers={**auth_headers(session_token), "Host": "localhost:4401"},
|
||||||
)
|
)
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
@@ -1569,7 +1549,7 @@ class TestAdminPermissions:
|
|||||||
create_permission(perm)
|
create_permission(perm)
|
||||||
|
|
||||||
response = await client.delete(
|
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"},
|
headers={**auth_headers(session_token), "Host": "localhost:4401"},
|
||||||
)
|
)
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
@@ -1587,7 +1567,7 @@ class TestAdminPermissions:
|
|||||||
admin_perm = next(p for p in perms if p.scope == "auth:admin")
|
admin_perm = next(p for p in perms if p.scope == "auth:admin")
|
||||||
|
|
||||||
response = await client.delete(
|
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"},
|
headers={**auth_headers(session_token), "Host": "localhost:4401"},
|
||||||
)
|
)
|
||||||
assert response.status_code == 400
|
assert response.status_code == 400
|
||||||
@@ -1613,7 +1593,7 @@ class TestAdminPermissions:
|
|||||||
|
|
||||||
# Now we can delete the original one
|
# Now we can delete the original one
|
||||||
response = await client.delete(
|
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"},
|
headers={**auth_headers(session_token), "Host": "localhost:4401"},
|
||||||
)
|
)
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
@@ -1642,7 +1622,7 @@ class TestAdminPermissions:
|
|||||||
original_admin_perm = admin_perms[0] # The one without domain
|
original_admin_perm = admin_perms[0] # The one without domain
|
||||||
|
|
||||||
response = await client.delete(
|
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"},
|
headers={**auth_headers(session_token), "Host": "localhost:4401"},
|
||||||
)
|
)
|
||||||
assert response.status_code == 400
|
assert response.status_code == 400
|
||||||
|
|||||||
@@ -355,34 +355,6 @@ class TestErrorHandling:
|
|||||||
class TestForwardAuthHtmlResponse:
|
class TestForwardAuthHtmlResponse:
|
||||||
"""Tests for forward auth HTML responses"""
|
"""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
|
@pytest.mark.asyncio
|
||||||
async def test_forward_with_expired_session_clears_cookie(
|
async def test_forward_with_expired_session_clears_cookie(
|
||||||
self, client: httpx.AsyncClient
|
self, client: httpx.AsyncClient
|
||||||
|
|||||||
Reference in New Issue
Block a user