Compare commits

...
49 Commits
Author SHA1 Message Date
LeoVasanko 5f7a5ed9b1 Added database snapshots, cleanup, better error messages. 2026-02-19 16:29:23 +00:00
LeoVasanko d64e63527b CLI main and RuntimeConfig cleanup. Added a session_ctx wrapper function for easier access and avoiding hostutil import in db. 2026-02-19 14:24:16 +00:00
LeoVasanko 733439b446 Logging cleanup, better color compatibility for Mac Terminal and consistent across DB and FastAPI access logs. 2026-02-19 14:19:54 +00:00
LeoVasanko 4e6f63e9ef Fix auth-host being added to origins even when no origins were wanted. 2026-02-19 01:00:13 +00:00
LeoVasanko d431c75297 Fix request header removal that was causing zstd compressed response when we wanted plain text. 2026-02-19 00:51:11 +00:00
LeoVasanko 6257071efe Cleaned Org Admin styling. 2026-02-19 00:37:44 +00:00
LeoVasanko 7958b6f365 Migrations cleanup by using a MigrationCtx object for meta. 2026-02-19 00:08:38 +00:00
LeoVasanko b3cb540098 Implement database file locking for extra safety. 2026-02-19 00:01:49 +00:00
LeoVasanko 22ba7231b1 Implement read-only database load at startup for CLI to get its settings. Full opening only when server has started. 2026-02-18 23:46:18 +00:00
LeoVasanko 9a9979fb62 Add missing new file. 2026-02-18 23:45:22 +00:00
LeoVasanko 9b7855c0af Faster and simplified hash_secret() that directly produces urlsafe entries. 2026-02-18 23:02:36 +00:00
LeoVasanko dfc4c76d43 Fix static asset serving in devserver mode. 2026-02-18 22:19:18 +00:00
LeoVasanko e1f0fdf664 Fix E2E tests 2026-02-18 22:18:31 +00:00
LeoVasanko f26ac8f33b Simplified My Profile authentication flows, fixed some UX issues with reauth cancelled/accepted leading to incorrect states. 2026-02-18 19:04:02 +00:00
LeoVasanko 880ced3b8c Always load user's theme from API if available, and update the localStorage cache. Previously in various situations the old cached value was being used instead, leading to inconsistent theming or wrong themeselector readout. 2026-02-18 18:35:41 +00:00
LeoVasanko af80b5eefc Make ResetApp Registration layout match the other dialog apps (centered). 2026-02-18 18:01:17 +00:00
LeoVasanko fa1e69d58b Imports to top of file. 2026-02-18 17:47:57 +00:00
LeoVasanko 39000ef831 Remove PASKIA_SITE_URL env, use PASKIA_VITE_URL instead, moving the correct site URL determination to paskia CLI directly. This resolves devserver issues where VITE URL was reported as the external site URL instead of configured auth-host or origins. Main CLI still simplified. Bump deps versions and cleanup pyproject.toml. 2026-02-18 17:21:03 +00:00
LeoVasanko 49119fac81 Fix HostProfile user properties access. 2026-02-18 03:47:08 +00:00
LeoVasanko 14aae74b60 Bump and synchronize paskia-js version. 2026-02-18 02:45:33 +00:00
LeoVasanko 68dccc1378 OAuth2 OpenID Connect provider support, API and DB refactoring (#3)
Allows Paskia to authenticate the user to a client site.
- User friendly client registration flow on the admin app
- Redirect-based authentication flow (per spec)
- Backchannel logout both ways to keep sessions synchronized
- Groups integrated with Paskia's permission system
- Adds email, preferred username and telephone fields on user profile
- All new user basic info layout to show the new information, better looks
- API and DB structures redesigned
- Various unrelated fixes to theming and layout
2026-02-18 02:40:27 +00:00
LeoVasanko 557ffaa0cd Add theme toggles that were missing from forward and reset apps. 2026-02-17 18:36:12 +00:00
LeoVasanko c0aba07326 Show credential UUID in logging as they are, no prettifying. 2026-02-17 18:32:47 +00:00
LeoVasanko f830d7d0ec More minimalistic light theme. UI hint for org admin user/role management. 2026-02-14 18:36:20 +00:00
LeoVasanko 7f52276c23 Use samesite=strict because we don't need the cookie for page loads. 2026-02-13 20:22:05 +00:00
LeoVasanko c1f8020f6b API cleanup, using msgspec structs rather than raw responses. Admin app cleanup, better breadcrumbs. 2026-02-13 20:09:41 +00:00
LeoVasanko 423abb0d1b Move CLI entry point to main module even though it runs the FastAPI app from a submodule. 2026-02-13 16:26:05 +00:00
LeoVasanko ef7a6c8011 Upgrade fastapi-vue-setup 1.0.2 2026-02-11 21:33:21 +00:00
LeoVasanko 97064f7bc7 Remove unused CLI bootstrap entry point. 2026-02-11 19:36:06 +00:00
LeoVasanko fc0541762e Add version indication and link to our site on profile page (bottom right corner). 2026-02-11 01:36:58 +00:00
LeoVasanko cebaa2a757 Less eagerly enable very wide layout for user profile (only if more than 8 items for passkeys or per site sessions). 2026-02-11 01:24:15 +00:00
LeoVasanko 4ebe5ae968 Style overhaul. 2026-02-11 01:18:19 +00:00
LeoVasanko a944224027 Inline get_config, rewrite update_config, DB init Config and rp_id defaults changed. 2026-02-10 23:01:02 +00:00
LeoVasanko aef0e0cb44 ResetToken.hash(phrase) added avoiding code duplication. 2026-02-10 22:56:49 +00:00
LeoVasanko d41cffc03e Remove remaining DB getter functions, inline at call site and add ResetToken.by_passphrase(). 2026-02-10 22:48:31 +00:00
LeoVasanko bad709a3ab Remove unnecessary odd getter from db.operations. 2026-02-10 22:37:57 +00:00
LeoVasanko 1cfde06de9 Refactor DB lifecycle functions init and cleanup to separate db.lifecycle module. 2026-02-10 22:28:38 +00:00
LeoVasanko b0b36e88b1 CRUD store and delete on the DB classes directly. 2026-02-10 22:19:02 +00:00
LeoVasanko 2237e6b5e9 Db operations: bootstrap separated to its own module. 2026-02-10 22:08:54 +00:00
LeoVasanko c2ea01e6d9 Use strictly same now timestamp over a transaction, even for UUIDv7s generated. 2026-02-10 21:45:09 +00:00
LeoVasanko 8b6bdd0f9c Calculate session expiry times in operations, using a common now timestamp for everything. 2026-02-10 21:35:56 +00:00
LeoVasanko 3ca784dc3c Set last seen and increment visits during registration, not only on authentication. 2026-02-10 21:25:20 +00:00
LeoVasanko d8876d9202 README 2026-02-09 19:22:05 +00:00
LeoVasanko aa22b7709f README 2026-02-09 18:52:53 +00:00
LeoVasanko a8222f4bba README 2026-02-09 18:46:03 +00:00
LeoVasanko 16ab111a89 Make config part of bootstrap. 2026-02-09 18:33:29 +00:00
LeoVasanko d826146932 Improved CLI logging of DB transactions. 2026-02-09 18:28:24 +00:00
LeoVasanko fda9b2545e Fix save config running before bootstrap for new databases. 2026-02-09 18:07:51 +00:00
LeoVasanko 0cf551cb28 Cleaner database error handling and fixes. 2026-02-09 17:55:03 +00:00
100 changed files with 6038 additions and 2384 deletions
+27 -6
View File
@@ -87,10 +87,10 @@ This starts the server on [localhost:4401](http://localhost:4401) with passkeys
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 ```fish
paskia --rp-id example.com --rp-name "Example Corp" --save paskia --rp-id=example.com --rp-name="Example Corp" --save
``` ```
This binds passkeys to `*.example.com`. The `--rp-name` is shown to users during passkey registration. The `--save` option stores these settings in the database, so future runs only need `paskia --rp-id example.com`. 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.
### Step 3: Set Up Caddy ### Step 3: Set Up Caddy
@@ -177,20 +177,20 @@ Create a system user paskia, install UV on the system, and create a systemd unit
```fish ```fish
sudo useradd --system --home-dir /srv/paskia --create-home paskia sudo useradd --system --home-dir /srv/paskia --create-home paskia
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
sudo systemctl edit --force --full paskia.service sudo systemctl edit --force --full paskia@.service
``` ```
Paste the following and save: Paste the following and save:
```ini ```ini
[Unit] [Unit]
Description=Paskia Authentication Server Description=Paskia for %i
[Service] [Service]
Type=simple Type=simple
User=paskia User=paskia
WorkingDirectory=/srv/paskia WorkingDirectory=/srv/paskia
ExecStart=uvx paskia --rp-id=example.com ExecStart=uvx paskia --rp-id=%i
[Install] [Install]
WantedBy=multi-user.target WantedBy=multi-user.target
@@ -199,9 +199,30 @@ WantedBy=multi-user.target
Then enable and start, view output for registration link: Then enable and start, view output for registration link:
```fish ```fish
sudo systemctl enable --now paskia && sudo journalctl -u paskia -f -n 20 -o cat sudo systemctl enable --now paskia@example.com && sudo journalctl -u paskia@example.com -f -n 30 -o cat
``` ```
### Optional: Dedicated Authentication Site
By default, Paskia serves login dialogs and admin interface at the `/auth/` path on each protected site. For a cleaner setup, you can use a dedicated authentication subdomain instead. We assume you have your DNS setup for that domain or a wildcard of all subdomains to current machine.
Configure Paskia with the authentication host:
```fish
paskia --rp-id example.com --auth-host=auth.example.com --save
```
Add a Caddy configuration for the authentication domain:
```caddyfile
auth.example.com {
reverse_proxy :4401
}
```
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.
## Further Documentation ## Further Documentation
+1 -1
View File
@@ -15,7 +15,7 @@ For integrating Paskia with your app frontend, see [integration](Integration.md)
| Method | Path | Used for | Notes | | Method | Path | Used for | Notes |
|---:|---|---|---| |---:|---|---|---|
| GET | `/auth/api/settings` | Paskia configuration | Returns RP info + base paths + session cookie name | | GET | `/auth/api/settings` | Paskia configuration | Returns RP info + base paths + session cookie name |
| POST | `/auth/api/user-info` | Full user profile | Basic information, credentials, sessions, permissions | | GET | `/auth/api/user-info` | Full user profile | Basic information, credentials, sessions, permissions |
| POST | `/auth/api/logout` | Terminate session and delete session cookie | Signs out of the current site | | POST | `/auth/api/logout` | Terminate session and delete session cookie | Signs out of the current site |
| POST | `/auth/api/validate` | Validate and renew session cookie | Optional query: `perm=` (repeatable), `max_age=` | | POST | `/auth/api/validate` | Validate and renew session cookie | Optional query: `perm=` (repeatable), `max_age=` |
| GET | `/auth/api/forward` | Validate access (Caddy/Nginx) | 204 on success; 401/403 otherwise (HTML if requested) | | GET | `/auth/api/forward` | Validate access (Caddy/Nginx) | 204 on success; 401/403 otherwise (HTML if requested) |
+1 -1
View File
@@ -91,7 +91,7 @@ if (response.status === 401 || response.status === 403) {
Get current user details: Get current user details:
```js ```js
const user = await apiJson('/auth/api/user-info', { method: 'POST' }) const user = await apiJson('/auth/api/user-info', { method: 'GET' })
// Returns: { uuid, display_name, credentials, sessions, permissions, ... } // Returns: { uuid, display_name, credentials, sessions, permissions, ... }
``` ```
+38 -26
View File
@@ -10,7 +10,7 @@
"devDependencies": { "devDependencies": {
"@playwright/test": "^1.49.0", "@playwright/test": "^1.49.0",
"@simplewebauthn/browser": "^13.1.2", "@simplewebauthn/browser": "^13.1.2",
"@types/bun": "^1.3.3", "@types/node": "*",
"c8": "^10.1.3" "c8": "^10.1.3"
} }
}, },
@@ -92,11 +92,13 @@
} }
}, },
"node_modules/@playwright/test": { "node_modules/@playwright/test": {
"version": "1.57.0", "version": "1.58.2",
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.58.2.tgz",
"integrity": "sha512-akea+6bHYBBfA9uQqSYmlJXn61cTa+jbO87xVLCWbTqbWadRVmhxlXATaOjOgcBaWU4ePo0wB41KMFv3o35IXA==",
"dev": true, "dev": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"dependencies": { "dependencies": {
"playwright": "1.57.0" "playwright": "1.58.2"
}, },
"bin": { "bin": {
"playwright": "cli.js" "playwright": "cli.js"
@@ -107,17 +109,11 @@
}, },
"node_modules/@simplewebauthn/browser": { "node_modules/@simplewebauthn/browser": {
"version": "13.2.2", "version": "13.2.2",
"resolved": "https://registry.npmjs.org/@simplewebauthn/browser/-/browser-13.2.2.tgz",
"integrity": "sha512-FNW1oLQpTJyqG5kkDg5ZsotvWgmBaC6jCHR7Ej0qUNep36Wl9tj2eZu7J5rP+uhXgHaLk+QQ3lqcw2vS5MX1IA==",
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/@types/bun": {
"version": "1.3.3",
"dev": true,
"license": "MIT",
"dependencies": {
"bun-types": "1.3.3"
}
},
"node_modules/@types/istanbul-lib-coverage": { "node_modules/@types/istanbul-lib-coverage": {
"version": "2.0.6", "version": "2.0.6",
"resolved": "https://registry.npmjs.org/@types/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.6.tgz", "resolved": "https://registry.npmjs.org/@types/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.6.tgz",
@@ -126,7 +122,9 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/@types/node": { "node_modules/@types/node": {
"version": "24.10.1", "version": "25.2.3",
"resolved": "https://registry.npmjs.org/@types/node/-/node-25.2.3.tgz",
"integrity": "sha512-m0jEgYlYz+mDJZ2+F4v8D1AyQb+QzsNqRuI7xg1VQX/KlKS0qT9r1Mo16yo5F/MtifXFgaofIFsdFMox2SxIbQ==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
@@ -176,14 +174,6 @@
"balanced-match": "^1.0.0" "balanced-match": "^1.0.0"
} }
}, },
"node_modules/bun-types": {
"version": "1.3.3",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/node": "*"
}
},
"node_modules/c8": { "node_modules/c8": {
"version": "10.1.3", "version": "10.1.3",
"resolved": "https://registry.npmjs.org/c8/-/c8-10.1.3.tgz", "resolved": "https://registry.npmjs.org/c8/-/c8-10.1.3.tgz",
@@ -412,6 +402,21 @@
"url": "https://github.com/sponsors/isaacs" "url": "https://github.com/sponsors/isaacs"
} }
}, },
"node_modules/fsevents": {
"version": "2.3.2",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}
},
"node_modules/get-caller-file": { "node_modules/get-caller-file": {
"version": "2.0.5", "version": "2.0.5",
"resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz",
@@ -426,6 +431,7 @@
"version": "10.5.0", "version": "10.5.0",
"resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz", "resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz",
"integrity": "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==", "integrity": "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==",
"deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me",
"dev": true, "dev": true,
"license": "ISC", "license": "ISC",
"dependencies": { "dependencies": {
@@ -674,11 +680,13 @@
} }
}, },
"node_modules/playwright": { "node_modules/playwright": {
"version": "1.57.0", "version": "1.58.2",
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.58.2.tgz",
"integrity": "sha512-vA30H8Nvkq/cPBnNw4Q8TWz1EJyqgpuinBcHET0YVJVFldr8JDNiU9LaWAE1KqSkRYazuaBhTpB5ZzShOezQ6A==",
"dev": true, "dev": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"dependencies": { "dependencies": {
"playwright-core": "1.57.0" "playwright-core": "1.58.2"
}, },
"bin": { "bin": {
"playwright": "cli.js" "playwright": "cli.js"
@@ -691,7 +699,9 @@
} }
}, },
"node_modules/playwright-core": { "node_modules/playwright-core": {
"version": "1.57.0", "version": "1.58.2",
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.58.2.tgz",
"integrity": "sha512-yZkEtftgwS8CsfYo7nm0KE8jsvm6i/PTgVtB8DL726wNf6H2IMsDuxCpJj59KDaxCtSnrWan2AeDqM7JBaultg==",
"dev": true, "dev": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"bin": { "bin": {
@@ -712,9 +722,9 @@
} }
}, },
"node_modules/semver": { "node_modules/semver": {
"version": "7.7.3", "version": "7.7.4",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz", "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz",
"integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==", "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==",
"dev": true, "dev": true,
"license": "ISC", "license": "ISC",
"bin": { "bin": {
@@ -894,6 +904,8 @@
}, },
"node_modules/undici-types": { "node_modules/undici-types": {
"version": "7.16.0", "version": "7.16.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz",
"integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==",
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
+8 -8
View File
@@ -5,18 +5,18 @@
"description": "E2E tests for Paskia using Playwright with Virtual Authenticator", "description": "E2E tests for Paskia using Playwright with Virtual Authenticator",
"type": "module", "type": "module",
"scripts": { "scripts": {
"test": "bunx playwright test", "test": "npx playwright test",
"test:headed": "bunx playwright test --headed", "test:headed": "npx playwright test --headed",
"test:debug": "bunx playwright test --debug", "test:debug": "npx playwright test --debug",
"test:ui": "bunx playwright test --ui", "test:ui": "npx playwright test --ui",
"test:coverage": "COVERAGE=1 bunx playwright test", "test:coverage": "COVERAGE=1 npx playwright test",
"report": "bunx playwright show-report", "report": "npx playwright show-report",
"install:browsers": "bunx playwright install chromium" "install:browsers": "npx playwright install chromium"
}, },
"devDependencies": { "devDependencies": {
"@playwright/test": "^1.49.0", "@playwright/test": "^1.49.0",
"@simplewebauthn/browser": "^13.1.2", "@simplewebauthn/browser": "^13.1.2",
"@types/bun": "^1.3.3", "@types/node": "*",
"c8": "^10.1.3" "c8": "^10.1.3"
} }
} }
+1 -1
View File
@@ -4,7 +4,7 @@ import { defineConfig, devices } from '@playwright/test'
* Playwright configuration for Paskia E2E tests. * Playwright configuration for Paskia E2E tests.
* Uses Chrome's Virtual Authenticator for automated passkey testing. * Uses Chrome's Virtual Authenticator for automated passkey testing.
* *
* Run with: bun run test * Run with: npm test
*/ */
export default defineConfig({ export default defineConfig({
+5 -5
View File
@@ -148,10 +148,10 @@ test.describe('Passkey Authentication E2E', () => {
const userInfo = await getUserInfo(page, baseUrl, sessionToken) const userInfo = await getUserInfo(page, baseUrl, sessionToken)
expect(userInfo.ctx.user.uuid).toBe(userUuid) expect(userInfo.user.uuid).toBe(userUuid)
expect(userInfo.ctx.user.display_name).toBe('Admin User') expect(userInfo.user.display_name).toBe('Admin User')
expect(userInfo.credentials).toBeDefined() expect(userInfo.credentials).toBeDefined()
expect(userInfo.credentials.length).toBeGreaterThanOrEqual(1) expect(Object.keys(userInfo.credentials).length).toBeGreaterThanOrEqual(1)
// Navigate to profile and take screenshot // Navigate to profile and take screenshot
const cookieName = getSessionCookieName() const cookieName = getSessionCookieName()
@@ -169,8 +169,8 @@ test.describe('Passkey Authentication E2E', () => {
await page.screenshot({ path: 'test-results/profile-view.png' }) await page.screenshot({ path: 'test-results/profile-view.png' })
console.log('✓ Screenshot saved: test-results/profile-view.png') console.log('✓ Screenshot saved: test-results/profile-view.png')
console.log(`✓ User info retrieved: ${userInfo.ctx.user.display_name}`) console.log(`✓ User info retrieved: ${userInfo.user.display_name}`)
console.log(`✓ Credentials count: ${userInfo.credentials.length}`) console.log(`✓ Credentials count: ${Object.keys(userInfo.credentials).length}`)
}) })
test('should authenticate with existing passkey', async ({ page, virtualAuthenticator }) => { test('should authenticate with existing passkey', async ({ page, virtualAuthenticator }) => {
+34 -18
View File
@@ -10,6 +10,12 @@ import {
logout, logout,
} from './fixtures/passkey-helpers' } from './fixtures/passkey-helpers'
import type { Page, Frame } from '@playwright/test' import type { Page, Frame } from '@playwright/test'
import { readFileSync } from 'fs'
import { join, dirname } from 'path'
import { fileURLToPath } from 'url'
const __filename = fileURLToPath(import.meta.url)
const __dirname = dirname(__filename)
/** /**
* E2E tests for API mode authentication flows. * E2E tests for API mode authentication flows.
@@ -55,8 +61,18 @@ async function clearSessionCookie(page: Page): Promise<void> {
/** /**
* Set up the test page using the examples page directly. * Set up the test page using the examples page directly.
* The examples page already has iframe handling - we just add a Promise wrapper. * The examples page already has iframe handling - we just add a Promise wrapper.
* We route the paskia-js module request to serve from the local dist.
*/ */
async function setupTestHarness(page: Page): Promise<void> { async function setupTestHarness(page: Page): Promise<void> {
// Serve paskia.js from the local filesystem since the server doesn't serve /paskia-js/
const paskiaJsPath = join(__dirname, '..', '..', 'paskia-js', 'dist', 'paskia.js')
await page.route('**/paskia-js/dist/paskia.js', async route => {
const body = readFileSync(paskiaJsPath, 'utf-8')
await route.fulfill({
body,
contentType: 'application/javascript',
})
})
// Navigate to the examples page which already has the auth iframe handling // Navigate to the examples page which already has the auth iframe handling
await page.goto(`${baseUrl}/auth/examples/`) await page.goto(`${baseUrl}/auth/examples/`)
} }
@@ -143,8 +159,8 @@ async function makeApiCall(page: Page, url: string, method = 'GET'): Promise<{ s
* Wait for auth iframe to appear and return a reference to it. * Wait for auth iframe to appear and return a reference to it.
*/ */
async function waitForAuthIframe(page: Page, timeout = 5000): Promise<Frame> { async function waitForAuthIframe(page: Page, timeout = 5000): Promise<Frame> {
await page.waitForSelector('#auth-iframe', { timeout }) await page.waitForSelector('#paskia-iframe', { timeout })
const iframe = page.frameLocator('#auth-iframe') const iframe = page.frameLocator('#paskia-iframe')
// Wait for iframe content to load // Wait for iframe content to load
await iframe.locator('.view-root').waitFor({ timeout }) await iframe.locator('.view-root').waitFor({ timeout })
return page.frame({ url: /\/auth\/restricted\// })! return page.frame({ url: /\/auth\/restricted\// })!
@@ -154,14 +170,14 @@ async function waitForAuthIframe(page: Page, timeout = 5000): Promise<Frame> {
* Wait for auth iframe to disappear. * Wait for auth iframe to disappear.
*/ */
async function waitForAuthIframeHidden(page: Page, timeout = 5000): Promise<void> { async function waitForAuthIframeHidden(page: Page, timeout = 5000): Promise<void> {
await page.waitForSelector('#auth-iframe', { state: 'detached', timeout }) await page.waitForSelector('#paskia-iframe', { state: 'detached', timeout })
} }
/** /**
* Click Back button in auth iframe. * Click Back button in auth iframe.
*/ */
async function clickBackInIframe(page: Page): Promise<void> { async function clickBackInIframe(page: Page): Promise<void> {
const iframe = page.frameLocator('#auth-iframe') const iframe = page.frameLocator('#paskia-iframe')
await iframe.getByRole('button', { name: 'Back' }).click() await iframe.getByRole('button', { name: 'Back' }).click()
} }
@@ -169,7 +185,7 @@ async function clickBackInIframe(page: Page): Promise<void> {
* Click Login button in auth iframe. * Click Login button in auth iframe.
*/ */
async function clickLoginInIframe(page: Page): Promise<void> { async function clickLoginInIframe(page: Page): Promise<void> {
const iframe = page.frameLocator('#auth-iframe') const iframe = page.frameLocator('#paskia-iframe')
await iframe.getByRole('button', { name: 'Login' }).click() await iframe.getByRole('button', { name: 'Login' }).click()
} }
@@ -177,7 +193,7 @@ async function clickLoginInIframe(page: Page): Promise<void> {
* Click Verify button in auth iframe (for reauth mode). * Click Verify button in auth iframe (for reauth mode).
*/ */
async function clickVerifyInIframe(page: Page): Promise<void> { async function clickVerifyInIframe(page: Page): Promise<void> {
const iframe = page.frameLocator('#auth-iframe') const iframe = page.frameLocator('#paskia-iframe')
await iframe.getByRole('button', { name: 'Verify' }).click() await iframe.getByRole('button', { name: 'Verify' }).click()
} }
@@ -185,7 +201,7 @@ async function clickVerifyInIframe(page: Page): Promise<void> {
* Click Logout button in auth iframe (for forbidden mode). * Click Logout button in auth iframe (for forbidden mode).
*/ */
async function clickLogoutInIframe(page: Page): Promise<void> { async function clickLogoutInIframe(page: Page): Promise<void> {
const iframe = page.frameLocator('#auth-iframe') const iframe = page.frameLocator('#paskia-iframe')
await iframe.getByRole('button', { name: 'Logout' }).click() await iframe.getByRole('button', { name: 'Logout' }).click()
} }
@@ -200,11 +216,11 @@ 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)
const iframe = page.frameLocator('#auth-iframe') const iframe = page.frameLocator('#paskia-iframe')
await expect(iframe.locator('h1')).toContainText('🔐') await expect(iframe.locator('h1')).toContainText('🔐')
await expect(iframe.getByRole('button', { name: 'Login' })).toBeVisible() await expect(iframe.getByRole('button', { name: 'Login' })).toBeVisible()
@@ -252,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)
@@ -268,7 +284,7 @@ test.describe('API Mode - 401 Login Flow', () => {
// Wait for API call to complete and verify result // Wait for API call to complete and verify result
const result = await apiCallPromise const result = await apiCallPromise
expect(result.status).toBe(200) expect(result.status).toBe(200)
expect(result.data.ctx).toBeDefined() expect(result.data.user).toBeDefined()
console.log('✓ API call succeeded after authentication') console.log('✓ API call succeeded after authentication')
// Save the session for other tests // Save the session for other tests
@@ -314,7 +330,7 @@ test.describe('API Mode - 401 Reauth Flow', () => {
console.log('✓ Reauth iframe appeared (session older than max_age)') console.log('✓ Reauth iframe appeared (session older than max_age)')
// Verify it's in reauth mode // Verify it's in reauth mode
const iframe = page.frameLocator('#auth-iframe') const iframe = page.frameLocator('#paskia-iframe')
await expect(iframe.locator('h1')).toContainText('Additional Authentication') await expect(iframe.locator('h1')).toContainText('Additional Authentication')
await expect(iframe.getByRole('button', { name: 'Verify' })).toBeVisible() await expect(iframe.getByRole('button', { name: 'Verify' })).toBeVisible()
@@ -362,7 +378,7 @@ test.describe('API Mode - 401 Reauth Flow', () => {
await waitForAuthIframe(page) await waitForAuthIframe(page)
console.log('✓ Reauth iframe appeared') console.log('✓ Reauth iframe appeared')
const iframe = page.frameLocator('#auth-iframe') const iframe = page.frameLocator('#paskia-iframe')
await expect(iframe.locator('h1')).toContainText('Additional Authentication') await expect(iframe.locator('h1')).toContainText('Additional Authentication')
// Click Verify - virtual authenticator handles passkey // Click Verify - virtual authenticator handles passkey
@@ -394,7 +410,7 @@ test.describe('API Mode - 403 Forbidden Flow', () => {
const apiCallPromise = makeApiCall(page, '/auth/api/forward?perm=auth:admin', 'GET').catch(e => e) const apiCallPromise = makeApiCall(page, '/auth/api/forward?perm=auth:admin', 'GET').catch(e => e)
// Check if auth iframe appeared // Check if auth iframe appeared
const iframeAppeared = await page.waitForSelector('#auth-iframe', { timeout: 3000 }).then(() => true).catch(() => false) const iframeAppeared = await page.waitForSelector('#paskia-iframe', { timeout: 3000 }).then(() => true).catch(() => false)
if (!iframeAppeared) { if (!iframeAppeared) {
// User might already have admin permission // User might already have admin permission
@@ -410,7 +426,7 @@ test.describe('API Mode - 403 Forbidden Flow', () => {
// Wait for view to stabilize and check mode // Wait for view to stabilize and check mode
await page.waitForTimeout(500) await page.waitForTimeout(500)
const iframe = page.frameLocator('#auth-iframe') const iframe = page.frameLocator('#paskia-iframe')
const headingText = await iframe.locator('h1').textContent() const headingText = await iframe.locator('h1').textContent()
console.log(` Heading: ${headingText}`) console.log(` Heading: ${headingText}`)
@@ -459,7 +475,7 @@ test.describe('API Mode - 403 Forbidden Flow', () => {
const apiCallPromise = makeApiCall(page, '/auth/api/forward?perm=auth:admin', 'GET').catch(e => e) const apiCallPromise = makeApiCall(page, '/auth/api/forward?perm=auth:admin', 'GET').catch(e => e)
// Check if auth iframe appeared // Check if auth iframe appeared
const iframeAppeared = await page.waitForSelector('#auth-iframe', { timeout: 3000 }).then(() => true).catch(() => false) const iframeAppeared = await page.waitForSelector('#paskia-iframe', { timeout: 3000 }).then(() => true).catch(() => false)
if (!iframeAppeared) { if (!iframeAppeared) {
const result = await apiCallPromise const result = await apiCallPromise
@@ -470,7 +486,7 @@ test.describe('API Mode - 403 Forbidden Flow', () => {
} }
await waitForAuthIframe(page) await waitForAuthIframe(page)
const iframe = page.frameLocator('#auth-iframe') const iframe = page.frameLocator('#paskia-iframe')
await page.waitForTimeout(500) await page.waitForTimeout(500)
const headingText = await iframe.locator('h1').textContent() const headingText = await iframe.locator('h1').textContent()
@@ -534,7 +550,7 @@ test.describe('API Mode - Direct API Response Format', () => {
expect(data.auth).toBeDefined() expect(data.auth).toBeDefined()
expect(data.auth.iframe).toBeDefined() expect(data.auth.iframe).toBeDefined()
expect(data.auth.mode).toBe('login') expect(data.auth.mode).toBe('login')
expect(data.auth.iframe).toContain('/auth/restricted/') expect(data.auth.iframe).toContain('/auth/restricted/iframe')
console.log(`✓ 401 response includes auth.iframe: ${data.auth.iframe}`) console.log(`✓ 401 response includes auth.iframe: ${data.auth.iframe}`)
}) })
+62 -12
View File
@@ -44,7 +44,7 @@ export interface UserInfo {
sign_count: number sign_count: number
is_current_session: boolean is_current_session: boolean
}> }>
aaguid_info: Record<string, { name: string; icon_light?: string; icon_dark?: string }> aaguid_info: Record<string, { name: string; icon?: string; icon_dark?: string }>
sessions: Array<{ sessions: Array<{
id: string id: string
credential: string credential: string
@@ -193,7 +193,8 @@ export async function registerPasskey(
baseUrl: string, baseUrl: string,
options: { resetToken?: string; displayName?: string } = {} options: { resetToken?: string; displayName?: string } = {}
): Promise<RegistrationResult> { ): Promise<RegistrationResult> {
return await page.evaluate(async ({ baseUrl, resetToken, displayName }) => { // Step 1: Do WebSocket registration + exchange code in browser context
const wsResult = await page.evaluate(async ({ baseUrl, resetToken, displayName }) => {
// Build WebSocket URL with query parameters // Build WebSocket URL with query parameters
let wsUrl = `${baseUrl.replace('http', 'ws')}/auth/ws/register` let wsUrl = `${baseUrl.replace('http', 'ws')}/auth/ws/register`
const params: string[] = [] const params: string[] = []
@@ -203,6 +204,7 @@ export async function registerPasskey(
return new Promise<any>((resolve, reject) => { return new Promise<any>((resolve, reject) => {
const ws = new WebSocket(wsUrl) const ws = new WebSocket(wsUrl)
let done = false
ws.onopen = () => { ws.onopen = () => {
console.log('WebSocket connected for registration') console.log('WebSocket connected for registration')
@@ -213,15 +215,31 @@ export async function registerPasskey(
// Check for error response // Check for error response
if (data.detail) { if (data.detail) {
done = true
ws.close() ws.close()
reject(new Error(data.detail)) reject(new Error(data.detail))
return return
} }
// Check if this is the final success response // Check if this is the final success response (exchange_code flow)
if (data.session_token) { if (data.exchange_code) {
done = true
ws.close() ws.close()
resolve(data) // Exchange the code for a session cookie
try {
const resp = await fetch(`${baseUrl}/auth/api/set-session`, {
method: 'POST',
headers: { 'Authorization': `Bearer ${data.exchange_code}` },
})
if (!resp.ok) throw new Error(`Exchange failed: ${resp.status}`)
resolve({
user: data.user,
credential: data.credential,
message: data.message || 'Registration successful',
})
} catch (err: any) {
reject(new Error(`Code exchange failed: ${err.message}`))
}
return return
} }
@@ -293,12 +311,21 @@ export async function registerPasskey(
} }
ws.onclose = (event) => { ws.onclose = (event) => {
if (!event.wasClean && event.code !== 1000) { if (!done && !event.wasClean && event.code !== 1000) {
reject(new Error(`WebSocket closed unexpectedly: ${event.code}`)) reject(new Error(`WebSocket closed unexpectedly: ${event.code}`))
} }
} }
}) })
}, { baseUrl, resetToken: options.resetToken, displayName: options.displayName }) }, { baseUrl, resetToken: options.resetToken, displayName: options.displayName })
// Step 2: Extract the session token from the cookie set by the exchange
const cookies = await page.context().cookies()
const cookieName = getSessionCookieName()
const sessionCookie = cookies.find(c => c.name === cookieName)
return {
...wsResult,
session_token: sessionCookie?.value || '',
}
} }
/** /**
@@ -309,11 +336,13 @@ export async function authenticatePasskey(
page: Page, page: Page,
baseUrl: string baseUrl: string
): Promise<AuthenticationResult> { ): Promise<AuthenticationResult> {
return await page.evaluate(async ({ baseUrl }) => { // Step 1: Do WebSocket authentication + exchange code in browser context
const wsResult = await page.evaluate(async ({ baseUrl }) => {
const wsUrl = `${baseUrl.replace('http', 'ws')}/auth/ws/authenticate` const wsUrl = `${baseUrl.replace('http', 'ws')}/auth/ws/authenticate`
return new Promise<any>((resolve, reject) => { return new Promise<any>((resolve, reject) => {
const ws = new WebSocket(wsUrl) const ws = new WebSocket(wsUrl)
let done = false
ws.onopen = () => { ws.onopen = () => {
console.log('WebSocket connected for authentication') console.log('WebSocket connected for authentication')
@@ -324,15 +353,27 @@ export async function authenticatePasskey(
// Check for error response // Check for error response
if (data.detail) { if (data.detail) {
done = true
ws.close() ws.close()
reject(new Error(data.detail)) reject(new Error(data.detail))
return return
} }
// Check if this is the final success response // Check if this is the final success response (exchange_code flow)
if (data.session_token) { if (data.exchange_code) {
done = true
ws.close() ws.close()
resolve(data) // Exchange the code for a session cookie
try {
const resp = await fetch(`${baseUrl}/auth/api/set-session`, {
method: 'POST',
headers: { 'Authorization': `Bearer ${data.exchange_code}` },
})
if (!resp.ok) throw new Error(`Exchange failed: ${resp.status}`)
resolve({ user: data.user })
} catch (err: any) {
reject(new Error(`Code exchange failed: ${err.message}`))
}
return return
} }
@@ -395,12 +436,21 @@ export async function authenticatePasskey(
} }
ws.onclose = (event) => { ws.onclose = (event) => {
if (!event.wasClean && event.code !== 1000) { if (!done && !event.wasClean && event.code !== 1000) {
reject(new Error(`WebSocket closed unexpectedly: ${event.code}`)) reject(new Error(`WebSocket closed unexpectedly: ${event.code}`))
} }
} }
}) })
}, { baseUrl }) }, { baseUrl })
// Step 2: Extract the session token from the cookie set by the exchange
const cookies = await page.context().cookies()
const cookieName = getSessionCookieName()
const sessionCookie = cookies.find(c => c.name === cookieName)
return {
...wsResult,
session_token: sessionCookie?.value || '',
}
} }
/** /**
@@ -429,7 +479,7 @@ export async function getUserInfo(
sessionToken: string sessionToken: string
): Promise<UserInfo> { ): Promise<UserInfo> {
const cookieName = getSessionCookieName() const cookieName = getSessionCookieName()
const response = await page.request.post(`${baseUrl}/auth/api/user-info`, { const response = await page.request.get(`${baseUrl}/auth/api/user-info`, {
headers: { headers: {
'Cookie': `${cookieName}=${sessionToken}`, 'Cookie': `${cookieName}=${sessionToken}`,
}, },
+10 -5
View File
@@ -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')
@@ -42,16 +47,16 @@ export default async function globalSetup() {
const serverArgs = COLLECT_COVERAGE const serverArgs = COLLECT_COVERAGE
? [ ? [
'run', 'coverage', 'run', '--parallel-mode', 'run', 'coverage', 'run', '--parallel-mode',
'-m', 'paskia.fastapi', 'localhost:4404', '-m', 'paskia', '-l', 'localhost:4404',
'--rp-id', 'localhost' '--rp-id', 'localhost'
] ]
: [ : [
'run', 'paskia', 'localhost:4404', 'run', 'paskia', '-l', 'localhost:4404',
'--rp-id', 'localhost' '--rp-id', 'localhost'
] ]
// Use a temporary jsonl file for test database // Use a fresh database file for tests
const testDbFile = join(testDataDir, 'test-db.jsonl') const testDbFile = join(testDataDir, 'test.paskiadb')
// Start the server using Node's spawn // Start the server using Node's spawn
const serverProcess = spawn('uv', serverArgs, { const serverProcess = spawn('uv', serverArgs, {
+1 -1
View File
@@ -60,7 +60,7 @@ export default async function globalTeardown() {
} }
// Clean up test database // Clean up test database
const testDbFile = join(testDataDir, 'test-db.jsonl') const testDbFile = join(testDataDir, 'test.paskiadb')
if (existsSync(testDbFile)) { if (existsSync(testDbFile)) {
console.log(' Removing test database...') console.log(' Removing test database...')
rmSync(testDbFile) rmSync(testDbFile)
+1 -1
View File
@@ -8,7 +8,7 @@
"skipLibCheck": true, "skipLibCheck": true,
"forceConsistentCasingInFileNames": true, "forceConsistentCasingInFileNames": true,
"resolveJsonModule": true, "resolveJsonModule": true,
"types": ["bun-types"] "types": ["node"]
}, },
"include": ["tests/**/*.ts", "playwright.config.ts"], "include": ["tests/**/*.ts", "playwright.config.ts"],
"exclude": ["node_modules"] "exclude": ["node_modules"]
+1 -1
View File
@@ -27,7 +27,7 @@
<div class="section"> <div class="section">
<h2>API Mode (not leaving the page)</h2> <h2>API Mode (not leaving the page)</h2>
<p>For SPAs and fetch() calls - shows auth in an iframe overlay:</p> <p>For SPAs and fetch() calls - shows auth in an iframe overlay:</p>
<button onclick="apiCall('/auth/api/user-info', 'POST')">📋 Get User Info</button> <button onclick="apiCall('/auth/api/user-info', 'GET')">📋 Get User Info</button>
<button onclick="apiCall('/auth/api/forward?max_age=10s')">🔄 Reauth (max_age=10s)</button> <button onclick="apiCall('/auth/api/forward?max_age=10s')">🔄 Reauth (max_age=10s)</button>
<button onclick="apiCall('/auth/api/forward?perm=auth:admin')">🛡️ Admin Only</button> <button onclick="apiCall('/auth/api/forward?perm=auth:admin')">🛡️ Admin Only</button>
<button onclick="logout()">🚪 Logout</button> <button onclick="logout()">🚪 Logout</button>
+32 -75
View File
@@ -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'
@@ -47,80 +47,50 @@ const isHostMode = computed(() => {
const configuredHost = normalizeHost(authHost) const configuredHost = normalizeHost(authHost)
return currentHost !== configuredHost return currentHost !== configuredHost
}) })
const userUuid = computed(() => store.userInfo?.ctx.user.uuid)
function terminateSession() { function onSessionLost(e) {
store.userInfo = null store.userInfo = null
store.ctx = null
if (e?.name === 'AuthCancelledError') {
viewState.value = 'terminal'
} else {
store.showMessage(e?.message || 'Session lost', 'error', 5000)
viewState.value = 'terminal' viewState.value = 'terminal'
} }
}
const userUuidGetter = () => store.userInfo?.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() {
try {
store.userInfo = await apiJson('/auth/api/user-info', { method: 'POST' })
viewState.value = 'profile'
return true
} catch {
store.userInfo = 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' viewState.value = 'loading'
loadingMessage.value = 'Loading user profile...' loadingMessage.value = 'Loading...'
loadUserInfo() try {
break // apiJson handles 401/403 with auth.iframe automatically:
// shows overlay iframe, waits for auth, retries the request.
case 'auth-error': const [validateData, userInfoData] = await Promise.all([
// Authentication failed - keep iframe open so user can retry apiJson('/auth/api/validate', { method: 'POST' }),
if (data.cancelled) { apiJson('/auth/api/user-info', { method: 'GET' })
console.log('Authentication cancelled by user') ])
} else { store.userInfo = userInfoData
store.showMessage(data.message || 'Authentication failed', 'error', 5000) store.ctx = validateData.ctx
updateThemeFromSession(store.userInfo)
// Verify that the user UUIDs match between user-info and validate responses
if (store.userInfo.user.uuid !== store.ctx.user.uuid) {
console.error('User UUID mismatch between user-info and validate responses')
window.location.reload()
return
} }
break viewState.value = 'profile'
} catch (e) {
case 'auth-cancelled': onSessionLost(e)
// 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()
@@ -134,20 +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>
<style scoped>
</style>
+274 -93
View File
@@ -9,10 +9,13 @@ import AccessDenied from '@/components/AccessDenied.vue'
import AdminOverview from '@/admin/AdminOverview.vue' import AdminOverview from '@/admin/AdminOverview.vue'
import AdminOrgDetail from '@/admin/AdminOrgDetail.vue' import AdminOrgDetail from '@/admin/AdminOrgDetail.vue'
import AdminUserDetail from '@/admin/AdminUserDetail.vue' import AdminUserDetail from '@/admin/AdminUserDetail.vue'
import AdminOidcDetail from '@/admin/AdminOidcDetail.vue'
import AdminDialogs from '@/admin/AdminDialogs.vue' 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 { getDirection } from '@/utils/keynav' import { getDirection } from '@/utils/keynav'
import { goBack } from '@/utils/helpers' import { goBack } from '@/utils/helpers'
@@ -24,9 +27,12 @@ const showBackMessage = ref(false)
const error = ref(null) const error = ref(null)
const orgs = ref([]) const orgs = ref([])
const permissions = ref([]) const permissions = ref([])
const oidcClients = ref([])
const currentOrgId = ref(null) // UUID of selected org for detail view const currentOrgId = ref(null) // UUID of selected org for detail view
const currentUserId = ref(null) // UUID for user detail view const currentUserId = ref(null) // UUID for user detail view
const currentOidcId = ref(null) // UUID for OIDC client detail view
const userDetail = ref(null) // cached user detail object const userDetail = ref(null) // cached user detail object
const editingOidcClient = ref(null) // OIDC client being edited (with local changes)
const authStore = useAuthStore() const authStore = useAuthStore()
const addingOrgForPermission = ref(null) const addingOrgForPermission = ref(null)
const PERMISSION_ID_PATTERN = '^[A-Za-z0-9:._~-]+$' const PERMISSION_ID_PATTERN = '^[A-Za-z0-9:._~-]+$'
@@ -43,6 +49,7 @@ const breadcrumbsRef = ref(null)
const adminOverviewRef = ref(null) const adminOverviewRef = ref(null)
const adminOrgDetailRef = ref(null) const adminOrgDetailRef = ref(null)
const adminUserDetailRef = ref(null) const adminUserDetailRef = ref(null)
const adminOidcDetailRef = ref(null)
// Check if any modal/dialog is open (blocks arrow key navigation) // Check if any modal/dialog is open (blocks arrow key navigation)
const hasActiveModal = computed(() => dialog.value.type !== null || showRegModal.value) const hasActiveModal = computed(() => dialog.value.type !== null || showRegModal.value)
@@ -79,11 +86,12 @@ onUnmounted(() => {
const permissionSummary = computed(() => { const permissionSummary = computed(() => {
const summary = {} const summary = {}
for (const o of orgs.value) { for (const o of orgs.value) {
const orgBase = { uuid: o.uuid, display_name: o.display_name } const orgBase = { uuid: o.uuid, display_name: o.org.display_name }
const orgPerms = new Set(o.permissions || []) // o.permissions is a dict[UUID, Permission]
const orgPermUuids = new Set(Object.keys(o.permissions || {}))
// Org-level permissions (direct) - only count if org can grant them // Org-level permissions (direct) - only count if org can grant them
for (const pid of o.permissions || []) { for (const pid of Object.keys(o.permissions || {})) {
if (!summary[pid]) summary[pid] = { orgs: [], orgSet: new Set(), userCount: 0 } if (!summary[pid]) summary[pid] = { orgs: [], orgSet: new Set(), userCount: 0 }
if (!summary[pid].orgSet.has(o.uuid)) { if (!summary[pid].orgSet.has(o.uuid)) {
summary[pid].orgs.push(orgBase) summary[pid].orgs.push(orgBase)
@@ -92,17 +100,18 @@ const permissionSummary = computed(() => {
} }
// Role-based permissions (inheritance) - only count if org can grant them // Role-based permissions (inheritance) - only count if org can grant them
for (const r of o.roles) { for (const [roleUuid, r] of Object.entries(o.roles || {})) {
for (const pid of r.permissions) { // r.permissions is dict[UUID, bool]
for (const pid of Object.keys(r.permissions || {})) {
// Only count if the org can grant this permission // Only count if the org can grant this permission
if (!orgPerms.has(pid)) continue if (!orgPermUuids.has(pid)) continue
if (!summary[pid]) summary[pid] = { orgs: [], orgSet: new Set(), userCount: 0 } if (!summary[pid]) summary[pid] = { orgs: [], orgSet: new Set(), userCount: 0 }
if (!summary[pid].orgSet.has(o.uuid)) { if (!summary[pid].orgSet.has(o.uuid)) {
summary[pid].orgs.push(orgBase) summary[pid].orgs.push(orgBase)
summary[pid].orgSet.add(o.uuid) summary[pid].orgSet.add(o.uuid)
} }
summary[pid].userCount += r.users.length summary[pid].userCount += roleUserCount(o, roleUuid)
} }
} }
} }
@@ -120,32 +129,76 @@ function parseHash() {
const h = window.location.hash || '' const h = window.location.hash || ''
currentOrgId.value = null currentOrgId.value = null
currentUserId.value = null currentUserId.value = null
currentOidcId.value = null
editingOidcClient.value = null
if (h.startsWith('#org/')) { if (h.startsWith('#org/')) {
currentOrgId.value = h.slice(5) currentOrgId.value = h.slice(5)
} else if (h.startsWith('#user/')) { } else if (h.startsWith('#user/')) {
currentUserId.value = h.slice(6) currentUserId.value = h.slice(6)
} else if (h.startsWith('#oidc:')) {
const oidcUuid = h.slice(6)
currentOidcId.value = oidcUuid
// Initialize editing client data
if (oidcUuid === 'new') {
// Generate client_id and secret for new client
const bytes = new Uint8Array(32)
crypto.getRandomValues(bytes)
const client_secret = btoa(String.fromCharCode(...bytes))
.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '')
editingOidcClient.value = {
client_id: uuidv7(),
client_secret,
isNew: true,
name: '',
redirect_uris: []
}
} else {
const client = oidcClients.value.find(c => c.uuid === oidcUuid)
if (client) {
editingOidcClient.value = {
...client,
client_id: client.uuid,
client_secret: null,
isNew: false
}
}
}
} }
} }
async function loadOrgs() { async function loadAdminData() {
const data = await apiJson('/auth/api/admin/orgs') const data = await apiJson('/auth/api/admin/info')
orgs.value = data.map(o => { // Convert dicts to arrays with uuid added
const roles = o.roles.map(r => ({ ...r, org: o.uuid, users: [] })) orgs.value = Object.entries(data.orgs).map(([uuid, o]) => ({ uuid, ...o }))
const roleMap = Object.fromEntries(roles.map(r => [r.display_name, r])) permissions.value = Object.entries(data.permissions).map(([uuid, p]) => ({ uuid, ...p }))
for (const u of o.users || []) { oidcClients.value = Object.entries(data.oidc_clients).map(([uuid, c]) => ({ uuid, ...c }))
if (roleMap[u.role]) roleMap[u.role].users.push(u)
} }
return { ...o, roles }
// Helper to get users for a role as sorted array of [uuid, user]
function roleUsers(org, roleUuid) {
return Object.entries(org.users)
.filter(([_, u]) => u.role === roleUuid)
.sort(([, a], [, b]) => {
const nameA = a.display_name.toLowerCase()
const nameB = b.display_name.toLowerCase()
return nameA.localeCompare(nameB)
}) })
} }
async function loadPermissions() { // Helper to count users in a role
permissions.value = await apiJson('/auth/api/admin/permissions') function roleUserCount(org, roleUuid) {
return Object.values(org.users).filter(u => u.role === roleUuid).length
}
// Helper to count total users in an org
function orgUserCount(org) {
return Object.keys(org.users).length
} }
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
} }
@@ -153,7 +206,9 @@ function clearSensitiveState() {
info.value = null info.value = null
orgs.value = [] orgs.value = []
permissions.value = [] permissions.value = []
oidcClients.value = []
userDetail.value = null userDetail.value = null
editingOidcClient.value = null
authenticated.value = false authenticated.value = false
} }
@@ -178,7 +233,7 @@ async function load() {
error.value = null error.value = null
try { try {
// Load admin data first - apiJson will handle 401/403 with iframe authentication // Load admin data first - apiJson will handle 401/403 with iframe authentication
await Promise.all([loadOrgs(), loadPermissions()]) await loadAdminData()
// If we get here, user has admin access - now fetch user info for display // If we get here, user has admin access - now fetch user info for display
await loadUserInfo() await loadUserInfo()
@@ -186,7 +241,6 @@ async function load() {
if (!window.location.hash || window.location.hash === '#overview') { if (!window.location.hash || window.location.hash === '#overview') {
currentOrgId.value = orgs.value[0].uuid currentOrgId.value = orgs.value[0].uuid
window.location.hash = `#org/${currentOrgId.value}` window.location.hash = `#org/${currentOrgId.value}`
authStore.showMessage(`Navigating to ${orgs.value[0].display_name} Administration`, 'info', 3000)
} else { } else {
parseHash() parseHash()
} }
@@ -207,17 +261,17 @@ function editUserName(user) { openDialog('user-update-name', { user, name: user.
async function performOrgDeletion(orgUuid) { async function performOrgDeletion(orgUuid) {
await apiJson(`/auth/api/admin/orgs/${orgUuid}`, { method: 'DELETE' }) await apiJson(`/auth/api/admin/orgs/${orgUuid}`, { method: 'DELETE' })
await Promise.all([loadOrgs(), loadPermissions()]) await Promise.all([loadAdminData()])
} }
function deleteOrg(org) { function deleteOrg(org) {
const userCount = org.roles.reduce((acc, r) => acc + r.users.length, 0) const userCount = orgUserCount(org)
if (userCount === 0) { if (userCount === 0) {
// No users in the organization, safe to delete directly // No users in the organization, safe to delete directly
performOrgDeletion(org.uuid) performOrgDeletion(org.uuid)
.then(() => { .then(() => {
authStore.showMessage(`Organization "${org.display_name}" deleted.`, 'success', 2500) authStore.showMessage(`Organization "${org.org.display_name}" deleted.`, 'success', 2500)
}) })
.catch(e => { .catch(e => {
authStore.showMessage(e.message || 'Failed to delete organization', 'error') authStore.showMessage(e.message || 'Failed to delete organization', 'error')
@@ -226,13 +280,14 @@ function deleteOrg(org) {
} }
// Build detailed breakdown of users by role // Build detailed breakdown of users by role
const roleParts = org.roles const roleParts = Object.entries(org.roles)
.filter(r => r.users.length > 0) .map(([uuid, r]) => ({ role: r, count: roleUserCount(org, uuid) }))
.map(r => `${r.users.length} ${r.display_name}`) .filter(x => x.count > 0)
.map(x => `${x.count} ${x.role.display_name}`)
const affects = roleParts.join(', ') const affects = roleParts.join(', ')
openDialog('confirm', { message: `Delete organization "${org.display_name}", including accounts of ${affects})?`, action: async () => { openDialog('confirm', { message: `Delete organization "${org.org.display_name}", including accounts of ${affects})?`, action: async () => {
await performOrgDeletion(org.uuid) await performOrgDeletion(org.uuid)
} }) } })
} }
@@ -240,7 +295,7 @@ function deleteOrg(org) {
function createUserInRole(org, role) { openDialog('user-create', { org, role }) } function createUserInRole(org, role) { openDialog('user-create', { org, role }) }
function deleteUser(user, userDetail) { function deleteUser(user, userDetail) {
const credentialCount = userDetail?.credentials?.length || 0 const credentialCount = userDetail?.credentials ? Object.keys(userDetail.credentials).length : 0
const userUuid = user.uuid const userUuid = user.uuid
const userName = user.display_name const userName = user.display_name
const orgUuid = user.org // org UUID is stored in selectedUser const orgUuid = user.org // org UUID is stored in selectedUser
@@ -264,44 +319,29 @@ async function performUserDeletion(userUuid, userName, orgUuid) {
try { try {
await apiJson(`/auth/api/admin/users/${userUuid}`, { method: 'DELETE' }) await apiJson(`/auth/api/admin/users/${userUuid}`, { method: 'DELETE' })
authStore.showMessage(`User "${userName}" deleted.`, 'success', 2500) authStore.showMessage(`User "${userName}" deleted.`, 'success', 2500)
await loadOrgs() await loadAdminData()
window.location.hash = `#org/${orgUuid}` window.location.hash = `#org/${orgUuid}`
} catch (e) { } catch (e) {
authStore.showMessage(e.message || 'Failed to delete user', 'error') authStore.showMessage(e.message || 'Failed to delete user', 'error')
} }
} }
async function moveUserToRole(user, targetRoleUuid) { async function moveUserToRole(userUuid, user, targetRoleUuid) {
if (user.role_uuid === targetRoleUuid) return if (user.role === targetRoleUuid) return
try { try {
await apiJson(`/auth/api/admin/users/${user.uuid}/role`, { await apiJson(`/auth/api/admin/users/${userUuid}/role`, {
method: 'PATCH', method: 'PATCH',
body: { role_uuid: targetRoleUuid } body: { role_uuid: targetRoleUuid }
}) })
await loadOrgs() await loadAdminData()
} catch (e) { } catch (e) {
authStore.showMessage(e.message || 'Failed to update user role') authStore.showMessage(e.message || 'Failed to update user role')
} }
} }
function onUserDragStart(e, user, org) { function moveUserToRoleFromDrag(userUuid, newRoleUuid) {
e.dataTransfer.effectAllowed = 'move' const user = selectedOrg.value?.users?.[userUuid]
e.dataTransfer.setData('text/plain', JSON.stringify({ user_uuid: user.uuid, 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.roles.flatMap(r => r.users).find(u => u.uuid === data.user_uuid)
if (user) moveUserToRole(user, role.uuid)
} catch (_) { /* ignore */ }
} }
// Role actions // Role actions
@@ -314,7 +354,7 @@ function deleteRole(role) {
apiJson(`/auth/api/admin/roles/${role.uuid}`, { method: 'DELETE' }) apiJson(`/auth/api/admin/roles/${role.uuid}`, { method: 'DELETE' })
.then(() => { .then(() => {
authStore.showMessage(`Role "${role.display_name}" deleted.`, 'success', 2500) authStore.showMessage(`Role "${role.display_name}" deleted.`, 'success', 2500)
loadOrgs() loadAdminData()
}) })
.catch(e => { .catch(e => {
authStore.showMessage(e.message || 'Failed to delete role', 'error') authStore.showMessage(e.message || 'Failed to delete role', 'error')
@@ -322,11 +362,14 @@ function deleteRole(role) {
} }
async function toggleRolePermission(role, pid, checked) { async function toggleRolePermission(role, pid, checked) {
// Optimistic update // Optimistic update - role.permissions is dict[UUID, bool]
const prevPermissions = [...role.permissions] const prevPermissions = { ...role.permissions }
const newPermissions = checked const newPermissions = { ...role.permissions }
? [...role.permissions, pid] if (checked) {
: role.permissions.filter(p => p !== pid) newPermissions[pid] = true
} else {
delete newPermissions[pid]
}
role.permissions = newPermissions role.permissions = newPermissions
try { try {
@@ -334,7 +377,7 @@ async function toggleRolePermission(role, pid, checked) {
await apiJson(`/auth/api/admin/roles/${role.uuid}/permissions/${pid}`, { await apiJson(`/auth/api/admin/roles/${role.uuid}/permissions/${pid}`, {
method method
}) })
await loadOrgs() await loadAdminData()
} catch (e) { } catch (e) {
authStore.showMessage(e.message || 'Failed to update role permission') authStore.showMessage(e.message || 'Failed to update role permission')
role.permissions = prevPermissions // revert role.permissions = prevPermissions // revert
@@ -345,7 +388,7 @@ async function toggleRolePermission(role, pid, checked) {
async function performPermissionDeletion(permissionUuid) { async function performPermissionDeletion(permissionUuid) {
const params = new URLSearchParams({ permission_uuid: permissionUuid }) const params = new URLSearchParams({ permission_uuid: permissionUuid })
await apiJson(`/auth/api/admin/permission?${params.toString()}`, { method: 'DELETE' }) await apiJson(`/auth/api/admin/permission?${params.toString()}`, { method: 'DELETE' })
await loadPermissions() await loadAdminData()
} }
function deletePermission(p) { function deletePermission(p) {
@@ -354,8 +397,8 @@ function deletePermission(p) {
// Count roles that have this permission // Count roles that have this permission
let roleCount = 0 let roleCount = 0
for (const org of orgs.value) { for (const org of orgs.value) {
for (const role of org.roles) { for (const role of Object.values(org.roles)) {
if (role.permissions.includes(p.uuid)) { if (p.uuid in (role.permissions || {})) {
roleCount++ roleCount++
} }
} }
@@ -383,6 +426,87 @@ function deletePermission(p) {
} }) } })
} }
// OIDC Client actions
async function sha256Hex(text) {
const hash = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(text))
return [...new Uint8Array(hash)].map(b => b.toString(16).padStart(2, '0')).join('')
}
function createOidcClient() {
// Navigate to new OIDC client page
window.location.hash = '#oidc:new'
}
function openOidcClient(client) {
// Navigate to OIDC client detail page
window.location.hash = `#oidc:${client.uuid}`
}
function resetOidcSecret(clientId) {
// Generate new secret locally; it will be sent to server on Save
const bytes = new Uint8Array(32)
crypto.getRandomValues(bytes)
const client_secret = btoa(String.fromCharCode(...bytes))
.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '')
// Update editingOidcClient if we're on the detail page
if (editingOidcClient.value?.client_id === clientId) {
editingOidcClient.value = { ...editingOidcClient.value, client_secret }
}
// Also update dialog if open (for backwards compatibility)
if (dialog.value.type === 'oidc-edit' && dialog.value.data?.client_id === clientId) {
dialog.value.data.client_secret = client_secret
}
}
function createPermissionForClient(clientId) {
openDialog('perm-create', { display_name: '', scope: '', domain: clientId })
}
function deleteOidcClient(client) {
openDialog('confirm', {
message: `Delete OIDC client "${client.name}"? This will break any applications using this client.`,
action: async () => {
await performOidcClientDeletion(client.uuid, client.name)
// Navigate back to overview if we were on the detail page
if (currentOidcId.value === client.uuid) {
window.location.hash = '#overview'
}
}
})
}
async function performOidcClientDeletion(clientUuid, clientName) {
await apiJson(`/auth/api/admin/oidc-clients/${clientUuid}`, { method: 'DELETE' })
authStore.showMessage(`OIDC client "${clientName}" deleted.`, 'success', 2500)
await loadAdminData()
}
async function handleOidcSave(data) {
const { client_id, client_secret, name, redirect_uris, isNew } = data
try {
if (client_secret) {
const secret_hash = await sha256Hex(client_secret)
if (isNew) {
await apiJson('/auth/api/admin/oidc-clients', { method: 'POST', body: { client_id, secret_hash, name, redirect_uris } })
} else {
await apiJson(`/auth/api/admin/oidc-clients/${client_id}`, { method: 'PATCH', body: { name, redirect_uris, secret_hash } })
}
} else {
await apiJson(`/auth/api/admin/oidc-clients/${client_id}`, { method: 'PATCH', body: { name, redirect_uris } })
}
authStore.showMessage(`OIDC client "${name}" ${isNew ? 'created' : 'updated'}.`, 'success', 2500)
await loadAdminData()
window.location.hash = '#overview'
} catch (e) {
authStore.showMessage(e.message || `Failed to ${isNew ? 'create' : 'update'} OIDC client`, 'error')
}
}
function handleOidcCancel() {
goOverview()
}
const selectedOrg = computed(() => orgs.value.find(o => o.uuid === currentOrgId.value) || null) const selectedOrg = computed(() => orgs.value.find(o => o.uuid === currentOrgId.value) || null)
function openOrg(o) { function openOrg(o) {
@@ -400,34 +524,41 @@ function openUser(u) {
const selectedUser = computed(() => { const selectedUser = computed(() => {
if (!currentUserId.value) return null if (!currentUserId.value) return null
for (const o of orgs.value) { for (const o of orgs.value) {
for (const r of o.roles) { const u = o.users[currentUserId.value]
const u = r.users.find(x => x.uuid === currentUserId.value) if (u) {
if (u) return { ...u, org: o.uuid, role_display_name: r.display_name } const role = o.roles[u.role]
return { ...u, uuid: currentUserId.value, org: o.uuid, role_display_name: role?.display_name }
} }
} }
return null return null
}) })
const pageHeading = computed(() => {
if (selectedUser.value) return 'Admin: User'
if (selectedOrg.value) return 'Admin: Org'
return ((authStore.settings?.rp_name) || 'Master') + ' Admin'
})
// Breadcrumb entries for admin app. // Breadcrumb entries for admin app.
const breadcrumbEntries = computed(() => { const breadcrumbEntries = computed(() => {
const entries = [ const entries = [
{ label: 'Auth', href: makeUiHref() }, { label: 'My Profile', href: makeUiHref() }
{ label: 'Admin', href: adminUiPath() }
] ]
// For org admins, combine Admin and their org
if (isOrgAdmin.value && !isMasterAdmin.value && orgs.value.length > 0) {
const org = orgs.value[0]
entries.push({ label: `Admin: ${org.org.display_name}`, href: `#org/${org.uuid}` })
} else {
entries.push({ label: 'Admin', href: adminUiPath() })
}
// Determine organization for user view if selectedOrg not explicitly chosen. // Determine organization for user view if selectedOrg not explicitly chosen.
let orgForUser = null let orgForUser = null
if (selectedUser.value) { if (selectedUser.value) {
orgForUser = orgs.value.find(o => o.uuid === selectedUser.value.org) || null orgForUser = orgs.value.find(o => o.uuid === selectedUser.value.org) || null
} }
const orgToShow = selectedOrg.value || orgForUser const orgToShow = selectedOrg.value || orgForUser
if (orgToShow) { // Add org breadcrumb only if it's not already included in the Admin entry
entries.push({ label: orgToShow.display_name, href: `#org/${orgToShow.uuid}` }) const adminOrg = (isOrgAdmin.value && !isMasterAdmin.value && orgs.value.length > 0) ? orgs.value[0] : null
if (orgToShow && (!adminOrg || orgToShow.uuid !== adminOrg.uuid)) {
entries.push({ label: orgToShow.org.display_name, href: `#org/${orgToShow.uuid}` })
}
if (currentOidcId.value) {
const label = editingOidcClient.value?.isNew ? 'New Client' : (editingOidcClient.value?.name || 'OIDC Client')
entries.push({ label, href: `#oidc:${currentOidcId.value}` })
} }
if (selectedUser.value) { if (selectedUser.value) {
entries.push({ label: selectedUser.value.display_name, href: `#user/${selectedUser.value.uuid}` }) entries.push({ label: selectedUser.value.display_name, href: `#user/${selectedUser.value.uuid}` })
@@ -450,18 +581,27 @@ function generateUserRegistrationLink(u) {
} }
async function toggleOrgPermission(org, permId, checked) { async function toggleOrgPermission(org, permId, checked) {
// Build next permission list // org.permissions is dict[UUID, Permission]
const has = org.permissions.includes(permId) const has = permId in org.permissions
if (checked && has) return if (checked && has) return
if (!checked && !has) return if (!checked && !has) return
const next = checked ? [...org.permissions, permId] : org.permissions.filter(p => p !== permId)
// Optimistic update // Optimistic update
const prev = [...org.permissions] const prev = { ...org.permissions }
if (checked) {
// Need to fetch the permission object to add it
const perm = permissions.value.find(p => p.uuid === permId)
if (perm) {
org.permissions = { ...org.permissions, [permId]: perm }
}
} else {
const next = { ...org.permissions }
delete next[permId]
org.permissions = next org.permissions = next
}
try { try {
const params = new URLSearchParams({ permission_uuid: permId }) const params = new URLSearchParams({ permission_uuid: permId })
await apiJson(`/auth/api/admin/orgs/${org.uuid}/permission?${params.toString()}`, { method: checked ? 'POST' : 'DELETE' }) await apiJson(`/auth/api/admin/orgs/${org.uuid}/permission?${params.toString()}`, { method: checked ? 'POST' : 'DELETE' })
await loadOrgs() await loadAdminData()
} catch (e) { } catch (e) {
authStore.showMessage(e.message || 'Failed to update organization permission', 'error') authStore.showMessage(e.message || 'Failed to update organization permission', 'error')
org.permissions = prev // revert org.permissions = prev // revert
@@ -571,7 +711,7 @@ function handlePanelNavigateOut(direction) {
} }
async function refreshUserDetail() { async function refreshUserDetail() {
await loadOrgs() await loadAdminData()
if (selectedUser.value) { if (selectedUser.value) {
try { try {
userDetail.value = await apiJson(`/auth/api/admin/users/${selectedUser.value.uuid}`) userDetail.value = await apiJson(`/auth/api/admin/users/${selectedUser.value.uuid}`)
@@ -597,7 +737,7 @@ async function submitDialog() {
apiJson('/auth/api/admin/orgs', { method: 'POST', body: { display_name: name, permissions: [] } }) apiJson('/auth/api/admin/orgs', { method: 'POST', body: { display_name: name, permissions: [] } })
.then(() => { .then(() => {
authStore.showMessage(`Organization "${name}" created.`, 'success', 2500) authStore.showMessage(`Organization "${name}" created.`, 'success', 2500)
Promise.all([loadOrgs(), loadPermissions()]) loadAdminData()
}) })
.catch(e => { .catch(e => {
authStore.showMessage(e.message || 'Failed to create organization', 'error') authStore.showMessage(e.message || 'Failed to create organization', 'error')
@@ -611,7 +751,7 @@ async function submitDialog() {
apiJson(`/auth/api/admin/orgs/${org.uuid}`, { method: 'PATCH', body: { display_name: name } }) apiJson(`/auth/api/admin/orgs/${org.uuid}`, { method: 'PATCH', body: { display_name: name } })
.then(() => { .then(() => {
authStore.showMessage(`Organization renamed to "${name}".`, 'success', 2500) authStore.showMessage(`Organization renamed to "${name}".`, 'success', 2500)
loadOrgs() loadAdminData()
}) })
.catch(e => { .catch(e => {
authStore.showMessage(e.message || 'Failed to update organization', 'error') authStore.showMessage(e.message || 'Failed to update organization', 'error')
@@ -625,7 +765,7 @@ async function submitDialog() {
apiJson(`/auth/api/admin/orgs/${org.uuid}/roles`, { method: 'POST', body: { display_name: name, permissions: [] } }) apiJson(`/auth/api/admin/orgs/${org.uuid}/roles`, { method: 'POST', body: { display_name: name, permissions: [] } })
.then(() => { .then(() => {
authStore.showMessage(`Role "${name}" created.`, 'success', 2500) authStore.showMessage(`Role "${name}" created.`, 'success', 2500)
loadOrgs() loadAdminData()
}) })
.catch(e => { .catch(e => {
authStore.showMessage(e.message || 'Failed to create role', 'error') authStore.showMessage(e.message || 'Failed to create role', 'error')
@@ -653,7 +793,7 @@ async function submitDialog() {
apiJson(`/auth/api/admin/orgs/${org.uuid}/users`, { method: 'POST', body: { display_name: name, role: role.display_name } }) apiJson(`/auth/api/admin/orgs/${org.uuid}/users`, { method: 'POST', body: { display_name: name, role: role.display_name } })
.then(() => { .then(() => {
authStore.showMessage(`User "${name}" added to ${role.display_name} role.`, 'success', 2500) authStore.showMessage(`User "${name}" added to ${role.display_name} role.`, 'success', 2500)
loadOrgs() loadAdminData()
}) })
.catch(e => { .catch(e => {
authStore.showMessage(e.message || 'Failed to add user', 'error') authStore.showMessage(e.message || 'Failed to add user', 'error')
@@ -664,7 +804,7 @@ async function submitDialog() {
// Close dialog immediately, then perform async operation // Close dialog immediately, then perform async operation
closeDialog() closeDialog()
apiJson(`/auth/api/admin/users/${user.uuid}/display-name`, { method: 'PATCH', body: { display_name: name } }) apiJson(`/auth/api/admin/users/${user.uuid}/info`, { method: 'PATCH', body: { display_name: name } })
.then(() => { .then(() => {
authStore.showMessage(`User renamed to "${name}".`, 'success', 2500) authStore.showMessage(`User renamed to "${name}".`, 'success', 2500)
onUserNameSaved() onUserNameSaved()
@@ -699,7 +839,7 @@ async function submitDialog() {
apiJson(`/auth/api/admin/permission?${params.toString()}`, { method: 'PATCH' }) apiJson(`/auth/api/admin/permission?${params.toString()}`, { method: 'PATCH' })
.then(() => { .then(() => {
authStore.showMessage(`Permission "${newDisplay}" updated.`, 'success', 2500) authStore.showMessage(`Permission "${newDisplay}" updated.`, 'success', 2500)
loadPermissions() loadAdminData()
}) })
.catch(e => { .catch(e => {
authStore.showMessage(e.message || 'Failed to update permission', 'error') authStore.showMessage(e.message || 'Failed to update permission', 'error')
@@ -715,12 +855,37 @@ async function submitDialog() {
apiJson('/auth/api/admin/permissions', { method: 'POST', body: { scope, display_name, domain: domain || undefined } }) apiJson('/auth/api/admin/permissions', { method: 'POST', body: { scope, display_name, domain: domain || undefined } })
.then(() => { .then(() => {
authStore.showMessage(`Permission "${display_name}" created.`, 'success', 2500) authStore.showMessage(`Permission "${display_name}" created.`, 'success', 2500)
loadPermissions() loadAdminData()
}) })
.catch(e => { .catch(e => {
authStore.showMessage(e.message || 'Failed to create permission', 'error') authStore.showMessage(e.message || 'Failed to create permission', 'error')
}) })
return // Don't call closeDialog() again return // Don't call closeDialog() again
} else if (t === 'oidc-edit') {
const { client_id, client_secret, isNew } = dialog.value.data
const name = dialog.value.data.name?.trim()
const uris = dialog.value.data.redirect_uris?.trim()
if (!name) throw new Error('Client name required')
const redirect_uris = uris ? uris.split('\n').map(u => u.trim()).filter(u => u) : []
// Close dialog immediately, then perform async operation
closeDialog()
const req = client_secret
? sha256Hex(client_secret).then(secret_hash => isNew
? apiJson('/auth/api/admin/oidc-clients', { method: 'POST', body: { client_id, secret_hash, name, redirect_uris } })
: apiJson(`/auth/api/admin/oidc-clients/${client_id}`, { method: 'PATCH', body: { name, redirect_uris, secret_hash } }))
: apiJson(`/auth/api/admin/oidc-clients/${client_id}`, { method: 'PATCH', body: { name, redirect_uris } })
req
.then(() => {
authStore.showMessage(`OIDC client "${name}" ${isNew ? 'created' : 'updated'}.`, 'success', 2500)
loadAdminData()
})
.catch(e => {
authStore.showMessage(e.message || `Failed to ${isNew ? 'create' : 'update'} OIDC client`, '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)
@@ -760,7 +925,6 @@ async function submitDialog() {
/> />
<section v-else-if="authenticated && (isMasterAdmin || isOrgAdmin)" class="view-root view-root--wide view-admin"> <section v-else-if="authenticated && (isMasterAdmin || isOrgAdmin)" class="view-root view-root--wide view-admin">
<header class="view-header"> <header class="view-header">
<h1>{{ pageHeading }}</h1>
<Breadcrumbs ref="breadcrumbsRef" :entries="breadcrumbEntries" @keydown="handleBreadcrumbKeydown" /> <Breadcrumbs ref="breadcrumbsRef" :entries="breadcrumbEntries" @keydown="handleBreadcrumbKeydown" />
</header> </header>
@@ -768,11 +932,12 @@ async function submitDialog() {
<div class="section-body admin-section-body"> <div class="section-body admin-section-body">
<div class="admin-panels"> <div class="admin-panels">
<AdminOverview <AdminOverview
v-if="!selectedUser && !selectedOrg && (isMasterAdmin || isOrgAdmin)" v-if="!selectedUser && !selectedOrg && !currentOidcId && (isMasterAdmin || isOrgAdmin)"
ref="adminOverviewRef" ref="adminOverviewRef"
:info="info" :info="info"
:orgs="orgs" :orgs="orgs"
:permissions="permissions" :permissions="permissions"
:oidc-clients="oidcClients"
:navigation-disabled="hasActiveModal" :navigation-disabled="hasActiveModal"
:permission-summary="permissionSummary" :permission-summary="permissionSummary"
@create-org="createOrg" @create-org="createOrg"
@@ -783,6 +948,9 @@ async function submitDialog() {
@open-dialog="openDialog" @open-dialog="openDialog"
@delete-permission="deletePermission" @delete-permission="deletePermission"
@rename-permission-display="renamePermissionDisplay" @rename-permission-display="renamePermissionDisplay"
@create-oidc-client="createOidcClient"
@open-oidc-client="openOidcClient"
@delete-oidc-client="deleteOidcClient"
@navigate-out="handlePanelNavigateOut" @navigate-out="handlePanelNavigateOut"
/> />
@@ -818,10 +986,23 @@ 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"
/>
<AdminOidcDetail
v-else-if="currentOidcId && editingOidcClient"
ref="adminOidcDetailRef"
:client="editingOidcClient"
:permissions="permissions"
:is-new="editingOidcClient.isNew"
:navigation-disabled="hasActiveModal"
@save="handleOidcSave"
@cancel="handleOidcCancel"
@delete="deleteOidcClient"
@reset-secret="resetOidcSecret"
@create-permission="createPermissionForClient"
@navigate-out="handlePanelNavigateOut" @navigate-out="handlePanelNavigateOut"
@on-role-drop="onRoleDrop"
@on-user-drag-start="onUserDragStart"
/> />
</div> </div>
@@ -835,14 +1016,14 @@ async function submitDialog() {
:settings="authStore.settings" :settings="authStore.settings"
@submit-dialog="submitDialog" @submit-dialog="submitDialog"
@close-dialog="closeDialog" @close-dialog="closeDialog"
@reset-oidc-secret="resetOidcSecret"
@create-permission-for-client="createPermissionForClient"
/> />
</div> </div>
</template> </template>
<style scoped> <style scoped>
.view-admin { padding-bottom: var(--space-3xl); } .view-admin { padding-bottom: var(--space-3xl); }
.view-header { display: flex; flex-direction: column; gap: var(--space-sm); }
.admin-section { margin-top: var(--space-xl); }
.admin-section-body { display: flex; flex-direction: column; gap: var(--space-xl); } .admin-section-body { display: flex; flex-direction: column; gap: var(--space-xl); }
.admin-panels { display: flex; flex-direction: column; gap: var(--space-xl); } .admin-panels { display: flex; flex-direction: column; gap: var(--space-xl); }
</style> </style>
+22 -2
View File
@@ -2,6 +2,7 @@
<RestrictedAuth <RestrictedAuth
:mode="authMode" :mode="authMode"
:remote-auth-token="remoteAuthToken" :remote-auth-token="remoteAuthToken"
:oidc-query-string="oidcQueryString"
@authenticated="handleAuthenticated" @authenticated="handleAuthenticated"
@back="handleBack" @back="handleBack"
/> />
@@ -15,6 +16,9 @@ import RestrictedAuth from '@/components/RestrictedAuth.vue'
// The token is a 5-word passphrase like "word1.word2.word3.word4.word5" // The token is a 5-word passphrase like "word1.word2.word3.word4.word5"
const remoteAuthToken = ref(null) const remoteAuthToken = ref(null)
// For OIDC flow, pass the raw query string to preserve exact param values
const oidcQueryString = window.location.search.includes('client_id=') ? window.location.search : null
function extractRemoteToken() { function extractRemoteToken() {
const path = window.location.pathname const path = window.location.pathname
// Match /auth/{token} where token is a passphrase with dots // Match /auth/{token} where token is a passphrase with dots
@@ -32,7 +36,18 @@ function extractRemoteToken() {
// Parse URL hash fragment // Parse URL hash fragment
const hashParams = new URLSearchParams(window.location.hash.slice(1)) const hashParams = new URLSearchParams(window.location.hash.slice(1))
const authMode = ['reauth', 'forbidden'].includes(hashParams.get('mode')) ? hashParams.get('mode') : 'login'
// Determine auth mode based on URL path
// - /auth/restricted/oidc: OIDC flow, no session dependency
// - /auth/restricted/iframe: iframe embedding, mode from hash params
// - Other paths: forward auth, mode from hash params
let authMode
if (window.location.pathname === '/auth/restricted/oidc') {
authMode = 'oidc'
} else {
// Both iframe and forward auth use hash params for mode (forbidden/login/reauth)
authMode = ['reauth', 'forbidden'].includes(hashParams.get('mode')) ? hashParams.get('mode') : 'login'
}
function postToParent(message) { function postToParent(message) {
if (window.parent && window.parent !== window) { if (window.parent && window.parent !== window) {
@@ -41,10 +56,15 @@ function postToParent(message) {
} }
function handleAuthenticated(result) { function handleAuthenticated(result) {
if (result.redirect_url) {
// OIDC flow: redirect to client with auth code
window.location.href = result.redirect_url
return
}
postToParent({ postToParent({
type: 'auth-success', type: 'auth-success',
authenticated: true, authenticated: true,
sessionToken: result.session_token exchangeCode: result.exchange_code
}) })
} }
+2 -2
View File
@@ -1,9 +1,9 @@
<!DOCTYPE html> <!DOCTYPE html>
<html lang="en" style="background: transparent"> <html lang="en">
<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')}</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>
+2 -2
View File
@@ -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
+1
View File
@@ -4,6 +4,7 @@
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Access Restricted</title> <title>Access Restricted</title>
<script>(localStorage.getItem('paskia-theme')==='dark'||localStorage.getItem('paskia-theme')!=='light'&&matchMedia('(prefers-color-scheme:dark)').matches)&&document.documentElement.classList.add('dark')</script>
</head> </head>
<body> <body>
<div id="app"></div> <div id="app"></div>
+19 -18
View File
@@ -1,14 +1,14 @@
<template> <template>
<div class="app-shell"> <div class="app-shell">
<div v-if="status.show" class="global-status" style="display: block;"> <div v-if="status.show" class="global-status show">
<div :class="['status', status.type]"> <div :class="['status', status.type]">
{{ status.message }} {{ status.message }}
</div> </div>
</div> </div>
<main class="view-root"> <main class="view-root">
<div class="surface surface--tight" style="max-width: 560px; margin: 0 auto; width: 100%;"> <div class="surface surface--tight reset-container">
<header class="view-header" style="text-align: center;"> <header class="view-header center">
<h1>🔑 Registration</h1> <h1>🔑 Registration</h1>
<p class="view-lede"> <p class="view-lede">
{{ subtitleMessage }} {{ subtitleMessage }}
@@ -23,7 +23,7 @@
<section class="section-block" v-else-if="!canRegister"> <section class="section-block" v-else-if="!canRegister">
<div class="section-body center"> <div class="section-body center">
<div class="button-row center" style="justify-content: center;"> <div class="button-row button-row--center">
<button class="btn-secondary" @click="goHome">Return to sign-in</button> <button class="btn-secondary" @click="goHome">Return to sign-in</button>
</div> </div>
</div> </div>
@@ -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
@@ -144,7 +146,7 @@ async function registerPasskey() {
} }
try { try {
await setSessionCookie(result) await exchangeCode(result)
} catch (error) { } catch (error) {
loading.value = false loading.value = false
const message = error?.message || 'Failed to establish session' const message = error?.message || 'Failed to establish session'
@@ -156,15 +158,13 @@ async function registerPasskey() {
setTimeout(() => { loading.value = false; goHome() }, 800) setTimeout(() => { loading.value = false; goHome() }, 800)
} }
async function setSessionCookie(result) { async function exchangeCode(result) {
if (!result?.session_token) { if (!result?.exchange_code) {
throw new Error('Registration response missing session_token') throw new Error('Registration response missing exchange_code')
} }
return await apiJson('/auth/api/set-session', { return await apiJson('/auth/api/set-session', {
method: 'POST', method: 'POST',
headers: { headers: { 'Authorization': `Bearer ${result.exchange_code}` }
Authorization: `Bearer ${result.session_token}`
}
}) })
} }
@@ -203,13 +203,14 @@ onMounted(async () => {
</script> </script>
<style scoped> <style scoped>
.center { main.view-root { min-height: 100vh; align-items: center; justify-content: center; padding: 2rem 1rem; }
text-align: center; .reset-container {
} max-width: 520px;
margin: 0 auto;
.button-row.center { width: 100%;
display: flex; display: flex;
justify-content: center; flex-direction: column;
gap: 1.75rem;
} }
.section-body { .section-body {
+1
View File
@@ -4,6 +4,7 @@
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Complete Passkey Setup</title> <title>Complete Passkey Setup</title>
<script>(localStorage.getItem('paskia-theme')==='dark'||localStorage.getItem('paskia-theme')!=='light'&&matchMedia('(prefers-color-scheme:dark)').matches)&&document.documentElement.classList.add('dark')</script>
</head> </head>
<body> <body>
<div id="app"></div> <div id="app"></div>
+3 -1
View File
@@ -14,7 +14,9 @@
"pinia": "^3.0.3", "pinia": "^3.0.3",
"qrcode": "^1.5.4", "qrcode": "^1.5.4",
"sirv": "^3.0.2", "sirv": "^3.0.2",
"vue": "^3.5.17" "uuidv7": "^1.1.0",
"vue": "^3.5.17",
"vuedraggable": "^4.1.0"
}, },
"devDependencies": { "devDependencies": {
"@vitejs/plugin-vue": "^6.0.0", "@vitejs/plugin-vue": "^6.0.0",
+35 -8
View File
@@ -2,6 +2,7 @@
import { computed } from 'vue' import { computed } from 'vue'
import Modal from '@/components/Modal.vue' import Modal from '@/components/Modal.vue'
import NameEditForm from '@/components/NameEditForm.vue' import NameEditForm from '@/components/NameEditForm.vue'
import { useAuthStore } from '@/stores/auth'
const props = defineProps({ const props = defineProps({
dialog: Object, dialog: Object,
@@ -9,10 +10,20 @@ const props = defineProps({
settings: Object settings: Object
}) })
const emit = defineEmits(['submitDialog', 'closeDialog']) const emit = defineEmits(['submitDialog', 'closeDialog', 'resetOidcSecret', 'createPermissionForClient'])
const NAME_EDIT_TYPES = new Set(['org-update', 'role-update', 'user-update-name']) const NAME_EDIT_TYPES = new Set(['org-update', 'role-update', 'user-update-name'])
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`)
// Copy-to-clipboard helper
const authStore = useAuthStore()
function copyText(value, label) {
navigator.clipboard.writeText(value).then(() => {
authStore.showMessage(`${label} copied to clipboard`, 'success', 1500)
})
}
</script> </script>
<template> <template>
@@ -25,6 +36,7 @@ const rpId = computed(() => props.settings?.rp_id || 'the configured domain')
<template v-else-if="dialog.type==='user-create'">Add User To Role</template> <template v-else-if="dialog.type==='user-create'">Add User To Role</template>
<template v-else-if="dialog.type==='user-update-name'">Edit User Name</template> <template v-else-if="dialog.type==='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==='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">
@@ -76,19 +88,19 @@ const rpId = computed(() => props.settings?.rp_id || 'the configured domain')
<input ref="displayNameInput" v-model="dialog.data.display_name" required /> <input ref="displayNameInput" v-model="dialog.data.display_name" required />
</label> </label>
<label>Permission Scope <label>Permission Scope
<input v-model="dialog.data.scope" :placeholder="dialog.type === 'perm-create' ? 'yourapp:permission' : dialog.data.permission.scope" required :pattern="PERMISSION_ID_PATTERN" title="Allowed: A-Za-z0-9:._~-" data-form-type="other" /> <input v-model="dialog.data.scope" required :pattern="PERMISSION_ID_PATTERN" title="Allowed: A-Za-z0-9:._~-" data-form-type="other" />
</label> </label>
<p class="small muted">E.g. yourapp:reports. Changing the scope name may break deployed applications.</p> <p class="small muted">E.g. yourapp:reports. Changing the scope name may break deployed applications.</p>
<label>Domain Scope <label>Domain Scope
<input v-model="dialog.data.domain" placeholder="e.g. app.example.com" data-form-type="other" /> <input v-model="dialog.data.domain" data-form-type="other" />
</label> </label>
<p class="small muted">If set, this permission is effective only on the specified domain, which can be {{ rpId }} or its subdomain.</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==='confirm'"> <template v-else-if="dialog.type==='confirm'">
<p>{{ dialog.data.message }}</p> <p>{{ dialog.data.message }}</p>
</template> </template>
<div v-if="dialog.error && !NAME_EDIT_TYPES.has(dialog.type)" class="error small">{{ dialog.error }}</div> <div v-if="dialog.error && !NAME_EDIT_TYPES.has(dialog.type)" class="error small">{{ dialog.error }}</div>
<div v-if="!NAME_EDIT_TYPES.has(dialog.type)" class="modal-actions"> <div v-if="!NAME_EDIT_TYPES.has(dialog.type) && !NO_SUBMIT_TYPES.has(dialog.type)" class="modal-actions">
<button <button
type="button" type="button"
class="btn-secondary" class="btn-secondary"
@@ -105,13 +117,28 @@ const rpId = computed(() => props.settings?.rp_id || 'the configured domain')
{{ dialog.type==='confirm' ? 'OK' : 'Save' }} {{ dialog.type==='confirm' ? 'OK' : 'Save' }}
</button> </button>
</div> </div>
<div v-else-if="NO_SUBMIT_TYPES.has(dialog.type)" class="modal-actions">
<button
type="button"
class="btn-primary"
@click="$emit('closeDialog')"
>
Close
</button>
</div>
</form> </form>
</Modal> </Modal>
</template> </template>
<style scoped> <style scoped>
.error { color: var(--color-danger-text); }
.small { font-size: 0.9rem; }
.muted { color: var(--color-text-muted); }
.optional { font-weight: normal; color: var(--color-text-muted); font-size: 0.85em; } .optional { font-weight: normal; color: var(--color-text-muted); font-size: 0.85em; }
.oidc-divider { border: none; border-top: 1px solid var(--color-border); margin: var(--space-sm) 0; }
.oidc-dl { display: grid; grid-template-columns: auto 1fr; gap: 0.2rem 1rem; align-items: baseline; margin: 0; }
.oidc-dl dt { font-size: 0.85rem; color: var(--color-text-muted); white-space: nowrap; }
.oidc-dl dd { margin: 0; cursor: pointer; overflow: hidden; }
.oidc-dl output { font-family: var(--font-mono, monospace); font-size: 0.85rem; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; display: block; }
.oidc-reset-row { display: flex; align-items: center; gap: var(--space-sm); flex-wrap: wrap; }
.oidc-groups { cursor: default; }
.oidc-group { cursor: pointer; }
.oidc-group output { white-space: normal; word-break: break-all; }
</style> </style>
+283
View File
@@ -0,0 +1,283 @@
<script setup>
import { ref, computed, watch, onMounted } from 'vue'
import { getDirection, navigateButtonRow } from '@/utils/keynav'
import { useAuthStore } from '@/stores/auth'
const props = defineProps({
client: Object,
permissions: Array,
isNew: { type: Boolean, default: false },
navigationDisabled: { type: Boolean, default: false }
})
const emit = defineEmits(['save', 'cancel', 'delete', 'resetSecret', 'createPermission', 'navigateOut'])
const authStore = useAuthStore()
const headerRef = ref(null)
// Helper function to build URLs
function authSitePath(path) {
const url = new URL(authStore.settings.auth_site_url)
url.pathname = path
return url.toString()
}
// Local form state
const name = ref('')
const redirectUris = ref('')
const clientSecret = ref(null)
// Computed
const clientId = computed(() => props.client?.client_id || props.client?.uuid || '')
const discoveryUrl = computed(() => authSitePath('/.well-known/openid-configuration'))
const iconUrl = computed(() => authSitePath('/favicon.ico'))
// Groups (permissions) scoped to this client
const clientGroups = computed(() => {
if (!props.client || !props.permissions) return []
const clientUuid = props.client.uuid || props.client.client_id
return props.permissions.filter(p => p.domain === clientUuid).sort((a, b) => a.scope.localeCompare(b.scope))
})
// Initialize form data from props
watch(() => props.client, (c) => {
if (c) {
name.value = c.name || ''
redirectUris.value = Array.isArray(c.redirect_uris) ? c.redirect_uris.join('\n') : (c.redirect_uris || '')
clientSecret.value = c.client_secret || null
}
}, { immediate: true })
// Copy-to-clipboard helper
function copyText(value, label) {
navigator.clipboard.writeText(value).then(() => {
authStore.showMessage(`${label} copied to clipboard`, 'success', 1500)
})
}
function handleResetSecret() {
emit('resetSecret', clientId.value)
}
// When parent resets secret, update local state
watch(() => props.client?.client_secret, (newSecret) => {
if (newSecret) {
clientSecret.value = newSecret
}
})
function handleSave() {
const trimmedName = name.value.trim()
if (!trimmedName) {
authStore.showMessage('Client name is required', 'error')
return
}
const uris = redirectUris.value.trim()
const redirect_uris = uris ? uris.split('\n').map(u => u.trim()).filter(u => u) : []
emit('save', {
client_id: clientId.value,
client_secret: clientSecret.value,
name: trimmedName,
redirect_uris,
isNew: props.isNew
})
}
function handleDelete() {
emit('delete', props.client)
}
function handleCreatePermission() {
emit('createPermission', clientId.value)
}
function handleCancel() {
emit('cancel')
}
// Keyboard navigation
function handleHeaderKeydown(event) {
if (props.navigationDisabled) return
const direction = getDirection(event)
if (!direction) return
event.preventDefault()
if (direction === 'left' || direction === 'right') {
navigateButtonRow(headerRef.value, event.target, direction, { itemSelector: 'button, a' })
} else if (direction === 'up') {
emit('navigateOut', 'up')
}
}
function focusFirstElement() {
const firstFocusable = headerRef.value?.querySelector('button, a, input')
if (firstFocusable) firstFocusable.focus()
}
defineExpose({ focusFirstElement })
</script>
<template>
<div class="oidc-detail">
<form @submit.prevent="handleSave" class="oidc-form">
<!-- Client credentials section -->
<section class="section-block">
<div class="section-header">
<h2>Client Configuration</h2>
<p class="section-description">Configure these values in the client application.</p>
</div>
<div class="section-body">
<dl class="oidc-dl">
<dt>Authentication Name</dt>
<dd>
<output @click="copyText(authStore.settings.rp_name, 'Authentication Name')" title="Click to copy">{{ authStore.settings.rp_name }}</output>
<span class="small muted"> (Login With, may affect URLs optional)</span>
</dd>
<dt>Client ID</dt>
<dd><output @click="copyText(clientId, 'Client ID')" title="Click to copy">{{ clientId }}</output></dd>
<dt>Client Secret <button v-if="!clientSecret" type="button" class="icon-btn" @click="handleResetSecret" title="Revoke and re-generate secret">🔄</button></dt>
<dd>
<output v-if="clientSecret" @click="copyText(clientSecret, 'Client Secret')" title="Click to copy">{{ clientSecret }}</output>
<span v-else class="small muted">(only stored in hashed form)</span>
</dd>
<dt>Auto Discovery URL</dt>
<dd><output @click="copyText(discoveryUrl, 'OpenID Connect Auto Discovery URL')" title="Click to copy">{{ discoveryUrl }}</output></dd>
<dt>Icon URL</dt>
<dd>
<output @click="copyText(iconUrl, 'Icon URL')" title="Click to copy">{{ iconUrl }}</output>
<span class="small muted"> (optional)</span>
</dd>
<template v-if="clientGroups.length">
<dt>Groups Claim Name</dt>
<dd>
<output @click="copyText('groups', 'Groups Claim Name')" title="Click to copy">groups</output>
</dd>
</template>
<dt>Groups <button type="button" class="icon-btn" @click="handleCreatePermission" title="Add permission scoped to this client"></button></dt>
<dd class="oidc-groups">
<template v-if="clientGroups.length">
<output v-for="group in clientGroups" :key="group.uuid" class="oidc-group" @click="copyText(group.scope, 'Group Value')" :title="group.display_name">{{ group.scope }}</output>
</template>
<span v-else class="small muted">(no permissions defined)</span>
</dd>
</dl>
<span class="warning-text">
<strong v-if="clientSecret"> {{ isNew ? 'Save the secret now it cannot be retrieved later.' : 'Saving will prevent access with the old secret.' }}</strong>
<span v-else> The client may use groups to check for required permissions.</span>
</span>
</div>
</section>
<!-- Editable fields -->
<section class="section-block">
<div class="section-header">
<h2>Paskia Configuration</h2>
</div>
<div class="section-body">
<label>Client Name
<input v-model="name" required />
</label>
<label>Redirect URIs
<p class="small muted">This should be provided by the client application.</p>
<textarea v-model="redirectUris" placeholder="(autodiscover one on first use)" rows="3"></textarea>
</label>
</div>
</section>
<!-- Actions -->
<div class="oidc-actions">
<button type="button" class="btn-secondary" @click="handleCancel">Cancel</button>
<button v-if="!isNew" type="button" class="btn-danger" @click="handleDelete">Delete Client</button>
<button type="submit" class="btn-primary">Save</button>
</div>
</form>
</div>
</template>
<style scoped>
.oidc-detail {
display: flex;
flex-direction: column;
gap: var(--space-lg);
}
.oidc-header {
margin-bottom: 0;
}
.oidc-header h2 {
margin: 0;
}
.oidc-form {
display: flex;
flex-direction: column;
gap: var(--space-lg);
}
.oidc-dl {
display: grid;
grid-template-columns: auto 1fr;
gap: 0.3rem 1rem;
align-items: baseline;
margin: var(--space-sm) 0;
}
.oidc-dl dt {
font-size: 0.85rem;
color: var(--color-text-muted);
white-space: nowrap;
}
.oidc-dl dd {
margin: 0;
overflow: hidden;
display: flex;
align-items: baseline;
gap: 0.5em;
}
.oidc-dl output {
font-family: var(--font-mono, monospace);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
min-width: 0;
}
.warning-text {
display: block;
font-size: 0.9rem;
min-height: 1.4em;
}
.oidc-group { display: block; }
.oidc-group { white-space: normal; word-break: break-all; }
.section-body label {
display: flex;
flex-direction: column;
gap: var(--space-xs);
font-weight: 500;
}
.oidc-actions {
display: flex;
gap: var(--space-sm);
justify-content: flex-end;
margin-top: var(--space-md);
}
</style>
+63 -47
View File
@@ -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)
@@ -16,7 +17,8 @@ const permMatrixRef = ref(null)
const rolesGridRef = ref(null) const rolesGridRef = ref(null)
const sortedRoles = computed(() => { const sortedRoles = computed(() => {
return [...props.selectedOrg.roles].sort((a, b) => { // o.roles is dict[UUID, Role], convert to array for sorting with uuid added
return Object.entries(props.selectedOrg.roles).map(([uuid, r]) => ({ uuid, ...r })).sort((a, b) => {
const nameA = a.display_name.toLowerCase() const nameA = a.display_name.toLowerCase()
const nameB = b.display_name.toLowerCase() const nameB = b.display_name.toLowerCase()
if (nameA !== nameB) { if (nameA !== nameB) {
@@ -28,10 +30,35 @@ const sortedRoles = computed(() => {
// Get org's grantable permissions as full permission objects (with UUIDs) // Get org's grantable permissions as full permission objects (with UUIDs)
const orgPermissions = computed(() => { const orgPermissions = computed(() => {
const uuidSet = new Set(props.selectedOrg.permissions || []) // props.selectedOrg.permissions is dict[UUID, Permission]
const uuidSet = new Set(Object.keys(props.selectedOrg.permissions || {}))
return props.permissions.filter(p => uuidSet.has(p.uuid)) return props.permissions.filter(p => uuidSet.has(p.uuid))
}) })
// Get users for a role as sorted array of { uuid, ...user }
function roleUsers(roleUuid) {
return Object.entries(props.selectedOrg.users)
.filter(([_, u]) => u.role === roleUuid)
.map(([uuid, u]) => ({ uuid, ...u }))
.sort((a, b) => {
const nameA = a.display_name.toLowerCase()
const nameB = b.display_name.toLowerCase()
return nameA.localeCompare(nameB)
})
}
function roleUserCount(roleUuid) {
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
} }
@@ -83,7 +110,7 @@ function handleMatrixKeydown(event) {
// Calculate grid dimensions // Calculate grid dimensions
const cols = sortedRoles.value.length const cols = sortedRoles.value.length
const rows = props.selectedOrg.permissions.length const rows = Object.keys(props.selectedOrg.permissions).length
const currentRow = Math.floor(currentIndex / cols) const currentRow = Math.floor(currentIndex / cols)
const currentCol = currentIndex % cols const currentCol = currentIndex % cols
@@ -219,7 +246,7 @@ function handleRoleHeaderKeydown(event, roleIndex) {
if (checkboxes?.length) { if (checkboxes?.length) {
// Focus the checkbox in the corresponding column // Focus the checkbox in the corresponding column
const cols = sortedRoles.value.length const cols = sortedRoles.value.length
const rows = props.selectedOrg.permissions.length const rows = Object.keys(props.selectedOrg.permissions).length
const targetIndex = (rows - 1) * cols + roleIndex const targetIndex = (rows - 1) * cols + roleIndex
if (checkboxes[targetIndex]) checkboxes[targetIndex].focus() if (checkboxes[targetIndex]) checkboxes[targetIndex].focus()
else checkboxes[checkboxes.length - 1].focus() else checkboxes[checkboxes.length - 1].focus()
@@ -287,7 +314,7 @@ defineExpose({ focusFirstElement })
<template> <template>
<h2 class="org-title" ref="orgTitleRef" @keydown="handleTitleKeydown" :title="selectedOrg.uuid"> <h2 class="org-title" ref="orgTitleRef" @keydown="handleTitleKeydown" :title="selectedOrg.uuid">
<span class="org-name">{{ selectedOrg.display_name }}</span> <span class="org-name">{{ selectedOrg.org.display_name }}</span>
<button @click="$emit('updateOrg', selectedOrg)" class="icon-btn" aria-label="Rename organization" title="Rename organization"></button> <button @click="$emit('updateOrg', selectedOrg)" class="icon-btn" aria-label="Rename organization" title="Rename organization"></button>
</h2> </h2>
@@ -317,7 +344,7 @@ defineExpose({ focusFirstElement })
> >
<input <input
type="checkbox" type="checkbox"
:checked="r.permissions.includes(p.uuid)" :checked="p.uuid in (r.permissions || {})"
@change="e => toggleRolePermission(r, p.uuid, e.target.checked)" @change="e => toggleRolePermission(r, p.uuid, e.target.checked)"
/> />
</div> </div>
@@ -332,35 +359,31 @@ 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">
<span>{{ r.display_name }}</span> <span>{{ r.display_name }}</span>
<button @click="$emit('updateRole', r)" class="icon-btn" aria-label="Edit role" title="Edit role"></button> <button @click="$emit('updateRole', r)" class="icon-btn" aria-label="Edit role" title="Edit role"></button>
<button v-if="r.users.length === 0" @click="$emit('deleteRole', r)" class="icon-btn delete-icon" aria-label="Delete role" title="Delete role"></button> <button v-if="roleUserCount(r.uuid) === 0" @click="$emit('deleteRole', r)" class="icon-btn delete-icon" aria-label="Delete role" title="Delete role"></button>
</strong> </strong>
<div class="role-actions"> <div class="role-actions">
<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="r.users.length > 0"> <div class="user-list-wrapper">
<ul class="user-list" @keydown="handleUserListKeydown"> <draggable
:list="roleUsers(r.uuid)"
group="users"
item-key="uuid"
tag="ul"
class="user-list"
@change="evt => onUserChange(evt, r.uuid)"
@keydown="handleUserListKeydown"
>
<template #item="{ element: u }">
<li <li
v-for="u in r.users.slice().sort((a, b) => {
const nameA = a.display_name.toLowerCase()
const nameB = b.display_name.toLowerCase()
if (nameA !== nameB) {
return nameA.localeCompare(nameB)
}
return a.uuid.localeCompare(b.uuid)
})"
:key="u.uuid"
class="user-chip" class="user-chip"
tabindex="0" tabindex="0"
draggable="true"
@dragstart="e => $emit('onUserDragStart', e, u, selectedOrg.uuid)"
@click="$emit('openUser', u)" @click="$emit('openUser', u)"
@keydown.enter="$emit('openUser', u)" @keydown.enter="$emit('openUser', u)"
:title="u.uuid" :title="u.uuid"
@@ -368,48 +391,41 @@ defineExpose({ focusFirstElement })
<span class="name">{{ u.display_name }}</span> <span class="name">{{ u.display_name }}</span>
<span class="meta">{{ u.last_seen ? new Date(u.last_seen).toLocaleDateString() : '—' }}</span> <span class="meta">{{ u.last_seen ? new Date(u.last_seen).toLocaleDateString() : '—' }}</span>
</li> </li>
</ul>
</template> </template>
<div v-else class="empty-role"> </draggable>
<div v-if="roleUserCount(r.uuid) === 0" class="empty-role">
<p class="empty-text muted">No members</p> <p class="empty-text muted">No members</p>
</div> </div>
</div> </div>
</div> </div>
</div>
<p v-if="sortedRoles.length >= 2" class="roles-hint muted">Members can be drag&dropped to different roles.</p>
</template> </template>
<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); }
.icon-btn { background: none; border: none; color: var(--color-text-muted); padding: 0.2rem; border-radius: var(--radius-sm); cursor: pointer; transition: background 0.2s ease, color 0.2s ease; }
.icon-btn:hover { color: var(--color-heading); background: var(--color-surface-muted); }
.matrix-wrapper { margin: var(--space-md) 0; padding: var(--space-lg); }
.matrix-scroll { overflow-x: auto; }
.matrix-hint { font-size: 0.8rem; color: var(--color-text-muted); }
.perm-matrix-grid { display: inline-grid; gap: 0.25rem; align-items: stretch; }
.perm-matrix-grid > * { padding: 0.35rem 0.45rem; font-size: 0.75rem; }
.perm-matrix-grid .grid-head { color: var(--color-text-muted); text-transform: uppercase; font-weight: 600; letter-spacing: 0.05em; }
.perm-matrix-grid .perm-head { display: flex; align-items: flex-end; justify-content: flex-start; padding: 0.35rem 0.45rem; font-size: 0.75rem; }
.perm-matrix-grid .role-head { display: flex; align-items: flex-end; justify-content: center; } .perm-matrix-grid .role-head { 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; }
.perm-name { font-weight: 600; color: var(--color-heading); padding: 0.35rem 0.45rem; font-size: 0.75rem; } .roles-grid { display: flex; flex-wrap: wrap; gap: var(--space-lg); margin-top: var(--space-lg); justify-content: flex-start; align-items: stretch; }
.roles-grid { display: flex; gap: var(--space-lg); margin-top: var(--space-lg); } .role-column { flex: 0 0 240px; border-radius: var(--radius-md); padding: var(--space-md); display: flex; flex-direction: column; }
.role-column { flex: 1; min-width: 200px; border: 1px solid var(--color-border); border-radius: var(--radius-md); padding: var(--space-md); }
.role-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: var(--space-md); } .role-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: var(--color-accent-soft); color: var(--color-accent); border: none; border-radius: var(--radius-sm); padding: 0.25rem 0.45rem; font-size: 1.1rem; cursor: pointer; } .plus-btn { 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; }
.delete-icon { color: var(--color-danger); }
.delete-icon:hover { background: var(--color-danger-bg); color: var(--color-danger-text); }
.muted { color: var(--color-text-muted); }
@media (max-width: 720px) { @media (max-width: 720px) {
.roles-grid { flex-direction: column; } .roles-grid { flex-direction: column; }
+97 -25
View File
@@ -1,16 +1,18 @@
<script setup> <script setup>
import { computed, ref } from 'vue' import { computed, ref } from 'vue'
import { getDirection, navigateButtonRow, focusPreferred, focusAtIndex } from '@/utils/keynav' import { getDirection, navigateButtonRow, focusPreferred, focusAtIndex } from '@/utils/keynav'
import { formatDate } from '@/utils/helpers'
const props = defineProps({ const props = defineProps({
info: Object, info: Object,
orgs: Array, orgs: Array,
permissions: Array, permissions: Array,
oidcClients: Array,
permissionSummary: Object, permissionSummary: Object,
navigationDisabled: { type: Boolean, default: false } navigationDisabled: { type: Boolean, default: false }
}) })
const emit = defineEmits(['createOrg', 'openOrg', 'updateOrg', 'deleteOrg', 'toggleOrgPermission', 'openDialog', 'deletePermission', 'renamePermissionDisplay', 'navigateOut']) const emit = defineEmits(['createOrg', 'openOrg', 'updateOrg', 'deleteOrg', 'toggleOrgPermission', 'openDialog', 'deletePermission', 'renamePermissionDisplay', 'createOidcClient', 'openOidcClient', 'deleteOidcClient', 'navigateOut'])
// Template refs for navigation // Template refs for navigation
const orgSection = ref(null) const orgSection = ref(null)
@@ -19,11 +21,41 @@ const orgTableRef = ref(null)
const permMatrixRef = ref(null) const permMatrixRef = ref(null)
const permActionsRef = ref(null) const permActionsRef = ref(null)
const permTableRef = ref(null) const permTableRef = ref(null)
const oidcActionsRef = ref(null)
const oidcTableRef = ref(null)
const sortedOrgs = computed(() => [...props.orgs].sort((a,b)=> { const sortedOrgs = computed(() => [...props.orgs].sort((a,b)=> {
const nameCompare = a.display_name.localeCompare(b.display_name) const nameCompare = a.org.display_name.localeCompare(b.org.display_name)
return nameCompare !== 0 ? nameCompare : a.uuid.localeCompare(b.uuid) return nameCompare !== 0 ? nameCompare : a.uuid.localeCompare(b.uuid)
})) }))
// Map OIDC client UUIDs to display names for permission domain column
const oidcClientNames = computed(() => {
const map = {}
for (const c of props.oidcClients || []) map[c.uuid] = c.name
return map
})
function domainDisplay(domain) {
if (!domain) return '—'
return oidcClientNames.value[domain] || domain
}
// Map OIDC client UUIDs to their group permissions (sorted by scope)
const clientGroups = computed(() => {
const map = {}
for (const p of props.permissions || []) {
if (p.domain) {
if (!map[p.domain]) map[p.domain] = []
map[p.domain].push(p)
}
}
// Sort each group array by scope
for (const key in map) {
map[key].sort((a, b) => a.scope.localeCompare(b.scope))
}
return map
})
const sortedPermissions = computed(() => [...props.permissions].sort((a,b)=> a.scope.localeCompare(b.scope))) const sortedPermissions = computed(() => [...props.permissions].sort((a,b)=> a.scope.localeCompare(b.scope)))
// Derive admin status from permissions (info contains ctx from validate response) // Derive admin status from permissions (info contains ctx from validate response)
@@ -35,13 +67,18 @@ function permissionDisplayName(scope) {
} }
function getRoleNames(org) { function getRoleNames(org) {
return org.roles // org.roles is dict[UUID, Role]
return Object.values(org.roles)
.slice() .slice()
.sort((a, b) => a.display_name.localeCompare(b.display_name)) .sort((a, b) => a.display_name.localeCompare(b.display_name))
.map(r => r.display_name) .map(r => r.display_name)
.join(', ') .join(', ')
} }
function orgUserCount(org) {
return Object.keys(org.users).length
}
// Table navigation for both org and permissions tables // Table navigation for both org and permissions tables
function handleTableKeydown(event, tableType) { function handleTableKeydown(event, tableType) {
if (props.navigationDisabled) return if (props.navigationDisabled) return
@@ -265,11 +302,11 @@ defineExpose({ focusFirstElement })
<tbody> <tbody>
<tr v-for="o in sortedOrgs" :key="o.uuid"> <tr v-for="o in sortedOrgs" :key="o.uuid">
<td> <td>
<a href="#org/{{o.uuid}}" @click.prevent="$emit('openOrg', o)">{{ o.display_name }}</a> <a href="#org/{{o.uuid}}" @click.prevent="$emit('openOrg', o)">{{ o.org.display_name }}</a>
<button v-if="isMasterAdmin || isOrgAdmin" @click="$emit('updateOrg', o)" class="icon-btn edit-org-btn" aria-label="Rename organization" title="Rename organization"></button> <button v-if="isMasterAdmin || isOrgAdmin" @click="$emit('updateOrg', o)" class="icon-btn edit-org-btn" aria-label="Rename organization" title="Rename organization"></button>
</td> </td>
<td class="role-names">{{ getRoleNames(o) }}</td> <td class="role-names">{{ getRoleNames(o) }}</td>
<td class="center">{{ o.roles.reduce((acc,r)=>acc + r.users.length,0) }}</td> <td class="center">{{ orgUserCount(o) }}</td>
<td v-if="isMasterAdmin" class="center"> <td v-if="isMasterAdmin" class="center">
<button @click="$emit('deleteOrg', o)" class="icon-btn delete-icon" aria-label="Delete organization" title="Delete organization"></button> <button @click="$emit('deleteOrg', o)" class="icon-btn delete-icon" aria-label="Delete organization" title="Delete organization"></button>
</td> </td>
@@ -291,9 +328,9 @@ defineExpose({ focusFirstElement })
v-for="o in sortedOrgs" v-for="o in sortedOrgs"
:key="'head-' + o.uuid" :key="'head-' + o.uuid"
class="grid-head org-head" class="grid-head org-head"
:title="o.display_name" :title="o.org.display_name"
> >
<span>{{ o.display_name }}</span> <span>{{ o.org.display_name }}</span>
</div> </div>
<template v-for="p in sortedPermissions" :key="p.uuid"> <template v-for="p in sortedPermissions" :key="p.uuid">
@@ -307,7 +344,7 @@ defineExpose({ focusFirstElement })
> >
<input <input
type="checkbox" type="checkbox"
:checked="o.permissions.includes(p.uuid)" :checked="p.uuid in o.permissions"
@change="e => $emit('toggleOrgPermission', o, p.uuid, e.target.checked)" @change="e => $emit('toggleOrgPermission', o, p.uuid, e.target.checked)"
/> />
</div> </div>
@@ -339,7 +376,7 @@ defineExpose({ focusFirstElement })
<span class="id-text">{{ p.scope }}</span> <span class="id-text">{{ p.scope }}</span>
</div> </div>
</td> </td>
<td class="perm-domain">{{ p.domain || '—' }}</td> <td class="perm-domain">{{ domainDisplay(p.domain) }}</td>
<td class="perm-members center">{{ permissionSummary[p.uuid]?.userCount || 0 }}</td> <td class="perm-members center">{{ permissionSummary[p.uuid]?.userCount || 0 }}</td>
<td class="perm-actions center"> <td class="perm-actions center">
<button @click="$emit('deletePermission', p)" class="icon-btn delete-icon" aria-label="Delete permission" title="Delete permission"></button> <button @click="$emit('deletePermission', p)" class="icon-btn delete-icon" aria-label="Delete permission" title="Delete permission"></button>
@@ -348,13 +385,57 @@ defineExpose({ focusFirstElement })
</tbody> </tbody>
</table> </table>
</div> </div>
<div v-if="isMasterAdmin" class="oidc-clients-section">
<div class="section-header">
<h2>OAuth2 / OpenID Connect</h2>
<p class="section-description">
Allow external websites and applications to securely authenticate users through this system.
The clients are remote sites or applications that we allow to use Paskia for Single Sign-On.
</p>
</div>
<div ref="oidcActionsRef">
<button @click="$emit('createOidcClient')">+ Add Site</button>
</div>
<table class="org-table" ref="oidcTableRef">
<thead>
<tr>
<th scope="col">Client</th>
<th scope="col">Groups</th>
<th scope="col" class="center">Sessions</th>
<th scope="col" class="center">Actions</th>
</tr>
</thead>
<tbody>
<tr v-if="!oidcClients || oidcClients.length === 0">
<td colspan="4" class="center muted">No OIDC clients configured</td>
</tr>
<tr v-for="client in oidcClients" :key="client.uuid">
<td class="perm-name-cell">
<div class="perm-title">
<a :href="'#oidc:' + client.uuid" @click.prevent="$emit('openOidcClient', client)">{{ client.name }}</a>
</div>
<div class="perm-id-info">
<span class="id-text">{{ client.uuid }}</span>
</div>
</td>
<td class="client-groups">
<span v-if="clientGroups[client.uuid]?.length">{{ clientGroups[client.uuid].map(g => g.scope).join(' ') }}</span>
<span v-else class="muted"></span>
</td>
<td class="center">{{ client.active_sessions || 0 }}</td>
<td class="center">
<button @click="$emit('deleteOidcClient', client)" class="icon-btn delete-icon" aria-label="Delete OIDC client" title="Delete OIDC client"></button>
</td>
</tr>
</tbody>
</table>
</div>
</template> </template>
<style scoped> <style scoped>
.permissions-section { margin-bottom: var(--space-xl); } .permissions-section { margin-bottom: var(--space-xl); }
.permissions-section h2 { margin-bottom: var(--space-md); } .permissions-section h2 { margin-bottom: var(--space-md); }
.actions { display: flex; flex-wrap: wrap; gap: var(--space-sm); align-items: center; }
.actions button { width: auto; }
.org-table a { text-decoration: none; color: var(--color-link); } .org-table a { text-decoration: none; color: var(--color-link); }
.org-table a:hover { text-decoration: underline; } .org-table a:hover { text-decoration: underline; }
.org-table .center { width: 6rem; min-width: 6rem; } .org-table .center { width: 6rem; min-width: 6rem; }
@@ -363,24 +444,15 @@ defineExpose({ focusFirstElement })
.perm-title { font-weight: 600; color: var(--color-heading); } .perm-title { font-weight: 600; color: var(--color-heading); }
.perm-id-info { font-size: 0.8rem; color: var(--color-text-muted); display: flex; align-items: center; gap: 0.5rem; flex-wrap: wrap; } .perm-id-info { font-size: 0.8rem; color: var(--color-text-muted); display: flex; align-items: center; gap: 0.5rem; flex-wrap: wrap; }
.perm-domain { color: var(--color-text-muted); font-size: 0.9rem; } .perm-domain { color: var(--color-text-muted); font-size: 0.9rem; }
.icon-btn { background: none; border: none; color: var(--color-text-muted); padding: 0.2rem; border-radius: var(--radius-sm); cursor: pointer; transition: background 0.2s ease, color 0.2s ease; }
.icon-btn:hover { color: var(--color-heading); background: var(--color-surface-muted); }
.delete-icon { color: var(--color-danger); }
.delete-icon:hover { background: var(--color-danger-bg); color: var(--color-danger-text); }
.matrix-wrapper { margin: var(--space-md) 0; padding: var(--space-lg); }
.matrix-scroll { overflow-x: auto; }
.matrix-hint { font-size: 0.8rem; color: var(--color-text-muted); }
.perm-matrix-grid { display: inline-grid; gap: 0.25rem; align-items: stretch; }
.perm-matrix-grid > * { padding: 0.35rem 0.45rem; font-size: 0.75rem; }
.perm-matrix-grid .grid-head { color: var(--color-text-muted); text-transform: uppercase; font-weight: 600; letter-spacing: 0.05em; }
.perm-matrix-grid .perm-head { display: flex; align-items: flex-end; justify-content: flex-start; padding: 0.35rem 0.45rem; font-size: 0.75rem; }
.perm-matrix-grid .org-head { display: flex; align-items: flex-end; justify-content: center; } .perm-matrix-grid .org-head { display: flex; align-items: flex-end; justify-content: center; }
.perm-matrix-grid .org-head span { writing-mode: vertical-rl; transform: rotate(180deg); font-size: 0.65rem; } .perm-matrix-grid .org-head span { writing-mode: vertical-rl; transform: rotate(180deg); font-size: 0.65rem; }
.perm-name { font-weight: 600; color: var(--color-heading); padding: 0.35rem 0.45rem; font-size: 0.75rem; }
.display-text { margin-right: var(--space-xs); } .display-text { margin-right: var(--space-xs); }
.edit-display-btn { padding: 0.1rem 0.2rem; font-size: 0.8rem; } .edit-display-btn { padding: 0.1rem 0.2rem; font-size: 0.8rem; }
.edit-org-btn { padding: 0.1rem 0.2rem; font-size: 0.8rem; margin-left: var(--space-xs); } .edit-org-btn { padding: 0.1rem 0.2rem; font-size: 0.8rem; margin-left: var(--space-xs); }
.perm-actions { text-align: center; } .perm-actions { text-align: center; }
.center { text-align: center; }
.muted { color: var(--color-text-muted); } /* OIDC Clients Section */
.oidc-clients-section { margin-bottom: var(--space-xl); margin-top: var(--space-2xl); }
.oidc-clients-section .section-header { display: flex; flex-direction: column; gap: 0.4rem; margin-bottom: var(--space-md); }
.client-groups { font-size: 0.85rem; color: var(--color-text-muted); max-width: 200px; font-family: var(--font-mono, monospace); }
</style> </style>
+23 -21
View File
@@ -24,6 +24,11 @@ const terminatingSessions = ref({})
const hoveredCredentialUuid = ref(null) const hoveredCredentialUuid = ref(null)
const hoveredSession = ref(null) const hoveredSession = ref(null)
// Convert credentials dict to array with uuid attached as 'credential'
const credentials = computed(() =>
Object.entries(props.userDetail?.credentials || {}).map(([uuid, c]) => ({ ...c, credential: uuid }))
)
// Template refs for navigation // Template refs for navigation
const userInfoRef = ref(null) const userInfoRef = ref(null)
const regActionsRef = ref(null) const regActionsRef = ref(null)
@@ -57,11 +62,11 @@ async function handleDelete(credential) {
} }
async function handleTerminateSession(session) { async function handleTerminateSession(session) {
const sessionId = session?.id const sessionKey = session?.key
if (!sessionId) return if (!sessionKey) return
terminatingSessions.value = { ...terminatingSessions.value, [sessionId]: true } terminatingSessions.value = { ...terminatingSessions.value, [sessionKey]: true }
try { try {
const data = await apiJson(`/auth/api/admin/users/${props.selectedUser.uuid}/sessions/${sessionId}`, { method: 'DELETE' }) const data = await apiJson(`/auth/api/admin/users/${props.selectedUser.uuid}/sessions/${sessionKey}`, { method: 'DELETE' })
if (data.status === 'ok') { if (data.status === 'ok') {
if (data.current_session_terminated) { if (data.current_session_terminated) {
sessionStorage.clear() sessionStorage.clear()
@@ -78,7 +83,7 @@ async function handleTerminateSession(session) {
authStore.showMessage(err.message || 'Failed to terminate session', 'error') authStore.showMessage(err.message || 'Failed to terminate session', 'error')
} finally { } finally {
const next = { ...terminatingSessions.value } const next = { ...terminatingSessions.value }
delete next[sessionId] delete next[sessionKey]
terminatingSessions.value = next terminatingSessions.value = next
} }
} }
@@ -180,23 +185,26 @@ defineExpose({ focusFirstElement })
<div ref="userInfoRef" @keydown="handleUserInfoKeydown"> <div ref="userInfoRef" @keydown="handleUserInfoKeydown">
<UserBasicInfo <UserBasicInfo
v-if="userDetail && !userDetail.error" v-if="userDetail && !userDetail.error"
:name="userDetail.display_name || selectedUser.display_name" :name="userDetail.user.display_name || selectedUser.display_name"
:visits="userDetail.visits" :visits="userDetail.user.visits"
:created-at="userDetail.created_at" :created-at="userDetail.user.created_at"
:last-seen="userDetail.last_seen" :last-seen="userDetail.user.last_seen"
:email="userDetail.user.email"
:telephone="userDetail.user.telephone"
:loading="loading" :loading="loading"
:org-display-name="userDetail.org.display_name" :org-display-name="userDetail.org.display_name"
:role-name="userDetail.role" :role-name="userDetail.role.display_name"
:update-endpoint="`/auth/api/admin/users/${selectedUser.uuid}/display-name`" :update-endpoint="`/auth/api/admin/users/${selectedUser.uuid}/info`"
@saved="$emit('onUserNameSaved')" @saved="$emit('onUserNameSaved')"
@edit-name="handleEditName" @edit="handleEditName"
> >
<div class="admin-actions"> <div class="admin-actions">
<button <button
class="btn-primary" class="btn-primary"
@click="$emit('generateUserRegistrationLink', selectedUser)" @click="$emit('generateUserRegistrationLink', selectedUser)"
:disabled="loading" :disabled="loading"
>{{ userDetail?.credentials?.length ? 'Recovery Link' : 'Registration Link' }}</button> title="Generate a one-time link for this user"
>{{ userDetail?.credentials && Object.keys(userDetail.credentials).length > 0 ? 'Recovery Link' : 'Registration Link' }}</button>
<button <button
class="btn-danger" class="btn-danger"
@click="handleDeleteUser" @click="handleDeleteUser"
@@ -215,7 +223,7 @@ defineExpose({ focusFirstElement })
<div class="section-body"> <div class="section-body">
<CredentialList <CredentialList
ref="credentialListRef" ref="credentialListRef"
:credentials="userDetail.credentials" :credentials="credentials"
:aaguid-info="userDetail.aaguid_info" :aaguid-info="userDetail.aaguid_info"
:allow-delete="true" :allow-delete="true"
:hovered-credential-uuid="hoveredCredentialUuid" :hovered-credential-uuid="hoveredCredentialUuid"
@@ -229,7 +237,7 @@ defineExpose({ focusFirstElement })
</section> </section>
<SessionList <SessionList
ref="sessionListRef" ref="sessionListRef"
:sessions="userDetail.sessions || []" :sessions="userDetail.sessions || {}"
:terminating-sessions="terminatingSessions" :terminating-sessions="terminatingSessions"
:hovered-credential-uuid="hoveredCredentialUuid" :hovered-credential-uuid="hoveredCredentialUuid"
:navigation-disabled="hasActiveModal" :navigation-disabled="hasActiveModal"
@@ -256,11 +264,5 @@ defineExpose({ focusFirstElement })
<style scoped> <style scoped>
.user-detail { display: flex; flex-direction: column; gap: var(--space-lg); } .user-detail { display: flex; flex-direction: column; gap: var(--space-lg); }
.admin-actions { display: flex; gap: 0.5rem; } .admin-actions { display: flex; gap: 0.5rem; }
.actions { display: flex; flex-wrap: wrap; gap: var(--space-sm); align-items: center; }
.ancillary-actions { margin-top: -0.5rem; } .ancillary-actions { margin-top: -0.5rem; }
.icon-btn { background: none; border: none; color: var(--color-text-muted); padding: 0.2rem; border-radius: var(--radius-sm); cursor: pointer; transition: background 0.2s ease, color 0.2s ease; }
.icon-btn:hover { color: var(--color-heading); background: var(--color-surface-muted); }
.error { color: var(--color-danger-text); }
.small { font-size: 0.9rem; }
.muted { color: var(--color-text-muted); }
</style> </style>
+252 -60
View File
@@ -9,70 +9,82 @@
--font-sans: "Inter", "Inter var", "Segoe UI", system-ui, -apple-system, "Helvetica Neue", sans-serif; --font-sans: "Inter", "Inter var", "Segoe UI", system-ui, -apple-system, "Helvetica Neue", sans-serif;
--font-mono: "DM Mono", "JetBrains Mono", "SFMono-Regular", Menlo, Monaco, Consolas, "Liberation Mono", monospace; --font-mono: "DM Mono", "JetBrains Mono", "SFMono-Regular", Menlo, Monaco, Consolas, "Liberation Mono", monospace;
--color-canvas: white; --color-canvas: white;
--color-surface: oklch(0.97 0.005 var(--hue)); --color-surface: white;
--color-surface-subtle: oklch(0.94 0.01 var(--hue)); --color-surface-subtle: white;
--color-dialog: white; --color-surface-hover: oklab(0.97 -0.01 -0.02);
--color-border: oklch(0.8 0.02 var(--hue)); --color-dialog: oklab(0.96 -0.01 -0.03);
--color-border-strong: oklch(0.55 0.15 var(--hue)); --color-border: oklab(0.82 -0.02 -0.06);
--color-heading: oklch(0.3 0.05 var(--hue)); --color-border-strong: oklab(0.55 -0.05 -0.14);
--color-text: oklch(0.25 0.02 var(--hue)); --color-heading: oklab(0.22 -0.03 -0.09);
--color-text-muted: oklch(0.5 0.02 var(--hue)); --color-text: oklab(0.2 -0.02 -0.05);
--color-link: oklch(0.5 0.18 var(--hue)); --color-text-muted: oklab(0.42 -0.02 -0.06);
--color-link-hover: oklch(0.45 0.2 var(--hue)); --color-link: oklab(0.5 -0.06 -0.17);
--color-accent: oklch(0.55 0.2 var(--hue)); --color-link-hover: oklab(0.45 -0.06 -0.19);
--color-accent-strong: oklch(0.45 0.2 var(--hue)); --color-accent: oklab(0.55 -0.06 -0.19);
--color-accent-strong: oklab(0.45 -0.06 -0.19);
--color-accent-contrast: white; --color-accent-contrast: white;
--color-secondary: oklch(0.55 0.05 var(--hue)); --color-secondary: oklab(0.55 -0.02 -0.05);
--color-secondary-strong: oklch(0.45 0.05 var(--hue)); --color-secondary-strong: oklab(0.45 -0.02 -0.05);
--color-success-text: oklch(0.4 0.15 0.4turn); --color-success-text: oklab(0.4 -0.12 0.09);
--color-success-bg: oklch(0.95 0.03 0.4turn); --color-success-bg: oklab(0.95 -0.02 0.02);
--color-error-text: oklch(0.45 0.2 0.07turn); --color-error-text: oklab(0.45 0.18 0.09);
--color-error-bg: oklch(0.95 0.03 0.07turn); --color-error-bg: oklab(0.95 0.03 0.01);
--color-info-text: oklch(0.45 0.15 var(--hue)); --color-info-text: oklab(0.45 -0.05 -0.14);
--color-info-bg: oklch(0.95 0.02 var(--hue)); --color-info-bg: oklab(0.95 -0.01 -0.02);
--color-danger: oklch(0.55 0.22 0.07turn); --color-danger: oklab(0.55 0.20 0.09);
--color-primary: oklab(0.55 -0.06 -0.19);
--color-error: oklab(0.45 0.18 0.09);
--color-success: oklab(0.4 -0.12 0.09);
--color-bg: oklab(0.94 -0.01 -0.03);
--color-accent-soft: oklab(0.92 -0.03 -0.08);
--shadow-soft: 0 0 .2rem black; --shadow-soft: 0 0 .2rem black;
--radius-none: 0; --shadow-xl: 0 10px 40px rgba(0, 0, 0, 0.15);
--radius-sm: 4px; --radius-sm: 4px;
--radius-md: 6px; --radius-md: 6px;
--radius-lg: 10px; --radius-lg: 10px;
--space-xxs: 0.25rem;
--space-xs: 0.5rem; --space-xs: 0.5rem;
--space-sm: 0.75rem; --space-sm: 0.75rem;
--space-md: 1rem; --space-md: 1rem;
--space-lg: 1.5rem; --space-lg: 1.5rem;
--space-xl: 2.25rem; --space-xl: 2.25rem;
--space-xxl: 3.5rem; --space-3xl: 5rem;
--layout-padding: clamp(1.5rem, 3vw + 1rem, 3.25rem); --layout-padding: clamp(1.5rem, 3vw + 1rem, 3.25rem);
--transition-base: 160ms ease; --transition-base: 160ms ease;
--focus-ring: 0 0 0 2px var(--color-accent); --focus-ring: 0 0 0 2px var(--color-accent);
} }
:root.dark { :root.dark {
--color-canvas: oklch(0.15 0.03 var(--hue)); --color-canvas: oklab(0.17 -0.02 -0.05);
--color-surface: oklch(0.18 0.03 var(--hue)); --color-surface: oklab(0.22 -0.02 -0.05);
--color-surface-subtle: oklch(0.22 0.03 var(--hue)); --color-surface-subtle: oklab(0.25 -0.02 -0.05);
--color-dialog: oklch(0.22 0.03 var(--hue)); --color-surface-hover: oklab(0.28 -0.02 -0.05);
--color-border: oklch(0.3 0.03 var(--hue)); --color-dialog: oklab(0.22 -0.02 -0.05);
--color-border-strong: oklch(0.4 0.04 var(--hue)); --color-border: oklab(0.3 -0.02 -0.05);
--color-border-strong: oklab(0.4 -0.02 -0.05);
--color-heading: white; --color-heading: white;
--color-text: oklch(0.9 0.01 var(--hue)); --color-text: oklab(0.9 0.00 -0.01);
--color-text-muted: oklch(0.7 0.02 var(--hue)); --color-text-muted: oklab(0.7 -0.01 -0.02);
--color-link: oklch(0.7 0.15 var(--hue)); --color-link: oklab(0.7 -0.05 -0.14);
--color-link-hover: oklch(0.8 0.12 var(--hue)); --color-link-hover: oklab(0.8 -0.04 -0.11);
--color-accent: oklch(0.7 0.15 var(--hue)); --color-accent: oklab(0.7 -0.05 -0.14);
--color-accent-strong: oklch(0.6 0.18 var(--hue)); --color-accent-strong: oklab(0.6 -0.06 -0.17);
--color-accent-contrast: oklch(0.12 0.03 var(--hue)); --color-accent-contrast: oklab(0.12 -0.01 -0.03);
--color-secondary: oklch(0.6 0.05 var(--hue)); --color-secondary: oklab(0.6 -0.02 -0.05);
--color-secondary-strong: oklch(0.5 0.05 var(--hue)); --color-secondary-strong: oklab(0.5 -0.02 -0.05);
--color-success-text: oklch(0.75 0.15 0.4turn); --color-success-text: oklab(0.75 -0.12 0.09);
--color-success-bg: oklch(0.3 0.08 0.4turn); --color-success-bg: oklab(0.3 -0.07 0.05);
--color-error-text: oklch(0.8 0.12 0.07turn); --color-error-text: oklab(0.8 0.11 0.05);
--color-error-bg: oklch(0.3 0.08 0.07turn); --color-error-bg: oklab(0.3 0.07 0.03);
--color-info-text: oklch(0.8 0.1 var(--hue)); --color-info-text: oklab(0.8 -0.03 -0.10);
--color-info-bg: oklch(0.3 0.05 var(--hue)); --color-info-bg: oklab(0.3 -0.02 -0.05);
--color-danger: oklch(0.7 0.18 0.07turn); --color-danger: oklab(0.7 0.16 0.08);
--color-primary: oklab(0.7 -0.05 -0.14);
--color-error: oklab(0.8 0.11 0.05);
--color-success: oklab(0.75 -0.12 0.09);
--color-bg: oklab(0.18 -0.01 -0.03);
--color-accent-soft: oklab(0.25 -0.03 -0.08);
--shadow-soft: 0 0 0 black; --shadow-soft: 0 0 0 black;
--shadow-xl: 0 10px 40px rgba(0, 0, 0, 0.4);
} }
*, *,
@@ -160,6 +172,25 @@ a:focus-visible {
max-width: 540px; max-width: 540px;
} }
.view-root--profile .view-header-wrapper {
width: 100%;
max-width: calc(1200px - 2 * var(--layout-padding));
margin: 0 auto;
position: relative;
}
.view-root--profile .theme-toggle {
position: absolute;
top: 0;
right: 0;
}
.section-block--constrained {
width: 100%;
max-width: calc(1200px - 2 * var(--layout-padding));
margin: 0 auto;
}
.view-header { .view-header {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@@ -207,6 +238,7 @@ a:focus-visible {
flex-wrap: nowrap; flex-wrap: nowrap;
gap: 0.75rem; gap: 0.75rem;
justify-content: flex-start; justify-content: flex-start;
width: 100%;
} }
.button-row button { .button-row button {
@@ -247,47 +279,53 @@ button:disabled {
filter: opacity(0.6); filter: opacity(0.6);
} }
output[title="Click to copy"] {
cursor: pointer;
}
.btn-primary { .btn-primary {
background: linear-gradient(to bottom, oklch(1 0 0 / 0.15), transparent 60%) var(--color-accent); background: linear-gradient(to bottom, oklab(1 0 0 / 0.15), transparent 60%) var(--color-accent);
color: var(--color-accent-contrast); color: var(--color-accent-contrast);
border-color: var(--color-accent); border-color: var(--color-accent);
} }
.btn-primary:hover:not(:disabled), .btn-primary:hover:not(:disabled),
.btn-primary:focus-visible { .btn-primary:focus-visible {
background: linear-gradient(to bottom, oklch(1 0 0 / 0.15), transparent 60%) var(--color-accent-strong); background: linear-gradient(to bottom, oklab(1 0 0 / 0.15), transparent 60%) var(--color-accent-strong);
border-color: var(--color-accent-strong); border-color: var(--color-accent-strong);
box-shadow: var(--shadow-soft); box-shadow: var(--shadow-soft);
} }
.btn-secondary { .btn-secondary {
background: linear-gradient(to bottom, oklch(1 0 0 / 0.15), transparent 60%) var(--color-secondary); background: linear-gradient(to bottom, oklab(1 0 0 / 0.15), transparent 60%) var(--color-secondary);
color: var(--color-accent-contrast); color: var(--color-accent-contrast);
border-color: transparent; border-color: transparent;
} }
.btn-secondary:hover:not(:disabled), .btn-secondary:hover:not(:disabled),
.btn-secondary:focus-visible { .btn-secondary:focus-visible {
background: linear-gradient(to bottom, oklch(1 0 0 / 0.15), transparent 60%) var(--color-secondary-strong); background: linear-gradient(to bottom, oklab(1 0 0 / 0.15), transparent 60%) var(--color-secondary-strong);
box-shadow: var(--shadow-soft); box-shadow: var(--shadow-soft);
} }
.btn-danger { .btn-danger {
background: linear-gradient(to bottom, oklch(1 0 0 / 0.15), transparent 60%) var(--color-danger); background: linear-gradient(to bottom, oklab(1 0 0 / 0.15), transparent 60%) var(--color-danger);
color: var(--color-accent-contrast); color: var(--color-accent-contrast);
border-color: transparent; border-color: transparent;
} }
.btn-danger:hover:not(:disabled), .btn-danger:hover:not(:disabled),
.btn-danger:focus-visible { .btn-danger:focus-visible {
background: linear-gradient(to bottom, oklch(1 0 0 / 0.15), transparent 60%) var(--color-danger); background: linear-gradient(to bottom, oklab(1 0 0 / 0.15), transparent 60%) var(--color-danger);
filter: brightness(0.92); filter: brightness(0.92);
box-shadow: var(--shadow-soft); box-shadow: var(--shadow-soft);
} }
input:not([type]),
input[type="text"], input[type="text"],
input[type="search"], input[type="search"],
input[type="email"], input[type="email"],
input[type="tel"],
textarea, textarea,
select { select {
font: inherit; font: inherit;
@@ -300,6 +338,18 @@ select {
transition: border-color var(--transition-base), box-shadow var(--transition-base); transition: border-color var(--transition-base), box-shadow var(--transition-base);
} }
input:not([type]):focus,
input[type="text"]:focus,
input[type="search"]:focus,
input[type="email"]:focus,
input[type="tel"]:focus,
textarea:focus,
select:focus {
outline: none;
border-color: var(--color-accent);
box-shadow: var(--focus-ring);
}
label { label {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@@ -340,10 +390,118 @@ th {
text-align: left; text-align: left;
} }
/* Permission matrix styles */
.matrix-wrapper {
margin: var(--space-md) 0;
padding: var(--space-lg);
}
.matrix-scroll {
overflow-x: auto;
}
.matrix-hint {
font-size: 0.8rem;
color: var(--color-text-muted);
}
.perm-matrix-grid {
display: inline-grid;
gap: 0.25rem;
align-items: stretch;
}
.perm-matrix-grid > * {
padding: 0.35rem 0.45rem;
font-size: 0.75rem;
}
.perm-matrix-grid .grid-head {
color: var(--color-text-muted);
text-transform: uppercase;
font-weight: 600;
letter-spacing: 0.05em;
}
.perm-matrix-grid .perm-head {
display: flex;
align-items: flex-end;
justify-content: flex-start;
padding: 0.35rem 0.45rem;
font-size: 0.75rem;
}
.perm-matrix-grid .rotated-head {
display: flex;
align-items: flex-end;
justify-content: center;
}
.perm-matrix-grid .rotated-head span {
writing-mode: vertical-rl;
transform: rotate(180deg);
font-size: 0.65rem;
}
.perm-name {
font-weight: 600;
color: var(--color-heading);
padding: 0.35rem 0.45rem;
font-size: 0.75rem;
}
.center { .center {
text-align: center; text-align: center;
} }
/* Utility classes */
.muted {
color: var(--color-text-muted);
}
.error {
color: var(--color-danger-text);
}
.small {
font-size: 0.9rem;
}
.icon-btn {
background: none;
border: none;
color: var(--color-text-muted);
padding: 0.2rem;
border-radius: var(--radius-sm);
cursor: pointer;
transition: background 0.2s ease, color 0.2s ease;
}
.icon-btn:hover {
color: var(--color-heading);
background: var(--color-surface-subtle);
}
.delete-icon {
color: var(--color-danger);
}
.delete-icon:hover {
background: var(--color-error-bg);
color: var(--color-error-text);
}
.actions {
display: flex;
flex-wrap: wrap;
gap: var(--space-sm);
align-items: center;
}
.button-row--center {
justify-content: center;
}
.badge { .badge {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
@@ -362,13 +520,17 @@ th {
left: 0; left: 0;
right: 0; right: 0;
margin: 0 auto; margin: 0 auto;
z-index: 1200; z-index: 2000;
width: fit-content; width: fit-content;
min-width: min(520px, calc(100% - 2rem)); min-width: min(520px, calc(100% - 2rem));
max-width: calc(100% - 2rem); max-width: calc(100% - 2rem);
display: none; display: none;
} }
.global-status.show {
display: block;
}
.global-status .status { .global-status .status {
display: flex; display: flex;
align-items: center; align-items: center;
@@ -439,7 +601,6 @@ th {
.qr-code { .qr-code {
padding: 1rem; padding: 1rem;
background: white; background: white;
box-shadow: var(--shadow-soft);
} }
.link-container, .link-container,
@@ -496,9 +657,18 @@ th {
.record-item.is-current, .record-item.is-current,
.credential-item.current-session, .credential-item.current-session,
.session-item.is-current {
border-color: var(--color-accent);
background-color: var(--color-surface-subtle);
}
.credential-item.is-hovered, .credential-item.is-hovered,
.session-item.is-current, .session-item.is-hovered {
.session-item.is-hovered { border-color: var(--color-accent); background-color: var(--color-surface-subtle); } border-color: var(--color-accent);
background-color: var(--color-surface-subtle);
box-shadow: 0 10px 24px rgba(15, 23, 42, 0.12);
transform: translateY(-1px);
}
.credential-item.is-linked-session, .credential-item.is-linked-session,
.session-item.is-linked-credential { border-color: var(--color-accent); background-color: var(--color-surface-subtle); } .session-item.is-linked-credential { border-color: var(--color-accent); background-color: var(--color-surface-subtle); }
@@ -653,9 +823,6 @@ th {
.user-info { .user-info {
display: grid; display: grid;
border-radius: var(--radius-md);
background: var(--color-surface);
padding: 1.1rem 1.25rem;
} }
.user-details { .user-details {
@@ -742,7 +909,7 @@ th {
.slot-machine { .slot-machine {
padding: 0.875rem 1rem; padding: 0.875rem 1rem;
background: var(--color-surface-hover, rgba(0, 0, 0, 0.03)); background: var(--color-surface-hover);
border: 1px solid var(--color-border); border: 1px solid var(--color-border);
border-radius: var(--radius-sm); border-radius: var(--radius-sm);
font-family: 'SF Mono', Monaco, 'Cascadia Code', 'Roboto Mono', Consolas, 'Courier New', monospace; font-family: 'SF Mono', Monaco, 'Cascadia Code', 'Roboto Mono', Consolas, 'Courier New', monospace;
@@ -763,3 +930,28 @@ th {
height: 1.8em; height: 1.8em;
position: relative; position: relative;
} }
/* Spinner utilities */
.spinner {
border: 3px solid var(--color-border);
border-top-color: var(--color-primary);
border-radius: 50%;
animation: spin 1s linear infinite;
}
.spinner--sm {
width: 16px;
height: 16px;
border-width: 2px;
}
.spinner--md {
width: 40px;
height: 40px;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
+2 -2
View File
@@ -148,8 +148,8 @@ const getCredentialAuthIcon = (credential) => {
const info = props.aaguidInfo?.[credential.aaguid] const info = props.aaguidInfo?.[credential.aaguid]
if (!info) return null if (!info) return null
const isDarkMode = window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches const isDarkMode = window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches
const iconKey = isDarkMode ? 'icon_dark' : 'icon_light' // Fall back to icon if icon_dark is not available
return info[iconKey] || null return (isDarkMode && info.icon_dark) || info.icon || null
} }
</script> </script>
+9 -16
View File
@@ -1,5 +1,5 @@
<template> <template>
<section class="view-root host-view" data-view="host-profile"> <section class="view-root view-root--wide host-view" data-view="host-profile">
<header class="view-header"> <header class="view-header">
<h1>{{ headingTitle }}</h1> <h1>{{ headingTitle }}</h1>
<p class="view-lede">{{ subheading }}</p> <p class="view-lede">{{ subheading }}</p>
@@ -10,9 +10,11 @@
<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"
:telephone="ctx.user.telephone"
:org-display-name="orgDisplayName" :org-display-name="orgDisplayName"
:role-name="roleDisplayName" :role-name="roleDisplayName"
:can-edit="false" :can-edit="false"
@@ -78,9 +80,9 @@ const currentHost = window.location.host
const userInfoSection = ref(null) const userInfoSection = ref(null)
const buttonRow = ref(null) const buttonRow = ref(null)
const ctx = computed(() => authStore.userInfo?.ctx || null) const ctx = computed(() => authStore.userInfo || null)
const orgDisplayName = computed(() => ctx.value?.org.display_name ?? '') const orgDisplayName = computed(() => ctx.value?.org?.display_name ?? '')
const roleDisplayName = computed(() => ctx.value?.role.display_name ?? '') const roleDisplayName = computed(() => ctx.value?.role?.display_name ?? '')
const headingTitle = computed(() => { const headingTitle = computed(() => {
const service = authStore.settings?.rp_name const service = authStore.settings?.rp_name
@@ -125,12 +127,3 @@ const handleButtonRowKeydown = (event) => {
// Down does nothing (no elements below to navigate to) // Down does nothing (no elements below to navigate to)
} }
</script> </script>
<style scoped>
.host-view { padding: 3rem 1.5rem 4rem; }
.host-actions { display: flex; flex-direction: column; gap: 0.75rem; }
.host-actions .button-row { gap: 0.75rem; flex-wrap: wrap; }
.host-actions .button-row button { flex: 1 1 0; }
.note { margin: 0; color: var(--color-text-muted); }
.empty-state { margin: 0; color: var(--color-text-muted); }
</style>
+2 -7
View File
@@ -27,17 +27,12 @@ defineProps({
.loading-spinner { .loading-spinner {
width: 40px; width: 40px;
height: 40px; height: 40px;
border: 4px solid var(--color-border); border: 3px solid var(--color-border);
border-top: 4px solid var(--color-primary); border-top-color: var(--color-primary);
border-radius: 50%; border-radius: 50%;
animation: spin 1s linear infinite; animation: spin 1s linear infinite;
} }
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
.loading-container p { .loading-container p {
color: var(--color-text-muted); color: var(--color-text-muted);
margin: 0; margin: 0;
+26 -43
View File
@@ -1,12 +1,15 @@
<template> <template>
<dialog ref="dialog" @close="$emit('close')" @keydown="handleDialogKeydown"> <div class="dialog-overlay" @click="$emit('close')">
<div ref="dialog" class="modal-panel" @keydown="handleDialogKeydown" @click.stop>
<slot /> <slot />
</dialog> </div>
</div>
</template> </template>
<script setup> <script setup>
import { ref, onMounted, onUnmounted, nextTick } from 'vue' import { ref, onMounted, onUnmounted, nextTick } from 'vue'
import { navigateButtonRow, getDirection, focusPreferred, focusDialogDefault } from '@/utils/keynav' import { navigateButtonRow, getDirection, focusPreferred, focusDialogDefault } from '@/utils/keynav'
import { holdGlobalBackdrop, releaseGlobalBackdrop } from 'paskia'
const props = defineProps({ const props = defineProps({
// Optional: provide a fallback element to focus if original element is gone // Optional: provide a fallback element to focus if original element is gone
@@ -17,7 +20,7 @@ const props = defineProps({
focusSiblingSelector: { type: String, default: '' } focusSiblingSelector: { type: String, default: '' }
}) })
defineEmits(['close']) const emit = defineEmits(['close'])
// Dialog element reference // Dialog element reference
const dialog = ref(null) const dialog = ref(null)
@@ -76,6 +79,13 @@ const restoreFocus = () => {
} }
const handleDialogKeydown = (event) => { const handleDialogKeydown = (event) => {
// ESC to close (previously handled by <dialog> natively)
if (event.key === 'Escape') {
event.preventDefault()
emit('close')
return
}
const direction = getDirection(event) const direction = getDirection(event)
if (!direction) return if (!direction) return
@@ -111,11 +121,11 @@ onMounted(() => {
// Save currently focused element before modal takes focus // Save currently focused element before modal takes focus
previouslyFocusedElement.value = document.activeElement previouslyFocusedElement.value = document.activeElement
// Show the dialog as a modal holdGlobalBackdrop()
// Focus the most appropriate element
nextTick(() => { nextTick(() => {
if (dialog.value) { if (dialog.value) {
dialog.value.showModal()
// Autofocus the most appropriate element: // Autofocus the most appropriate element:
// - For form dialogs (rename, edit): focus first input and select text // - For form dialogs (rename, edit): focus first input and select text
// - For other dialogs: focus primary button (or fallback) // - For other dialogs: focus primary button (or fallback)
@@ -131,14 +141,16 @@ onMounted(() => {
}) })
onUnmounted(() => { onUnmounted(() => {
releaseGlobalBackdrop()
// Restore focus when modal closes // Restore focus when modal closes
restoreFocus() restoreFocus()
}) })
</script> </script>
<style scoped> <style scoped>
dialog { .modal-panel {
background: var(--color-surface); background: var(--color-dialog);
color: var(--color-text);
border: 1px solid var(--color-border); border: 1px solid var(--color-border);
border-radius: var(--radius-lg); border-radius: var(--radius-lg);
box-shadow: var(--shadow-xl); box-shadow: var(--shadow-xl);
@@ -147,65 +159,36 @@ dialog {
width: min(500px, 90vw); width: min(500px, 90vw);
max-height: 90vh; max-height: 90vh;
overflow-y: auto; overflow-y: auto;
position: fixed;
inset: 0;
margin: auto;
height: fit-content;
} }
dialog::backdrop { .modal-panel :deep(.modal-title),
background: transparent; .modal-panel :deep(h3) {
backdrop-filter: blur(.1rem) brightness(0.7);
-webkit-backdrop-filter: blur(.1rem) brightness(0.7);
}
dialog :deep(.modal-title),
dialog :deep(h3) {
margin: 0 0 var(--space-md); margin: 0 0 var(--space-md);
font-size: 1.25rem; font-size: 1.25rem;
font-weight: 600; font-weight: 600;
color: var(--color-heading); color: var(--color-heading);
} }
dialog :deep(form) { .modal-panel :deep(form) {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: var(--space-md); gap: var(--space-md);
} }
dialog :deep(.modal-form) { .modal-panel :deep(.modal-form) {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: var(--space-md); gap: var(--space-md);
} }
dialog :deep(.modal-form label) { .modal-panel :deep(.modal-form label) {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: var(--space-xs); gap: var(--space-xs);
font-weight: 500; font-weight: 500;
} }
dialog :deep(.modal-form input), .modal-panel :deep(.modal-actions) {
dialog :deep(.modal-form textarea) {
padding: var(--space-md);
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
background: var(--color-bg);
color: var(--color-text);
font-size: 1rem;
line-height: 1.4;
min-height: 2.5rem;
}
dialog :deep(.modal-form input:focus),
dialog :deep(.modal-form textarea:focus) {
outline: none;
border-color: var(--color-accent);
box-shadow: 0 0 0 2px #c7d2fe;
}
dialog :deep(.modal-actions) {
display: flex; display: flex;
justify-content: flex-end; justify-content: flex-end;
gap: var(--space-sm); gap: var(--space-sm);
-8
View File
@@ -84,12 +84,4 @@ function handleCancel() {
flex-direction: column; flex-direction: column;
gap: var(--space-md); gap: var(--space-md);
} }
.error {
color: var(--color-danger-text);
}
.small {
font-size: 0.9rem;
}
</style> </style>
+125 -55
View File
@@ -1,26 +1,32 @@
<template> <template>
<section class="view-root" data-view="profile"> <section class="view-root view-root--profile" data-view="profile">
<div class="view-header-wrapper">
<div class="theme-toggle"> <div class="theme-toggle">
<ThemeSelector /> <ThemeSelector />
</div> </div>
<header class="view-header"> <header class="view-header">
<h1>User Profile</h1>
<Breadcrumbs ref="breadcrumbs" :entries="breadcrumbEntries" @keydown="handleBreadcrumbKeydown" /> <Breadcrumbs ref="breadcrumbs" :entries="breadcrumbEntries" @keydown="handleBreadcrumbKeydown" />
<p class="view-lede">Account dashboard for managing credentials and authenticating with other devices.</p> <p class="view-lede">Account dashboard to manage your profile and authentications.</p>
</header> </header>
</div>
<section class="section-block" ref="userInfoSection"> <section class="section-block section-block--constrained" ref="userInfoSection">
<UserBasicInfo <UserBasicInfo
v-if="authStore.userInfo?.ctx" v-if="authStore.userInfo?.user"
ref="userBasicInfo" ref="userBasicInfo"
:name="authStore.userInfo.ctx.user.display_name" :name="authStore.userInfo.user.display_name"
:visits="authStore.userInfo.visits" :email="authStore.userInfo.user.email"
:created-at="authStore.userInfo.created_at" :preferred_username="authStore.userInfo.user.preferred_username"
:last-seen="authStore.userInfo.last_seen" :telephone="authStore.userInfo.user.telephone"
:visits="authStore.userInfo.user.visits"
:created-at="authStore.userInfo.user.created_at"
:last-seen="authStore.userInfo.user.last_seen"
:loading="authStore.isLoading" :loading="authStore.isLoading"
update-endpoint="/auth/api/user/display-name" :org-display-name="authStore.userInfo.org.display_name"
:role-name="authStore.userInfo.role.display_name"
update-endpoint="/auth/api/user/info"
@saved="authStore.loadUserInfo()" @saved="authStore.loadUserInfo()"
@edit-name="openNameDialog" @edit="openEditDialog"
@keydown="handleUserInfoKeydown" @keydown="handleUserInfoKeydown"
> >
<div class="remote-auth-inline"> <div class="remote-auth-inline">
@@ -34,11 +40,11 @@
@device-info-visible="showDeviceInfo = $event" @device-info-visible="showDeviceInfo = $event"
/> />
</div> </div>
<p class="remote-auth-description">Provided by another device requesting remote auth.</p> <p class="remote-auth-description">Login from another device</p>
</UserBasicInfo> </UserBasicInfo>
</section> </section>
<section class="section-block"> <section :class="['section-block', { 'section-block--constrained': !useWideLayout }]">
<div class="section-header"> <div class="section-header">
<h2>Your Passkeys</h2> <h2>Your Passkeys</h2>
<p class="section-description">Ideally have at least two passkeys in case you lose one. More than one user can be registered on the same device, giving you a choice at login. <a href="https://bitwarden.com/pricing/" target="_blank" rel="noopener noreferrer">Bitwarden</a> can sync one passkey to all your devices. Other secure options include <b>local passkeys</b>, as well as hardware keys such as <a href="https://www.yubico.com" target="_blank" rel="noopener noreferrer">YubiKey</a>. Cloud sync via Google, Microsoft or iCloud is discouraged.</p> <p class="section-description">Ideally have at least two passkeys in case you lose one. More than one user can be registered on the same device, giving you a choice at login. <a href="https://bitwarden.com/pricing/" target="_blank" rel="noopener noreferrer">Bitwarden</a> can sync one passkey to all your devices. Other secure options include <b>local passkeys</b>, as well as hardware keys such as <a href="https://www.yubico.com" target="_blank" rel="noopener noreferrer">YubiKey</a>. Cloud sync via Google, Microsoft or iCloud is discouraged.</p>
@@ -46,8 +52,8 @@
<div class="section-body"> <div class="section-body">
<CredentialList <CredentialList
ref="credentialList" ref="credentialList"
:credentials="authStore.userInfo?.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"
@@ -70,25 +76,14 @@
:terminating-sessions="terminatingSessions" :terminating-sessions="terminatingSessions"
:hovered-credential-uuid="hoveredCredentialUuid" :hovered-credential-uuid="hoveredCredentialUuid"
:navigation-disabled="hasActiveModal" :navigation-disabled="hasActiveModal"
:section-class="useWideLayout ? '' : 'section-block--constrained'"
@terminate="terminateSession" @terminate="terminateSession"
@session-hover="hoveredSession = $event" @session-hover="hoveredSession = $event"
@navigate-out="handleSessionNavigateOut" @navigate-out="handleSessionNavigateOut"
section-description="You are currently signed in to the following sessions. If you don't recognize something, consider deleting not only the session but the associated passkey you suspect is compromised, as only this terminates all linked sessions and prevents logging in again." section-description="You are currently signed in to the following sessions. If you don't recognize something, consider deleting not only the session but the associated passkey you suspect is compromised, as only this terminates all linked sessions and prevents logging in again."
/> />
<Modal v-if="showNameDialog" @close="showNameDialog = false"> <section :class="['section-block', { 'section-block--constrained': !useWideLayout }]">
<h3>Edit Display Name</h3>
<form @submit.prevent="saveName" class="modal-form">
<NameEditForm
label="Display Name"
v-model="newName"
:busy="saving"
@cancel="showNameDialog = false"
/>
</form>
</Modal>
<section class="section-block">
<div class="button-row" ref="logoutButtons"> <div class="button-row" ref="logoutButtons">
<button <button
type="button" type="button"
@@ -104,9 +99,38 @@
<button @click="logoutEverywhere" class="btn-danger" :disabled="authStore.isLoading" @keydown="handleLogoutButtonKeydown">All</button> <button @click="logoutEverywhere" class="btn-danger" :disabled="authStore.isLoading" @keydown="handleLogoutButtonKeydown">All</button>
</template> </template>
</div> </div>
<div class="logout-footer">
<p class="logout-note" v-if="!hasMultipleSessions"><strong>Logout</strong> from {{ currentSessionHost }}.</p> <p class="logout-note" v-if="!hasMultipleSessions"><strong>Logout</strong> from {{ currentSessionHost }}.</p>
<p class="logout-note" v-else><strong>Logout</strong> this session on {{ currentSessionHost }}, or <strong>All</strong> sessions across all sites and devices for {{ rpName }}. You'll need to log in again with your passkey afterwards.</p> <p class="logout-note" v-else><strong>Logout</strong> this session on {{ currentSessionHost }}, or <strong>All</strong> sessions across all sites and devices for {{ rpName }}.</p>
<a class="paskia-version" href="https://git.zi.fi/leovasanko/paskia" target="_blank" rel="noopener noreferrer">Paskia {{ paskiaVersion }}</a>
</div>
</section> </section>
<Modal v-if="showEditDialog" @close="showEditDialog = false">
<h3>Edit Profile</h3>
<form @submit.prevent="saveProfile" class="modal-form">
<div class="profile-edit-form">
<label for="edit-display-name">Display Name
<input id="edit-display-name" type="text" v-model="editName" :disabled="saving" required />
</label>
<label for="edit-email">Email
<input id="edit-email" type="email" v-model="editEmail" :disabled="saving" />
</label>
<label for="edit-username">Preferred Username
<input id="edit-username" type="text" v-model="editUsername" :disabled="saving" placeholder="username" />
</label>
<label for="edit-telephone">Telephone
<input id="edit-telephone" type="tel" v-model="editTelephone" :disabled="saving" />
</label>
<div v-if="editError" class="error small">{{ editError }}</div>
<div class="modal-actions">
<button type="button" class="btn-secondary" @click="showEditDialog = false" :disabled="saving">Cancel</button>
<button type="submit" class="btn-primary" :disabled="saving" data-nav-primary>Save</button>
</div>
</div>
</form>
</Modal>
<RegistrationLinkModal <RegistrationLinkModal
v-if="showRegLink" v-if="showRegLink"
endpoint="/auth/api/user/create-link" endpoint="/auth/api/user/create-link"
@@ -123,7 +147,6 @@ import CredentialList from '@/components/CredentialList.vue'
import ThemeSelector from '@/components/ThemeSelector.vue' import ThemeSelector from '@/components/ThemeSelector.vue'
import UserBasicInfo from '@/components/UserBasicInfo.vue' import UserBasicInfo from '@/components/UserBasicInfo.vue'
import Modal from '@/components/Modal.vue' import Modal from '@/components/Modal.vue'
import NameEditForm from '@/components/NameEditForm.vue'
import SessionList from '@/components/SessionList.vue' import SessionList from '@/components/SessionList.vue'
import RegistrationLinkModal from '@/components/RegistrationLinkModal.vue' import RegistrationLinkModal from '@/components/RegistrationLinkModal.vue'
import RemoteAuthPermit from '@/components/RemoteAuthPermit.vue' import RemoteAuthPermit from '@/components/RemoteAuthPermit.vue'
@@ -136,10 +159,14 @@ import { navigateButtonRow, focusPreferred, focusAtIndex, getDirection } from '@
const authStore = useAuthStore() const authStore = useAuthStore()
const updateInterval = ref(null) const updateInterval = ref(null)
const showNameDialog = ref(false) const showEditDialog = ref(false)
const showRegLink = ref(false) const showRegLink = ref(false)
const newName = ref('') const editName = ref('')
const editEmail = ref('')
const editUsername = ref('')
const editTelephone = ref('')
const saving = ref(false) const saving = ref(false)
const editError = ref('')
const hoveredCredentialUuid = ref(null) const hoveredCredentialUuid = ref(null)
const hoveredSession = ref(null) const hoveredSession = ref(null)
const showDeviceInfo = ref(false) const showDeviceInfo = ref(false)
@@ -153,9 +180,17 @@ const userBasicInfo = ref(null)
const userInfoSection = ref(null) const userInfoSection = ref(null)
// Check if any modal/dialog is open (blocks arrow key navigation) // Check if any modal/dialog is open (blocks arrow key navigation)
const hasActiveModal = computed(() => showNameDialog.value || showRegLink.value) const hasActiveModal = computed(() => showEditDialog.value || showRegLink.value)
watch(showNameDialog, (newVal) => { if (newVal) newName.value = authStore.userInfo?.ctx.user.display_name ?? '' }) watch(showEditDialog, (open) => {
if (!open) return
const user = authStore.userInfo.user
editName.value = user.display_name ?? ''
editEmail.value = user.email ?? ''
editUsername.value = user.preferred_username ?? ''
editTelephone.value = user.telephone ?? ''
editError.value = ''
})
onMounted(() => { onMounted(() => {
updateInterval.value = setInterval(() => { if (authStore.userInfo) authStore.userInfo = { ...authStore.userInfo } }, 60000) updateInterval.value = setInterval(() => { if (authStore.userInfo) authStore.userInfo = { ...authStore.userInfo } }, 60000)
@@ -305,57 +340,92 @@ const handleDelete = async (credential) => {
} }
const rpName = computed(() => authStore.settings?.rp_name || 'this service') const rpName = computed(() => authStore.settings?.rp_name || 'this service')
const sessions = computed(() => authStore.userInfo?.sessions || []) const paskiaVersion = computed(() => authStore.settings?.version || '')
const sessions = computed(() => authStore.userInfo.sessions)
const currentSessionHost = computed(() => { const currentSessionHost = computed(() => {
const currentSession = 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'
}) })
const terminatingSessions = ref({}) const terminatingSessions = ref({})
const terminateSession = async (session) => { const terminateSession = async (session) => {
const sessionId = session?.id const sessionKey = session?.key
if (!sessionId) return if (!sessionKey) return
terminatingSessions.value = { ...terminatingSessions.value, [sessionId]: true } terminatingSessions.value = { ...terminatingSessions.value, [sessionKey]: true }
try { await authStore.terminateSession(sessionId) } try { await authStore.terminateSession(sessionKey) }
catch (error) { authStore.showMessage(error.message || 'Failed to terminate session', 'error', 5000) } catch (error) { authStore.showMessage(error.message || 'Failed to terminate session', 'error', 5000) }
finally { finally {
const next = { ...terminatingSessions.value } const next = { ...terminatingSessions.value }
delete next[sessionId] delete next[sessionKey]
terminatingSessions.value = next terminatingSessions.value = next
} }
} }
const logoutEverywhere = async () => { await authStore.logoutEverywhere() } const logoutEverywhere = async () => { await authStore.logoutEverywhere() }
const logout = async () => { await authStore.logout() } const logout = async () => { await authStore.logout() }
const openNameDialog = () => { newName.value = authStore.userInfo?.ctx.user.display_name ?? ''; showNameDialog.value = true } const openEditDialog = () => { showEditDialog.value = true }
const isAdmin = computed(() => { const isAdmin = computed(() => {
const perms = authStore.userInfo?.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(() => sessions.value.length > 1) const hasMultipleSessions = computed(() => Object.keys(sessions.value).length > 1)
const breadcrumbEntries = computed(() => { const entries = [{ label: 'Auth', href: makeUiHref() }]; if (isAdmin.value) entries.push({ label: 'Admin', href: adminUiPath() }); return entries }) const credentials = computed(() =>
Object.entries(authStore.userInfo.credentials).map(([uuid, c]) => ({ ...c, credential: uuid }))
)
const useWideLayout = computed(() => {
// Check if any single site has more than 8 sessions
const groups = {}
for (const session of Object.values(sessions.value)) {
const host = session.host || ''
if (!groups[host]) groups[host] = []
groups[host].push(session)
}
const hasLargeSessionGroup = Object.values(groups).some(group => group.length > 8)
const saveName = async () => { // Check if passkeys exceed 8
const name = newName.value.trim() const hasManyCredentials = credentials.value.length > 8
if (!name) { authStore.showMessage('Name cannot be empty', 'error'); return }
return hasLargeSessionGroup || hasManyCredentials
})
const breadcrumbEntries = computed(() => { const entries = [{ label: 'My Profile', href: makeUiHref() }]; if (isAdmin.value) entries.push({ label: 'Admin', href: adminUiPath() }); return entries })
const saveProfile = async () => {
const name = editName.value.trim()
if (!name) { editError.value = 'Name cannot be empty'; return }
const user = authStore.userInfo.user
const emailVal = editEmail.value.trim() || null
const usernameVal = editUsername.value.trim() || null
const telephoneVal = editTelephone.value.trim() || null
try { try {
editError.value = ''
saving.value = true saving.value = true
await apiJson('/auth/api/user/display-name', { method: 'PATCH', body: { display_name: name } }) const body = {}
showNameDialog.value = false if (name !== user.display_name) body.display_name = name
if (emailVal !== (user.email || null)) body.email = emailVal
if (usernameVal !== (user.preferred_username || null)) body.preferred_username = usernameVal
if (telephoneVal !== (user.telephone || null)) body.telephone = telephoneVal
if (Object.keys(body).length) {
await apiJson('/auth/api/user/info', { method: 'PATCH', body })
await authStore.loadUserInfo() await authStore.loadUserInfo()
authStore.showMessage('Name updated successfully!', 'success', 3000) authStore.showMessage('Profile updated!', 'success', 3000)
} catch (e) { authStore.showMessage(e.message || 'Failed to update name', 'error') } }
finally { saving.value = false } showEditDialog.value = false
} catch (e) {
editError.value = e.message || 'Failed to update profile'
} finally { saving.value = false }
} }
</script> </script>
<style scoped> <style scoped>
.view-lede { margin: 0; color: var(--color-text-muted); font-size: 1rem; }
.section-header { display: flex; flex-direction: column; gap: 0.4rem; } .section-header { display: flex; flex-direction: column; gap: 0.4rem; }
.empty-state { margin: 0; color: var(--color-text-muted); text-align: center; padding: 1rem 0; } .empty-state { margin: 0; color: var(--color-text-muted); text-align: center; padding: 1rem 0; }
.logout-note { margin: 0.75rem 0 0; color: var(--color-text-muted); font-size: 0.875rem; } .logout-note { margin: 0; color: var(--color-text-muted); }
.logout-footer { display: flex; justify-content: space-between; align-items: baseline; gap: 1rem; margin-top: 0.75rem; }
.paskia-version { color: var(--color-text-muted); text-decoration: none; white-space: nowrap; font-weight: 700; }
.paskia-version:hover { color: var(--color-link-hover); }
.remote-auth-inline { display: flex; flex-direction: column; gap: 0.5rem; } .remote-auth-inline { display: flex; flex-direction: column; gap: 0.5rem; }
.remote-auth-label { display: block; margin: 0; font-size: 0.875rem; color: var(--color-text-muted); font-weight: 500; } .remote-auth-label { display: block; margin: 0; font-size: 0.875rem; color: var(--color-text-muted); font-weight: 500; }
.remote-auth-description { font-size: 0.75rem; color: var(--color-text-muted); } .remote-auth-description { font-size: 0.75rem; color: var(--color-text-muted); }
.theme-toggle { position: absolute; top: var(--layout-padding); right: var(--layout-padding); } .theme-toggle { position: absolute; top: var(--layout-padding); right: var(--layout-padding); }
.profile-edit-form { display: flex; flex-direction: column; gap: var(--space-md); }
</style> </style>
@@ -1,5 +1,6 @@
<template> <template>
<dialog ref="dialog" @close="$emit('close')" @keydown="handleDialogKeydown"> <div v-if="linkUrl" class="dialog-overlay" @click="$emit('close')">
<div ref="dialog" class="modal-panel" @keydown="handleDialogKeydown" @click.stop>
<div class="device-dialog" role="dialog" aria-modal="true" aria-labelledby="regTitle"> <div class="device-dialog" role="dialog" aria-modal="true" aria-labelledby="regTitle">
<div class="reg-header-row"> <div class="reg-header-row">
<h2 id="regTitle" class="reg-title"> <h2 id="regTitle" class="reg-title">
@@ -29,13 +30,14 @@
<button class="btn-secondary" @click="$emit('close')">Close</button> <button class="btn-secondary" @click="$emit('close')">Close</button>
</div> </div>
</div> </div>
</dialog> </div>
</div>
</template> </template>
<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 } 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'
@@ -77,21 +79,20 @@ async function generateLink() {
expiresAt.value = data.expires ? new Date(data.expires) : null expiresAt.value = data.expires ? new Date(data.expires) : null
tokenType.value = data.token_type || null tokenType.value = data.token_type || null
// Show the dialog as modal holdGlobalBackdrop()
await nextTick()
if (dialog.value) {
dialog.value.showModal()
// Focus primary button (or first button if no primary) after content renders // Focus primary button (or first button if no primary) after content renders
await nextTick()
const actions = actionsRow.value const actions = actionsRow.value
const target = actions?.querySelector('.btn-primary') || actions?.querySelector('button') const target = actions?.querySelector('.btn-primary') || actions?.querySelector('button')
target?.focus() target?.focus()
}
} else { } else {
emit('close') emit('close')
} }
} catch (e) { } catch (e) {
if (!(e instanceof AuthCancelledError)) {
authStore.showMessage(e.message || 'Failed to generate link', 'error') authStore.showMessage(e.message || 'Failed to generate link', 'error')
}
emit('close') emit('close')
} }
} }
@@ -101,7 +102,12 @@ function onCopied() {
} }
const handleDialogKeydown = (event) => { const handleDialogKeydown = (event) => {
// ESC is handled automatically by <dialog> // ESC to close
if (event.key === 'Escape') {
event.preventDefault()
emit('close')
return
}
// Handle other key navigation // Handle other key navigation
const direction = getDirection(event) const direction = getDirection(event)
if (!direction) return if (!direction) return
@@ -147,6 +153,7 @@ onMounted(() => {
}) })
onUnmounted(() => { onUnmounted(() => {
if (linkUrl.value) releaseGlobalBackdrop()
// Restore focus when modal closes // Restore focus when modal closes
const prev = previouslyFocusedElement.value const prev = previouslyFocusedElement.value
if (prev && document.body.contains(prev) && !prev.disabled) { if (prev && document.body.contains(prev) && !prev.disabled) {
@@ -156,23 +163,6 @@ onUnmounted(() => {
</script> </script>
<style scoped> <style scoped>
dialog {
border: none;
background: transparent;
padding: 0;
max-width: none;
width: fit-content;
height: fit-content;
position: fixed;
inset: 0;
margin: auto;
}
dialog::backdrop {
-webkit-backdrop-filter: blur(.2rem) brightness(0.5);
backdrop-filter: blur(.2rem) brightness(0.5);
}
.icon-btn { background: none; border: none; cursor: pointer; font-size: 1rem; opacity: .6; } .icon-btn { background: none; border: none; cursor: pointer; font-size: 1rem; opacity: .6; }
.icon-btn:hover { opacity: 1; } .icon-btn:hover { opacity: 1; }
.reg-header-row { display: flex; justify-content: space-between; align-items: center; gap: .75rem; margin-bottom: .75rem; } .reg-header-row { display: flex; justify-content: space-between; align-items: center; gap: .75rem; margin-bottom: .75rem; }
+13 -11
View File
@@ -55,15 +55,14 @@
<p class="device-permit-text">Permit {{ deviceInfo.action === 'register' ? 'registration' : 'login' }} to <strong>{{ deviceInfo.host }}</strong></p> <p class="device-permit-text">Permit {{ deviceInfo.action === 'register' ? 'registration' : 'login' }} to <strong>{{ deviceInfo.host }}</strong></p>
<p class="device-meta">{{ deviceInfo.user_agent_pretty || '—' }}</p> <p class="device-meta">{{ deviceInfo.user_agent_pretty || '—' }}</p>
<p v-if="error" class="error-message" style="margin-top: 0.5rem;">{{ error }}</p> <p v-if="error" class="error-message">{{ error }}</p>
<div class="button-row" style="margin-top: 0.75rem; display: flex; gap: 0.5rem;"> <div class="button-row device-actions">
<button <button
type="button" type="button"
class="btn-secondary" class="btn-secondary"
:disabled="loading" :disabled="loading"
@click="deny" @click="deny"
style="flex: 1;"
> >
Deny Deny
</button> </button>
@@ -72,7 +71,6 @@
type="submit" type="submit"
:disabled="loading" :disabled="loading"
class="btn-primary" class="btn-primary"
style="flex: 1;"
> >
{{ loading ? 'Authenticating…' : 'Authorize' }} {{ loading ? 'Authenticating…' : 'Authorize' }}
</button> </button>
@@ -773,7 +771,6 @@ defineExpose({ reset, deny, code, handleInput, loading, error })
.input-wrapper { .input-wrapper {
position: relative; position: relative;
display: flex; display: flex;
width: 280px;
max-width: 100%; max-width: 100%;
} }
@@ -921,10 +918,6 @@ defineExpose({ reset, deny, code, handleInput, loading, error })
animation: spin 0.8s linear infinite; animation: spin 0.8s linear infinite;
} }
@keyframes spin {
to { transform: rotate(360deg); }
}
.device-info { .device-info {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@@ -945,9 +938,18 @@ defineExpose({ reset, deny, code, handleInput, loading, error })
} }
.error-message { .error-message {
margin: 0; margin: 0.5rem 0 0;
font-size: 0.875rem; font-size: 0.875rem;
color: var(--color-error, #ef4444); color: var(--color-error, #ef4444);
margin-bottom: 1rem; }
.device-actions {
margin-top: 0.75rem;
display: flex;
gap: 0.5rem;
}
.device-actions button {
flex: 1;
} }
</style> </style>
@@ -8,7 +8,7 @@
<!-- Error state --> <!-- Error state -->
<div v-else-if="error" class="error-section"> <div v-else-if="error" class="error-section">
<p class="error-message">{{ error }}</p> <p class="error-message">{{ error }}</p>
<button class="btn-primary" @click="retry" style="margin-top: 0.75rem;">Try Again</button> <button class="btn-primary" @click="retry">Try Again</button>
</div> </div>
<!-- Connecting phase --> <!-- Connecting phase -->
@@ -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)
@@ -196,7 +199,7 @@ async function startRemoteAuth() {
} else if (msg.status === 'authenticated') { } else if (msg.status === 'authenticated') {
// Success // Success
completed.value = true completed.value = true
emit('authenticated', { session_token: msg.session_token }) emit('authenticated', { exchange_code: msg.exchange_code })
break break
} else if (msg.status === 'denied') { } else if (msg.status === 'denied') {
// Explicitly denied by the authenticating device // Explicitly denied by the authenticating device
@@ -293,10 +296,6 @@ defineExpose({ retry, cancel })
animation: spin 0.8s linear infinite; animation: spin 0.8s linear infinite;
} }
@keyframes spin {
to { transform: rotate(360deg); }
}
.auth-display { .auth-display {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@@ -499,6 +498,10 @@ defineExpose({ retry, cancel })
color: var(--color-error, #ef4444); color: var(--color-error, #ef4444);
} }
.error-section button {
margin-top: 0.75rem;
}
/* Responsive adjustments */ /* Responsive adjustments */
@media (max-width: 640px) { @media (max-width: 640px) {
.auth-content { .auth-content {
+28 -12
View File
@@ -1,6 +1,6 @@
<template> <template>
<div class="app-shell"> <div class="app-shell">
<div v-if="status.show" class="global-status" style="display: block;"> <div v-if="status.show" class="global-status show">
<div :class="['status', status.type]"> <div :class="['status', status.type]">
{{ status.message }} {{ status.message }}
</div> </div>
@@ -18,7 +18,7 @@
<div class="section-body center"> <div class="section-body center">
<!-- Local passkey authentication view --> <!-- Local passkey authentication view -->
<div v-if="authView === 'local'" class="auth-view"> <div v-if="authView === 'local'" class="auth-view">
<div class="button-row center" ref="buttonRow"> <div class="button-row button-row--center" ref="buttonRow">
<slot name="actions" <slot name="actions"
:loading="loading" :loading="loading"
:can-authenticate="canAuthenticate" :can-authenticate="canAuthenticate"
@@ -61,12 +61,17 @@ 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: {
type: String, type: String,
default: 'login', default: 'login',
validator: (value) => ['login', 'reauth', 'forbidden'].includes(value) validator: (value) => ['login', 'reauth', 'forbidden', 'oidc'].includes(value)
},
oidcQueryString: {
type: String,
default: null
} }
}) })
@@ -143,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)
@@ -163,7 +169,7 @@ async function authenticateUser() {
loading.value = true loading.value = true
showMessage('Starting authentication…', 'info') showMessage('Starting authentication…', 'info')
let result let result
try { result = await passkey.authenticate() } catch (error) { try { result = await passkey.authenticate(props.oidcQueryString) } catch (error) {
loading.value = false loading.value = false
const message = error?.message || 'Passkey authentication cancelled' const message = error?.message || 'Passkey authentication cancelled'
const cancelled = message === 'Passkey authentication cancelled' const cancelled = message === 'Passkey authentication cancelled'
@@ -171,7 +177,13 @@ async function authenticateUser() {
emit('auth-error', { message, cancelled }) emit('auth-error', { message, cancelled })
return return
} }
try { await setSessionCookie(result) } catch (error) { // OIDC flow: no session cookie, just emit the redirect_url
if (result.redirect_url) {
loading.value = false
emit('authenticated', result)
return
}
try { await exchangeCode(result) } catch (error) {
loading.value = false loading.value = false
const message = error?.message || 'Failed to establish session' const message = error?.message || 'Failed to establish session'
showMessage(message, 'error', 4000) showMessage(message, 'error', 4000)
@@ -202,13 +214,13 @@ function openProfile() {
if (profileWindow) profileWindow.focus() if (profileWindow) profileWindow.focus()
} }
async function setSessionCookie(result) { async function exchangeCode(result) {
if (!result?.session_token) { if (!result?.exchange_code) {
console.error('setSessionCookie called with missing session_token:', result) console.error('exchangeCode called with missing exchange_code:', result)
throw new Error('Authentication response missing session_token') throw new Error('Authentication response missing exchange_code')
} }
return await fetchJson('/auth/api/set-session', { return await fetchJson('/auth/api/set-session', {
method: 'POST', headers: { Authorization: `Bearer ${result.session_token}` } method: 'POST', headers: { 'Authorization': `Bearer ${result.exchange_code}` }
}) })
} }
@@ -223,7 +235,7 @@ function switchToLocal() {
async function handleRemoteAuthenticated(result) { async function handleRemoteAuthenticated(result) {
showMessage('Authenticated from another device!', 'success', 2000) showMessage('Authenticated from another device!', 'success', 2000)
try { try {
await setSessionCookie(result) await exchangeCode(result)
} catch (error) { } catch (error) {
const message = error?.message || 'Failed to establish session' const message = error?.message || 'Failed to establish session'
showMessage(message, 'error', 4000) showMessage(message, 'error', 4000)
@@ -265,7 +277,12 @@ watch(initializing, (newVal) => {
onMounted(async () => { onMounted(async () => {
await fetchSettings() await fetchSettings()
// OIDC mode doesn't depend on session state - skip validation
if (props.mode !== 'oidc') {
await validateSession() await validateSession()
} else {
currentView.value = 'login'
}
initializing.value = false initializing.value = false
// Add click handler for inline links // Add click handler for inline links
@@ -284,7 +301,6 @@ defineExpose({
</script> </script>
<style scoped> <style scoped>
.button-row.center { display: flex; justify-content: center; gap: 0.75rem; flex-wrap: wrap; }
.user-line { margin: 0.5rem 0 0; font-weight: 500; color: var(--color-text); } .user-line { margin: 0.5rem 0 0; font-weight: 500; color: var(--color-text); }
main.view-root { min-height: 100vh; align-items: center; justify-content: center; padding: 2rem 1rem; } main.view-root { min-height: 100vh; align-items: center; justify-content: center; padding: 2rem 1rem; }
.surface.surface--tight { .surface.surface--tight {
+33 -33
View File
@@ -1,25 +1,26 @@
<template> <template>
<section class="section-block" data-component="session-list-section"> <section :class="['section-block', sectionClass]" data-component="session-list-section">
<div class="section-header"> <div class="section-header">
<h2>Active Sessions</h2> <h2>Active Sessions</h2>
<p class="section-description">{{ sectionDescription }}</p> <p class="section-description">{{ sectionDescription }}</p>
</div> </div>
<div class="section-body"> <div class="section-body">
<div> <div>
<template v-if="Array.isArray(sessions) && sessions.length"> <template v-if="sessionsArray.length">
<div v-for="(group, host) in groupedSessions" :key="host" class="session-group" tabindex="0" @keydown="handleGroupKeydown($event, host)"> <div v-for="(group, key) in groupedSessions" :key="key" class="session-group" tabindex="0" @keydown="handleGroupKeydown($event, key)">
<span :class="['session-group-host', { 'is-current-site': group.isCurrentSite }]"> <span :class="['session-group-host', { 'is-current-site': group.isCurrentSite }]">
<span class="session-group-icon">🌐</span> <span class="session-group-icon">{{ group.isOIDC ? '🪪' : '🌐' }}</span>
<a v-if="host" :href="hostUrl(host)" tabindex="-1" target="_blank" rel="noopener noreferrer">{{ host }}</a> <template v-if="group.isOIDC">{{ group.displayName }}</template>
<a v-else-if="key" :href="hostUrl(key)" tabindex="-1" target="_blank" rel="noopener noreferrer">{{ key }}</a>
<template v-else>Unbound host</template> <template v-else>Unbound host</template>
</span> </span>
<div class="session-list"> <div class="session-list">
<div <div
v-for="session in group.sessions" v-for="session in group.sessions"
:key="session.id" :key="session.key"
:class="['session-item', { :class="['session-item', {
'is-current': session.is_current && !hoveredIp && !hoveredCredentialUuid, 'is-current': session.is_current && !hoveredIp && !hoveredCredentialUuid,
'is-hovered': hoveredSession?.id === session.id, 'is-hovered': hoveredSession?.key === session.key,
'is-linked-credential': hoveredCredentialUuid === session.credential 'is-linked-credential': hoveredCredentialUuid === session.credential
}]" }]"
tabindex="-1" tabindex="-1"
@@ -33,14 +34,14 @@
<h4 class="item-title">{{ session.user_agent || '—' }}</h4> <h4 class="item-title">{{ session.user_agent || '—' }}</h4>
<div class="item-actions"> <div class="item-actions">
<span v-if="session.is_current && !hoveredIp && !hoveredCredentialUuid" class="badge badge-current">Current</span> <span v-if="session.is_current && !hoveredIp && !hoveredCredentialUuid" class="badge badge-current">Current</span>
<span v-else-if="hoveredSession?.id === session.id" class="badge badge-current">Selected</span> <span v-else-if="hoveredSession?.key === session.key" class="badge badge-current">Selected</span>
<span v-else-if="hoveredCredentialUuid === session.credential" class="badge badge-current">Linked</span> <span v-else-if="hoveredCredentialUuid === session.credential" class="badge badge-current">Linked</span>
<span v-else-if="!hoveredCredentialUuid && isSameHost(session.ip)" class="badge">Same IP</span> <span v-else-if="!hoveredCredentialUuid && isSameHost(session.ip)" class="badge">Same IP</span>
<button <button
@click="$emit('terminate', session)" @click="$emit('terminate', session)"
class="btn-card-delete" class="btn-card-delete"
:disabled="isTerminating(session.id)" :disabled="isTerminating(session.key)"
:title="isTerminating(session.id) ? 'Terminating...' : 'Terminate session'" :title="isTerminating(session.key) ? 'Terminating...' : 'Terminate session'"
tabindex="-1" tabindex="-1"
></button> ></button>
</div> </div>
@@ -69,12 +70,13 @@ import { hostIP } from '@/utils/helpers'
import { navigateGrid, handleDeleteKey, handleEscape, getDirection } from '@/utils/keynav' import { navigateGrid, handleDeleteKey, handleEscape, getDirection } from '@/utils/keynav'
const props = defineProps({ const props = defineProps({
sessions: { type: Array, default: () => [] }, sessions: { type: Object, default: () => ({}) },
emptyMessage: { type: String, default: 'You currently have no other active sessions.' }, emptyMessage: { type: String, default: 'You currently have no other active sessions.' },
sectionDescription: { type: String, default: "Review where you're signed in and end any sessions you no longer recognize." }, sectionDescription: { type: String, default: "Review where you're signed in and end any sessions you no longer recognize." },
terminatingSessions: { type: Object, default: () => ({}) }, terminatingSessions: { type: Object, default: () => ({}) },
hoveredCredentialUuid: { type: String, default: null }, hoveredCredentialUuid: { type: String, default: null },
navigationDisabled: { type: Boolean, default: false }, navigationDisabled: { type: Boolean, default: false },
sectionClass: { type: String, default: '' },
}) })
const emit = defineEmits(['terminate', 'sessionHover', 'navigate-out']) const emit = defineEmits(['terminate', 'sessionHover', 'navigate-out'])
@@ -106,7 +108,7 @@ const handleCardClick = (event) => {
} }
} }
const isTerminating = (sessionId) => !!props.terminatingSessions[sessionId] const isTerminating = (sessionKey) => !!props.terminatingSessions[sessionKey]
const handleGroupKeydown = (event, host) => { const handleGroupKeydown = (event, host) => {
const group = event.currentTarget const group = event.currentTarget
@@ -149,7 +151,7 @@ const handleGroupKeydown = (event, host) => {
const handleItemKeydown = (event, session) => { const handleItemKeydown = (event, session) => {
// Handle delete (always allowed even with modal) // Handle delete (always allowed even with modal)
handleDeleteKey(event, () => { handleDeleteKey(event, () => {
if (!isTerminating(session.id)) emit('terminate', session) if (!isTerminating(session.key)) emit('terminate', session)
}) })
if (event.defaultPrevented) return if (event.defaultPrevented) return
@@ -208,9 +210,14 @@ const copyIp = async (ip) => {
const displayIp = ip => hostIP(ip) ?? ip const displayIp = ip => hostIP(ip) ?? ip
// Convert sessions dict to array with key attached
const sessionsArray = computed(() =>
Object.entries(props.sessions || {}).map(([key, session]) => ({ ...session, key }))
)
const currentHostIP = computed(() => { const currentHostIP = computed(() => {
if (hoveredIp.value) return hostIP(hoveredIp.value) if (hoveredIp.value) return hostIP(hoveredIp.value)
const current = props.sessions.find(s => s.is_current) const current = sessionsArray.value.find(s => s.is_current)
return current ? hostIP(current.ip) : null return current ? hostIP(current.ip) : null
}) })
@@ -218,27 +225,20 @@ const isSameHost = ip => currentHostIP.value && hostIP(ip) === currentHostIP.val
const groupedSessions = computed(() => { const groupedSessions = computed(() => {
const groups = {} const groups = {}
for (const session of props.sessions) { for (const session of sessionsArray.value) {
const host = session.host || '' const groupKey = session.client || session.host || ''
if (!groups[host]) { if (!groups[groupKey]) {
groups[host] = { sessions: [], isCurrentSite: false } groups[groupKey] = { sessions: [], isCurrentSite: false, isOIDC: !!session.client, displayName: session.client_name || groupKey }
} }
groups[host].sessions.push(session) groups[groupKey].sessions.push(session)
if (session.is_current_host) { if (session.is_current_host) groups[groupKey].isCurrentSite = true
groups[host].isCurrentSite = true
} }
} for (const groupKey in groups) groups[groupKey].sessions.sort((a, b) => new Date(b.last_renewed) - new Date(a.last_renewed))
// Sort sessions within each group by last_renewed descending
for (const host in groups) {
groups[host].sessions.sort((a, b) => new Date(b.last_renewed) - new Date(a.last_renewed))
}
// Sort groups by host name (natural sort)
const collator = new Intl.Collator(undefined, { numeric: true, sensitivity: 'base' }) const collator = new Intl.Collator(undefined, { numeric: true, sensitivity: 'base' })
const sortedHosts = Object.keys(groups).sort(collator.compare) const sorted = Object.entries(groups).sort(([, a], [, b]) => {
const sortedGroups = {} if (a.isOIDC !== b.isOIDC) return a.isOIDC ? 1 : -1
for (const host of sortedHosts) { return collator.compare(a.displayName, b.displayName) || collator.compare(a.sessions[0]?.client || '', b.sessions[0]?.client || '')
sortedGroups[host] = groups[host] })
} return Object.fromEntries(sorted)
return sortedGroups
}) })
</script> </script>
+1 -1
View File
@@ -1,5 +1,5 @@
<template> <template>
<div v-if="authStore.status.show" class="global-status" style="display: block;"> <div v-if="authStore.status.show" class="global-status show">
<div :class="['status', authStore.status.type]"> <div :class="['status', authStore.status.type]">
{{ authStore.status.message }} {{ authStore.status.message }}
</div> </div>
+65 -46
View File
@@ -1,23 +1,38 @@
<template> <template>
<div v-if="userLoaded" class="user-info" :class="{ 'has-extra': $slots.default }"> <div v-if="userLoaded" class="user-info" :class="{ 'has-extra': $slots.default }">
<div class="user-info-content">
<div class="user-picture">
<span>👤</span>
</div>
<h3 class="user-name-heading"> <h3 class="user-name-heading">
<span class="icon">👤</span>
<span class="user-name-row"> <span class="user-name-row">
<span class="display-name" :title="name">{{ name }}</span> <span class="display-name" :title="name">{{ name }}</span>
<button v-if="canEdit && updateEndpoint" class="mini-btn" @click="emit('editName')" title="Edit name"></button> <button v-if="canEdit && updateEndpoint" class="mini-btn" @click="emit('edit')" title="Edit profile"></button>
</span> </span>
</h3> </h3>
<div v-if="orgDisplayName || roleName" class="org-role-sub"> <div v-if="orgDisplayName || roleName" class="org-role-sub">
<div class="org-line" v-if="orgDisplayName">{{ orgDisplayName }}</div> <div class="org-line" v-if="orgDisplayName">{{ orgDisplayName }}</div>
<div class="role-line" v-if="roleName">{{ roleName }}</div> <div class="role-line" v-if="roleName">{{ roleName }}</div>
</div> </div>
<div class="user-details"> <div class="info-fields-block">
<span class="date-label"><strong>Visits:</strong></span> <div v-if="preferred_username" class="contact-item">🆔 {{ preferred_username }}</div>
<span class="date-value">{{ visits || 0 }}</span> <a v-if="email" :href="`mailto:${email}`" class="contact-link"> {{ email }}</a>
<span class="date-label"><strong>Registered:</strong></span> <a v-if="telephone" :href="`tel:${telephone}`" class="contact-link">📞 {{ telephone }}</a>
<span class="date-value">{{ formatDate(createdAt) }}</span> </div>
<span class="date-label"><strong>Last seen:</strong></span> <div class="info-line">
<span class="date-value">{{ formatDate(lastSeen) }}</span> <span v-if="visits">
<span class="info-date">{{ formatDate(createdAt) }}</span>
<span class="info-punct"> </span>
<span class="info-date">{{ formatDate(lastSeen) }}</span>
<span class="info-punct"> ×</span>
<span class="info-count">{{ visits }}</span>
</span>
<span v-else>
<span class="info-label">Created </span>
<span class="info-date">{{ formatDate(createdAt) }}</span>
<span class="info-punct"> Never signed in</span>
</span>
</div>
</div> </div>
<div v-if="$slots.default" class="user-info-extra"> <div v-if="$slots.default" class="user-info-extra">
<slot></slot> <slot></slot>
@@ -26,12 +41,15 @@
</template> </template>
<script setup> <script setup>
import { ref, computed, watch } from 'vue' import { computed } from 'vue'
import { useAuthStore } from '@/stores/auth' import { useAuthStore } from '@/stores/auth'
import { formatDate } from '@/utils/helpers' import { formatDate } from '@/utils/helpers'
const props = defineProps({ const props = defineProps({
name: { type: String, required: true }, name: { type: String, required: true },
email: { type: String, default: null },
preferred_username: { type: String, default: null },
telephone: { type: String, default: null },
visits: { type: [Number, String], default: 0 }, visits: { type: [Number, String], default: 0 },
createdAt: { type: [String, Number, Date], default: null }, createdAt: { type: [String, Number, Date], default: null },
lastSeen: { type: [String, Number, Date], default: null }, lastSeen: { type: [String, Number, Date], default: null },
@@ -42,7 +60,7 @@ const props = defineProps({
roleName: { type: String, default: '' } roleName: { type: String, default: '' }
}) })
const emit = defineEmits(['saved', 'editName']) const emit = defineEmits(['saved', 'edit'])
const authStore = useAuthStore() const authStore = useAuthStore()
const userLoaded = computed(() => !!props.name) const userLoaded = computed(() => !!props.name)
@@ -50,56 +68,57 @@ const userLoaded = computed(() => !!props.name)
<style scoped> <style scoped>
.user-info.has-extra { .user-info.has-extra {
grid-template-columns: auto 1fr 2fr; grid-template-columns: minmax(0, 1fr) 14rem;
grid-template-areas: grid-template-areas:
"heading heading extra" "content extra";
"org org extra" gap: 1.5rem;
"label1 value1 extra"
"label2 value2 extra"
"label3 value3 extra";
} }
.user-info:not(.has-extra) { .user-info:not(.has-extra) {
grid-template-columns: auto 1fr; grid-template-columns: minmax(0, 1fr);
grid-template-areas: grid-template-areas:
"heading heading" "content";
"org org"
"label1 value1"
"label2 value2"
"label3 value3";
} }
@media (max-width: 720px) { @media (max-width: 720px) {
.user-info.has-extra { .user-info.has-extra {
grid-template-columns: auto 1fr; grid-template-columns: 1fr;
grid-template-areas: grid-template-areas:
"heading heading" "content"
"org org" "extra";
"label1 value1"
"label2 value2"
"label3 value3"
"extra extra";
} }
} }
.user-name-heading { grid-area: heading; display: flex; align-items: center; flex-wrap: wrap; margin: 0 0 0.25rem 0; } .user-info-content {
.org-role-sub { grid-area: org; display:flex; flex-direction:column; margin: -0.15rem 0 0.25rem; } grid-area: content;
display: grid;
grid-template-columns: auto minmax(0, 1fr) minmax(0, 1fr);
grid-template-areas:
"picture heading fields"
"picture org fields"
". info info";
gap: 0 1rem;
min-width: 0;
}
.user-picture { grid-area: picture; display: flex; align-items: flex-start; font-size: 2em; line-height: 1; }
.user-name-heading { grid-area: heading; display: flex; align-items: center; flex-wrap: wrap; margin: 0 0 0.25rem 0; min-width: 0; }
.org-role-sub { grid-area: org; display: flex; flex-direction: column; min-width: 0; }
.org-line { font-size: .7rem; font-weight: 600; line-height: 1.1; color: var(--color-text-muted); text-transform: uppercase; letter-spacing: 0.05em; } .org-line { font-size: .7rem; font-weight: 600; line-height: 1.1; color: var(--color-text-muted); text-transform: uppercase; letter-spacing: 0.05em; }
.role-line { font-size: .65rem; color: var(--color-text-muted); line-height: 1.1; } .role-line { font-size: .65rem; color: var(--color-text-muted); line-height: 1.1; }
.info-label:nth-of-type(1) { grid-area: label1; } .info-fields-block { grid-area: fields; display: flex; flex-direction: column; gap: 0.25rem; min-width: 0; }
.info-value:nth-of-type(2) { grid-area: value1; } .contact-item { display: block; color: var(--color-text); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.info-label:nth-of-type(3) { grid-area: label2; } .contact-link { color: var(--color-text); text-decoration: none; display: block; transition: transform 0.1s ease; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.info-value:nth-of-type(4) { grid-area: value2; } .contact-link:hover { transform: scale(1.01); }
.info-label:nth-of-type(5) { grid-area: label3; } .info-line { grid-area: info; line-height: 1.4; font-size: 0.9em; }
.info-value:nth-of-type(6) { grid-area: value3; } .info-date { color: var(--color-text) !important; }
.user-info-extra { grid-area: extra; padding-left: 2rem; border-left: 1px solid var(--color-border); } .info-label { color: var(--color-text) !important; }
.user-name-row { display: inline-flex; align-items: center; gap: 0.35rem; max-width: 100%; } .info-punct { color: var(--color-text-muted) !important; }
.user-name-row.editing { flex: 1 1 auto; } .info-count { color: var(--color-text-muted) !important; }
.display-name { font-weight: 600; font-size: 1.05em; line-height: 1.2; max-width: 14ch; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .user-info-extra { grid-area: extra; padding-left: 1rem; border-left: 1px solid var(--color-border); flex-shrink: 0; }
.name-input { width: auto; flex: 1 1 140px; min-width: 120px; padding: 6px 8px; font-size: 0.9em; border: 1px solid var(--color-border-strong); border-radius: 6px; background: var(--color-surface); color: var(--color-text); } .user-name-row { display: inline-flex; align-items: center; gap: 0.35rem; max-width: 100%; min-width: 0; }
.user-name-heading .name-input { width: auto; } .display-name { font-weight: 600; font-size: 1.05em; line-height: 1.2; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; min-width: 0; }
.name-input:focus { outline: none; border-color: var(--color-accent); box-shadow: var(--focus-ring); } .mini-btn { width: auto; padding: 4px 6px; margin: 0; font-size: 0.75em; line-height: 1; cursor: pointer; background: transparent; }
.mini-btn { width: auto; padding: 4px 6px; margin: 0; font-size: 0.75em; line-height: 1; cursor: pointer; }
.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; }
+5 -4
View File
@@ -8,6 +8,7 @@ export const useAuthStore = defineStore('auth', {
state: () => ({ state: () => ({
// Auth State // Auth State
userInfo: null, // Contains the full user info response: {user, credentials, aaguid_info} userInfo: null, // Contains the full user info response: {user, credentials, aaguid_info}
ctx: null, // Session context from validate
isLoading: false, isLoading: false,
// Settings // Settings
@@ -86,8 +87,8 @@ export const useAuthStore = defineStore('auth', {
}, },
async loadUserInfo() { async loadUserInfo() {
try { try {
this.userInfo = await apiJson('/auth/api/user-info', { method: 'POST' }) this.userInfo = await apiJson('/auth/api/user-info', { method: 'GET' })
updateThemeFromSession(this.userInfo?.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
@@ -103,9 +104,9 @@ export const useAuthStore = defineStore('auth', {
await apiJson(`/auth/api/user/credential/${uuid}`, { method: 'DELETE' }) await apiJson(`/auth/api/user/credential/${uuid}`, { method: 'DELETE' })
await this.loadUserInfo() await this.loadUserInfo()
}, },
async terminateSession(sessionId) { async terminateSession(sessionKey) {
try { try {
const payload = await apiJson(`/auth/api/user/session/${sessionId}`, { method: 'DELETE' }) const payload = await apiJson(`/auth/api/user/session/${sessionKey}`, { method: 'DELETE' })
if (payload?.current_session_terminated) { if (payload?.current_session_terminated) {
sessionStorage.clear() sessionStorage.clear()
location.reload() location.reload()
-32
View File
@@ -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')
}
+7 -2
View File
@@ -54,8 +54,13 @@ export async function register(resetToken = null, displayName = null, onstartreg
} }
} }
export async function authenticate() { export async function authenticate(queryString = null) {
const ws = await aWebSocket(await makeUrl('/auth/ws/authenticate')) // Build URL, optionally appending raw query string (e.g. for OIDC params)
let url = await makeUrl('/auth/ws/authenticate')
if (queryString) {
url += queryString.startsWith('?') ? queryString : `?${queryString}`
}
const ws = await aWebSocket(url)
try { try {
let res = await ws.receive_json() let res = await ws.receive_json()
if (res.status >= 400) throw new Error(res.detail || `Authentication failed: ${res.status}`) if (res.status >= 400) throw new Error(res.detail || `Authentication failed: ${res.status}`)
+13
View File
@@ -16,6 +16,7 @@ export default defineConfig(({ command }) => ({
fastapiVue({ paths: [ fastapiVue({ paths: [
"/auth/api", "/auth/api",
"/auth/ws", "/auth/ws",
"/.well-known/openid-configuration",
// Passphrase links: /auth/word1.word2.word3.word4.word5 // Passphrase links: /auth/word1.word2.word3.word4.word5
"^/auth/[a-z]+\\.[a-z]+\\.[a-z]+\\.[a-z]+\\.[a-z]+$", "^/auth/[a-z]+\\.[a-z]+\\.[a-z]+\\.[a-z]+\\.[a-z]+$",
// Passphrase links: /word1.word2.word3.word4.word5 // Passphrase links: /word1.word2.word3.word4.word5
@@ -46,6 +47,18 @@ export default defineConfig(({ command }) => ({
}) })
} }
}, },
{
name: 'restricted-endpoints-rewrite',
configureServer(server) {
server.middlewares.use((req, _res, next) => {
// Rewrite /auth/restricted/iframe and /auth/restricted/oidc to /auth/restricted/
if (req.url === '/auth/restricted/iframe' || req.url === '/auth/restricted/oidc') {
req.url = '/auth/restricted/'
}
next()
})
}
},
{ {
name: 'serve-examples', name: 'serve-examples',
configureServer(server) { configureServer(server) {
+86
View File
@@ -0,0 +1,86 @@
# OIDC Provider Implementation
OpenID Connect 1.0 provider enabling third-party apps to authenticate users via passkey. Also supports native cookie-based authentication.
## Data Models
**User** — Added: `email`, `preferred_username`
**Session** — Added: `client_uuid` (None = native, set = OIDC)
- `key: bytes` — hashed DB key, never stored raw
- `secret``hash_secret("session", secret)` → DB lookup
- OIDC `sid``base64url.encode(hash_secret("oidc", session.key))`
**OIDClient**`uuid, client_secret_hash, name, redirect_uris`
## Auth Codes (In-Memory Only)
60-second lifetime, auto-cleaned:
```python
from paskia.authcode import AuthCode, OIDC, codes
class AuthCode(msgspec.Struct):
session_key: str # Session DB key
created: datetime
oidc: OIDC | None # Only for OIDC mode
class OIDC(msgspec.Struct):
redirect_uri, scope, nonce, code_challenge, code_challenge_method: str
```
Usage: `code = authcode.store(AuthCode(...))` → later `codes.pop(code, None)`
## Authorization Flows
### OIDC (Authorization Code)
1. `GET /auth/restricted/oidc?client_id=UUID&redirect_uri=...&scope=openid&nonce=...&code_challenge=...`
2. Frontend → WebSocket: `/auth/ws/authenticate?client_id=...&redirect_uri=...&...`
3. Validate client/redirect_uri, authenticate via passkey
4. `db.oidc_login()``(secret, session_key)`
5. Create `AuthCode(session_key, oidc=OIDC(...))` → code
6. Return: `{"redirect_url": "{redirect_uri}?code={code}&state={state}"}`
7. Client exchanges code at `/auth/oidc/token` with `code_verifier` (PKCE S256)
**Token:** `access_token, id_token, refresh_token={secret}, expires_in=3600`
**ID token:** `sub, sid (base64url), name, preferred_username, email, groups`
### Native (Cookie)
1. WebSocket: `/auth/ws/authenticate` (no OIDC params)
2. Authenticate via passkey
3. `db.login()` → secret
4. Create `AuthCode(session_key=secret, oidc=None)` → exchange_code
5. Return: `{"user": "UUID", "exchange_code": "..."}`
6. `POST /auth/api/exchange` with code → sets cookie
## Refresh & Logout
**Refresh:** `POST /auth/oidc/token` with `grant_type=refresh_token&refresh_token={secret}&client_id=...&client_secret=...`
- Looks up session, validates client match
- Extends expiry +24h (sliding window)
- Returns new tokens with same `sid`
**Back-channel logout:** `POST /auth/oidc/backchannel-logout` with `logout_token={jwt}`
- Verify signature, extract `sid` or `sub`
- Delete matched sessions
- Return 200 OK
Discovery: `backchannel_logout_supported: true`
## Endpoints
- `GET /.well-known/openid-configuration` — Discovery
- `GET /auth/oidc/keys` — Keys (EdDSA)
- `POST /auth/oidc/token` — Exchange/refresh
- `GET /auth/oidc/userinfo` — User (bearer token)
- `POST /auth/oidc/backchannel-logout` — Logout
- `POST /auth/api/exchange` — Native auth code → cookie
## Files
**Created:** [paskia/authcode.py](paskia/authcode.py), [paskia/util/crypto.py](paskia/util/crypto.py), [paskia/fastapi/oid.py](paskia/fastapi/oid.py)
**Modified:** [paskia/db/structs.py](paskia/db/structs.py), [paskia/db/operations.py](paskia/db/operations.py), [paskia/fastapi/ws.py](paskia/fastapi/ws.py), [paskia/fastapi/api.py](paskia/fastapi/api.py), [paskia/globals.py](paskia/globals.py), [paskia/fastapi/mainapp.py](paskia/fastapi/mainapp.py)
+1 -1
View File
@@ -68,7 +68,7 @@ The JSON variants set headers automatically, with body and response in JSON.
Normally you use apiJson/apiFetch and they handle this automatically. If you need to wire it yourself, on a 401/403 response that includes `auth.iframe`, call `showAuthIframe(...)` and then retry the original request. Normally you use apiJson/apiFetch and they handle this automatically. If you need to wire it yourself, on a 401/403 response that includes `auth.iframe`, call `showAuthIframe(...)` and then retry the original request.
The backend returns 401/403 responses with the correct URL for proper user feedback. Alternatively you may use `/auth/restricted/#mode=login`, `mode=reauth` or `mode=forbidden` to trigger the UX flow you need. The backend returns 401/403 responses with the correct URL for proper user feedback. Alternatively you may use `/auth/restricted/iframe#mode=login`, `mode=reauth` or `mode=forbidden` to trigger the UX flow you need.
```js ```js
import { showAuthIframe, AuthCancelledError } from 'paskia' import { showAuthIframe, AuthCancelledError } from 'paskia'
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "paskia", "name": "paskia",
"version": "0.1.3", "version": "1.1.0",
"description": "Paskia authentication utilities for JavaScript", "description": "Paskia authentication utilities for JavaScript",
"author": "Leo Vasanko", "author": "Leo Vasanko",
"license": "Unlicense", "license": "Unlicense",
-2
View File
@@ -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'
+1
View File
@@ -57,6 +57,7 @@ export class AuthCancelledError extends Error {
} }
export function holdGlobalBackdrop(): void { export function holdGlobalBackdrop(): void {
injectStyles()
backdropHolders++ backdropHolders++
document.body.classList.add('paskia-backdrop') document.body.classList.add('paskia-backdrop')
} }
+161
View File
@@ -0,0 +1,161 @@
import argparse
import logging
import os
from urllib.parse import urlparse
import msgspec
from fastapi_vue import server
from fastapi_vue.hostutil import parse_endpoints
from paskia.db.jsonl import load_readonly
from paskia.util import startupbox
from paskia.util.hostutil import normalize_origin
from paskia.util.runtime import RuntimeConfig
DEFAULT_PORT = 4401
DEVMODE = os.getenv("PASKIA_DEV") == "1"
EPILOG = """\
Example:
paskia --rp-id example.com --rp-name "Example Corporation" --auth-host auth.example.com
"""
def is_subdomain(sub: str, domain: str) -> bool:
"""Check if sub is a subdomain of domain (or equal)."""
sub_parts = sub.lower().split(".")
domain_parts = domain.lower().split(".")
if len(sub_parts) < len(domain_parts):
return False
return sub_parts[-len(domain_parts) :] == domain_parts
def validate_auth_host(auth_host: str, rp_id: str) -> None:
"""Validate that auth_host is a subdomain of rp_id."""
parsed = urlparse(auth_host if "://" in auth_host else f"//{auth_host}")
host = parsed.hostname or parsed.path
if not host:
raise SystemExit(f"Invalid auth-host: '{auth_host}'")
if not is_subdomain(host, rp_id):
raise SystemExit(
f"auth-host '{auth_host}' is not a subdomain of rp-id '{rp_id}'"
)
def add_common_options(p: argparse.ArgumentParser) -> None:
p.add_argument(
"--rp-id", default="localhost", help="Relying Party ID (default: localhost)"
)
p.add_argument("--rp-name", help="Relying Party name (default: same as rp-id)")
p.add_argument(
"--origin",
action="append",
dest="origins",
metavar="URL",
help="Allowed origin URL(s). May be specified multiple times. If any are specified, only those origins are permitted for WebSocket authentication.",
)
p.add_argument(
"--auth-host",
help=("Dedicated authentication site (optionally with scheme/port)"),
)
p.add_argument(
"--save",
action="store_true",
help="Save the CLI options to database for future runs.",
)
def main():
# Configure logging to remove the "ERROR:root:" prefix
logging.basicConfig(level=logging.INFO, format="%(message)s", force=True)
parser = argparse.ArgumentParser(
prog="paskia",
description="Paskia authentication server",
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog=EPILOG,
)
parser.add_argument(
"-l",
"--listen",
action="append",
metavar="LISTEN",
help=(
"Endpoint to listen on (default: localhost:4401). "
"Forms: host:port port :port [ipv6]:port unix:path /path.sock"
),
)
add_common_options(parser)
args = parser.parse_args()
# Load stored config (read-only, no writes, no global state)
db_path = os.environ.get("PASKIA_DB", f"{args.rp_id}.paskiadb")
config = load_readonly(db_path, rp_id=args.rp_id).config
# Override stored config with CLI args, or clear with empty string
if args.rp_name is not None:
config.rp_name = args.rp_name or None
if args.auth_host is not None:
config.auth_host = args.auth_host or None
if args.origins is not None:
config.origins = None if args.origins == [""] else args.origins
if args.listen is not None:
config.listen = None if args.listen == [""] else args.listen
# Process and normalize auth_host
if config.auth_host:
if "://" not in config.auth_host:
config.auth_host = f"https://{config.auth_host}"
config.auth_host = config.auth_host.rstrip("/")
validate_auth_host(config.auth_host, config.rp_id)
if config.origins:
config.origins.insert(0, config.auth_host) # Ensure first in origins
# Normalize and deduplicate while preserving order
if config.origins:
config.origins = list({normalize_origin(o): ... for o in config.origins})
# Parse first endpoint for site_url fallback
ep = next(iter(parse_endpoints(config.listen, DEFAULT_PORT)), {})
port = ep.get("port")
# Compute site_url and site_path
# Priority: auth_host > origins[0] > PASKIA_VITE_URL > http://localhost:port > https://rp_id
site_path = "/auth/"
if config.auth_host:
site_url, site_path = config.auth_host, "/"
elif config.origins:
site_url = config.origins[0]
elif vite_url := os.environ.get("PASKIA_VITE_URL"):
site_url = vite_url.rstrip("/") # Devserver
elif config.rp_id == "localhost" and port:
site_url = f"http://localhost:{port}" # Backend directly if we can
else:
site_url = f"https://{config.rp_id}" # Assume external reverse proxy
# Build runtime configuration for the server
runtime = RuntimeConfig(
config=config,
site_url=site_url,
site_path=site_path,
save=args.save,
)
startupbox.print_startup_config(runtime)
os.environ["PASKIA_CONFIG"] = msgspec.json.encode(runtime).decode()
# Run the server (spawns processes in dev mode)
dev = {"reload": True, "reload_dirs": ["paskia"]} if DEVMODE else {}
server.run(
"paskia.fastapi.mainapp:app",
listen=config.listen,
default_port=DEFAULT_PORT,
log_level="warning",
access_log=False,
**dev,
)
if __name__ == "__main__":
main()
+13 -1
View File
@@ -30,4 +30,16 @@ def filter(aaguids: Iterable[UUID]) -> dict[str, dict]:
Dictionary mapping AAGUID string to authenticator information for only Dictionary mapping AAGUID string to authenticator information for only
the AAGUIDs that the user has and that we have data for the AAGUIDs that the user has and that we have data for
""" """
return {(s := str(a)): AAGUID[s] for a in aaguids if (s := str(a)) in AAGUID} result = {}
for a in aaguids:
s = str(a)
if s in AAGUID:
info = AAGUID[s].copy()
# Rename icon_light to icon
if "icon_light" in info:
info["icon"] = info.pop("icon_light")
# If icons are the same, set dark to None to save space
if info.get("icon") == info.get("icon_dark"):
info["icon_dark"] = None
result[s] = info
return result
+113
View File
@@ -0,0 +1,113 @@
"""
Authorization code management for OIDC and cookie exchange flows.
Codes are short-lived (60 seconds) and stored in-memory only.
Two separate stores maintain full isolation between OIDC and cookie flows.
"""
from __future__ import annotations
import asyncio
import logging
import secrets
from datetime import UTC, datetime, timedelta
import msgspec
_logger = logging.getLogger(__name__)
# Auth codes expire after this duration
AUTH_CODE_LIFETIME = timedelta(seconds=60)
class OIDCCode(msgspec.Struct):
"""An OIDC authorization code pending token exchange.
PKCE uses S256 only when provided (verified at token exchange).
"""
session_key: str
created: datetime
redirect_uri: str
scope: str
nonce: str | None = None
code_challenge: str | None = None
class CookieCode(msgspec.Struct):
"""A cookie exchange code for setting session cookie after WebSocket auth."""
session_key: str
created: datetime
# Separate stores for each code type
oidc_codes: dict[str, OIDCCode] = {}
cookie_codes: dict[str, CookieCode] = {}
# Background cleanup task
_cleanup_task: asyncio.Task | None = None
async def start():
"""Start the cleanup background task."""
global _cleanup_task
if _cleanup_task is None:
_cleanup_task = asyncio.create_task(_cleanup_loop())
async def stop():
"""Stop the cleanup background task."""
global _cleanup_task
if _cleanup_task:
_cleanup_task.cancel()
try:
await _cleanup_task
except asyncio.CancelledError:
pass
_cleanup_task = None
async def _cleanup_loop():
while True:
try:
await asyncio.sleep(30) # Check every 30 seconds
_cleanup_expired()
except asyncio.CancelledError:
break
except Exception:
_logger.exception("Error in auth code cleanup loop")
def _cleanup_expired():
oldest = datetime.now(UTC) - AUTH_CODE_LIFETIME
for code, auth_code in list(oidc_codes.items()):
if auth_code.created < oldest:
del oidc_codes[code]
for code, auth_code in list(cookie_codes.items()):
if auth_code.created < oldest:
del cookie_codes[code]
def store_oidc(code: OIDCCode) -> str:
"""Store an OIDC authorization code and return the code string."""
token = secrets.token_urlsafe(12)
oidc_codes[token] = code
return token
def consume_oidc(token: str) -> OIDCCode | None:
"""Consume an OIDC code, returning it if valid. Atomic removal."""
return oidc_codes.pop(token, None)
def store_cookie(code: CookieCode) -> str:
"""Store a cookie exchange code and return the code string."""
token = secrets.token_urlsafe(12)
cookie_codes[token] = code
return token
def consume_cookie(token: str) -> CookieCode | None:
"""Consume a cookie exchange code, returning it if valid. Atomic removal."""
return cookie_codes.pop(token, None)
+8 -2
View File
@@ -14,6 +14,7 @@ from uuid import UUID
from paskia import db from paskia import db
from paskia.config import RESET_LIFETIME, SESSION_LIFETIME from paskia.config import RESET_LIFETIME, SESSION_LIFETIME
from paskia.db.structs import ResetToken
from paskia.util import hostutil from paskia.util import hostutil
if TYPE_CHECKING: if TYPE_CHECKING:
@@ -22,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
@@ -33,7 +39,7 @@ def reset_expires() -> datetime:
def get_reset(token: str) -> "ResetToken": def get_reset(token: str) -> "ResetToken":
"""Validate a credential reset token.""" """Validate a credential reset token."""
record = db.get_reset_token(token) record = ResetToken.by_passphrase(token)
if record: if record:
return record return record
raise ValueError("This authentication link is no longer valid.") raise ValueError("This authentication link is no longer valid.")
@@ -41,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)
+26 -26
View File
@@ -6,10 +6,10 @@ including creating default admin user, organization, permissions, and
generating a reset link for initial admin setup. generating a reset link for initial admin setup.
""" """
import asyncio
import logging import logging
from paskia import authsession, db, globals from paskia import authsession, db
from paskia.db.structs import Config
from paskia.util import hostutil from paskia.util import hostutil
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -30,15 +30,18 @@ def _log_reset_link(passphrase: str, message: str | None = None) -> str:
return reset_link return reset_link
async def bootstrap_system() -> None: async def bootstrap_system(config: Config | None = None) -> None:
""" """
Bootstrap the entire system with default data. Bootstrap the entire system with default data.
Uses db.bootstrap() which performs all operations in a single transaction. Uses db.bootstrap() which performs all operations in a single transaction.
The transaction log will show a single "bootstrap" action with all changes. The transaction log will show a single "bootstrap" action with all changes.
Args:
config: Configuration to store (rp_id, rp_name, origins, etc.)
""" """
# Call the single-transaction bootstrap function # Call the single-transaction bootstrap function
reset_passphrase = db.bootstrap() reset_passphrase = db.bootstrap(config=config)
# Log the reset link (this is separate from the transaction log) # Log the reset link (this is separate from the transaction log)
_log_reset_link(reset_passphrase, "✅ Bootstrap completed!") _log_reset_link(reset_passphrase, "✅ Bootstrap completed!")
@@ -52,17 +55,24 @@ async def check_admin_credentials() -> bool:
bool: True if a reset link was created, False if admin already has credentials bool: True if a reset link was created, False if admin already has credentials
""" """
try: try:
# Get permission organizations to find admin users # Find the auth:admin permission
p = next( p = next(
(p for p in db.data().permissions.values() if p.scope == "auth:admin"), None (p for p in db.data().permissions.values() if p.scope == "auth:admin"), None
) )
if not p or not p.orgs: if not p:
return False return False
# Get users from the first organization with admin permission perm_uuid = p.uuid
first_org_uuid = next(iter(p.orgs))
org_users = db.get_organization_users(first_org_uuid) # Find all roles that have the auth:admin permission
admin_users = [user for user, role in org_users if role == "Administration"] admin_roles = [
r for r in db.data().roles.values() if perm_uuid in r.permissions
]
# Collect all users from those roles
admin_users = []
for role in admin_roles:
admin_users.extend(role.users)
if not admin_users: if not admin_users:
return False return False
@@ -70,7 +80,7 @@ async def check_admin_credentials() -> bool:
# Check first admin user for credentials # Check first admin user for credentials
admin_user = admin_users[0] admin_user = admin_users[0]
if not db.get_user_credential_ids(admin_user.uuid): if not admin_user.credential_ids:
# Admin exists but has no credentials, create reset link # Admin exists but has no credentials, create reset link
logger.info("⚠️ Admin user has no credentials!") logger.info("⚠️ Admin user has no credentials!")
@@ -89,10 +99,13 @@ async def check_admin_credentials() -> bool:
return False return False
async def bootstrap_if_needed() -> bool: async def bootstrap_if_needed(config: Config | None = None) -> bool:
""" """
Check if system needs bootstrapping and perform it if necessary. Check if system needs bootstrapping and perform it if necessary.
Args:
config: Configuration to store during bootstrap (rp_id, rp_name, origins, etc.)
Returns: Returns:
bool: True if bootstrapping was performed, False if system was already set up bool: True if bootstrapping was performed, False if system was already set up
""" """
@@ -105,18 +118,5 @@ async def bootstrap_if_needed() -> bool:
# No admin permission found, need to bootstrap # No admin permission found, need to bootstrap
# Bootstrap creates the admin user AND the reset link, so no need to check credentials after # Bootstrap creates the admin user AND the reset link, so no need to check credentials after
await bootstrap_system() await bootstrap_system(config=config)
return True return True
# CLI interface
async def main():
"""Main CLI entry point for bootstrapping."""
# Configure logging for CLI usage
logging.basicConfig(level=logging.INFO, format="%(message)s", force=True)
await globals.init()
if __name__ == "__main__":
asyncio.run(main())
-17
View File
@@ -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
+26 -28
View File
@@ -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)
@@ -26,20 +25,22 @@ from paskia.db.background import (
stop_background, stop_background,
stop_cleanup, stop_cleanup,
) )
from paskia.db.bootstrap import bootstrap
from paskia.db.jsonl import load_readonly
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,
add_permission_to_role, add_permission_to_role,
bootstrap,
cleanup_expired,
create_credential, create_credential,
create_credential_session, create_credential_session,
create_oid_client,
create_org, create_org,
create_permission, create_permission,
create_reset_token, create_reset_token,
create_role, create_role,
create_session,
create_user, create_user,
delete_credential, delete_credential,
delete_oid_client,
delete_org, delete_org,
delete_permission, delete_permission,
delete_reset_token, delete_reset_token,
@@ -47,28 +48,28 @@ from paskia.db.operations import (
delete_session, delete_session,
delete_sessions_for_user, delete_sessions_for_user,
delete_user, delete_user,
get_config, is_username_taken,
get_organization_users,
get_reset_token,
get_user_credential_ids,
get_user_organization,
init,
login, login,
oidc_login,
remove_permission_from_org, remove_permission_from_org,
remove_permission_from_role, remove_permission_from_role,
set_config, reset_oid_client_secret,
set_session_host, set_session_host,
update_config,
update_credential_sign_count, update_credential_sign_count,
update_oid_client,
update_org_name, update_org_name,
update_permission, update_permission,
update_role_name, update_role_name,
update_session, update_session,
update_user_display_name, update_user_display_name,
update_user_info,
update_user_role, update_user_role,
update_user_theme,
) )
from paskia.db.structs import ( from paskia.db.structs import (
DB, DB,
Client,
Config,
Credential, Credential,
Org, Org,
Permission, Permission,
@@ -87,8 +88,10 @@ def data() -> DB:
__all__ = [ __all__ = [
# Types # Types
"Config",
"Credential", "Credential",
"DB", "DB",
"Client",
"Org", "Org",
"Permission", "Permission",
"ResetToken", "ResetToken",
@@ -99,24 +102,13 @@ __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
"get_config",
"get_organization_users",
"get_reset_token",
"get_user_credential_ids",
"get_user_organization",
# Write ops # Write ops
"add_permission_to_org", "add_permission_to_org",
"add_permission_to_role", "add_permission_to_role",
@@ -128,7 +120,6 @@ __all__ = [
"create_permission", "create_permission",
"create_reset_token", "create_reset_token",
"create_role", "create_role",
"create_session",
"create_user", "create_user",
"delete_credential", "delete_credential",
"delete_org", "delete_org",
@@ -139,16 +130,23 @@ __all__ = [
"delete_sessions_for_user", "delete_sessions_for_user",
"delete_user", "delete_user",
"login", "login",
"oidc_login",
"remove_permission_from_org", "remove_permission_from_org",
"remove_permission_from_role", "remove_permission_from_role",
"set_config",
"set_session_host", "set_session_host",
"update_config",
"update_credential_sign_count", "update_credential_sign_count",
"update_org_name", "update_org_name",
"update_permission", "update_permission",
"update_role_name", "update_role_name",
"update_session", "update_session",
"update_user_display_name", "update_user_display_name",
"update_user_info",
"update_user_role", "update_user_role",
"update_user_theme", "is_username_taken",
# OIDC
"create_oid_client",
"update_oid_client",
"reset_oid_client_secret",
"delete_oid_client",
] ]
+11 -5
View File
@@ -8,7 +8,8 @@ import asyncio
import logging import logging
from datetime import UTC, datetime from datetime import UTC, datetime
from paskia.db.operations import _store, cleanup_expired import paskia.db.operations as _ops
from paskia.db.lifecycle import cleanup_expired
FLUSH_INTERVAL = 0.1 # Flush to disk FLUSH_INTERVAL = 0.1 # Flush to disk
CLEANUP_INTERVAL = 1 # Expired item cleanup CLEANUP_INTERVAL = 1 # Expired item cleanup
@@ -20,11 +21,11 @@ _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._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
await _store.flush() await store.flush()
async def _background_loop(): async def _background_loop():
@@ -47,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()
@@ -89,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()
@@ -98,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
+126
View File
@@ -0,0 +1,126 @@
"""
Bootstrap operations for initial system setup.
"""
from datetime import UTC, datetime
import uuid7
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.util.crypto import secret_key
def bootstrap(
org_name: str = "Organization",
admin_name: str = "Admin",
reset_passphrase: str | None = None,
reset_expiry: datetime | None = None,
config: Config | None = None,
) -> str:
"""Bootstrap the entire system in a single transaction.
Creates:
- auth:admin permission (Master Admin)
- auth:org:admin permission (Org Admin)
- Organization with Administration role
- Admin user with Administration role
- Reset token for admin registration
- Config (if provided)
This is the only way to create a new database file.
All data is created atomically - if any step fails, nothing is written.
Args:
org_name: Display name for the organization (default: "Organization")
admin_name: Display name for the admin user (default: "Admin")
reset_passphrase: Passphrase for the reset token (generated if not provided)
reset_expiry: Expiry datetime for the reset token (default: 14 days)
config: Configuration to store (rp_id, rp_name, origins, etc.)
Returns:
The reset passphrase for admin registration.
"""
# Check if system is already bootstrapped
for p in _ops._db.permissions.values():
if p.scope == "auth:admin":
raise ValueError(
"System already bootstrapped (auth:admin permission exists)"
)
# Generate UUIDs upfront
now = datetime.now(UTC)
perm_admin_uuid = uuid7.create(now)
perm_org_admin_uuid = uuid7.create(now)
org_uuid = uuid7.create(now)
role_uuid = uuid7.create(now)
user_uuid = uuid7.create(now)
# Set reset token expiry (passphrase generated by ResetToken.create)
if reset_expiry is None:
reset_expiry = reset_expires()
with _ops._db.transaction("bootstrap"):
# Create auth:admin permission
perm_admin = Permission(
scope="auth:admin",
display_name="Master Admin",
orgs={org_uuid: True}, # Grant to org
)
perm_admin.uuid = perm_admin_uuid
perm_admin.store()
# Create auth:org:admin permission
perm_org_admin = Permission(
scope="auth:org:admin",
display_name="Org Admin",
orgs={org_uuid: True}, # Grant to org
)
perm_org_admin.uuid = perm_org_admin_uuid
perm_org_admin.store()
# Create organization
new_org = Org.create(display_name=org_name)
new_org.uuid = org_uuid
new_org.store()
# Create Administration role with both permissions
admin_role = Role(
org_uuid=org_uuid,
display_name="Administration",
permissions={perm_admin_uuid: True, perm_org_admin_uuid: True},
)
admin_role.uuid = role_uuid
admin_role.store()
# Create admin user
admin_user = User(
display_name=admin_name,
role_uuid=role_uuid,
created_at=now,
last_seen=None,
visits=0,
theme="",
)
admin_user.uuid = user_uuid
admin_user.store()
# Create reset token
reset_token, reset_passphrase = ResetToken.create(
user=user_uuid,
expiry=reset_expiry,
token_type="admin bootstrap",
passphrase=reset_passphrase,
)
reset_token.store()
# Set config if provided
if config is not None:
_ops._db.config = config
# Generate OIDC signing key
_ops._db.oidc.key = secret_key()
return reset_passphrase
+247
View File
@@ -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()}")
+188 -124
View File
@@ -2,8 +2,11 @@
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 signal
from collections import deque from collections import deque
from contextlib import contextmanager from contextlib import contextmanager
from datetime import UTC, datetime from datetime import UTC, datetime
@@ -11,124 +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"})
async def flush_changes(
db_path: Path,
pending_changes: deque[_ChangeRecord],
) -> bool:
"""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)
Returns:
True if flush succeeded, False otherwise
"""
if not pending_changes:
return True
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,
)
pending_changes.clear()
return False
changes_to_write = list(pending_changes)
pending_changes.clear()
try:
lines = [_change_encoder.encode(change) for change in changes_to_write]
if not lines:
return True
async with aiofiles.open(db_path, "ab") as f:
await f.write(b"\n".join(lines) + b"\n")
return True
except OSError:
_logger.exception("Failed to flush database changes")
# Re-queue the changes on failure
for change in reversed(changes_to_write):
pending_changes.appendleft(change)
return False
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"
@@ -140,53 +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, ValueError, msgspec.DecodeError) as e:
raise ValueError(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
@@ -199,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
@@ -214,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) 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(
@@ -237,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! "
@@ -264,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
@@ -282,6 +304,48 @@ class JsonlStore:
self._in_transaction = False self._in_transaction = False
self._transaction_snapshot = None self._transaction_snapshot = None
async def flush(self) -> bool: async def flush(self) -> None:
"""Write all pending changes to disk.""" """Write all pending changes to disk.
return 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()
+47
View File
@@ -0,0 +1,47 @@
"""
Database lifecycle: initialization and maintenance.
"""
import logging
import os
from datetime import UTC, datetime
import paskia.db.operations as _ops
from paskia import oidc_notify
from paskia.authsession import EXPIRES
from paskia.db.jsonl import JsonlStore
_logger = logging.getLogger(__name__)
async def init(rp_id: str, *args, **kwargs):
"""Load database from JSONL file."""
if _ops._db._store:
_logger.debug("Database already initialized, skipping reload")
return
db_path = os.environ.get("PASKIA_DB", f"{rp_id}.paskiadb")
store = JsonlStore(_ops._db, db_path)
await store.load(db_path, rp_id=rp_id)
_ops._db = store.db
_ops._db._store = store
# Request a snapshot after successful startup
store._snapshot.request_force()
def cleanup_expired() -> int:
"""Remove expired sessions and reset tokens. Returns count removed."""
now = datetime.now(UTC)
count = 0
limit = now - EXPIRES
expired_sessions = [k for k, s in _ops._db.sessions.items() if s.validated < limit]
if expired_sessions:
oidc_notify.schedule_notifications(expired_sessions)
with _ops._db.transaction("expiry"):
for k in expired_sessions:
del _ops._db.sessions[k]
count += 1
expired_tokens = [k for k, t in _ops._db.reset_tokens.items() if t.expiry < now]
for k in expired_tokens:
del _ops._db.reset_tokens[k]
count += 1
return count
+217 -69
View File
@@ -3,15 +3,27 @@ Database change logging with pretty-printed diffs.
Provides a logger for JSONL database changes that formats diffs Provides a logger for JSONL database changes that formats diffs
in a human-readable path.notation style with color coding. in a human-readable path.notation style with color coding.
UUIDs are replaced with display names where available, or the full UUID string
for types without display names.
""" """
import logging import logging
import re import re
import sys import sys
from typing import Any from typing import TYPE_CHECKING, Any
from uuid import UUID
if TYPE_CHECKING:
from paskia.db.structs import DB
logger = logging.getLogger("paskia.db") logger = logging.getLogger("paskia.db")
# UUID regex pattern (8-4-4-4-12 hex format)
_UUID_PATTERN = re.compile(
r"^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$"
)
# Pattern to match control characters and bidirectional overrides # Pattern to match control characters and bidirectional overrides
_UNSAFE_CHARS = re.compile( _UNSAFE_CHARS = re.compile(
r"[\x00-\x1f\x7f-\x9f" # C0 and C1 control characters r"[\x00-\x1f\x7f-\x9f" # C0 and C1 control characters
@@ -23,22 +35,123 @@ _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
_USER = "\033[0;34m" # Blue for user display _USER = "\033[0;34m" # Blue for user display
def _use_color() -> bool: def _is_uuid(value: str) -> bool:
"""Check if we should use color output.""" """Check if a string is a UUID."""
return sys.stderr.isatty() return bool(_UUID_PATTERN.match(value))
def _format_value(value: Any, use_color: bool, max_len: int = 60) -> str: class UuidResolver:
"""Format a value for display, truncating if needed.""" """Resolve UUIDs to display names or short suffixes.
Uses the previous state for lookups to show the name before any changes.
"""
def __init__(self, db: "DB | None" = None, previous: dict | None = None):
self._db = db
self._previous = previous
def resolve(self, uuid_str: str) -> str:
"""Resolve a UUID to its display name or the full UUID string."""
display = self._get_display_name(uuid_str)
if display:
return display
return uuid_str
def _get_display_name(self, uuid_str: str) -> str | None:
"""Look up display name for a UUID.
First checks the previous state (to show names before changes),
then falls back to the current database.
"""
# Try previous state first (for showing name before a change)
name = self._lookup_in_previous(uuid_str)
if name:
return name
# Fall back to current database
return self._lookup_in_db(uuid_str)
def _lookup_in_previous(self, uuid_str: str) -> str | None:
"""Look up display name in the previous state dict."""
if not self._previous:
return None
# Check users
if "users" in self._previous and uuid_str in self._previous["users"]:
user_data = self._previous["users"][uuid_str]
if isinstance(user_data, dict) and "display_name" in user_data:
return user_data["display_name"]
# Check orgs
if "orgs" in self._previous and uuid_str in self._previous["orgs"]:
org_data = self._previous["orgs"][uuid_str]
if isinstance(org_data, dict) and "display_name" in org_data:
return org_data["display_name"]
# Check roles
if "roles" in self._previous and uuid_str in self._previous["roles"]:
role_data = self._previous["roles"][uuid_str]
if isinstance(role_data, dict) and "display_name" in role_data:
return role_data["display_name"]
# Check permissions
if (
"permissions" in self._previous
and uuid_str in self._previous["permissions"]
):
perm_data = self._previous["permissions"][uuid_str]
if isinstance(perm_data, dict) and "display_name" in perm_data:
return perm_data["display_name"]
return None
def _lookup_in_db(self, uuid_str: str) -> str | None:
"""Look up display name in the current database."""
if not self._db:
return None
try:
uuid_obj = UUID(uuid_str)
except ValueError:
return None
# Check users
if uuid_obj in self._db.users:
return self._db.users[uuid_obj].display_name
# Check orgs
if uuid_obj in self._db.orgs:
return self._db.orgs[uuid_obj].display_name
# Check roles
if uuid_obj in self._db.roles:
return self._db.roles[uuid_obj].display_name
# Check permissions
if uuid_obj in self._db.permissions:
return self._db.permissions[uuid_obj].display_name
return None
def _format_value(
value: Any,
max_len: int = 60,
resolver: UuidResolver | None = None,
) -> str:
"""Format a value for display, truncating if needed.
If resolver is provided, UUIDs are replaced with display names or short suffixes.
"""
if value is None: if value is None:
return "null" return "null"
@@ -49,6 +162,9 @@ def _format_value(value: Any, use_color: bool, max_len: int = 60) -> str:
return str(value) return str(value)
if isinstance(value, str): if isinstance(value, str):
# Check if it's a UUID and resolve to display name
if resolver and _is_uuid(value):
return resolver.resolve(value)
# Filter out control characters and bidirectional overrides # Filter out control characters and bidirectional overrides
value = _UNSAFE_CHARS.sub("", value) value = _UNSAFE_CHARS.sub("", value)
# Truncate long strings # Truncate long strings
@@ -59,18 +175,24 @@ def _format_value(value: Any, use_color: bool, max_len: int = 60) -> str:
if isinstance(value, dict): if isinstance(value, dict):
if not value: if not value:
return "{}" return "{}"
# For small dicts, show inline # Check if all values are True - render as set-like {key1, key2}
if len(value) == 1: all_true = all(v is True for v in value.values())
k, v = next(iter(value.items())) parts = []
return "{" + f"{k}: {_format_value(v, use_color, max_len=30)}" + "}" for k, v in value.items():
return f"{{...{len(value)} keys}}" # Replace UUID keys with display names
key_display = resolver.resolve(k) if resolver and _is_uuid(k) else k
if all_true:
parts.append(key_display)
else:
val_display = _format_value(v, max_len=30, resolver=resolver)
parts.append(f"{key_display}: {val_display}")
return "{" + ", ".join(parts) + "}"
if isinstance(value, list): if isinstance(value, list):
if not value: if not value:
return "[]" return "[]"
if len(value) == 1: parts = [_format_value(v, max_len=30, resolver=resolver) for v in value]
return "[" + _format_value(value[0], use_color, max_len=30) + "]" return "[" + ", ".join(parts) + "]"
return f"[...{len(value)} items]"
# Fallback for other types # Fallback for other types
text = str(value) text = str(value)
@@ -79,12 +201,18 @@ def _format_value(value: Any, use_color: bool, max_len: int = 60) -> str:
return text return text
def _format_path(path: list[str], use_color: bool) -> str: def _format_path(path: list[str], resolver: UuidResolver | None = None) -> str:
"""Format a path as dot notation with prefix in dark grey, final in default.""" """Format a path as dot notation with prefix in dark grey, final in default.
If resolver is provided, UUIDs in the path are replaced with display names.
"""
if not path: if not path:
return "" return ""
if not use_color:
return ".".join(path) # Replace UUIDs in path with display names
if resolver:
path = [resolver.resolve(p) if _is_uuid(p) else p for p in path]
if len(path) == 1: if len(path) == 1:
return f"{_PATH_FINAL}{path[0]}{_RESET}" return f"{_PATH_FINAL}{path[0]}{_RESET}"
prefix = ".".join(path[:-1]) prefix = ".".join(path[:-1])
@@ -176,16 +304,35 @@ def _collect_changes(
def _format_change_lines( def _format_change_lines(
change_type: str, path: list[str], value: Any, use_color: bool change_type: str,
path: list[str],
value: Any,
resolver: UuidResolver | None = None,
) -> list[str]: ) -> list[str]:
"""Format a single change as one or more lines.""" """Format a single change as one or more lines.
If resolver is provided, UUIDs are replaced with display names.
"""
# Helper to format a value, checking for censored paths
def fmt_value(v: Any, child_path: list[str]) -> str:
if child_path[-2:] == ["oidc", "key"]:
return f"{_SEP}<hidden>{_RESET}"
return _format_value(v, resolver=resolver)
# Helper to format path with UUID replacement
def fmt_path(p: list[str]) -> list[str]:
if resolver:
return [resolver.resolve(x) if _is_uuid(x) else x for x in p]
return p
formatted_path = fmt_path(path)
if change_type == "delete": if change_type == "delete":
if not use_color: if len(formatted_path) == 1:
return [f" {'.'.join(path)}"] return [f" {_DELETE}{formatted_path[0]}{_RESET}"]
if len(path) == 1: prefix = ".".join(formatted_path[:-1])
return [f" {_DELETE}{path[0]}{_RESET}"] final = formatted_path[-1]
prefix = ".".join(path[:-1])
final = path[-1]
return [f" {_PATH_PREFIX}{prefix}.{_RESET}{_DELETE}{final}{_RESET}"] return [f" {_PATH_PREFIX}{prefix}.{_RESET}{_DELETE}{final}{_RESET}"]
if change_type == "add": if change_type == "add":
@@ -194,87 +341,83 @@ def _format_change_lines(
if isinstance(value, dict) and value: if isinstance(value, dict) and value:
lines = [] lines = []
# First line: path with green final element and grey = # First line: path with green final element and grey =
if not use_color: if len(formatted_path) == 1:
lines.append(f" {'.'.join(path)} =") lines.append(f" {_ADD}{formatted_path[0]}{_RESET} {_SEP}={_RESET}")
elif len(path) == 1:
lines.append(f" {_ADD}{path[0]}{_RESET} {_DIM}={_RESET}")
else: else:
prefix = ".".join(path[:-1]) prefix = ".".join(formatted_path[:-1])
final = 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
max_key_len = max(len(k) for k in value.keys()) # Format keys (may contain UUIDs)
field_width = max(max_key_len, 12) # minimum 12 chars formatted_items = []
for k, v in value.items(): for k, v in value.items():
v_str = _format_value(v, use_color) k_display = resolver.resolve(k) if resolver and _is_uuid(k) else k
padding = " " * (field_width - len(k)) v_str = fmt_value(v, path + [k])
if use_color: formatted_items.append((k_display, v_str))
lines.append(f" {k}{_DIM}:{_RESET}{padding} {v_str}") max_key_len = max(len(k) for k, _ in formatted_items)
else: field_width = max(max_key_len, 12) # minimum 12 chars
lines.append(f" {k}:{padding} {v_str}") for k_display, v_str in formatted_items:
padding = " " * (field_width - len(k_display))
lines.append(f" {k_display}{_SEP}:{_RESET}{padding} {v_str}")
return lines return lines
else: else:
value_str = _format_value(value, use_color) value_str = fmt_value(value, path)
if not use_color: if len(formatted_path) == 1:
return [f" {'.'.join(path)} = {value_str}"]
if len(path) == 1:
return [f" {_ADD}{path[0]}{_RESET} {_DIM}={_RESET} {value_str}"]
prefix = ".".join(path[:-1])
final = path[-1]
return [ return [
f" {_PATH_PREFIX}{prefix}.{_RESET}{_ADD}{final}{_RESET} {_DIM}={_RESET} {value_str}" f" {_ADD}{formatted_path[0]}{_RESET} {_SEP}={_RESET} {value_str}"
]
prefix = ".".join(formatted_path[:-1])
final = formatted_path[-1]
return [
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 = _format_value(value, use_color) value_str = fmt_value(value, path)
path_str = _format_path(path, use_color) path_str = _format_path(path, resolver=resolver)
if use_color: return [f" {path_str} {_SEP}={_RESET} {value_str}"]
return [f" {path_str} {_DIM}={_RESET} {value_str}"]
return [f" {path_str} = {value_str}"]
def format_diff(diff: dict, previous: dict | None = None) -> list[str]: def format_diff(
diff: dict, previous: dict | None = None, db: "DB | None" = None
) -> list[str]:
""" """
Format a JSON diff as human-readable lines. Format a JSON diff as human-readable lines.
Args: Args:
diff: The JSON diff dict diff: The JSON diff dict
previous: The previous state dict (for determining add vs update) previous: The previous state dict (for determining add vs update)
db: Optional database for looking up display names
Returns a list of formatted lines (without newlines). Returns a list of formatted lines (without newlines).
Single changes return one line, multiple changes return multiple lines. UUIDs are replaced with display names (using previous state for lookups).
""" """
use_color = _use_color()
changes: list[tuple[str, list[str], Any]] = [] changes: list[tuple[str, list[str], Any]] = []
_collect_changes(diff, [], changes, previous) _collect_changes(diff, [], changes, previous)
if not changes: if not changes:
return [] return []
# Create resolver for UUID replacement (uses previous state for lookups)
resolver = UuidResolver(db, previous)
# Format each change # Format each change
lines = [] lines = []
for change_type, path, value in changes: for change_type, path, value in changes:
lines.extend(_format_change_lines(change_type, path, value, use_color)) lines.extend(_format_change_lines(change_type, path, value, resolver))
return lines return lines
def format_action_header(action: str, user_display: str | None = None) -> str: def format_action_header(action: str, user_display: str | None = None) -> str:
"""Format the action header line.""" """Format the action header line."""
use_color = _use_color()
if use_color:
action_str = f"{_ACTION}{action}{_RESET}" action_str = f"{_ACTION}{action}{_RESET}"
if user_display: if user_display:
user_str = f"{_USER}{user_display}{_RESET}" user_str = f"{_USER}{user_display}{_RESET}"
return f"{action_str} by {user_str}" return f"{action_str} by {user_str}"
return action_str return action_str
else:
if user_display:
return f"{action} by {user_display}"
return action
def log_change( def log_change(
@@ -282,18 +425,23 @@ def log_change(
diff: dict, diff: dict,
user_display: str | None = None, user_display: str | None = None,
previous: dict | None = None, previous: dict | None = None,
db: "DB | None" = None,
) -> None: ) -> None:
""" """
Log a database change with pretty-printed diff. Log a database change with pretty-printed diff.
UUIDs are replaced with display names for readability. For types without
display names, the full UUID string is used.
Args: Args:
action: The action name (e.g., "login", "admin:delete_user") action: The action name (e.g., "login", "admin:delete_user")
diff: The JSON diff dict diff: The JSON diff dict
user_display: Optional display name of the user who performed the action user_display: Optional display name of the user who performed the action
previous: The previous state dict (for determining add vs update) previous: The previous state dict (for determining add vs update)
db: Optional database for looking up display names
""" """
header = format_action_header(action, user_display) header = format_action_header(action, user_display)
diff_lines = format_diff(diff, previous) diff_lines = format_diff(diff, previous, db)
if not diff_lines: if not diff_lines:
logger.info(header) logger.info(header)
+45 -6
View File
@@ -5,19 +5,44 @@ Migrations are applied during database load based on the version field.
Each migration should be idempotent and only run when needed. Each migration should be idempotent and only run when needed.
""" """
import base64
from collections.abc import Awaitable, Callable from collections.abc import Awaitable, Callable
import msgspec
def migrate_v1(d: dict, **kwargs) -> None: from paskia.util.crypto import secret_key
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, ctx: MigrationCtx) -> None:
"""Ensure all users have visits field."""
for user_data in d["users"].values():
user_data.setdefault("visits", 0)
def migrate_v4(d: dict, ctx: MigrationCtx) -> None:
"""OpenID Connect support and hardened session keys."""
# Session keys changed to hashes, drop old sessions
d["sessions"] = {}
# Create OIDC structure with a generated new key
d["oidc"] = {"clients": {}, "key": base64.standard_b64encode(secret_key()).decode()}
migrations = sorted( migrations = sorted(
@@ -28,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)
+263 -323
View File
@@ -6,21 +6,18 @@ Context lookup: _db.session_ctx() returns full SessionContext with effective per
Write operations: Functions that validate and commit, or raise ValueError. Write operations: Functions that validate and commit, or raise ValueError.
""" """
import hashlib
import logging import logging
import os
import secrets import secrets
from datetime import UTC, datetime from datetime import UTC, datetime, timedelta
from uuid import UUID from uuid import UUID
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,
Config, Config,
Credential, Credential,
Org, Org,
@@ -31,92 +28,27 @@ from paskia.db.structs import (
SessionContext, SessionContext,
User, User,
) )
from paskia.util.passphrase import is_well_formed as _is_passphrase from paskia.util.crypto import hash_secret
from paskia.util.nameutil import slugify_name
_logger = logging.getLogger(__name__) _logger = logging.getLogger(__name__)
# Sentinel for distinguishing "not provided" from None
_UNSET = object()
# Global database instance (empty until init() loads data) # Global database instance (empty until init() loads data)
_db = DB() _db = DB(config=Config(rp_id="uninitialized.invalid"))
_store = JsonlStore(_db)
_db._store = _store
_initialized = False
async def init(rp_id: str = "localhost", *args, **kwargs): def is_username_taken(username: str, exclude_uuid: UUID | None = None) -> bool:
"""Load database from JSONL file.""" """Check if a preferred_username is already taken by another user."""
global _db, _initialized if not username:
if _initialized: return False
_logger.debug("Database already initialized, skipping reload") for user in _db.users.values():
return if user.preferred_username == username:
default_path = f"{rp_id}.paskiadb" if exclude_uuid is None or user.uuid != exclude_uuid:
db_path = os.environ.get("PASKIA_DB", default_path) return True
await _store.load(db_path, rp_id=rp_id) return False
_db = _store.db
_initialized = True
# -------------------------------------------------------------------------
# Read/lookup functions
# -------------------------------------------------------------------------
def get_user_organization(user_uuid: UUID) -> tuple[Org, str]:
"""Get the organization a user belongs to and their role name.
Raises ValueError if user not found.
Call sites:
- admin_create_user_registration_link: org only
- admin_get_user_detail: org and role
- admin_update_user_display_name: org only
- admin_delete_user_credential: org only
- admin_delete_user_session: org only
- admin_update_user_role: org only
"""
if user_uuid not in _db.users:
raise ValueError(f"User {user_uuid} not found")
user = _db.users[user_uuid]
role = user.role
return role.org, role.display_name
def get_organization_users(org_uuid: UUID) -> list[tuple[User, str]]:
"""Get all users in an organization with their role names.
Returns list of (User, role_display_name) tuples.
"""
org = _db.orgs[org_uuid]
return [(u, u.role.display_name) for role in org.roles for u in role.users]
def get_user_credential_ids(user_uuid: UUID) -> list[bytes]:
"""Get credential IDs for a user (for WebAuthn exclude lists).
Returns empty list if user has no credentials.
"""
assert user_uuid
return [c.credential_id for c in _db.users[user_uuid].credentials]
def _reset_key(passphrase: str) -> bytes:
"""Hash a passphrase to bytes for reset token storage."""
if not _is_passphrase(passphrase):
raise ValueError(
"Trying to reset with a session token in place of a passphrase"
if len(passphrase) == 16
else "Invalid passphrase format"
)
return hashlib.sha512(passphrase.encode()).digest()[:9]
def get_reset_token(passphrase: str) -> ResetToken | None:
"""Get reset token by passphrase.
Call sites:
- Get reset token to validate it (authsession.py:34)
"""
key = _reset_key(passphrase)
return _db.reset_tokens.get(key)
# ------------------------------------------------------------------------- # -------------------------------------------------------------------------
@@ -124,12 +56,18 @@ def get_reset_token(passphrase: str) -> ResetToken | None:
# ------------------------------------------------------------------------- # -------------------------------------------------------------------------
async def update_config(config: Config) -> None:
"""Update the stored configuration."""
with _db.transaction("update_config"):
_db.config = config
def create_permission(perm: Permission, *, ctx: SessionContext | None = None) -> None: def create_permission(perm: Permission, *, ctx: SessionContext | None = None) -> None:
"""Create a new permission.""" """Create a new permission."""
if perm.uuid in _db.permissions: if perm.uuid in _db.permissions:
raise ValueError(f"Permission {perm.uuid} already exists") raise ValueError(f"Permission {perm.uuid} already exists")
with _db.transaction("admin:create_permission", ctx): with _db.transaction("admin:create_permission", ctx):
_db.permissions[perm.uuid] = perm perm.store()
def update_permission( def update_permission(
@@ -157,10 +95,7 @@ def delete_permission(uuid: UUID, *, ctx: SessionContext | None = None) -> None:
if uuid not in _db.permissions: if uuid not in _db.permissions:
raise ValueError(f"Permission {uuid} not found") raise ValueError(f"Permission {uuid} not found")
with _db.transaction("admin:delete_permission", ctx): with _db.transaction("admin:delete_permission", ctx):
# Remove this permission from all roles _db.permissions[uuid].delete()
for role in _db.roles.values():
role.permissions.pop(uuid, None)
del _db.permissions[uuid]
def create_org(org: Org, *, ctx: SessionContext | None = None) -> None: def create_org(org: Org, *, ctx: SessionContext | None = None) -> None:
@@ -170,13 +105,14 @@ def create_org(org: Org, *, ctx: SessionContext | None = None) -> None:
""" """
if org.uuid in _db.orgs: if org.uuid in _db.orgs:
raise ValueError(f"Organization {org.uuid} already exists") raise ValueError(f"Organization {org.uuid} already exists")
now = datetime.now(UTC)
with _db.transaction("admin:create_org", ctx): with _db.transaction("admin:create_org", ctx):
new_org = Org.create(display_name=org.display_name) new_org = Org.create(display_name=org.display_name, created_at=now)
new_org.uuid = org.uuid new_org.uuid = org.uuid
_db.orgs[org.uuid] = new_org new_org.store()
# Create Administration role with org admin permission # Create Administration role with org admin permission
admin_role_uuid = uuid7.create() admin_role_uuid = uuid7.create(now)
# Find the auth:org:admin permission UUID # Find the auth:org:admin permission UUID
org_admin_perm_uuid = None org_admin_perm_uuid = None
for pid, p in _db.permissions.items(): for pid, p in _db.permissions.items():
@@ -190,7 +126,7 @@ def create_org(org: Org, *, ctx: SessionContext | None = None) -> None:
permissions=role_permissions, permissions=role_permissions,
) )
admin_role.uuid = admin_role_uuid admin_role.uuid = admin_role_uuid
_db.roles[admin_role_uuid] = admin_role admin_role.store()
def update_org_name( def update_org_name(
@@ -211,16 +147,7 @@ def delete_org(uuid: UUID, *, ctx: SessionContext | None = None) -> None:
if uuid not in _db.orgs: if uuid not in _db.orgs:
raise ValueError(f"Organization {uuid} not found") raise ValueError(f"Organization {uuid} not found")
with _db.transaction("admin:delete_org", ctx): with _db.transaction("admin:delete_org", ctx):
org = _db.orgs[uuid] _db.orgs[uuid].delete()
# Remove org from all permissions
for p in _db.permissions.values():
p.orgs.pop(uuid, None)
# Delete roles in this org and their users
for role in org.roles:
for user in role.users:
del _db.users[user.uuid]
del _db.roles[role.uuid]
del _db.orgs[uuid]
def add_permission_to_org( def add_permission_to_org(
@@ -264,7 +191,7 @@ def create_role(role: Role, *, ctx: SessionContext | None = None) -> None:
if role.org_uuid not in _db.orgs: if role.org_uuid not in _db.orgs:
raise ValueError(f"Organization {role.org_uuid} not found") raise ValueError(f"Organization {role.org_uuid} not found")
with _db.transaction("admin:create_role", ctx): with _db.transaction("admin:create_role", ctx):
_db.roles[role.uuid] = role role.store()
def update_role_name( def update_role_name(
@@ -317,7 +244,7 @@ def delete_role(uuid: UUID, *, ctx: SessionContext | None = None) -> None:
if role.users: if role.users:
raise ValueError(f"Cannot delete role {uuid}: users still assigned") raise ValueError(f"Cannot delete role {uuid}: users still assigned")
with _db.transaction("admin:delete_role", ctx): with _db.transaction("admin:delete_role", ctx):
del _db.roles[uuid] _db.roles[uuid].delete()
def create_user(new_user: User, *, ctx: SessionContext | None = None) -> None: def create_user(new_user: User, *, ctx: SessionContext | None = None) -> None:
@@ -327,7 +254,7 @@ def create_user(new_user: User, *, ctx: SessionContext | None = None) -> None:
if new_user.role_uuid not in _db.roles: if new_user.role_uuid not in _db.roles:
raise ValueError(f"Role {new_user.role_uuid} not found") raise ValueError(f"Role {new_user.role_uuid} not found")
with _db.transaction("admin:create_user", ctx): with _db.transaction("admin:create_user", ctx):
_db.users[new_user.uuid] = new_user new_user.store()
def update_user_display_name( def update_user_display_name(
@@ -341,30 +268,103 @@ def update_user_display_name(
The acting user should be logged via ctx. The acting user should be logged via ctx.
For self-service (user updating own name), pass user's ctx. For self-service (user updating own name), pass user's ctx.
For admin operations, pass admin's ctx. For admin operations, pass admin's ctx.
If the user's preferred_username is currently None, this will auto-fill it
with a slugified version of the display name (if unique and non-empty).
""" """
if isinstance(uuid, str): if isinstance(uuid, str):
uuid = UUID(uuid) uuid = UUID(uuid)
if uuid not in _db.users: if uuid not in _db.users:
raise ValueError(f"User {uuid} not found") raise ValueError(f"User {uuid} not found")
display_name = (display_name or "").strip()
if not display_name:
raise ValueError("Display name cannot be empty")
user = _db.users[uuid]
with _db.transaction("update_user_display_name", ctx): with _db.transaction("update_user_display_name", ctx):
_db.users[uuid].display_name = display_name user.display_name = display_name
# Auto-fill preferred_username if not already set
if user.preferred_username is None:
slug = slugify_name(display_name)
if slug and not is_username_taken(slug, exclude_uuid=uuid):
user.preferred_username = slug
def update_user_theme( def update_user_info(
uuid: UUID, uuid: UUID,
theme: str,
*, *,
display_name: str | object = _UNSET,
theme: str | object = _UNSET,
email: str | None | object = _UNSET,
preferred_username: str | None | object = _UNSET,
telephone: str | None | object = _UNSET,
ctx: SessionContext | None = None, ctx: SessionContext | None = None,
) -> None: ) -> None:
"""Update user theme preference ('' for auto, 'light', 'dark').""" """Update user profile information.
Pass only the fields you want to update. Use None to clear optional fields.
This does NOT auto-fill preferred_username - use update_user_display_name
for the registration flow where auto-fill is desired.
Args:
uuid: User UUID
display_name: User display name (cannot be empty)
theme: Theme preference ('' for auto, 'light', 'dark')
email: Email address (None to clear)
preferred_username: Username for OIDC claims (None to clear, must be unique)
telephone: Phone number (None to clear)
ctx: Session context for audit logging
"""
if isinstance(uuid, str): if isinstance(uuid, str):
uuid = UUID(uuid) uuid = UUID(uuid)
if uuid not in _db.users: if uuid not in _db.users:
raise ValueError(f"User {uuid} not found") raise ValueError(f"User {uuid} not found")
user = _db.users[uuid]
# Validate all fields before transaction
if display_name is not _UNSET:
display_name = (display_name or "").strip()
if not display_name:
raise ValueError("Display name cannot be empty")
if theme is not _UNSET:
if theme not in ("", "light", "dark"): if theme not in ("", "light", "dark"):
raise ValueError(f"Invalid theme: {theme}") raise ValueError(f"Invalid theme: {theme}")
with _db.transaction("update_user_theme", ctx):
_db.users[uuid].theme = theme if email is not _UNSET and email is not None:
email = (email or "").strip()
if not email:
email = None
elif "@" not in email or len(email) > 254:
raise ValueError("Invalid email format")
if preferred_username is not _UNSET and preferred_username is not None:
preferred_username = (preferred_username or "").strip()
if not preferred_username:
raise ValueError("Preferred username cannot be empty (use None to clear)")
if len(preferred_username) > 128:
raise ValueError("preferred_username too long")
if is_username_taken(preferred_username, exclude_uuid=uuid):
raise ValueError("Username already taken")
if telephone is not _UNSET and telephone is not None:
telephone = (telephone or "").strip()
if not telephone:
telephone = None
elif len(telephone) > 32:
raise ValueError("telephone too long")
with _db.transaction("update_user_info", ctx):
if display_name is not _UNSET:
user.display_name = display_name
if theme is not _UNSET:
user.theme = theme
if email is not _UNSET:
user.email = email
if preferred_username is not _UNSET:
user.preferred_username = preferred_username
if telephone is not _UNSET:
user.telephone = telephone
def update_user_role( def update_user_role(
@@ -386,19 +386,8 @@ def delete_user(uuid: UUID, *, ctx: SessionContext | None = None) -> None:
"""Delete user and their credentials/sessions.""" """Delete user and their credentials/sessions."""
if uuid not in _db.users: if uuid not in _db.users:
raise ValueError(f"User {uuid} not found") raise ValueError(f"User {uuid} not found")
user = _db.users[uuid]
with _db.transaction("admin:delete_user", ctx): with _db.transaction("admin:delete_user", ctx):
# Delete credentials _db.users[uuid].delete()
for cred in user.credentials:
del _db.credentials[cred.uuid]
# Delete sessions
for sess in user.sessions:
del _db.sessions[sess.key]
# Delete reset tokens (iterate over dict items to get correct keys)
for key, token in list(_db.reset_tokens.items()):
if token.user_uuid == uuid:
del _db.reset_tokens[key]
del _db.users[uuid]
def create_credential(cred: Credential, *, ctx: SessionContext | None = None) -> None: def create_credential(cred: Credential, *, ctx: SessionContext | None = None) -> None:
@@ -408,7 +397,7 @@ def create_credential(cred: Credential, *, ctx: SessionContext | None = None) ->
if cred.user_uuid not in _db.users: if cred.user_uuid not in _db.users:
raise ValueError(f"User {cred.user_uuid} not found") raise ValueError(f"User {cred.user_uuid} not found")
with _db.transaction("create_credential", ctx): with _db.transaction("create_credential", ctx):
_db.credentials[cred.uuid] = cred cred.store()
def update_credential_sign_count( def update_credential_sign_count(
@@ -444,49 +433,15 @@ def delete_credential(
if cred.user_uuid != user_uuid: if cred.user_uuid != user_uuid:
raise ValueError(f"Credential {uuid} does not belong to user {user_uuid}") raise ValueError(f"Credential {uuid} does not belong to user {user_uuid}")
with _db.transaction("delete_credential", ctx): with _db.transaction("delete_credential", ctx):
# Delete all sessions using this credential cred.delete()
for sess in cred.sessions:
print(sess, repr(sess.key))
del _db.sessions[sess.key]
del _db.credentials[uuid]
def create_session(
user_uuid: UUID,
credential_uuid: UUID,
host: str,
ip: str,
user_agent: str,
expiry: datetime,
*,
ctx: SessionContext | None = None,
) -> str:
"""Create a new session. Returns the session key."""
if user_uuid not in _db.users:
raise ValueError(f"User {user_uuid} not found")
if credential_uuid not in _db.credentials:
raise ValueError(f"Credential {credential_uuid} not found")
session = Session.create(
user=user_uuid,
credential=credential_uuid,
host=host,
ip=ip,
user_agent=user_agent,
expiry=expiry,
)
if session.key in _db.sessions:
raise ValueError("Session already exists")
with _db.transaction("create_session", ctx):
_db.sessions[session.key] = session
return session.key
def update_session( def update_session(
key: str, key: bytes,
host: str | None = None, host: str | None = None,
ip: str | None = None, ip: str | None = None,
user_agent: str | None = None, user_agent: str | None = None,
expiry: datetime | None = None, validated: datetime | None = None,
*, *,
ctx: SessionContext | None = None, ctx: SessionContext | None = None,
) -> None: ) -> None:
@@ -501,11 +456,13 @@ def update_session(
s.ip = ip s.ip = ip
if user_agent is not None: if user_agent is not None:
s.user_agent = user_agent s.user_agent = user_agent
if expiry is not None: if validated is not None:
s.expiry = expiry s.validated = validated
def set_session_host(key: str, host: str, *, ctx: SessionContext | None = None) -> None: def set_session_host(
key: bytes, host: str, *, ctx: SessionContext | None = None
) -> None:
"""Set the host for a session (first-time binding).""" """Set the host for a session (first-time binding)."""
update_session(key, host=host, ctx=ctx) update_session(key, host=host, ctx=ctx)
@@ -521,8 +478,10 @@ 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")
oidc_notify.schedule_notifications([key])
with _db.transaction(action, ctx): with _db.transaction(action, ctx):
del _db.sessions[key] _db.sessions[key].delete()
def delete_sessions_for_user( def delete_sessions_for_user(
@@ -537,9 +496,12 @@ 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
keys = [s.key for s in user.sessions]
oidc_notify.schedule_notifications(keys)
with _db.transaction("admin:delete_sessions_for_user", ctx): with _db.transaction("admin:delete_sessions_for_user", ctx):
for sess in user.sessions: for sess in user.sessions:
del _db.sessions[sess.key] sess.delete()
def create_reset_token( def create_reset_token(
@@ -569,7 +531,7 @@ def create_reset_token(
if token.key in _db.reset_tokens: if token.key in _db.reset_tokens:
raise ValueError("Reset token already exists") raise ValueError("Reset token already exists")
with _db.transaction("create_reset_token", ctx, user=user): with _db.transaction("create_reset_token", ctx, user=user):
_db.reset_tokens[token.key] = token token.store()
return passphrase return passphrase
@@ -578,28 +540,7 @@ def delete_reset_token(key: bytes, *, ctx: SessionContext | None = None) -> None
if key not in _db.reset_tokens: if key not in _db.reset_tokens:
raise ValueError("Reset token not found") raise ValueError("Reset token not found")
with _db.transaction("delete_reset_token", ctx): with _db.transaction("delete_reset_token", ctx):
del _db.reset_tokens[key] _db.reset_tokens[key].delete()
# -------------------------------------------------------------------------
# Cleanup (called by background task)
# -------------------------------------------------------------------------
def cleanup_expired() -> int:
"""Remove expired sessions and reset tokens. Returns count removed."""
now = datetime.now(UTC)
count = 0
with _db.transaction("expiry"):
expired_sessions = [k for k, s in _db.sessions.items() if s.expiry < now]
for k in expired_sessions:
del _db.sessions[k]
count += 1
expired_tokens = [k for k, t in _db.reset_tokens.items() if t.expiry < now]
for k in expired_tokens:
del _db.reset_tokens[k]
count += 1
return count
# ------------------------------------------------------------------------- # -------------------------------------------------------------------------
@@ -607,11 +548,6 @@ def cleanup_expired() -> int:
# ------------------------------------------------------------------------- # -------------------------------------------------------------------------
def _create_token() -> str:
"""Generate a 16-character URL-safe session token."""
return secrets.token_urlsafe(12)
def login( def login(
user_uuid: UUID, user_uuid: UUID,
credential_uuid: UUID, credential_uuid: UUID,
@@ -619,7 +555,7 @@ def login(
host: str, host: str,
ip: str, ip: str,
user_agent: str, user_agent: str,
expiry: datetime, duration: timedelta = SESSION_LIFETIME,
) -> str: ) -> str:
"""Update user/credential on login and create session in a single transaction. """Update user/credential on login and create session in a single transaction.
@@ -639,25 +575,51 @@ def login(
if credential_uuid not in _db.credentials: if credential_uuid not in _db.credentials:
raise ValueError(f"Credential {credential_uuid} not found") raise ValueError(f"Credential {credential_uuid} not found")
# Generate token and derive key
token = secrets.token_urlsafe(12)
session = Session.create( session = Session.create(
user=user_uuid, user=user_uuid,
credential=credential_uuid, credential=credential_uuid,
key=hash_secret("cookie", token),
host=host, host=host,
ip=ip, ip=ip,
user_agent=user_agent, user_agent=user_agent,
expiry=expiry, validated=now,
) )
user_str = str(user_uuid) user_str = str(user_uuid)
with _db.transaction("login", user=user_str): with _db.transaction("login", user=user_str):
# Update user session.store(now)
_db.users[user_uuid].last_seen = now # Update credential
_db.users[user_uuid].visits += 1 _db.credentials[credential_uuid].sign_count = sign_count
_db.credentials[credential_uuid].last_used = now
return token
def oidc_login(
session: Session,
credential_uuid: UUID,
sign_count: int,
) -> None:
"""Store an OIDC session and update credential in a single transaction.
The caller is responsible for generating the token, deriving the key,
and creating the Session object. This function only handles the
database transaction.
Updates:
- user.last_seen, user.visits
- credential.sign_count, credential.last_used
Stores:
- the provided session
"""
now = datetime.now(UTC)
user_str = str(session.user_uuid)
with _db.transaction("oidc_login", user=user_str):
session.store(now)
# Update credential # Update credential
_db.credentials[credential_uuid].sign_count = sign_count _db.credentials[credential_uuid].sign_count = sign_count
_db.credentials[credential_uuid].last_used = now _db.credentials[credential_uuid].last_used = now
# Create session
_db.sessions[session.key] = session
return session.key
def create_credential_session( def create_credential_session(
@@ -681,18 +643,22 @@ def create_credential_session(
""" """
now = datetime.now(UTC) now = datetime.now(UTC)
expiry = now + SESSION_LIFETIME
if user_uuid not in _db.users: if user_uuid not in _db.users:
raise ValueError(f"User {user_uuid} not found") raise ValueError(f"User {user_uuid} not found")
# Generate token and derive key
token = secrets.token_urlsafe(12)
key = hash_secret("cookie", token)
session = Session.create( session = Session.create(
user=user_uuid, user=user_uuid,
credential=credential.uuid, credential=credential.uuid,
key=key,
host=host, host=host,
ip=ip, ip=ip,
user_agent=user_agent, user_agent=user_agent,
expiry=expiry, validated=now,
) )
user_str = str(user_uuid) user_str = str(user_uuid)
with _db.transaction("create_credential_session", user=user_str): with _db.transaction("create_credential_session", user=user_str):
@@ -700,141 +666,115 @@ def create_credential_session(
if display_name: if display_name:
_db.users[user_uuid].display_name = display_name _db.users[user_uuid].display_name = display_name
# Create credential # Align credential timestamps with transaction time
_db.credentials[credential.uuid] = credential credential.created_at = now
credential.last_used = now
credential.last_verified = now
# Create session # Create credential
_db.sessions[session.key] = session credential.store()
# Store session and record visit
session.store(now)
# Delete reset token if provided # Delete reset token if provided
if reset_key: if reset_key:
if reset_key in _db.reset_tokens: reset_token = _db.reset_tokens.get(reset_key)
del _db.reset_tokens[reset_key] if reset_token:
return session.key reset_token.delete()
return token
# ------------------------------------------------------------------------- # -------------------------------------------------------------------------
# Bootstrap (single transaction for initial system setup) # OIDC Provider operations
# ------------------------------------------------------------------------- # -------------------------------------------------------------------------
def bootstrap( def create_oid_client(client: Client, *, ctx: SessionContext | None = None) -> None:
org_name: str = "Organization", """Create a new OIDC client."""
admin_name: str = "Admin", if client.uuid in _db.oidc.clients:
reset_passphrase: str | None = None, raise ValueError(f"OIDC client {client.uuid} already exists")
reset_expiry: datetime | None = None, with _db.transaction("admin:create_oid_client", ctx):
) -> str: _db.oidc.clients[client.uuid] = client
"""Bootstrap the entire system in a single transaction.
Creates:
- auth:admin permission (Master Admin)
- auth:org:admin permission (Org Admin)
- Organization with Administration role
- Admin user with Administration role
- Reset token for admin registration
This is the only way to create a new database file. def update_oid_client(
All data is created atomically - if any step fails, nothing is written. client_uuid: UUID,
name: str | None = None,
redirect_uris: list[str] | None = None,
secret_hash: bytes | None = None,
backchannel_logout_uri: str | None = _UNSET,
*,
ctx: SessionContext | None = None,
) -> None:
"""Update an OIDC client's name, redirect URIs, and/or secret."""
if client_uuid not in _db.oidc.clients:
raise ValueError(f"OIDC client {client_uuid} not found")
Args: client = _db.oidc.clients[client_uuid]
org_name: Display name for the organization (default: "Organization") changes = {}
admin_name: Display name for the admin user (default: "Admin")
reset_passphrase: Passphrase for the reset token (generated if not provided)
reset_expiry: Expiry datetime for the reset token (default: 14 days)
Returns: if name is not None and name != client.name:
The reset passphrase for admin registration. changes["name"] = name
""" if redirect_uris is not None and redirect_uris != client.redirect_uris:
changes["redirect_uris"] = redirect_uris
if secret_hash is not None and secret_hash != client.client_secret_hash:
changes["client_secret_hash"] = secret_hash
if (
backchannel_logout_uri is not _UNSET
and backchannel_logout_uri != client.backchannel_logout_uri
):
changes["backchannel_logout_uri"] = backchannel_logout_uri
# Check if system is already bootstrapped if not changes:
for p in _db.permissions.values(): return # No changes to make
if p.scope == "auth:admin":
raise ValueError( new_logout_uri = (
"System already bootstrapped (auth:admin permission exists)" backchannel_logout_uri
if backchannel_logout_uri is not _UNSET
else client.backchannel_logout_uri
) )
# Generate UUIDs upfront with _db.transaction("admin:update_oid_client", ctx):
perm_admin_uuid = uuid7.create() # Create updated client with new values
perm_org_admin_uuid = uuid7.create() updated_client = Client(
org_uuid = uuid7.create() client_secret_hash=secret_hash
role_uuid = uuid7.create() if secret_hash is not None
user_uuid = uuid7.create() else client.client_secret_hash,
name=name if name is not None else client.name,
# Set reset token expiry (passphrase generated by ResetToken.create) redirect_uris=redirect_uris
if reset_expiry is None: if redirect_uris is not None
from paskia.authsession import reset_expires # noqa: PLC0415 else client.redirect_uris,
backchannel_logout_uri=new_logout_uri,
reset_expiry = reset_expires()
now = datetime.now(UTC)
with _db.transaction("bootstrap"):
# Create auth:admin permission
perm_admin = Permission(
scope="auth:admin",
display_name="Master Admin",
orgs={org_uuid: True}, # Grant to org
) )
perm_admin.uuid = perm_admin_uuid updated_client.uuid = client.uuid
_db.permissions[perm_admin_uuid] = perm_admin _db.oidc.clients[client_uuid] = updated_client
# Create auth:org:admin permission
perm_org_admin = Permission( def reset_oid_client_secret(
scope="auth:org:admin", client_uuid: UUID,
display_name="Org Admin", new_secret_hash: bytes,
orgs={org_uuid: True}, # Grant to org *,
ctx: SessionContext | None = None,
) -> None:
"""Reset an OIDC client's secret."""
if client_uuid not in _db.oidc.clients:
raise ValueError(f"OIDC client {client_uuid} not found")
client = _db.oidc.clients[client_uuid]
with _db.transaction("admin:reset_oid_client_secret", ctx):
updated = Client(
client_secret_hash=new_secret_hash,
name=client.name,
redirect_uris=client.redirect_uris,
backchannel_logout_uri=client.backchannel_logout_uri,
) )
perm_org_admin.uuid = perm_org_admin_uuid updated.uuid = client.uuid
_db.permissions[perm_org_admin_uuid] = perm_org_admin _db.oidc.clients[client_uuid] = updated
# Create organization
new_org = Org.create(display_name=org_name)
new_org.uuid = org_uuid
_db.orgs[org_uuid] = new_org
# Create Administration role with both permissions
admin_role = Role(
org_uuid=org_uuid,
display_name="Administration",
permissions={perm_admin_uuid: True, perm_org_admin_uuid: True},
)
admin_role.uuid = role_uuid
_db.roles[role_uuid] = admin_role
# Create admin user
admin_user = User(
display_name=admin_name,
role_uuid=role_uuid,
created_at=now,
last_seen=None,
visits=0,
)
admin_user.uuid = user_uuid
_db.users[user_uuid] = admin_user
# Create reset token
reset_token, reset_passphrase = ResetToken.create(
user=user_uuid,
expiry=reset_expiry,
token_type="admin bootstrap",
passphrase=reset_passphrase,
)
_db.reset_tokens[reset_token.key] = reset_token
return reset_passphrase
# ------------------------------------------------------------------------- def delete_oid_client(client_uuid: UUID, *, ctx: SessionContext | None = None) -> None:
# Config operations """Delete an OIDC client."""
# ------------------------------------------------------------------------- if client_uuid not in _db.oidc.clients:
raise ValueError(f"OIDC client {client_uuid} not found")
with _db.transaction("admin:delete_oid_client", ctx):
def get_config() -> Config: del _db.oidc.clients[client_uuid]
"""Get the stored configuration."""
return _db.config
async def set_config(config: Config) -> None:
"""Update the stored configuration."""
async with _db.transaction("update_config"):
_db.config = config
+88
View File
@@ -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
+245 -34
View File
@@ -7,11 +7,10 @@ from uuid import UUID
import msgspec import msgspec
import uuid7 import uuid7
from msgspec import field
from paskia import db from paskia import db
from paskia.util.hostutil import normalize_host from paskia.util import passphrase as passphrase_util
from paskia.util.passphrase import generate as generate_passphrase from paskia.util.crypto import hash_secret
# Sentinel for uuid fields before they are set by create() or DB post init # Sentinel for uuid fields before they are set by create() or DB post init
_UUID_UNSET = UUID(int=0) _UUID_UNSET = UUID(int=0)
@@ -48,20 +47,36 @@ class Permission(msgspec.Struct, dict=True, omit_defaults=True):
if org_uuid in db.data().orgs if org_uuid in db.data().orgs
] ]
def store(self) -> None:
"""Store this permission in the database. Must be called inside a transaction."""
db.data().permissions[self.uuid] = self
def delete(self) -> None:
"""Delete this permission and remove it from all roles.
Must be called inside a transaction.
"""
_data = db.data()
for role in _data.roles.values():
role.permissions.pop(self.uuid, None)
del _data.permissions[self.uuid]
@classmethod @classmethod
def create( def create(
cls, cls,
scope: str, scope: str,
display_name: str, display_name: str,
domain: str | None = None, domain: str | None = None,
created_at: datetime | None = None,
) -> Permission: ) -> Permission:
"""Create a new Permission with auto-generated uuid7.""" """Create a new Permission with auto-generated uuid7."""
now = created_at or datetime.now(UTC)
perm = cls( perm = cls(
scope=scope, scope=scope,
display_name=display_name, display_name=display_name,
domain=domain, domain=domain,
) )
perm.uuid = uuid7.create() perm.uuid = uuid7.create(now)
return perm return perm
@@ -84,11 +99,30 @@ class Org(msgspec.Struct, dict=True):
"""Get all permissions that this organization can grant.""" """Get all permissions that this organization can grant."""
return [p for p in db.data().permissions.values() if self.uuid in p.orgs] return [p for p in db.data().permissions.values() if self.uuid in p.orgs]
def store(self) -> None:
"""Store this organization in the database. Must be called inside a transaction."""
db.data().orgs[self.uuid] = self
def delete(self) -> None:
"""Delete this org and cascade to roles, users. Remove from permissions.
Must be called inside a transaction.
"""
_data = db.data()
for p in _data.permissions.values():
p.orgs.pop(self.uuid, None)
for role in self.roles:
for user in role.users:
del _data.users[user.uuid]
del _data.roles[role.uuid]
del _data.orgs[self.uuid]
@classmethod @classmethod
def create(cls, display_name: str) -> Org: def create(cls, display_name: str, created_at: datetime | None = None) -> Org:
"""Create a new Org with auto-generated uuid7.""" """Create a new Org with auto-generated uuid7."""
now = created_at or datetime.now(UTC)
org = cls(display_name=display_name) org = cls(display_name=display_name)
org.uuid = uuid7.create() org.uuid = uuid7.create(now)
return org return org
@@ -132,28 +166,38 @@ class Role(msgspec.Struct, dict=True, omit_defaults=True):
"""Get all users that have this role.""" """Get all users that have this role."""
return [u for u in db.data().users.values() if u.role_uuid == self.uuid] return [u for u in db.data().users.values() if u.role_uuid == self.uuid]
def store(self) -> None:
"""Store this role in the database. Must be called inside a transaction."""
db.data().roles[self.uuid] = self
def delete(self) -> None:
"""Delete this role from the database. Must be called inside a transaction."""
del db.data().roles[self.uuid]
@classmethod @classmethod
def create( def create(
cls, cls,
org: UUID | Org, org: UUID | Org,
display_name: str, display_name: str,
permissions: set[UUID] | None = None, permissions: set[UUID] | None = None,
created_at: datetime | None = None,
) -> Role: ) -> Role:
"""Create a new Role with auto-generated uuid7.""" """Create a new Role with auto-generated uuid7."""
now = created_at or datetime.now(UTC)
org_uuid = org if isinstance(org, UUID) else org.uuid org_uuid = org if isinstance(org, UUID) else org.uuid
role = cls( role = cls(
org_uuid=org_uuid, org_uuid=org_uuid,
display_name=display_name, display_name=display_name,
permissions={p: True for p in (permissions or set())}, permissions={p: True for p in (permissions or set())},
) )
role.uuid = uuid7.create() role.uuid = uuid7.create(now)
return role return role
class User(msgspec.Struct, dict=True, omit_defaults=True): class User(msgspec.Struct, dict=True, omit_defaults=True, kw_only=True):
"""User data structure. """User data structure.
Mutable fields: display_name, role_uuid, last_seen, visits, theme Mutable fields: display_name, role_uuid, last_seen, visits, theme, email, preferred_username
Immutable fields: created_at (set at creation, never modified) Immutable fields: created_at (set at creation, never modified)
uuid is derived from created_at using uuid7. uuid is derived from created_at using uuid7.
""" """
@@ -161,9 +205,12 @@ class User(msgspec.Struct, dict=True, omit_defaults=True):
display_name: str display_name: str
role_uuid: UUID = msgspec.field(name="role") role_uuid: UUID = msgspec.field(name="role")
created_at: datetime created_at: datetime
visits: int
last_seen: datetime | None = None last_seen: datetime | None = None
visits: int = 0 theme: str = ""
theme: str = "" # "" or "auto" = OS default, "light", "dark" email: str | None = None # OIDC email claim
preferred_username: str | None = None # OIDC preferred_username claim
telephone: str | None = None # Telephone number
def __post_init__(self): def __post_init__(self):
if not hasattr(self, "uuid"): if not hasattr(self, "uuid"):
@@ -184,6 +231,11 @@ class User(msgspec.Struct, dict=True, omit_defaults=True):
"""Get all credentials for this user.""" """Get all credentials for this user."""
return [c for c in db.data().credentials.values() if c.user_uuid == self.uuid] return [c for c in db.data().credentials.values() if c.user_uuid == self.uuid]
@property
def credential_ids(self) -> list[bytes]:
"""Get credential IDs for this user (for WebAuthn exclude lists)."""
return [c.credential_id for c in self.credentials]
@property @property
def sessions(self) -> list[Session]: def sessions(self) -> list[Session]:
"""Get all sessions for this user.""" """Get all sessions for this user."""
@@ -194,6 +246,24 @@ class User(msgspec.Struct, dict=True, omit_defaults=True):
"""Get all reset tokens for this user.""" """Get all reset tokens for this user."""
return [t for t in db.data().reset_tokens.values() if t.user_uuid == self.uuid] return [t for t in db.data().reset_tokens.values() if t.user_uuid == self.uuid]
def store(self) -> None:
"""Store this user in the database. Must be called inside a transaction."""
db.data().users[self.uuid] = self
def delete(self) -> None:
"""Delete this user and cascade to credentials, sessions, reset tokens.
Must be called inside a transaction.
"""
_data = db.data()
for cred in self.credentials:
del _data.credentials[cred.uuid]
for sess in self.sessions:
del _data.sessions[sess.key]
for token in self.reset_tokens:
del _data.reset_tokens[token.key]
del _data.users[self.uuid]
@classmethod @classmethod
def create( def create(
cls, cls,
@@ -207,6 +277,9 @@ class User(msgspec.Struct, dict=True, omit_defaults=True):
display_name=display_name, display_name=display_name,
role_uuid=role_uuid, role_uuid=role_uuid,
created_at=created_at or datetime.now(UTC), created_at=created_at or datetime.now(UTC),
last_seen=None,
visits=0,
theme="",
) )
user.uuid = uuid7.create(user.created_at) user.uuid = uuid7.create(user.created_at)
return user return user
@@ -245,6 +318,20 @@ class Credential(msgspec.Struct, dict=True):
s for s in db.data().sessions.values() if s.credential_uuid == self.uuid s for s in db.data().sessions.values() if s.credential_uuid == self.uuid
] ]
def store(self) -> None:
"""Store this credential in the database. Must be called inside a transaction."""
db.data().credentials[self.uuid] = self
def delete(self) -> None:
"""Delete this credential and all its sessions.
Must be called inside a transaction.
"""
_data = db.data()
for sess in self.sessions:
del _data.sessions[sess.key]
del _data.credentials[self.uuid]
@classmethod @classmethod
def create( def create(
cls, cls,
@@ -272,12 +359,17 @@ class Credential(msgspec.Struct, dict=True):
return cred return cred
class Session(msgspec.Struct, dict=True): class Session(msgspec.Struct, dict=True, omit_defaults=True):
"""Session data structure. """Session data structure.
Mutable fields: expiry (updated on session refresh) Mutable fields: validated (updated on session refresh)
Immutable fields: user_uuid, credential_uuid, host, ip, user_agent Immutable fields: user_uuid, credential_uuid, host, ip, user_agent, client_uuid
key is stored in the dict key, not in the struct. key is the hashed db_key, stored in the dict key, not in the struct.
If client_uuid is set, this is an OIDC session.
Security: The database stores only derived keys, never the raw secret.
A database leak does not expose working session credentials.
""" """
user_uuid: UUID = msgspec.field(name="user") user_uuid: UUID = msgspec.field(name="user")
@@ -285,7 +377,8 @@ class Session(msgspec.Struct, dict=True):
host: str host: str
ip: str ip: str
user_agent: str user_agent: str
expiry: datetime validated: datetime
client_uuid: UUID | None = msgspec.field(name="client", default=None)
def __post_init__(self): def __post_init__(self):
if not hasattr(self, "key"): if not hasattr(self, "key"):
@@ -306,33 +399,60 @@ class Session(msgspec.Struct, dict=True):
return { return {
"ip": self.ip, "ip": self.ip,
"user_agent": self.user_agent, "user_agent": self.user_agent,
"expiry": self.expiry.isoformat(), "validated": self.validated.isoformat(),
} }
def store(self, last_seen: datetime) -> None:
"""Store this session in the database and record a visit.
Updates user.last_seen and user.visits. Must be called inside
a database transaction.
"""
_data = db.data()
_data.sessions[self.key] = self
_data.users[self.user_uuid].last_seen = last_seen
_data.users[self.user_uuid].visits += 1
def delete(self) -> None:
"""Delete this session from the database. Must be called inside a transaction."""
del db.data().sessions[self.key]
@classmethod @classmethod
def create( def create(
cls, cls,
user: UUID | User, user: UUID | User,
credential: UUID | Credential, credential: UUID | Credential,
key: str,
host: str, host: str,
ip: str, ip: str,
user_agent: str, user_agent: str,
expiry: datetime, validated: datetime,
client: UUID | None = None,
) -> Session: ) -> Session:
"""Create a new Session with auto-generated key.""" """Create a new Session with the provided key.
Args:
key: The hashed session key (derived from secret via hash_secret)
Returns:
Session object with key set
"""
user_uuid = user if isinstance(user, UUID) else user.uuid user_uuid = user if isinstance(user, UUID) else user.uuid
credential_uuid = ( credential_uuid = (
credential if isinstance(credential, UUID) else credential.uuid credential if isinstance(credential, UUID) else credential.uuid
) )
session = cls( session = cls(
user_uuid=user_uuid, user_uuid=user_uuid,
credential_uuid=credential_uuid, credential_uuid=credential_uuid,
host=host, host=host,
ip=ip, ip=ip,
user_agent=user_agent, user_agent=user_agent,
expiry=expiry, validated=validated,
client_uuid=client,
) )
session.key = secrets.token_urlsafe(12) session.key = key
return session return session
@@ -349,13 +469,38 @@ 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:
"""Get the User object for this reset token.""" """Get the User object for this reset token."""
return db.data().users[self.user_uuid] return db.data().users[self.user_uuid]
def store(self) -> None:
"""Store this reset token in the database. Must be called inside a transaction."""
db.data().reset_tokens[self.key] = self
def delete(self) -> None:
"""Delete this reset token from the database. Must be called inside a transaction."""
del db.data().reset_tokens[self.key]
@staticmethod
def hash(passphrase: str) -> str:
"""Hash a passphrase to string for reset token storage."""
if not passphrase_util.is_well_formed(passphrase):
raise ValueError(
"Trying to reset with a session token in place of a passphrase"
if len(passphrase) == 16
else "Invalid passphrase format"
)
return hash_secret("reset", passphrase)
@classmethod
def by_passphrase(cls, passphrase: str) -> ResetToken | None:
"""Get a reset token by passphrase."""
key = cls.hash(passphrase)
return db.data().reset_tokens.get(key)
@classmethod @classmethod
def create( def create(
cls, cls,
@@ -377,8 +522,8 @@ class ResetToken(msgspec.Struct, dict=True):
code to give to the user. code to give to the user.
""" """
if passphrase is None: if passphrase is None:
passphrase = generate_passphrase() passphrase = passphrase_util.generate()
key = hashlib.sha512(passphrase.encode()).digest()[:9] key = cls.hash(passphrase)
user_uuid = user if isinstance(user, UUID) else user.uuid user_uuid = user if isinstance(user, UUID) else user.uuid
token = cls( token = cls(
user_uuid=user_uuid, user_uuid=user_uuid,
@@ -389,6 +534,58 @@ class ResetToken(msgspec.Struct, dict=True):
return token, passphrase return token, passphrase
# -------------------------------------------------------------------------
# OIDC Provider structures
# -------------------------------------------------------------------------
class Client(msgspec.Struct, dict=True, omit_defaults=True):
"""OIDC client (relying party) registration.
client_id is the dict key (UUID).
"""
client_secret_hash: bytes
name: str
redirect_uris: list[str]
backchannel_logout_uri: str | None = None
def __post_init__(self):
if not hasattr(self, "uuid"):
self.uuid: UUID = _UUID_UNSET
@classmethod
def create(
cls,
name: str,
redirect_uris: list[str],
client_secret: str,
created_at: datetime | None = None,
backchannel_logout_uri: str | None = None,
) -> tuple[Client, str]:
"""Create a new OIDClient with hashed secret.
Returns (client, client_secret) tuple.
"""
now = created_at or datetime.now(UTC)
secret_hash = hashlib.sha256(client_secret.encode()).digest()
client = cls(
client_secret_hash=secret_hash,
name=name,
redirect_uris=redirect_uris,
backchannel_logout_uri=backchannel_logout_uri,
)
client.uuid = uuid7.create(now)
return client, client_secret
def verify_secret(self, client_secret: str) -> bool:
"""Verify a client secret against stored hash."""
return secrets.compare_digest(
self.client_secret_hash,
hashlib.sha256(client_secret.encode()).digest(),
)
class SessionContext(msgspec.Struct): class SessionContext(msgspec.Struct):
session: Session session: Session
user: User user: User
@@ -398,14 +595,19 @@ class SessionContext(msgspec.Struct):
permissions: list[Permission] = [] permissions: list[Permission] = []
class Config(msgspec.Struct, frozen=True, dict=True, omit_defaults=True): class OIDC(msgspec.Struct, dict=True):
clients: dict[UUID, Client] = {}
key: bytes | None = None
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
# ------------------------------------------------------------------------- # -------------------------------------------------------------------------
@@ -416,14 +618,16 @@ class Config(msgspec.Struct, frozen=True, dict=True, omit_defaults=True):
class DB(msgspec.Struct, dict=True, omit_defaults=False): class DB(msgspec.Struct, dict=True, omit_defaults=False):
"""In-memory database. Access fields directly for reads.""" """In-memory database. Access fields directly for reads."""
config: Config
permissions: dict[UUID, Permission] = {} permissions: dict[UUID, Permission] = {}
orgs: dict[UUID, Org] = {} orgs: dict[UUID, Org] = {}
roles: dict[UUID, Role] = {} roles: dict[UUID, Role] = {}
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] = {}
config: Config = field(default_factory=lambda: Config(rp_id="localhost")) # OIDC provider data
oidc: OIDC = msgspec.field(default_factory=lambda: OIDC())
def __post_init__(self): def __post_init__(self):
# Store reference for persistence (not serialized) # Store reference for persistence (not serialized)
@@ -443,32 +647,39 @@ class DB(msgspec.Struct, dict=True, omit_defaults=False):
session.key = key session.key = key
for key, token in self.reset_tokens.items(): for key, token in self.reset_tokens.items():
token.key = key token.key = key
# OIDC
for uuid, client in self.oidc.clients.items():
client.uuid = uuid
def transaction(self, action, ctx=None, *, user=None): def transaction(self, action, ctx=None, *, user=None):
"""Wrap writes in transaction. Delegates to JsonlStore.""" """Wrap writes in transaction. Delegates to JsonlStore."""
return self._store.transaction(action, ctx, user=user) return self._store.transaction(action, ctx, user=user)
def session_ctx( def session_ctx(
self, session_key: str, host: str | None = None self, session_secret: str, host: str | None = None
) -> SessionContext | None: ) -> SessionContext | None:
"""Get full session context with effective permissions. """Get full session context with effective permissions.
Args: Args:
session_key: The session key string session_secret: The session secret (cookie value) - will be hashed for lookup
host: Optional host for binding/validation and domain-scoped permissions host: Optional host for binding/validation and domain-scoped permissions
Returns: Returns:
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 = hash_secret("cookie", session_secret)
try: try:
s = self.sessions[session_key] s = self.sessions[key]
except KeyError: except KeyError:
return None return None
# Normalize host for comparison (stored hosts are already normalized) # OIDC sessions (client_uuid set) are not valid for cookie-based auth
normalized_input = normalize_host(host) if s.client_uuid is not None:
return None
# 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
-260
View File
@@ -1,260 +0,0 @@
import argparse
import asyncio
import json
import logging
import os
from urllib.parse import urlparse
from fastapi_vue.hostutil import parse_endpoint
from uvicorn import Config as UvicornConfig
from uvicorn import Server
from uvicorn import run as uvicorn_run
from paskia import globals as _globals
from paskia.bootstrap import bootstrap_if_needed
from paskia.config import PaskiaConfig
from paskia.db import get_config, set_config
from paskia.db import init as db_init
from paskia.db.background import flush
from paskia.db.structs import Config
from paskia.util import startupbox
from paskia.util.hostutil import normalize_origin
DEFAULT_PORT = 4401
DEVMODE = bool(os.getenv("PASKIA_FRONTEND_URL"))
EPILOG = """\
Example:
paskia --rp-id example.com --rp-name "Example Corporation" --auth-host auth.example.com
"""
def is_subdomain(sub: str, domain: str) -> bool:
"""Check if sub is a subdomain of domain (or equal)."""
sub_parts = sub.lower().split(".")
domain_parts = domain.lower().split(".")
if len(sub_parts) < len(domain_parts):
return False
return sub_parts[-len(domain_parts) :] == domain_parts
def validate_auth_host(auth_host: str, rp_id: str) -> None:
"""Validate that auth_host is a subdomain of rp_id."""
parsed = urlparse(auth_host if "://" in auth_host else f"//{auth_host}")
host = parsed.hostname or parsed.path
if not host:
raise SystemExit(f"Invalid auth-host: '{auth_host}'")
if not is_subdomain(host, rp_id):
raise SystemExit(
f"auth-host '{auth_host}' is not a subdomain of rp-id '{rp_id}'"
)
def add_common_options(p: argparse.ArgumentParser) -> None:
p.add_argument(
"--rp-id", default="localhost", help="Relying Party ID (default: localhost)"
)
p.add_argument("--rp-name", help="Relying Party name (default: same as rp-id)")
p.add_argument(
"--origin",
action="append",
dest="origins",
metavar="URL",
help="Allowed origin URL(s). May be specified multiple times. If any are specified, only those origins are permitted for WebSocket authentication.",
)
p.add_argument(
"--auth-host",
help=("Dedicated authentication site (optionally with scheme/port)"),
)
p.add_argument(
"--save",
action="store_true",
help="Save the CLI options to database for future runs.",
)
def main():
# Configure logging to remove the "ERROR:root:" prefix
logging.basicConfig(level=logging.INFO, format="%(message)s", force=True)
parser = argparse.ArgumentParser(
prog="paskia",
description="Paskia authentication server",
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog=EPILOG,
)
parser.add_argument(
"-l",
"--listen",
metavar="LISTEN",
help=(
"Endpoint to listen on (default: localhost:4401). "
"Forms: host:port port :port [ipv6]:port unix:path /path.sock"
),
)
add_common_options(parser)
args = parser.parse_args()
# Handle clearing options
if getattr(args, "auth_host", None) == "":
args.auth_host = None
if getattr(args, "rp_name", None) == "":
args.rp_name = None
if getattr(args, "listen", None) == "":
args.listen = None
# Init db and load stored config
asyncio.run(db_init(rp_id=args.rp_id))
stored_config = get_config()
# Apply defaults from stored config
if args.rp_name is None and stored_config.rp_name is not None:
args.rp_name = stored_config.rp_name
if args.origins is None and stored_config.origins is not None:
args.origins = stored_config.origins
if args.auth_host is None and stored_config.auth_host is not None:
args.auth_host = stored_config.auth_host
if args.listen is None and stored_config.listen is not None:
args.listen = stored_config.listen
# Parse endpoint using fastapi_vue.hostutil
endpoints = parse_endpoint(args.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")
uds = ep.get("uds")
# Collect and normalize origins, handle auth_host
origins = [normalize_origin(o) for o in (getattr(args, "origins", None) or [])]
if args.auth_host:
# Normalize auth_host with scheme
if "://" not in args.auth_host:
args.auth_host = f"https://{args.auth_host}"
validate_auth_host(args.auth_host, args.rp_id)
# If origins are configured, ensure auth_host is included at top
if origins:
# Insert auth_host at the beginning
origins.insert(0, args.auth_host)
# Remove duplicates while preserving order
seen = set()
origins = [x for x in origins if not (x in seen or seen.add(x))]
# Compute site_url and site_path for reset links
# Priority: PASKIA_SITE_URL (explicit) > auth_host > first origin with localhost > http://localhost:port
explicit_site_url = os.environ.get("PASKIA_SITE_URL")
if explicit_site_url:
# Explicit site URL from devserver or deployment config
site_url = explicit_site_url.rstrip("/")
site_path = "/" if args.auth_host else "/auth/"
elif args.auth_host:
site_url = args.auth_host.rstrip("/")
site_path = "/"
elif origins:
# Find localhost origin if rp_id is localhost, else use first origin
localhost_origin = (
next((o for o in origins if "://localhost" in o), None)
if args.rp_id == "localhost"
else None
)
site_url = (localhost_origin or origins[0]).rstrip("/")
site_path = "/auth/"
elif args.rp_id == "localhost" and port:
# Dev mode: use http with port
site_url = f"http://localhost:{port}"
site_path = "/auth/"
else:
site_url = f"https://{args.rp_id}"
site_path = "/auth/"
# Build runtime configuration
config = PaskiaConfig(
rp_id=args.rp_id,
rp_name=args.rp_name or None,
origins=origins or None,
auth_host=args.auth_host or None,
site_url=site_url,
site_path=site_path,
host=host,
port=port,
uds=uds,
)
# Export configuration via single JSON env variable for worker processes
config_json = {
"rp_id": config.rp_id,
"rp_name": config.rp_name,
"origins": config.origins,
"auth_host": config.auth_host,
"site_url": config.site_url,
"site_path": config.site_path,
}
os.environ["PASKIA_CONFIG"] = json.dumps(config_json)
startupbox.print_startup_config(config)
if args.save:
new_config = Config(
rp_id=args.rp_id,
rp_name=args.rp_name,
origins=args.origins,
auth_host=args.auth_host,
listen=args.listen,
)
asyncio.run(set_config(new_config))
run_kwargs: dict = {
"log_level": "warning", # Suppress startup messages; we use custom logging
"access_log": False, # We use custom AccessLogMiddleware instead
}
if DEVMODE:
# Security: dev mode must run on localhost:4402 to prevent
# accidental public exposure of the Vite dev server
if host != "localhost" or port != 4402:
raise SystemExit(f"Dev mode requires localhost:4402, got {host}:{port}")
run_kwargs["reload"] = True
run_kwargs["reload_dirs"] = ["paskia"]
async def async_main():
await _globals.init(
rp_id=config.rp_id,
rp_name=config.rp_name,
origins=config.origins,
bootstrap=False,
)
await bootstrap_if_needed()
await flush()
if len(endpoints) > 1:
async with asyncio.TaskGroup() as tg:
for ep in endpoints:
tg.create_task(
Server(
UvicornConfig(app="paskia.fastapi:app", **run_kwargs, **ep)
).serve()
)
elif DEVMODE:
# Use uvicorn.run for proper reload support (it handles subprocess spawning)
ep = endpoints[0]
uvicorn_run("paskia.fastapi:app", **run_kwargs, **ep)
else:
server = Server(
UvicornConfig(app="paskia.fastapi:app", **run_kwargs, **endpoints[0])
)
await server.serve()
try:
asyncio.run(async_main())
except KeyboardInterrupt:
pass
if __name__ == "__main__":
main()
+383 -120
View File
@@ -1,17 +1,20 @@
import logging import logging
from uuid import UUID from uuid import UUID
from fastapi import Body, FastAPI, HTTPException, Query, Request, Response from fastapi import Body, FastAPI, HTTPException, Query, Request
from fastapi.responses import JSONResponse from fastapi.responses import JSONResponse
from paskia import aaguid as aaguid_mod from paskia import aaguid as aaguid_mod
from paskia import db from paskia import db
from paskia.authsession import EXPIRES, reset_expires from paskia.authsession import reset_expires
from paskia.db import Org as OrgDC from paskia.db import Org as OrgDC
from paskia.db import Permission as PermDC from paskia.db import Permission as PermDC
from paskia.db import Role as RoleDC from paskia.db import Role as RoleDC
from paskia.db import User as UserDC from paskia.db import User as UserDC
from paskia.db.operations import _UNSET
from paskia.db.structs import Client
from paskia.fastapi import authz from paskia.fastapi import authz
from paskia.fastapi.front import frontend
from paskia.fastapi.response import MsgspecResponse from paskia.fastapi.response import MsgspecResponse
from paskia.fastapi.session import AUTH_COOKIE from paskia.fastapi.session import AUTH_COOKIE
from paskia.globals import passkey from paskia.globals import passkey
@@ -21,7 +24,20 @@ from paskia.util import (
querysafe, querysafe,
vitedev, vitedev,
) )
from paskia.util.apistructs import ApiPermission, ApiSession, format_datetime from paskia.util.apistructs import (
ApiAaguidInfo,
ApiAdminInfo,
ApiCreateLinkResponse,
ApiOidcClient,
ApiOrg,
ApiOrgResponse,
ApiPermission,
ApiRole,
ApiUser,
ApiUserDetail,
ApiUserSession,
ApiUuidResponse,
)
from paskia.util.hostutil import normalize_host from paskia.util.hostutil import normalize_host
app = FastAPI(docs_url=None, redoc_url=None, openapi_url=None) app = FastAPI(docs_url=None, redoc_url=None, openapi_url=None)
@@ -63,56 +79,12 @@ async def general_exception_handler(_request, exc: Exception): # pragma: no cov
@app.get("/") @app.get("/")
async def adminapp(request: Request, auth=AUTH_COOKIE): async def adminapp(request: Request, auth=AUTH_COOKIE):
return Response(*await vitedev.read("/auth/admin/index.html")) return await vitedev.handle(request, frontend, "/auth/admin/")
# -------------------- Organizations -------------------- # -------------------- Organizations --------------------
@app.get("/orgs")
async def admin_list_orgs(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 = 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):
users = db.get_organization_users(o.uuid)
return {
"uuid": o.uuid,
"display_name": o.display_name,
"permissions": {p.uuid for p in o.permissions},
"roles": [
{
"uuid": r.uuid,
"org": r.org_uuid,
"display_name": r.display_name,
"permissions": list(r.permissions.keys()),
}
for r in o.roles
],
"users": [
{
"uuid": u.uuid,
"display_name": u.display_name,
"role": role_name,
"role_uuid": u.role_uuid,
"visits": u.visits,
"last_seen": u.last_seen,
}
for (u, role_name) in users
],
}
return MsgspecResponse([org_to_dict(o) for o in orgs])
@app.post("/orgs") @app.post("/orgs")
async def admin_create_org( async def admin_create_org(
request: Request, payload: dict = Body(...), auth=AUTH_COOKIE request: Request, payload: dict = Body(...), auth=AUTH_COOKIE
@@ -129,7 +101,7 @@ async def admin_create_org(
for perm in permissions: for perm in permissions:
db.add_permission_to_org(str(org.uuid), perm, ctx=ctx) db.add_permission_to_org(str(org.uuid), perm, ctx=ctx)
return {"uuid": str(org.uuid)} return MsgspecResponse(ApiUuidResponse(uuid=str(org.uuid)))
@app.patch("/orgs/{org_uuid}") @app.patch("/orgs/{org_uuid}")
@@ -278,7 +250,7 @@ async def admin_create_role(
permissions=permission_uuids, permissions=permission_uuids,
) )
db.create_role(role, ctx=ctx) db.create_role(role, ctx=ctx)
return {"uuid": str(role.uuid)} return MsgspecResponse(ApiUuidResponse(uuid=str(role.uuid)))
@app.patch("/roles/{role_uuid}") @app.patch("/roles/{role_uuid}")
@@ -451,7 +423,7 @@ async def admin_create_user(
role=role_obj.uuid, role=role_obj.uuid,
) )
db.create_user(user, ctx=ctx) db.create_user(user, ctx=ctx)
return {"uuid": str(user.uuid)} return MsgspecResponse(ApiUuidResponse(uuid=str(user.uuid)))
@app.patch("/users/{user_uuid}/role") @app.patch("/users/{user_uuid}/role")
@@ -462,8 +434,8 @@ async def admin_update_user_role(
auth=AUTH_COOKIE, auth=AUTH_COOKIE,
): ):
try: try:
user_org, _current_role = db.get_user_organization(user_uuid) user = db.data().users[user_uuid]
except ValueError: except KeyError:
raise HTTPException(status_code=404, detail="User not found") raise HTTPException(status_code=404, detail="User not found")
ctx = await authz.verify( ctx = await authz.verify(
auth, auth,
@@ -471,7 +443,7 @@ async def admin_update_user_role(
match=permutil.has_any, match=permutil.has_any,
host=request.headers.get("host"), host=request.headers.get("host"),
) )
if not can_manage_org(ctx, user_org.uuid): if not can_manage_org(ctx, user.org.uuid):
raise authz.AuthException( raise authz.AuthException(
status_code=403, detail="Insufficient permissions", mode="forbidden" status_code=403, detail="Insufficient permissions", mode="forbidden"
) )
@@ -483,7 +455,7 @@ async def admin_update_user_role(
except (ValueError, TypeError): except (ValueError, TypeError):
raise ValueError("Invalid role UUID") raise ValueError("Invalid role UUID")
new_role = db.data().roles.get(new_role_uuid) new_role = db.data().roles.get(new_role_uuid)
if not new_role or new_role.org_uuid != user_org.uuid: if not new_role or new_role.org_uuid != user.org.uuid:
raise ValueError("Role not found in organization") raise ValueError("Role not found in organization")
# Sanity check: prevent admin from removing their own access # Sanity check: prevent admin from removing their own access
@@ -511,8 +483,8 @@ async def admin_create_user_registration_link(
auth=AUTH_COOKIE, auth=AUTH_COOKIE,
): ):
try: try:
user_org, _role_name = db.get_user_organization(user_uuid) user = db.data().users[user_uuid]
except ValueError: except KeyError:
raise HTTPException(status_code=404, detail="User not found") raise HTTPException(status_code=404, detail="User not found")
ctx = await authz.verify( ctx = await authz.verify(
auth, auth,
@@ -521,13 +493,13 @@ async def admin_create_user_registration_link(
host=request.headers.get("host"), host=request.headers.get("host"),
max_age="5m", max_age="5m",
) )
if not can_manage_org(ctx, user_org.uuid): if not can_manage_org(ctx, user.org.uuid):
raise authz.AuthException( raise authz.AuthException(
status_code=403, detail="Insufficient permissions", mode="forbidden" status_code=403, detail="Insufficient permissions", mode="forbidden"
) )
# Check if user has existing credentials # Check if user has existing credentials
has_credentials = db.get_user_credential_ids(user_uuid) has_credentials = db.data().users[user_uuid].credential_ids
token_type = "user registration" if not has_credentials else "account recovery" token_type = "user registration" if not has_credentials else "account recovery"
expiry = reset_expires() expiry = reset_expires()
@@ -538,11 +510,13 @@ async def admin_create_user_registration_link(
ctx=ctx, ctx=ctx,
) )
url = hostutil.reset_link_url(token) url = hostutil.reset_link_url(token)
return { return MsgspecResponse(
"url": url, ApiCreateLinkResponse(
"expires": format_datetime(expiry), url=url,
"token_type": token_type, expires=expiry,
} token_type=token_type,
)
)
@app.get("/users/{user_uuid}") @app.get("/users/{user_uuid}")
@@ -552,8 +526,8 @@ async def admin_get_user_detail(
auth=AUTH_COOKIE, auth=AUTH_COOKIE,
): ):
try: try:
user_org, role_name = db.get_user_organization(user_uuid) user = db.data().users[user_uuid]
except ValueError: except KeyError:
raise HTTPException(status_code=404, detail="User not found") raise HTTPException(status_code=404, detail="User not found")
ctx = await authz.verify( ctx = await authz.verify(
auth, auth,
@@ -561,56 +535,52 @@ async def admin_get_user_detail(
match=permutil.has_any, match=permutil.has_any,
host=request.headers.get("host"), host=request.headers.get("host"),
) )
if not can_manage_org(ctx, user_org.uuid): if not can_manage_org(ctx, user.org.uuid):
raise authz.AuthException( raise authz.AuthException(
status_code=403, detail="Insufficient permissions", mode="forbidden" status_code=403, detail="Insufficient permissions", mode="forbidden"
) )
user = db.data().users.get(user_uuid)
normalized_host = hostutil.normalize_host(request.headers.get("host")) normalized_host = hostutil.normalize_host(request.headers.get("host"))
return MsgspecResponse( sessions = {
{ s.key: ApiUserSession.from_db(
"display_name": user.display_name,
"org": {"display_name": user_org.display_name},
"role": role_name,
"visits": user.visits,
"created_at": user.created_at,
"last_seen": user.last_seen,
"credentials": [
{
"credential": c.uuid,
"aaguid": c.aaguid,
"created_at": c.created_at,
"last_used": c.last_used,
"last_verified": c.last_verified,
"sign_count": c.sign_count,
}
for c in user.credentials
],
"aaguid_info": aaguid_mod.filter(c.aaguid for c in user.credentials),
"sessions": [
ApiSession.from_db(
s, s,
current_key=auth, current_key=ctx.session.key,
normalized_host=normalized_host, normalized_host=normalized_host,
expires_delta=EXPIRES,
) )
for s in user.sessions 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("/users/{user_uuid}/display-name") @app.patch("/users/{user_uuid}/info")
async def admin_update_user_display_name( async def admin_update_user_info(
user_uuid: UUID, user_uuid: UUID,
request: Request, request: Request,
payload: dict = Body(...), payload: dict = Body(...),
auth=AUTH_COOKIE, 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: try:
user_org, _role_name = db.get_user_organization(user_uuid) user = db.data().users[user_uuid]
except ValueError: except KeyError:
raise HTTPException(status_code=404, detail="User not found") raise HTTPException(status_code=404, detail="User not found")
ctx = await authz.verify( ctx = await authz.verify(
auth, auth,
@@ -618,16 +588,30 @@ async def admin_update_user_display_name(
match=permutil.has_any, match=permutil.has_any,
host=request.headers.get("host"), host=request.headers.get("host"),
) )
if not can_manage_org(ctx, user_org.uuid): if not can_manage_org(ctx, user.org.uuid):
raise authz.AuthException( raise authz.AuthException(
status_code=403, detail="Insufficient permissions", mode="forbidden" status_code=403, detail="Insufficient permissions", mode="forbidden"
) )
new_name = (payload.get("display_name") or "").strip()
if not new_name: kwargs = {}
raise HTTPException(status_code=400, detail="display_name required") if "display_name" in payload:
if len(new_name) > 64: 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") raise HTTPException(status_code=400, detail="display_name too long")
db.update_user_display_name(user_uuid, new_name, ctx=ctx) 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"} return {"status": "ok"}
@@ -639,8 +623,8 @@ async def admin_delete_user(
): ):
"""Delete a user and all their credentials/sessions.""" """Delete a user and all their credentials/sessions."""
try: try:
user_org, _role_name = db.get_user_organization(user_uuid) user = db.data().users[user_uuid]
except ValueError: except KeyError:
raise HTTPException(status_code=404, detail="User not found") raise HTTPException(status_code=404, detail="User not found")
ctx = await authz.verify( ctx = await authz.verify(
auth, auth,
@@ -649,7 +633,7 @@ async def admin_delete_user(
host=request.headers.get("host"), host=request.headers.get("host"),
max_age="5m", max_age="5m",
) )
if not can_manage_org(ctx, user_org.uuid): if not can_manage_org(ctx, user.org.uuid):
raise authz.AuthException( raise authz.AuthException(
status_code=403, detail="Insufficient permissions", mode="forbidden" status_code=403, detail="Insufficient permissions", mode="forbidden"
) )
@@ -668,8 +652,8 @@ async def admin_delete_user_credential(
auth=AUTH_COOKIE, auth=AUTH_COOKIE,
): ):
try: try:
user_org, _role_name = db.get_user_organization(user_uuid) user = db.data().users[user_uuid]
except ValueError: except KeyError:
raise HTTPException(status_code=404, detail="User not found") raise HTTPException(status_code=404, detail="User not found")
ctx = await authz.verify( ctx = await authz.verify(
auth, auth,
@@ -678,7 +662,7 @@ async def admin_delete_user_credential(
host=request.headers.get("host"), host=request.headers.get("host"),
max_age="5m", max_age="5m",
) )
if not can_manage_org(ctx, user_org.uuid): if not can_manage_org(ctx, user.org.uuid):
raise authz.AuthException( raise authz.AuthException(
status_code=403, detail="Insufficient permissions", mode="forbidden" status_code=403, detail="Insufficient permissions", mode="forbidden"
) )
@@ -694,8 +678,8 @@ async def admin_delete_user_session(
auth=AUTH_COOKIE, auth=AUTH_COOKIE,
): ):
try: try:
user_org, _role_name = db.get_user_organization(user_uuid) user = db.data().users[user_uuid]
except ValueError: except KeyError:
raise HTTPException(status_code=404, detail="User not found") raise HTTPException(status_code=404, detail="User not found")
ctx = await authz.verify( ctx = await authz.verify(
auth, auth,
@@ -703,19 +687,21 @@ async def admin_delete_user_session(
match=permutil.has_any, match=permutil.has_any,
host=request.headers.get("host"), host=request.headers.get("host"),
) )
if not can_manage_org(ctx, user_org.uuid): if not can_manage_org(ctx, user.org.uuid):
raise authz.AuthException( raise authz.AuthException(
status_code=403, detail="Insufficient permissions", mode="forbidden" status_code=403, detail="Insufficient permissions", mode="forbidden"
) )
target_session = db.data().sessions.get(session_id) session_key = session_id
target_session = db.data().sessions.get(session_key)
if not target_session or target_session.user_uuid != user_uuid: if not target_session or target_session.user_uuid != user_uuid:
raise HTTPException(status_code=404, detail="Session not found") raise HTTPException(status_code=404, detail="Session not found")
db.delete_session(session_id, ctx=ctx, action="admin:delete_session") db.delete_session(session_key, ctx=ctx, action="admin:delete_session")
# Check if admin terminated their own session # Check if admin terminated their own session
current_terminated = session_id == auth current_terminated = session_key == ctx.session.key
return {"status": "ok", "current_session_terminated": current_terminated} return {"status": "ok", "current_session_terminated": current_terminated}
@@ -723,14 +709,24 @@ async def admin_delete_user_session(
def _validate_permission_domain(domain: str | None) -> None: def _validate_permission_domain(domain: str | None) -> None:
"""Validate that domain is rp_id or a subdomain of it.""" """Validate that domain is rp_id, a subdomain of it, or an OIDC client UUID."""
if domain is None: if domain is None:
return 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 rp_id = passkey.instance.rp_id
if domain == rp_id or domain.endswith(f".{rp_id}"): if domain == rp_id or domain.endswith(f".{rp_id}"):
return return
raise ValueError(f"Domain '{domain}' must be '{rp_id}' or its subdomain") raise ValueError(
f"Domain '{domain}' must be '{rp_id}', its subdomain, or an OIDC client UUID"
)
def _check_admin_lockout( def _check_admin_lockout(
@@ -804,16 +800,62 @@ def _check_admin_lockout_on_delete(perm_uuid: str, current_host: str | None) ->
) )
@app.get("/permissions") @app.get("/info")
async def admin_list_permissions(request: Request, auth=AUTH_COOKIE): async def admin_info(request: Request, auth=AUTH_COOKIE):
ctx = await authz.verify( ctx = await authz.verify(
auth, auth,
["auth:admin", "auth:org:admin"], ["auth:admin", "auth:org:admin"],
match=permutil.has_any, match=permutil.has_any,
host=request.headers.get("host"), 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 = db.data().permissions.values() if master_admin(ctx) else ctx.org.permissions
return MsgspecResponse([ApiPermission.from_db(p) for p in perms]) 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,
)
)
@app.post("/permissions") @app.post("/permissions")
@@ -913,3 +955,224 @@ async def admin_delete_permission(
db.delete_permission(permission_uuid, ctx=ctx) db.delete_permission(permission_uuid, ctx=ctx)
return {"status": "ok"} return {"status": "ok"}
# -------------------- OIDC Clients --------------------
@app.post("/oidc-clients")
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("/oidc-clients/{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("/oidc-clients/{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("/oidc-clients/{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"}
+78 -59
View File
@@ -13,15 +13,17 @@ from fastapi import (
from fastapi.responses import JSONResponse from fastapi.responses import JSONResponse
from fastapi.security import HTTPBearer from fastapi.security import HTTPBearer
from paskia import db from paskia import authcode, db
from paskia.authsession import EXPIRES, expires, get_reset from paskia._version import __version__
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
bearer_auth = HTTPBearer(auto_error=True) bearer_auth = HTTPBearer(auto_error=False)
app = FastAPI(docs_url=None, redoc_url=None, openapi_url=None) app = FastAPI(docs_url=None, redoc_url=None, openapi_url=None)
@@ -87,23 +89,23 @@ async def validate_token(
raise raise
renewed = False renewed = False
if auth: if auth:
consumed = EXPIRES - (ctx.session.expiry - datetime.now(UTC)) consumed = datetime.now(UTC) - ctx.session.validated
if not timedelta(0) < consumed < _REFRESH_INTERVAL: if not timedelta(0) < consumed < _REFRESH_INTERVAL:
db.update_session( db.update_session(
auth, ctx.session.key,
ip=get_client_ip(request), ip=get_client_ip(request),
user_agent=request.headers.get("user-agent") or "", user_agent=request.headers.get("user-agent") or "",
expiry=expires(), validated=datetime.now(UTC),
ctx=ctx, ctx=ctx,
) )
session.set_session_cookie(response, auth) session.set_session_cookie(response, auth)
renewed = True renewed = True
return MsgspecResponse( return MsgspecResponse(
{ ApiValidateResponse(
"valid": True, valid=True,
"renewed": renewed, renewed=renewed,
"ctx": userinfo.build_session_context(ctx), ctx=userinfo.build_session_context(ctx),
} )
) )
@@ -127,7 +129,7 @@ async def forward_authentication(
- If Accept header contains "text/html": HTML page for authentication - If Accept header contains "text/html": HTML page for authentication
with data attributes for mode and other metadata. with data attributes for mode and other metadata.
- Otherwise: JSON response with error details and an `iframe` field - Otherwise: JSON response with error details and an `iframe` field
pointing to /auth/restricted/?mode=... for iframe-based authentication. pointing to /auth/restricted/iframe#mode=... for iframe-based authentication.
""" """
try: try:
ctx = await authz.verify( ctx = await authz.verify(
@@ -150,11 +152,7 @@ async def forward_authentication(
"Remote-Role": str(ctx.role.uuid), "Remote-Role": str(ctx.role.uuid),
"Remote-Role-Name": ctx.role.display_name, "Remote-Role-Name": ctx.role.display_name,
"Remote-Session-Expires": ( "Remote-Session-Expires": (
ctx.session.expiry.astimezone(UTC).isoformat().replace("+00:00", "Z") (ctx.session.validated + EXPIRES).isoformat().replace("+00:00", "Z")
if ctx.session.expiry.tzinfo
else ctx.session.expiry.replace(tzinfo=UTC)
.isoformat()
.replace("+00:00", "Z")
), ),
"Remote-Credential": str(ctx.session.credential), "Remote-Credential": str(ctx.session.credential),
} }
@@ -163,24 +161,14 @@ 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 # API request - return JSON with iframe srcdoc HTML
return JSONResponse( return JSONResponse(
status_code=e.status_code, status_code=e.status_code, content=await authz.auth_error_content(e)
content=await authz.auth_error_content(e),
) )
@@ -188,17 +176,20 @@ async def forward_authentication(
async def get_settings(): async def get_settings():
pk = global_passkey.instance pk = global_passkey.instance
base_path = hostutil.ui_base_path() base_path = hostutil.ui_base_path()
return { return MsgspecResponse(
"rp_id": pk.rp_id, ApiSettings(
"rp_name": pk.rp_name, rp_id=pk.rp_id,
"ui_base_path": base_path, rp_name=pk.rp_name,
"auth_host": hostutil.dedicated_auth_host(), ui_base_path=base_path,
"auth_site_url": hostutil.auth_site_url(), auth_host=hostutil.dedicated_auth_host(),
"session_cookie": AUTH_COOKIE_NAME, auth_site_url=hostutil.auth_site_url(),
} session_cookie=AUTH_COOKIE_NAME,
version=__version__,
)
)
@app.post("/user-info") @app.get("/user-info")
async def api_user_info( async def api_user_info(
request: Request, request: Request,
response: Response, response: Response,
@@ -211,16 +202,21 @@ 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(
user_uuid=ctx.user.uuid, user_uuid=ctx.user.uuid,
auth=auth, session_key=ctx.session.key,
session_record=ctx.session,
request_host=request.headers.get("host"), request_host=request.headers.get("host"),
ctx=ctx,
) )
) )
@@ -228,6 +224,8 @@ async def api_user_info(
@app.get("/token-info") @app.get("/token-info")
async def token_info(credentials=Depends(bearer_auth)): async def token_info(credentials=Depends(bearer_auth)):
"""Get reset/device-add token info. Pass token via Bearer header.""" """Get reset/device-add token info. Pass token via Bearer header."""
if not credentials or not credentials.credentials:
raise HTTPException(401, "Bearer token required")
token = credentials.credentials token = credentials.credentials
if not passphrase.is_well_formed(token): if not passphrase.is_well_formed(token):
raise HTTPException(400, "Invalid token format") raise HTTPException(400, "Invalid token format")
@@ -237,10 +235,13 @@ async def token_info(credentials=Depends(bearer_auth)):
raise HTTPException(401, str(e)) raise HTTPException(401, str(e))
u = reset_token.user u = reset_token.user
return { return MsgspecResponse(
"token_type": reset_token.token_type, ApiTokenInfo(
"display_name": u.display_name, token_type=reset_token.token_type,
} display_name=u.display_name,
theme=u.theme,
)
)
@app.post("/logout") @app.post("/logout")
@@ -248,11 +249,11 @@ 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):
db.delete_session(auth, ctx=ctx, action="logout") db.delete_session(ctx.session.key, ctx=ctx, action="logout")
session.clear_session_cookie(response) session.clear_session_cookie(response)
return {"message": "Logged out successfully"} return {"message": "Logged out successfully"}
@@ -261,11 +262,29 @@ async def api_logout(request: Request, response: Response, auth=AUTH_COOKIE):
async def api_set_session( async def api_set_session(
request: Request, response: Response, auth=Depends(bearer_auth) request: Request, response: Response, auth=Depends(bearer_auth)
): ):
ctx = db.data().session_ctx(auth.credentials, request.headers.get("host")) """Exchange an auth code for setting the session cookie.
Called by frontend after WebSocket authentication.
The code is ephemeral (60s TTL) and can only be used once.
"""
if not auth or not auth.credentials:
raise HTTPException(400, "Bearer token required")
# Verify host is provided
host = hostutil.normalize_host(request.headers.get("host", ""))
if not host:
raise HTTPException(400, "Host header required")
a = authcode.consume_cookie(auth.credentials)
if not a:
raise HTTPException(401, "Code expired or already used")
secret = a.session_key
# Verify the session exists
ctx = session_ctx(secret, host)
if not ctx: if not ctx:
raise HTTPException(401, "Session expired") raise HTTPException(401, f"Session not found on {host}")
session.set_session_cookie(response, auth.credentials)
return { session.set_session_cookie(response, secret)
"message": "Session cookie set successfully", return {"status": "ok", "user": str(ctx.user.uuid)}
"user": str(ctx.user.uuid),
}
+1 -1
View File
@@ -41,7 +41,7 @@ async def auth_error_content(exc: AuthException) -> dict:
# Build hash fragment from mode and metadata # Build hash fragment from mode and metadata
params = {"mode": exc.mode, **exc.metadata} params = {"mode": exc.mode, **exc.metadata}
fragment = "&".join(f"{k}={v}" for k, v in params.items() if v is not None) fragment = "&".join(f"{k}={v}" for k, v in params.items() if v is not None)
iframe_url = f"/auth/restricted/#{fragment}" iframe_url = f"/auth/restricted/iframe#{fragment}"
return { return {
"detail": exc.detail, "detail": exc.detail,
"auth": { "auth": {
+10
View File
@@ -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",
)
+1 -26
View File
@@ -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 = ( origin_str = f" {_RESET}from {_HOST}{origin_host}{_RESET}" if show_origin else ""
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}")
+68 -26
View File
@@ -1,21 +1,26 @@
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 globals from paskia import authcode, db, globals
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, ws from paskia.fastapi import admin, api, auth_host, oid, ws
from paskia.fastapi.__main__ import DEVMODE
# 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 +28,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 +40,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 +54,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:
await 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:
@@ -67,6 +69,7 @@ async def lifespan(app: FastAPI): # pragma: no cover - startup path
await start_background() await start_background()
yield yield
await stop_background() await stop_background()
await authcode.stop()
app = FastAPI( app = FastAPI(
@@ -87,12 +90,52 @@ app.middleware("http")(auth_host.redirect_middleware)
app.mount("/auth/api/admin/", admin.app) app.mount("/auth/api/admin/", admin.app)
app.mount("/auth/api/", api.app) app.mount("/auth/api/", api.app)
app.mount("/auth/ws/", ws.app) app.mount("/auth/ws/", ws.app)
app.mount("/auth/oidc/", oid.app)
@app.get("/auth/restricted/") # OIDC Well-Known endpoints (must be at site root)
async def restricted_view(): @app.get("/.well-known/openid-configuration")
"""Serve the restricted/authentication UI for iframe embedding.""" async def openid_configuration(request: Request):
return Response(*await vitedev.read("/auth/restricted/index.html")) """OpenID Connect Discovery document."""
# Build issuer URL from request
scheme = request.headers.get("x-forwarded-proto", request.url.scheme)
host = request.headers.get("host", request.url.netloc)
issuer = f"{scheme}://{host}"
return {
"issuer": issuer,
"authorization_endpoint": f"{issuer}/auth/restricted/oidc",
"token_endpoint": f"{issuer}/auth/oidc/token",
"userinfo_endpoint": f"{issuer}/auth/oidc/userinfo",
"jwks_uri": f"{issuer}/auth/oidc/keys",
"backchannel_logout_supported": True,
"backchannel_logout_session_supported": True,
"response_types_supported": ["code"],
"grant_types_supported": ["authorization_code", "refresh_token"],
"subject_types_supported": ["public"],
"id_token_signing_alg_values_supported": ["EdDSA"],
"scopes_supported": ["openid", "profile", "email"],
"token_endpoint_auth_methods_supported": [
"client_secret_post",
"client_secret_basic",
],
"code_challenge_methods_supported": ["S256"],
"claims_supported": [
"sub",
"name",
"preferred_username",
"email",
"groups",
"sid",
],
}
@app.get("/auth/restricted/iframe")
@app.get("/auth/restricted/oidc")
async def restricted_view(request: Request):
"""Serve the restricted/authentication UI for iframe or OpenID Connect."""
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
@@ -107,7 +150,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)
@@ -139,14 +182,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.
@@ -154,4 +196,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/")
+574
View File
@@ -0,0 +1,574 @@
"""
OIDC Provider endpoints.
Implements OpenID Connect 1.0 Authorization Code flow:
- POST /token - Token endpoint (code exchange)
- GET /userinfo - UserInfo endpoint (bearer token)
Authorization is handled by /auth/restricted/oidc which passes OIDC params to
the /auth/ws/authenticate WebSocket.
"""
import base64
import hashlib
import logging
from datetime import UTC, datetime
from uuid import UUID
import base64url
from fastapi import Depends, FastAPI, Form, HTTPException, Request
from fastapi.responses import JSONResponse
from fastapi.security import HTTPBearer
from paskia import authcode, db
from paskia.db.structs import Session
from paskia.util import oidjwt
from paskia.util.crypto import hash_secret
_logger = logging.getLogger(__name__)
app = FastAPI(docs_url=None, redoc_url=None, openapi_url=None)
@app.get("/keys")
async def keys():
"""JSON Web Key Set for token verification."""
return oidjwt.get_jwks()
def _oidc_session_by_token(
token: str, client_uuid: UUID | None = None
) -> Session | None:
"""Look up an OIDC session by token (refresh token value)."""
key = hash_secret("oidc", token)
s = db.data().sessions.get(key)
if not s or s.client_uuid is None:
return None
if client_uuid is not None and s.client_uuid != client_uuid:
return None
return s
def _oidc_session_by_sid(sid: bytes, client_uuid: UUID | None = None) -> Session | None:
"""Look up an OIDC session by sid (for backchannel logout)."""
for s in db.data().sessions.values():
if s.client_uuid is None:
continue
if client_uuid is not None and s.client_uuid != client_uuid:
continue
if base64url.dec(s.key) == sid:
return s
return None
def _get_issuer(request: Request) -> str:
"""Build issuer URL from request."""
scheme = request.headers.get("x-forwarded-proto", request.url.scheme)
host = request.headers.get("host", request.url.netloc)
return f"{scheme}://{host}"
def _verify_pkce(code_verifier: str, code_challenge: str) -> bool:
"""Verify PKCE code_verifier against stored code_challenge (S256 only)."""
digest = hashlib.sha256(code_verifier.encode("ascii")).digest()
computed = base64.urlsafe_b64encode(digest).rstrip(b"=").decode("ascii")
return computed == code_challenge
def _parse_client_credentials(
request: Request,
client_id: str | None,
client_secret: str | None,
) -> tuple[str, str]:
"""Extract client credentials from request (Basic auth or body params)."""
auth_header = request.headers.get("authorization", "")
if auth_header.lower().startswith("basic "):
try:
decoded = base64.b64decode(auth_header[6:]).decode("utf-8")
client_id, client_secret = decoded.split(":", 1)
except Exception:
raise HTTPException(401, "Invalid Authorization header")
if not client_id or not client_secret:
raise HTTPException(401, "Missing client credentials")
return client_id, client_secret
@app.post("/token")
async def token(
request: Request,
grant_type: str = Form(...),
code: str | None = Form(None),
redirect_uri: str | None = Form(None),
client_id: str | None = Form(None),
client_secret: str | None = Form(None),
code_verifier: str | None = Form(None),
refresh_token: str | None = Form(None),
):
"""OIDC Token endpoint.
Supports:
- grant_type=authorization_code: Exchange code for tokens
- grant_type=refresh_token: Refresh access token using sid
Supports client_secret_post and client_secret_basic authentication.
Per RFC 6749 Section 4.1.3: MUST use POST with application/x-www-form-urlencoded.
"""
# RFC 6749: Token endpoint MUST NOT accept query parameters
if request.url.query:
return JSONResponse(
{
"error": "invalid_request",
"error_description": "Query parameters not allowed",
},
status_code=400,
)
# RFC 6749: MUST use application/x-www-form-urlencoded
content_type = request.headers.get("content-type", "")
if "application/x-www-form-urlencoded" not in content_type:
return JSONResponse(
{
"error": "invalid_request",
"error_description": "Content-Type must be application/x-www-form-urlencoded",
},
status_code=400,
)
# Get client credentials (required for all grant types)
client_id, client_secret = _parse_client_credentials(
request, client_id, client_secret
)
# Validate client
try:
client_uuid = UUID(client_id)
except ValueError:
return JSONResponse({"error": "invalid_client"}, status_code=401)
client = db.data().oidc.clients.get(client_uuid)
if not client or not client.verify_secret(client_secret):
return JSONResponse({"error": "invalid_client"}, status_code=401)
if grant_type == "authorization_code":
return await _handle_authorization_code(
request, client, client_id, code, redirect_uri, code_verifier
)
elif grant_type == "refresh_token":
return await _handle_refresh_token(request, client, client_id, refresh_token)
else:
return JSONResponse(
{"error": "unsupported_grant_type"},
status_code=400,
)
async def _handle_authorization_code(
request: Request,
client,
client_id: str,
code: str | None,
redirect_uri: str | None,
code_verifier: str | None,
):
"""Handle grant_type=authorization_code."""
if not code:
return JSONResponse(
{"error": "invalid_request", "error_description": "Missing code"},
status_code=400,
)
# Consume auth code (atomic delete + return)
oidc_code = authcode.consume_oidc(code)
if not oidc_code:
return JSONResponse(
{"error": "invalid_grant", "error_description": "Code expired or invalid"},
status_code=400,
)
# Look up the OIDC session by token
session = _oidc_session_by_token(oidc_code.session_key, client.uuid)
if not session:
return JSONResponse(
{
"error": "invalid_grant",
"error_description": "Session not found or not OIDC",
},
status_code=400,
)
# Verify redirect_uri matches
if redirect_uri and redirect_uri != oidc_code.redirect_uri:
return JSONResponse(
{"error": "invalid_grant", "error_description": "redirect_uri mismatch"},
status_code=400,
)
# Verify PKCE if code_challenge was provided at authorization time
if oidc_code.code_challenge:
if not code_verifier:
return JSONResponse(
{
"error": "invalid_grant",
"error_description": "Missing code_verifier",
},
status_code=400,
)
if not _verify_pkce(code_verifier, oidc_code.code_challenge):
return JSONResponse(
{
"error": "invalid_grant",
"error_description": "Invalid code_verifier",
},
status_code=400,
)
# Get user from session
user = db.data().users.get(session.user_uuid)
if not user:
return JSONResponse(
{"error": "invalid_grant", "error_description": "User not found"},
status_code=400,
)
# Derive sid from session key
sid = session.key
return _build_token_response(
request,
user,
client_id,
oidc_code.session_key,
sid,
oidc_code.nonce,
oidc_code.scope,
credential_uuid=session.credential_uuid,
)
async def _handle_refresh_token(
request: Request,
client,
client_id: str,
refresh_token_value: str | None,
):
"""Handle grant_type=refresh_token.
The refresh_token is the session secret. On refresh:
- Validates session exists and belongs to client
- Extends session expiry (24h sliding window)
- Records current IP and user_agent
- Issues new access_token and id_token
"""
if not refresh_token_value:
return JSONResponse(
{"error": "invalid_request", "error_description": "Missing refresh_token"},
status_code=400,
)
# Look up session by refresh token
session = _oidc_session_by_token(refresh_token_value, client.uuid)
if not session:
return JSONResponse(
{
"error": "invalid_grant",
"error_description": "Invalid or expired refresh_token",
},
status_code=400,
)
# Get user
user = db.data().users.get(session.user_uuid)
if not user:
return JSONResponse(
{"error": "invalid_grant", "error_description": "User not found"},
status_code=400,
)
# Refresh the session - extend expiry and record IP/user_agent
now = datetime.now(UTC)
ip = request.headers.get("x-forwarded-for", "").split(",")[0].strip()
if not ip:
ip = request.client.host if request.client else ""
user_agent = request.headers.get("user-agent", "")
db.update_session(
session.key,
ip=ip,
user_agent=user_agent,
validated=now,
)
_logger.info("OIDC session refreshed: %s", session.key)
# Base64url encode session's derived sid for JWT claim
sid_str = session.key
return _build_token_response(
request,
user,
client_id,
refresh_token_value,
sid_str,
nonce=None,
scope="openid",
credential_uuid=session.credential_uuid,
)
def _build_token_response(
request: Request,
user,
client_id: str,
secret: str,
sid: str,
nonce: str | None,
scope: str,
credential_uuid: UUID | None = None,
):
"""Build the token response with access_token, id_token, and refresh_token."""
issuer = _get_issuer(request)
# Get user's permissions scoped to this OIDC client (domain == client UUID)
role = user.role
org = role.org
org_perm_uuids = {p.uuid for p in org.permissions}
groups = []
for perm_uuid in role.permission_set:
if perm_uuid not in org_perm_uuids:
continue
p = db.data().permissions.get(perm_uuid)
if p and p.domain == client_id:
groups.append(p.scope)
# Get credential's last_used as auth_time
auth_time = None
if credential_uuid:
try:
credential = db.data().credentials[credential_uuid]
if credential.last_used:
auth_time = credential.last_used
except KeyError:
pass
# Create ID token
id_token = oidjwt.create_id_token(
issuer=issuer,
subject=user.uuid,
audience=client_id,
nonce=nonce,
sid=sid,
name=user.display_name,
preferred_username=user.preferred_username,
email=user.email,
groups=groups or None,
auth_time=auth_time,
)
# Create access token
access_token = oidjwt.create_access_token(
issuer=issuer,
subject=user.uuid,
audience=client_id,
scope=scope,
)
return JSONResponse(
{
"access_token": access_token,
"token_type": "Bearer",
"expires_in": 3600,
"refresh_token": secret,
"id_token": id_token,
}
)
bearer_auth = HTTPBearer(auto_error=False)
@app.get("/userinfo")
async def userinfo(
request: Request,
credentials=Depends(bearer_auth),
):
"""OIDC UserInfo endpoint.
Returns claims about the authenticated user.
Requires Bearer token from /token endpoint.
"""
if not credentials:
raise HTTPException(401, "Bearer token required")
issuer = _get_issuer(request)
payload = oidjwt.decode_access_token(credentials.credentials, issuer)
if not payload:
raise HTTPException(401, "Invalid or expired token")
# Verify audience is a valid client
aud = payload.get("aud")
if not aud:
raise HTTPException(401, "Invalid token (missing aud claim)")
try:
client_uuid = UUID(aud)
except ValueError:
raise HTTPException(401, "Invalid token (invalid aud format)")
if not db.data().oidc.clients.get(client_uuid):
raise HTTPException(401, "Invalid token (unknown client)")
# Get user
try:
user_uuid = UUID(payload["sub"])
except (KeyError, ValueError):
raise HTTPException(401, "Invalid token")
user = db.data().users.get(user_uuid)
if not user:
raise HTTPException(401, "User not found")
# Get user's permissions scoped to this OIDC client (domain == client UUID)
role = user.role
org = role.org
org_perm_uuids = {p.uuid for p in org.permissions}
groups = []
for perm_uuid in role.permission_set:
if perm_uuid not in org_perm_uuids:
continue
p = db.data().permissions.get(perm_uuid)
if p and p.domain == aud:
groups.append(p.scope)
# Build userinfo response based on scope
scope = payload.get("scope", "openid").split()
response = {"sub": str(user.uuid)}
if "profile" in scope:
response["name"] = user.display_name
if user.preferred_username:
response["preferred_username"] = user.preferred_username
if "email" in scope and user.email:
response["email"] = user.email
# Include client-scoped permissions as groups
if groups:
response["groups"] = groups
return response
@app.post("/backchannel-logout")
async def backchannel_logout(
request: Request,
logout_token: str = Form(...),
):
"""OIDC Back-Channel Logout endpoint.
Receives a logout_token JWT from the RP and invalidates the session.
The logout_token must contain either 'sid' (session ID) or 'sub' (user ID).
Per OIDC Back-Channel Logout 1.0: uses application/x-www-form-urlencoded.
"""
# Validate content type
content_type = request.headers.get("content-type", "")
if "application/x-www-form-urlencoded" not in content_type:
return JSONResponse(
{
"error": "invalid_request",
"error_description": "Content-Type must be application/x-www-form-urlencoded",
},
status_code=400,
)
# Decode and verify the logout token
issuer = _get_issuer(request)
payload = oidjwt.decode_access_token(logout_token, issuer)
if not payload:
return JSONResponse(
{"error": "invalid_request", "error_description": "Invalid logout_token"},
status_code=400,
)
# Validate required claims
sid = payload.get("sid")
sub = payload.get("sub")
# Verify audience is a valid client (if present)
aud = payload.get("aud")
client_uuid = None
if aud:
try:
client_uuid = UUID(aud)
if not db.data().oidc.clients.get(client_uuid):
return JSONResponse(
{
"error": "invalid_request",
"error_description": "Unknown client in logout_token",
},
status_code=400,
)
except ValueError:
return JSONResponse(
{
"error": "invalid_request",
"error_description": "Invalid client format in logout_token",
},
status_code=400,
)
if not sid and not sub:
return JSONResponse(
{
"error": "invalid_request",
"error_description": "logout_token must contain sid or sub",
},
status_code=400,
)
# Delete session(s)
deleted = 0
if sid:
# Decode sid from base64url to bytes
try:
sid_bytes = base64url.dec(sid)
except Exception:
return JSONResponse(
{"error": "invalid_request", "error_description": "Invalid sid format"},
status_code=400,
)
# Delete specific session by sid
session = _oidc_session_by_sid(sid_bytes, client_uuid)
if session:
db.delete_session(session.key)
deleted = 1
_logger.info("Back-channel logout: deleted session %s", sid)
elif sub:
# Delete all OIDC sessions for this user/client
try:
user_uuid = UUID(sub)
except ValueError:
return JSONResponse(
{"error": "invalid_request", "error_description": "Invalid sub claim"},
status_code=400,
)
# Find and delete matching sessions
sessions_to_delete = [
s
for s in db.data().sessions.values()
if s.user_uuid == user_uuid
and s.client_uuid is not None
and (client_uuid is None or s.client_uuid == client_uuid)
]
for session in sessions_to_delete:
db.delete_session(session.key)
deleted += 1
if deleted:
_logger.info(
"Back-channel logout: deleted %d sessions for user %s", deleted, sub
)
# Return 200 OK even if no sessions were found (per spec)
return JSONResponse({"deleted": deleted})
+14 -5
View File
@@ -10,12 +10,14 @@ Endpoints:
""" """
import asyncio import asyncio
from datetime import UTC, datetime
from uuid import UUID from uuid import UUID
import base64url import base64url
from fastapi import FastAPI, WebSocket, WebSocketDisconnect from fastapi import FastAPI, WebSocket, WebSocketDisconnect
from paskia import db, remoteauth from paskia import authcode, db, remoteauth
from paskia.authcode import CookieCode
from paskia.authsession import expires from paskia.authsession import expires
from paskia.fastapi.session import AUTH_COOKIE, infodict from paskia.fastapi.session import AUTH_COOKIE, infodict
from paskia.fastapi.wschat import authenticate_and_login from paskia.fastapi.wschat import authenticate_and_login
@@ -183,7 +185,7 @@ async def websocket_remote_auth_request(ws: WebSocket):
"user": str(result_data["user_uuid"]), "user": str(result_data["user_uuid"]),
} }
if result_data.get("session_token"): if result_data.get("session_token"):
response["session_token"] = result_data["session_token"] response["exchange_code"] = result_data["session_token"]
if result_data.get("reset_token"): if result_data.get("reset_token"):
response["reset_token"] = result_data["reset_token"] response["reset_token"] = result_data["reset_token"]
await ws.send_json(response) await ws.send_json(response)
@@ -310,9 +312,8 @@ async def websocket_remote_auth_permit(ws: WebSocket, auth=AUTH_COOKIE):
# Handle authenticate request (no PoW needed - already validated during lookup) # Handle authenticate request (no PoW needed - already validated during lookup)
if msg.get("authenticate") and request is not None: if msg.get("authenticate") and request is not None:
ctx = await authenticate_and_login(ws, auth) ctx, secret = await authenticate_and_login(ws, auth)
session_token = ctx.session.key
reset_token = None reset_token = None
if request.action == "register": if request.action == "register":
@@ -325,11 +326,19 @@ async def websocket_remote_auth_permit(ws: WebSocket, auth=AUTH_COOKIE):
user=str(ctx.user.uuid), user=str(ctx.user.uuid),
) )
# Create exchange code for the session (don't expose raw secret)
exchange_code = authcode.store_cookie(
CookieCode(
session_key=secret,
created=datetime.now(UTC),
)
)
# Complete the remote auth request (notifies the waiting device) # Complete the remote auth request (notifies the waiting device)
cred = db.data().credentials[ctx.session.credential_uuid] cred = db.data().credentials[ctx.session.credential_uuid]
completed = await remoteauth.instance.complete_request( completed = await remoteauth.instance.complete_request(
token=request.key, token=request.key,
session_token=session_token, session_token=exchange_code,
user_uuid=ctx.user.uuid, user_uuid=ctx.user.uuid,
credential_uuid=cred.uuid, credential_uuid=cred.uuid,
reset_token=reset_token, reset_token=reset_token,
+2 -2
View File
@@ -70,7 +70,7 @@ def set_session_cookie(response: Response, token: str) -> None:
httponly=True, httponly=True,
secure=True, secure=True,
path="/", path="/",
samesite="lax", samesite="strict",
) )
@@ -84,5 +84,5 @@ def clear_session_cookie(response: Response) -> None:
httponly=True, httponly=True,
secure=True, secure=True,
path="/", path="/",
samesite="lax", samesite="strict",
) )
+65 -19
View File
@@ -1,4 +1,3 @@
from datetime import UTC
from uuid import UUID from uuid import UUID
from fastapi import ( from fastapi import (
@@ -14,10 +13,13 @@ 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.session import AUTH_COOKIE from paskia.fastapi.session import AUTH_COOKIE
from paskia.util import hostutil from paskia.util import hostutil
from paskia.util.apistructs import ApiCreateLinkResponse
app = FastAPI(docs_url=None, redoc_url=None, openapi_url=None) app = FastAPI(docs_url=None, redoc_url=None, openapi_url=None)
@@ -38,12 +40,13 @@ async def user_update_display_name(
payload: dict = Body(...), payload: dict = Body(...),
auth=AUTH_COOKIE, auth=AUTH_COOKIE,
): ):
"""Update display name only. Used by registration flow (auto-fills preferred_username)."""
if not auth: if not auth:
raise authz.AuthException( raise authz.AuthException(
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"
@@ -57,6 +60,49 @@ async def user_update_display_name(
return {"status": "ok"} return {"status": "ok"}
@app.patch("/info")
async def user_update_info(
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.
Does NOT auto-fill preferred_username (unlike /display-name endpoint).
"""
if not auth:
raise authz.AuthException(
status_code=401, detail="Authentication Required", mode="login"
)
ctx = session_ctx(auth, request.headers.get("host"))
if not ctx:
raise authz.AuthException(
status_code=401, detail="Session expired", mode="login"
)
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(ctx.user.uuid, **kwargs, ctx=ctx)
return {"status": "ok"}
@app.patch("/theme") @app.patch("/theme")
async def user_update_theme( async def user_update_theme(
request: Request, request: Request,
@@ -67,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"
@@ -75,7 +121,7 @@ async def user_update_theme(
theme = payload.get("theme", "") theme = payload.get("theme", "")
if theme not in ("", "light", "dark"): if theme not in ("", "light", "dark"):
raise HTTPException(status_code=400, detail="Invalid theme") raise HTTPException(status_code=400, detail="Invalid theme")
db.update_user_theme(ctx.user.uuid, theme, ctx=ctx) db.update_user_info(ctx.user.uuid, theme=theme, ctx=ctx)
return {"status": "ok"} return {"status": "ok"}
@@ -84,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"
@@ -106,18 +152,20 @@ 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"
) )
target_session = db.data().sessions.get(session_id) session_key = session_id
target_session = db.data().sessions.get(session_key)
if not target_session or target_session.user_uuid != ctx.user.uuid: if not target_session or target_session.user_uuid != ctx.user.uuid:
raise HTTPException(status_code=404, detail="Session not found") raise HTTPException(status_code=404, detail="Session not found")
db.delete_session(session_id, ctx=ctx) db.delete_session(session_key, ctx=ctx)
current_terminated = session_id == auth current_terminated = session_key == ctx.session.key
if current_terminated: if current_terminated:
session.clear_session_cookie(response) # explicit because 200 session.clear_session_cookie(response) # explicit because 200
return {"status": "ok", "current_session_terminated": current_terminated} return {"status": "ok", "current_session_terminated": current_terminated}
@@ -157,13 +205,11 @@ async def api_create_link(
ctx=ctx, ctx=ctx,
) )
url = hostutil.reset_link_url(token) url = hostutil.reset_link_url(token)
return { return MsgspecResponse(
"message": "Registration link generated successfully", ApiCreateLinkResponse(
"url": url, message="Registration link generated successfully",
"expires": ( url=url,
expiry.astimezone(UTC).isoformat().replace("+00:00", "Z") expires=expiry,
if expiry.tzinfo token_type="device addition",
else expiry.replace(tzinfo=UTC).isoformat().replace("+00:00", "Z") )
), )
"token_type": "device addition",
}
+169 -11
View File
@@ -1,13 +1,36 @@
import secrets
from datetime import UTC, datetime
from urllib.parse import urlencode
from uuid import UUID
from fastapi import FastAPI, WebSocket from fastapi import FastAPI, WebSocket
from paskia import db from paskia import authcode, db
from paskia.authsession import get_reset from paskia.authcode import CookieCode, OIDCCode
from paskia.authsession import get_reset, session_ctx
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
from paskia.fastapi.wschat import authenticate_and_login, register_chat from paskia.fastapi.wschat import (
authenticate_and_login,
authenticate_chat,
register_chat,
)
from paskia.fastapi.wsutil import validate_origin, websocket_error_handler from paskia.fastapi.wsutil import validate_origin, websocket_error_handler
from paskia.globals import passkey from paskia.globals import passkey
from paskia.util import hostutil, passphrase from paskia.util import hostutil, passphrase
from paskia.util.crypto import hash_secret
def create_exchange_code(session_key: str) -> str:
"""Create an ephemeral exchange code for session authentication."""
now = datetime.now(UTC)
cookie_code = CookieCode(
session_key=session_key,
created=now,
)
return authcode.store_cookie(cookie_code)
# Create a FastAPI subapp for WebSocket endpoints # Create a FastAPI subapp for WebSocket endpoints
app = FastAPI(docs_url=None, redoc_url=None, openapi_url=None) app = FastAPI(docs_url=None, redoc_url=None, openapi_url=None)
@@ -52,7 +75,7 @@ async def websocket_register_add(
stripped = name.strip() stripped = name.strip()
if stripped: if stripped:
user_name = stripped user_name = stripped
credential_ids = db.get_user_credential_ids(user_uuid) or None credential_ids = user.credential_ids or None
# WebAuthn registration # WebAuthn registration
credential = await register_chat(ws, user_uuid, user_name, origin, credential_ids) credential = await register_chat(ws, user_uuid, user_name, origin, credential_ids)
@@ -68,14 +91,17 @@ async def websocket_register_add(
ip=metadata["ip"], ip=metadata["ip"],
user_agent=metadata["user_agent"], user_agent=metadata["user_agent"],
) )
auth = token session_key = token
assert isinstance(auth, str) and len(auth) == 16 # Create exchange code (ephemeral, 60s TTL)
exchange_code = create_exchange_code(session_key)
assert isinstance(session_key, str) and len(session_key) == 16
await ws.send_json( await ws.send_json(
{ {
"user": str(user.uuid), "user": str(user.uuid),
"credential": str(credential.uuid), "credential": str(credential.uuid),
"session_token": auth, "exchange_code": exchange_code,
"message": "New credential added successfully", "message": "New credential added successfully",
} }
) )
@@ -83,26 +109,158 @@ async def websocket_register_add(
@app.websocket("/authenticate") @app.websocket("/authenticate")
@websocket_error_handler @websocket_error_handler
async def websocket_authenticate(ws: WebSocket, auth=AUTH_COOKIE): async def websocket_authenticate(
ws: WebSocket,
auth=AUTH_COOKIE,
# OIDC params (when present, creates auth code instead of session)
client_id: str | None = None,
redirect_uri: str | None = None,
scope: str = "openid",
state: str | None = None,
nonce: str | None = None,
code_challenge: str | None = None,
code_challenge_method: str | None = None,
):
origin = validate_origin(ws) origin = validate_origin(ws)
host = origin.split("://", 1)[1] host = origin.split("://", 1)[1]
# OIDC mode: validate client before auth
oidc_client = None
if client_id and redirect_uri:
try:
client_uuid = UUID(client_id)
except ValueError:
await ws.send_json({"status": 400, "detail": "Invalid client_id"})
return
oidc_client = db.data().oidc.clients.get(client_uuid)
if not oidc_client:
await ws.send_json({"status": 400, "detail": "Unknown client_id"})
return
# Redirect URI autodiscovery: if no URIs are defined, store the first one
if not oidc_client.redirect_uris:
# Basic validation: must be an HTTP(S) URL
if not redirect_uri.startswith(("http://", "https://")):
await ws.send_json({"status": 400, "detail": "Invalid redirect_uri"})
return
# Store as the only allowed redirect URI
db.update_oid_client(client_uuid, redirect_uris=[redirect_uri])
# Reload client to get updated redirect_uris
oidc_client = db.data().oidc.clients.get(client_uuid)
elif redirect_uri not in oidc_client.redirect_uris:
await ws.send_json({"status": 400, "detail": "Invalid redirect_uri"})
return
scopes = scope.split()
if "openid" not in scopes:
await ws.send_json({"status": 400, "detail": "Scope must include openid"})
return
# PKCE: when code_challenge is present, only S256 is supported
# If method is omitted, default to S256 per best practice (not "plain" per RFC 7636)
# When code_challenge is absent, ignore code_challenge_method entirely
if code_challenge:
if code_challenge_method and code_challenge_method != "S256":
await ws.send_json(
{
"status": 400,
"detail": "Only S256 code_challenge_method is supported",
}
)
return
# Default to S256 when method not specified (implicit)
# Validate state parameter if provided (defensive against injection)
if state:
# State should be short-lived and contain only safe characters
# Per OAuth 2.0 spec: unreserved characters - alphanumerics and -._~
if len(state) > 500:
await ws.send_json(
{
"status": 400,
"detail": "state parameter is too long (max 500 chars)",
}
)
return
if not all(c.isalnum() or c in "-._~" for c in state):
await ws.send_json(
{
"status": 400,
"detail": "state parameter contains invalid characters",
}
)
return
# 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
ctx = await authenticate_and_login(ws, auth) if oidc_client:
# OIDC mode: authenticate and create OIDC session
cred, new_sign_count = await authenticate_chat(ws)
# Get metadata for session
origin = validate_origin(ws)
host = origin.split("://", 1)[1]
normalized_host = hostutil.normalize_host(host)
metadata = infodict(ws, "oidc_auth")
# Use same timestamp for session and auth code
now = datetime.now(UTC)
# Generate token and create OIDC session
token = secrets.token_urlsafe(12)
session = Session.create(
user=cred.user_uuid,
credential=cred.uuid,
key=hash_secret("oidc", token),
host=normalized_host,
ip=metadata["ip"],
user_agent=metadata["user_agent"],
validated=now,
client=oidc_client.uuid,
)
db.oidc_login(
session=session,
credential_uuid=cred.uuid,
sign_count=new_sign_count,
)
# Create auth code (in-memory only)
oidc_code = OIDCCode(
session_key=token,
created=now,
redirect_uri=redirect_uri,
scope=scope,
nonce=nonce,
code_challenge=code_challenge,
)
code = authcode.store_oidc(oidc_code)
# Build redirect URL
params = {"code": code}
if state:
params["state"] = state
redirect_url = f"{redirect_uri}?{urlencode(params)}"
await ws.send_json({"redirect_url": redirect_url})
else:
# Normal mode: authenticate and create session
ctx, session_key = await authenticate_and_login(ws, auth)
# If reauth mode, verify the credential belongs to the session's user # If reauth mode, verify the credential belongs to the session's user
if session_user_uuid and ctx.user.uuid != session_user_uuid: if session_user_uuid and ctx.user.uuid != session_user_uuid:
raise ValueError("This passkey belongs to a different account") raise ValueError("This passkey belongs to a different account")
# Create exchange code (ephemeral, 60s TTL)
exchange_code = create_exchange_code(session_key)
await ws.send_json( await ws.send_json(
{ {
"user": str(ctx.user.uuid), "user": str(ctx.user.uuid),
"session_token": ctx.session.key, "exchange_code": exchange_code,
} }
) )
+8 -9
View File
@@ -7,7 +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 expires 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
@@ -69,13 +69,13 @@ async def authenticate_chat(
async def authenticate_and_login( async def authenticate_and_login(
ws: WebSocket, ws: WebSocket,
auth: str | None = None, auth: str | None = None,
) -> SessionContext: ) -> tuple[SessionContext, str]:
"""Run WebAuthn authentication flow, create session, and return the session context. """Run WebAuthn authentication flow, create session, and return the session context.
If auth is provided, restrict authentication to credentials of that session's user. If auth is provided, restrict authentication to credentials of that session's user.
Returns: Returns:
SessionContext for the authenticated session Tuple of (SessionContext for the authenticated session, session secret)
""" """
origin = validate_origin(ws) origin = validate_origin(ws)
host = origin.split("://", 1)[1] host = origin.split("://", 1)[1]
@@ -91,25 +91,24 @@ 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 = db.get_user_credential_ids(existing_ctx.user.uuid) or None credential_ids = existing_ctx.user.credential_ids or None
cred, new_sign_count = await authenticate_chat(ws, credential_ids) cred, new_sign_count = await authenticate_chat(ws, credential_ids)
# Create session and update user/credential # Create session and update user/credential
token = db.login( secret = db.login(
user_uuid=cred.user_uuid, user_uuid=cred.user_uuid,
credential_uuid=cred.uuid, credential_uuid=cred.uuid,
sign_count=new_sign_count, sign_count=new_sign_count,
host=normalized_host, host=normalized_host,
ip=metadata["ip"], ip=metadata["ip"],
user_agent=metadata["user_agent"], user_agent=metadata["user_agent"],
expiry=expires(),
) )
# Fetch and return the full session context # Fetch and return the full session context
ctx = db.data().session_ctx(token, 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 return ctx, secret
+4 -1
View File
@@ -1,6 +1,6 @@
from typing import Generic, TypeVar from typing import Generic, TypeVar
from paskia import db, remoteauth from paskia import authcode, db, remoteauth
from paskia.bootstrap import bootstrap_if_needed from paskia.bootstrap import bootstrap_if_needed
from paskia.sansio import Passkey from paskia.sansio import Passkey
@@ -58,6 +58,9 @@ async def init(
# Initialize remote auth manager # Initialize remote auth manager
await remoteauth.init() await remoteauth.init()
# Initialize auth code manager
await authcode.start()
if bootstrap: if bootstrap:
# Bootstrap system if needed # Bootstrap system if needed
+118
View File
@@ -0,0 +1,118 @@
"""
OIDC Back-Channel Logout notifications.
When sessions are deleted (logout, admin, expiry), this module notifies
any OIDC clients that have a backchannel_logout_uri configured.
"""
import asyncio
import logging
from uuid import UUID
import httpx
from paskia import db
from paskia.util import oidjwt
from paskia.util.runtime import _load_config
_logger = logging.getLogger(__name__)
# Timeout for back-channel logout requests
_TIMEOUT = httpx.Timeout(10.0, connect=5.0)
def _issuer() -> str:
"""Derive issuer URL from config (same base as discovery document)."""
cfg = _load_config()
return cfg.get("site_url", "https://localhost")
def _collect_oidc_sessions(
session_keys: list[str],
) -> list[tuple[str, str, UUID, UUID | None]]:
"""Collect (backchannel_logout_uri, sid, client_uuid, user_uuid) for OIDC sessions.
Must be called before the sessions are deleted from the database.
Returns only sessions whose client has a backchannel_logout_uri configured.
"""
notifications = []
data = db.data()
for key in session_keys:
session = data.sessions.get(key)
if not session or session.client_uuid is None:
continue
client = data.oidc.clients.get(session.client_uuid)
if not client or not client.backchannel_logout_uri:
continue
sid = session.key
notifications.append(
(client.backchannel_logout_uri, sid, session.client_uuid, session.user_uuid)
)
return notifications
async def _send_logout_token(
client: httpx.AsyncClient,
uri: str,
token: str,
) -> None:
"""POST a logout_token to a single client endpoint."""
try:
resp = await client.post(
uri,
data={"logout_token": token},
headers={"Content-Type": "application/x-www-form-urlencoded"},
)
if resp.status_code == 200:
_logger.debug("Back-channel logout OK: %s", uri)
else:
_logger.warning(
"Back-channel logout %s returned %d: %s",
uri,
resp.status_code,
resp.text[:200],
)
except Exception:
_logger.warning("Back-channel logout failed: %s", uri, exc_info=True)
async def notify(
notifications: list[tuple[str, str, UUID, UUID | None]],
) -> None:
"""Send back-channel logout tokens to all collected endpoints.
Args:
notifications: list of (backchannel_logout_uri, sid, client_uuid, user_uuid)
as returned by _collect_oidc_sessions.
"""
if not notifications:
return
issuer = _issuer()
async with httpx.AsyncClient(timeout=_TIMEOUT) as client:
tasks = []
for uri, sid, client_uuid, user_uuid in notifications:
token = oidjwt.create_logout_token(
issuer=issuer,
audience=str(client_uuid),
sid=sid,
sub=user_uuid,
)
tasks.append(_send_logout_token(client, uri, token))
await asyncio.gather(*tasks, return_exceptions=True)
def schedule_notifications(session_keys: list[str]) -> None:
"""Collect OIDC info from sessions (before deletion) and schedule async notifications.
Must be called BEFORE the sessions are deleted. The actual HTTP requests
are fire-and-forget via the running event loop.
"""
notifications = _collect_oidc_sessions(session_keys)
if not notifications:
return
try:
loop = asyncio.get_running_loop()
loop.create_task(notify(notifications))
except RuntimeError:
_logger.debug("No event loop for back-channel logout notifications")
+169 -38
View File
@@ -4,32 +4,17 @@ msgspec handles UUID and datetime conversion automatically.
API structs inherit from db structs with kw_only=True to add uuid/key fields. API structs inherit from db structs with kw_only=True to add uuid/key fields.
""" """
from datetime import UTC, datetime from __future__ import annotations
from datetime import datetime
from uuid import UUID from uuid import UUID
import msgspec import msgspec
from paskia.db.structs import Org, Permission, Role, User from paskia import db
from paskia.db.structs import Credential, Org, Permission, Role, User
from paskia.util import useragent from paskia.util import useragent
def _utc_datetime(dt: datetime | None) -> datetime | None:
"""Convert datetime to UTC, handling both aware and naive datetimes."""
if dt is None:
return None
if dt.tzinfo:
return dt.astimezone(UTC)
return dt.replace(tzinfo=UTC)
def format_datetime(dt: datetime | None) -> str | None:
"""Format a datetime to ISO 8601 string with Z suffix for UTC."""
if dt is None:
return None
utc_dt = _utc_datetime(dt)
return utc_dt.isoformat().replace("+00:00", "Z") if utc_dt else None
# ------------------------------------------------------------------------- # -------------------------------------------------------------------------
# API structs - inherit from db structs, add uuid for serialization # API structs - inherit from db structs, add uuid for serialization
# ------------------------------------------------------------------------- # -------------------------------------------------------------------------
@@ -41,7 +26,7 @@ class ApiUser(User, kw_only=True):
uuid: UUID uuid: UUID
@classmethod @classmethod
def from_db(cls, u: User) -> "ApiUser": def from_db(cls, u: User) -> ApiUser:
return cls(uuid=u.uuid, **msgspec.structs.asdict(u)) return cls(uuid=u.uuid, **msgspec.structs.asdict(u))
@@ -51,7 +36,7 @@ class ApiOrg(Org, kw_only=True):
uuid: UUID uuid: UUID
@classmethod @classmethod
def from_db(cls, o: Org) -> "ApiOrg": def from_db(cls, o: Org) -> ApiOrg:
return cls(uuid=o.uuid, **msgspec.structs.asdict(o)) return cls(uuid=o.uuid, **msgspec.structs.asdict(o))
@@ -61,31 +46,65 @@ class ApiRole(Role, kw_only=True):
uuid: UUID uuid: UUID
@classmethod @classmethod
def from_db(cls, r: Role) -> "ApiRole": def from_db(cls, r: Role) -> ApiRole:
return cls(uuid=r.uuid, **msgspec.structs.asdict(r)) return cls(uuid=r.uuid, **msgspec.structs.asdict(r))
class ApiPermission(Permission, kw_only=True): class ApiPermission(msgspec.Struct, kw_only=True):
"""Permission with uuid serialized.""" """Permission for API responses, without org details."""
uuid: UUID scope: str
display_name: str
domain: str | None = None
@classmethod @classmethod
def from_db(cls, p: Permission) -> "ApiPermission": def from_db(cls, p: Permission) -> ApiPermission:
return cls(uuid=p.uuid, **msgspec.structs.asdict(p)) return cls(
scope=p.scope,
display_name=p.display_name,
domain=p.domain,
)
class ApiSession(msgspec.Struct): class ApiOidcClient(msgspec.Struct, kw_only=True):
"""Session for API responses with computed fields.""" """OIDC Client for API responses."""
name: str
redirect_uris: list[str]
backchannel_logout_uri: str | None = None
active_sessions: int = 0
@classmethod
def from_db(cls, c, active_sessions: int = 0):
return cls(
name=c.name,
redirect_uris=c.redirect_uris,
backchannel_logout_uri=c.backchannel_logout_uri,
active_sessions=active_sessions,
)
class ApiAaguidInfo(msgspec.Struct, kw_only=True, omit_defaults=True):
"""AAGUID information for authenticators."""
name: str
icon: str | None = None
icon_dark: str | None = None
class ApiUserSession(msgspec.Struct, omit_defaults=True):
"""Session for user info responses with computed fields."""
id: str
credential_uuid: UUID = msgspec.field(name="credential") credential_uuid: UUID = msgspec.field(name="credential")
host: str host: str
ip: str ip: str
user_agent: str user_agent: str
validated: datetime
last_renewed: datetime last_renewed: datetime
is_current: bool = False is_current: bool = False
is_current_host: bool = False is_current_host: bool = False
client_uuid: UUID | None = msgspec.field(name="client", default=None)
client_name: str | None = None
@classmethod @classmethod
def from_db( def from_db(
@@ -94,17 +113,129 @@ class ApiSession(msgspec.Struct):
*, *,
current_key: str, current_key: str,
normalized_host: str | None, normalized_host: str | None,
expires_delta, # timedelta ) -> ApiUserSession:
) -> "ApiSession": client_name = None
if s.client_uuid:
c = db.data().oidc.clients.get(s.client_uuid)
client_name = c.name if c else str(s.client_uuid)
return cls( return cls(
id=s.key,
credential_uuid=s.credential_uuid, credential_uuid=s.credential_uuid,
host=s.host, host=s.host,
ip=s.ip, ip=s.ip,
user_agent=useragent.compact_user_agent(s.user_agent), user_agent=useragent.compact_user_agent(s.user_agent),
last_renewed=s.expiry - expires_delta, validated=s.validated,
last_renewed=s.validated,
is_current=s.key == current_key, is_current=s.key == current_key,
is_current_host=bool( is_current_host=not s.client_uuid
normalized_host and s.host and s.host == normalized_host and bool(normalized_host and s.host and s.host == normalized_host),
), client_uuid=s.client_uuid,
client_name=client_name,
) )
class ApiUserDetail(msgspec.Struct, kw_only=True):
"""User detail response with credentials and sessions."""
user: ApiUser
credentials: dict[UUID, Credential]
aaguid_info: dict[str, ApiAaguidInfo]
sessions: dict[bytes, ApiUserSession]
permissions: dict[UUID, ApiPermission] = {}
org: ApiOrg | None = None
role: ApiRole | None = None
# -------------------------------------------------------------------------
# Nested API structs for org response - without uuid
# -------------------------------------------------------------------------
class ApiOrgResponse(msgspec.Struct, kw_only=True):
"""Org response containing Org with roles and users as UUID-keyed dicts."""
org: ApiOrg
permissions: dict[UUID, Permission]
roles: dict[UUID, Role]
users: dict[UUID, User]
class ApiSettings(msgspec.Struct):
"""Settings response struct."""
rp_id: str
rp_name: str
ui_base_path: str
auth_host: str | None
auth_site_url: str
session_cookie: str
version: str
class ApiTokenInfo(msgspec.Struct, omit_defaults=True):
"""Token info response struct."""
token_type: str
display_name: str
theme: str = ""
class ApiUuidResponse(msgspec.Struct):
"""Response struct for creation endpoints returning a UUID."""
uuid: str
class ApiCreateLinkResponse(msgspec.Struct):
"""Response struct for create-link endpoints."""
url: str
expires: datetime
token_type: str
message: str | None = None
class ApiUserContext(msgspec.Struct, omit_defaults=True):
"""User context for session validation."""
uuid: UUID
display_name: str
theme: str = ""
class ApiOrgContext(msgspec.Struct):
"""Org context for session validation."""
uuid: UUID
display_name: str
class ApiRoleContext(msgspec.Struct):
"""Role context for session validation."""
uuid: UUID
display_name: str
class ApiSessionContext(msgspec.Struct):
"""Session context struct."""
user: ApiUserContext
org: ApiOrgContext
role: ApiRoleContext
permissions: list[str]
class ApiValidateResponse(msgspec.Struct):
"""Response struct for validate endpoint."""
valid: bool
renewed: bool
ctx: ApiSessionContext
class ApiAdminInfo(msgspec.Struct, kw_only=True):
"""Combined admin info response."""
orgs: dict[UUID, ApiOrgResponse]
permissions: dict[UUID, ApiPermission]
oidc_clients: dict[UUID, ApiOidcClient] = {}
+50
View File
@@ -0,0 +1,50 @@
import hashlib
import base64url
from cryptography.hazmat.primitives import serialization
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
def hash_secret(*data: str | bytes, length=12) -> str:
"""A custom HMAC that securily combines and hashes the given data. The first argument should be a namespacing string."""
p = [d.encode() if hasattr(d, "encode") else d for d in data]
p += [len(x).to_bytes(8, "little") for x in [p, *p]]
return base64url.enc(hashlib.sha256(b"".join(p)).digest()[:length])
def secret_key() -> bytes:
"""Generate a new Ed25519 private key and return as 32 raw bytes."""
private_key = Ed25519PrivateKey.generate()
return private_key.private_bytes(
encoding=serialization.Encoding.Raw,
format=serialization.PrivateFormat.Raw,
encryption_algorithm=serialization.NoEncryption(),
)
def public_key_from_secret(secret_key_bytes: bytes) -> Ed25519PrivateKey:
"""Load Ed25519 private key from 32 raw bytes."""
return Ed25519PrivateKey.from_private_bytes(secret_key_bytes)
def get_public_key_der(private_key: Ed25519PrivateKey) -> bytes:
"""Get DER-encoded public key for kid generation."""
public_key = private_key.public_key()
return public_key.public_bytes(
encoding=serialization.Encoding.DER,
format=serialization.PublicFormat.SubjectPublicKeyInfo,
)
def generate_kid(public_key_der: bytes) -> str:
"""Generate key ID from public key DER bytes."""
return hashlib.sha256(public_key_der).hexdigest()[:16]
def get_public_key_raw(private_key: Ed25519PrivateKey) -> bytes:
"""Get raw 32-byte public key for JWKS."""
public_key = private_key.public_key()
return public_key.public_bytes(
encoding=serialization.Encoding.Raw,
format=serialization.PublicFormat.Raw,
)
+1 -1
View File
@@ -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:
+26 -16
View File
@@ -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,10 @@ 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 reload_config() -> None: def reload_config() -> None:
@@ -74,3 +72,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}"
+33
View File
@@ -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.
+37
View File
@@ -0,0 +1,37 @@
"""Name and username utilities."""
import re
import unicodedata
def slugify_name(name: str) -> str:
"""Convert display name to slug-compatible username.
Uses dots as separators, preserves existing dots and dashes.
Strips trailing parenthesized content and other unwanted punctuation.
Examples:
'John Doe''john.doe'
'María José García''maria.jose.garcia'
'Jean-Pierre''jean-pierre'
'John.Doe''john.doe'
'John Doe (Admin)''john.doe'
' Multiple Spaces ''multiple.spaces'
Returns empty string for empty/whitespace-only input.
"""
if not name:
return ""
# Strip trailing parenthesized content (e.g., " (Admin)")
name = re.sub(r"\s*\([^)]*\)\s*$", "", name)
# Normalize unicode → ASCII equivalent (é → e, ñ → n)
name = unicodedata.normalize("NFKD", name)
name = name.encode("ascii", "ignore").decode("ascii")
# Lowercase
name = name.lower()
# Replace non-alphanumeric (except . and -) with dots
name = re.sub(r"[^a-z0-9.-]+", ".", name)
# Collapse multiple dots
name = re.sub(r"\.+", ".", name)
# Strip leading/trailing dots
return name.strip(".")
+230
View File
@@ -0,0 +1,230 @@
"""
OIDC JWT utilities for signing ID tokens and serving JWKS.
"""
import hashlib
from base64 import urlsafe_b64encode
from datetime import UTC, datetime, timedelta
from uuid import UUID
import jwt
from paskia import db
from paskia.util.crypto import (
generate_kid,
get_public_key_der,
get_public_key_raw,
public_key_from_secret,
secret_key,
)
# JWT signing key (loaded on first use)
_private_key = None
_public_key = None
_kid: str | None = None
def _load_or_generate_key() -> None:
"""Load existing Ed25519 key or generate a new one."""
global _private_key, _public_key, _kid
data = db.data()
if data.oidc.key is not None:
_private_key = public_key_from_secret(data.oidc.key)
else:
raw_key = secret_key()
with data.transaction("oidc_key"):
data.oidc.key = raw_key
_private_key = public_key_from_secret(raw_key)
_public_key = _private_key.public_key()
# Generate kid from public key fingerprint
pub_der = get_public_key_der(_private_key)
_kid = generate_kid(pub_der)
def _ensure_key() -> None:
"""Ensure key is loaded."""
if _private_key is None:
_load_or_generate_key()
def get_jwks() -> dict:
"""Get JWKS (JSON Web Key Set) for public key verification."""
_ensure_key()
assert _public_key is not None
# Ed25519 public key is 32 bytes raw
pub_bytes = get_public_key_raw(_private_key)
return {
"keys": [
{
"kty": "OKP",
"crv": "Ed25519",
"use": "sig",
"alg": "EdDSA",
"kid": _kid,
"x": urlsafe_b64encode(pub_bytes).rstrip(b"=").decode("ascii"),
}
]
}
def create_id_token(
issuer: str,
subject: UUID,
audience: str, # client_id
nonce: str | None = None,
sid: str | None = None,
name: str | None = None,
preferred_username: str | None = None,
email: str | None = None,
groups: list[str] | None = None,
auth_time: datetime | None = None,
expires_in: int = 3600,
) -> str:
"""Create a signed ID token (JWT).
Args:
issuer: Token issuer (site URL)
subject: User UUID (sub claim)
audience: Client ID (aud claim)
nonce: Nonce from authorization request
sid: Session ID for backchannel logout
name: User's display name
preferred_username: User's preferred username
email: User's email address
groups: List of permission scopes (groups claim)
auth_time: When the user authenticated (last credential use time)
expires_in: Token lifetime in seconds
Returns:
Signed JWT string
"""
_ensure_key()
now = datetime.now(UTC)
payload = {
"iss": issuer,
"sub": str(subject),
"aud": audience,
"iat": int(now.timestamp()),
"exp": int((now + timedelta(seconds=expires_in)).timestamp()),
}
if nonce:
payload["nonce"] = nonce
if sid:
payload["sid"] = sid
if name:
payload["name"] = name
if preferred_username:
payload["preferred_username"] = preferred_username
if email:
payload["email"] = email
if groups:
payload["groups"] = groups
if auth_time:
payload["auth_time"] = int(auth_time.timestamp())
return jwt.encode(payload, _private_key, algorithm="EdDSA", headers={"kid": _kid})
def create_access_token(
issuer: str,
subject: UUID,
audience: str,
scope: str,
expires_in: int = 3600,
) -> str:
"""Create a signed access token (JWT) for userinfo endpoint.
Args:
issuer: Token issuer (site URL)
subject: User UUID
audience: Client ID
scope: Granted scopes
expires_in: Token lifetime in seconds
Returns:
Signed JWT string
"""
_ensure_key()
now = datetime.now(UTC)
payload = {
"iss": issuer,
"sub": str(subject),
"aud": audience,
"scope": scope,
"iat": int(now.timestamp()),
"exp": int((now + timedelta(seconds=expires_in)).timestamp()),
}
return jwt.encode(payload, _private_key, algorithm="EdDSA", headers={"kid": _kid})
def decode_access_token(
token: str, issuer: str, audience: str | None = None
) -> dict | None:
"""Decode and verify an access token.
Args:
token: JWT string
issuer: Expected issuer
audience: Optional expected audience (client_id). If provided, aud claim must match.
Returns:
Decoded payload or None if invalid
"""
_ensure_key()
try:
# PyJWT requires audience parameter when token has aud claim.
# When audience is None, we skip PyJWT's audience validation and validate manually.
options = {}
decode_kwargs = {
"algorithms": ["EdDSA"],
"issuer": issuer,
}
if audience is not None:
decode_kwargs["audience"] = audience
else:
options["verify_aud"] = False
return jwt.decode(token, _public_key, options=options, **decode_kwargs)
except jwt.PyJWTError:
return None
def create_logout_token(
issuer: str,
audience: str,
sid: str | None = None,
sub: UUID | None = None,
) -> str:
"""Create a signed logout token for back-channel logout notification.
Per OIDC Back-Channel Logout 1.0, the logout token must contain
either sid (session) or sub (user), or both.
Args:
issuer: Token issuer (site URL)
audience: Client ID (aud claim)
sid: Session ID (base64url-encoded)
sub: User UUID
Returns:
Signed JWT string
"""
_ensure_key()
now = datetime.now(UTC)
payload = {
"iss": issuer,
"aud": audience,
"iat": int(now.timestamp()),
"exp": int((now + timedelta(seconds=120)).timestamp()),
"events": {"http://schemas.openid.net/event/backchannel-logout": {}},
"jti": hashlib.sha256(
f"{now.timestamp()}{audience}{sid}{sub}".encode()
).hexdigest()[:16],
}
if sid:
payload["sid"] = sid
if sub:
payload["sub"] = str(sub)
return jwt.encode(payload, _private_key, algorithm="EdDSA", headers={"kid": _kid})
+2 -2
View File
@@ -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)
+31
View File
@@ -0,0 +1,31 @@
"""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)
+2 -3
View File
@@ -1,8 +1,7 @@
"""Utility functions for session validation and checking.""" """Utility functions for session validation, derivation, and checking."""
from datetime import UTC, datetime from datetime import UTC, datetime
from paskia.authsession import EXPIRES
from paskia.db import SessionContext from paskia.db import SessionContext
from paskia.util.timeutil import parse_duration from paskia.util.timeutil import parse_duration
@@ -32,7 +31,7 @@ def check_session_age(ctx: SessionContext, max_age: str | None) -> bool:
if ctx.credential and ctx.credential.last_used: if ctx.credential and ctx.credential.last_used:
auth_time = ctx.credential.last_used auth_time = ctx.credential.last_used
else: else:
auth_time = ctx.session.expiry - EXPIRES auth_time = ctx.session.validated
time_since_auth = datetime.now(UTC) - auth_time time_since_auth = datetime.now(UTC) - auth_time
return time_since_auth <= max_age_delta return time_since_auth <= max_age_delta
+28 -24
View File
@@ -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):
+51 -43
View File
@@ -1,63 +1,71 @@
"""User information formatting and retrieval logic.""" """User information formatting and retrieval logic."""
from paskia import aaguid, db from paskia import aaguid, db
from paskia.authsession import EXPIRES
from paskia.db import SessionContext from paskia.db import SessionContext
from paskia.util import hostutil, permutil from paskia.util import hostutil
from paskia.util.apistructs import ApiSession from paskia.util.apistructs import (
ApiAaguidInfo,
ApiOrg,
ApiOrgContext,
ApiPermission,
ApiRole,
ApiRoleContext,
ApiSessionContext,
ApiUser,
ApiUserContext,
ApiUserDetail,
ApiUserSession,
)
def build_session_context(ctx: SessionContext) -> dict: def build_session_context(ctx: SessionContext) -> ApiSessionContext:
"""Build session context dict from SessionContext.""" """Build session context struct from SessionContext."""
result = { user = ApiUserContext(
"user": {"uuid": ctx.user.uuid, "display_name": ctx.user.display_name}, uuid=ctx.user.uuid,
"org": {"uuid": ctx.org.uuid, "display_name": ctx.org.display_name}, display_name=ctx.user.display_name,
"role": {"uuid": ctx.role.uuid, "display_name": ctx.role.display_name}, theme=ctx.user.theme,
"permissions": [p.scope for p in ctx.permissions], )
} org = ApiOrgContext(uuid=ctx.org.uuid, display_name=ctx.org.display_name)
if ctx.user.theme: role = ApiRoleContext(uuid=ctx.role.uuid, display_name=ctx.role.display_name)
result["user"]["theme"] = ctx.user.theme return ApiSessionContext(
return result user=user,
org=org,
role=role,
permissions=[p.scope for p in ctx.permissions],
)
async def build_user_info( async def build_user_info(
*, *,
user_uuid, user_uuid,
auth: str, session_key: str,
session_record,
request_host: str | None, request_host: str | None,
) -> dict: ctx: SessionContext | None = None,
"""Build user info dict for authenticated users.""" ) -> ApiUserDetail:
ctx = await permutil.session_context(auth, request_host) """Build user info struct for authenticated users."""
user = db.data().users[user_uuid] user = db.data().users[user_uuid]
normalized_host = hostutil.normalize_host(request_host) normalized_host = hostutil.normalize_host(request_host)
credentials = sorted(user.credentials, key=lambda c: c.created_at) sessions = {
return { s.key: ApiUserSession.from_db(
"ctx": build_session_context(ctx),
"created_at": ctx.user.created_at,
"last_seen": ctx.user.last_seen,
"visits": ctx.user.visits,
"credentials": [
{
"credential": c.uuid,
"aaguid": c.aaguid,
"created_at": c.created_at,
"last_used": c.last_used,
"last_verified": c.last_verified,
"sign_count": c.sign_count,
"is_current_session": session_record.credential == c.uuid,
}
for c in credentials
],
"aaguid_info": aaguid.filter(c.aaguid for c in credentials),
"sessions": [
ApiSession.from_db(
s, s,
current_key=auth, current_key=session_key,
normalized_host=normalized_host, normalized_host=normalized_host,
expires_delta=EXPIRES,
) )
for s in user.sessions for s in user.sessions
],
} }
return 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.filter(c.aaguid for c in user.credentials).items()
},
sessions=sessions,
permissions={p.uuid: ApiPermission.from_db(p) for p in ctx.permissions}
if ctx
else {},
org=ApiOrg.from_db(ctx.org) if ctx else None,
role=ApiRole.from_db(ctx.role) if ctx else None,
)
+31 -27
View File
@@ -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)
+18 -29
View File
@@ -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>=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,18 +74,8 @@ 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.fastapi.__main__:main" paskia = "paskia.__main__:main"
[tool.hatch.build] [tool.hatch.build]
artifacts = ["paskia/frontend-build"] artifacts = ["paskia/frontend-build"]
+41 -56
View File
@@ -1,28 +1,5 @@
#!/usr/bin/env -S uv run #!/usr/bin/env -S uv run
"""Run Vite development server for frontend and Paskia backend with auto-reload. """Run Vite development server for Vue app and FastAPI backend with auto-reload."""
This script is only available when running from the git repository source,
not from the installed package. It starts both the Vite frontend dev server
and the Paskia backend with auto-reload enabled.
Usage:
uv run scripts/devserver.py [-l host:port] [options...]
The optional -l/--listen argument sets where the Vite frontend listens.
All other options are forwarded to `paskia`.
Backend always listens on localhost:4402.
Environment:
PASKIA_FRONTEND_URL Set by this script for the backend to know where Vite is.
PASKIA_BACKEND_URL Set by this script for Vite to know where to proxy API calls.
PASKIA_SITE_URL User-facing URL for reset links (Caddy HTTPS or Vite HTTP).
Options:
--caddy Run Caddy as HTTPS proxy on port 443 (requires sudo)
--rp-id HOST Relying Party ID (used as hostname for Caddy)
--origin URL Allowed origin(s), passed to backend
--auth-host H Dedicated auth host, passed to backend
"""
import argparse import argparse
import asyncio import asyncio
@@ -45,8 +22,8 @@ from devutil import ( # noqa: E402
setup_vite, setup_vite,
) )
DEFAULT_VITE_PORT = 4403 # overrides by CLI option DEFAULT_VITE_PORT = 4403
BACKEND_PORT = 4402 # hardcoded, also in vite.config.ts DEFAULT_DEV_PORT = 4402
CADDY_PORT = 443 # HTTPS port for Caddy proxy CADDY_PORT = 443 # HTTPS port for Caddy proxy
CADDY_HTTP_PORT = 80 # HTTP port for ACME challenges CADDY_HTTP_PORT = 80 # HTTP port for ACME challenges
@@ -54,17 +31,17 @@ CADDYFILE_SITE_BLOCK = """\
SITE_ADDR { SITE_ADDR {
# WebSockets bypass directly to backend (workaround for bun proxy bug) # WebSockets bypass directly to backend (workaround for bun proxy bug)
handle /auth/ws/* { handle /auth/ws/* {
reverse_proxy localhost:BACKEND_PORT reverse_proxy BACKEND_ADDR
} }
# Everything else goes to or via Vite # Everything else goes to or via Vite
handle { handle {
reverse_proxy localhost:VITE_PORT reverse_proxy VITE_ADDR
} }
} }
""" """
def build_caddyfile(origins: list[str], vite_port: int) -> str: def build_caddyfile(origins: list[str], viteurl: str, backurl: str) -> str:
"""Build a Caddyfile for the given origins.""" """Build a Caddyfile for the given origins."""
caddyfile_parts = [] caddyfile_parts = []
for origin in origins: for origin in origins:
@@ -78,21 +55,23 @@ def build_caddyfile(origins: list[str], vite_port: int) -> str:
site_addr = f"{scheme}://{host}:{port}" site_addr = f"{scheme}://{host}:{port}"
block = ( block = (
CADDYFILE_SITE_BLOCK.replace("SITE_ADDR", site_addr) CADDYFILE_SITE_BLOCK.replace("SITE_ADDR", site_addr)
.replace("BACKEND_PORT", str(BACKEND_PORT)) .replace("BACKEND_ADDR", backurl)
.replace("VITE_PORT", str(vite_port)) .replace("VITE_ADDR", viteurl)
) )
caddyfile_parts.append(block) caddyfile_parts.append(block)
return "\n".join(caddyfile_parts) return "\n".join(caddyfile_parts)
async def run_caddy(origins: list[str], vite_port: int) -> asyncio.subprocess.Process: async def run_caddy(
origins: list[str], viteurl: str, backurl: str
) -> asyncio.subprocess.Process:
"""Start Caddy as HTTPS reverse proxy, wait for ready signal.""" """Start Caddy as HTTPS reverse proxy, wait for ready signal."""
caddy_path = shutil.which("caddy") caddy_path = shutil.which("caddy")
if not caddy_path: if not caddy_path:
logger.warning("Caddy not found. Install it to use --caddy option.") logger.warning("Caddy not found. Install it to use --caddy option.")
raise SystemExit(1) raise SystemExit(1)
caddyfile = build_caddyfile(origins, vite_port) caddyfile = build_caddyfile(origins, viteurl, backurl)
cmd = ["sudo", caddy_path, "run", "--config", "-", "--adapter", "caddyfile"] cmd = ["sudo", caddy_path, "run", "--config", "-", "--adapter", "caddyfile"]
logger.info(">>> sudo caddy @ %s", " ".join(origins)) logger.info(">>> sudo caddy @ %s", " ".join(origins))
@@ -163,10 +142,7 @@ async def run_devserver(args: argparse.Namespace, remaining: list[str]) -> None:
raise SystemExit(1) raise SystemExit(1)
viteurl, npm_install, vite = setup_vite(args.listen, DEFAULT_VITE_PORT) viteurl, npm_install, vite = setup_vite(args.listen, DEFAULT_VITE_PORT)
backurl, paskia = setup_cli("paskia", f"localhost:{BACKEND_PORT}", BACKEND_PORT) backurl, paskia = setup_cli("paskia", args.backend, DEFAULT_DEV_PORT)
# Extract vite port for Caddy config
vite_port = int(viteurl.rsplit(":", 1)[1])
# Build paskia command with options # Build paskia command with options
paskia.extend(["--rp-id", args.rp_id]) paskia.extend(["--rp-id", args.rp_id])
@@ -177,7 +153,16 @@ 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 # Set environment for subprocesses
os.environ["PASKIA_VITE_URL"] = viteurl
os.environ["PASKIA_BACKEND_URL"] = backurl
os.environ["PASKIA_DEV"] = "1"
if args.auth_host:
os.environ["PASKIA_AUTH_HOST"] = args.auth_host
async with ProcessGroup() as pg:
# Start Caddy first if requested (needs to bind ports)
if args.caddy:
caddy_origins = [] caddy_origins = []
if args.auth_host: if args.auth_host:
auth_host = args.auth_host auth_host = args.auth_host
@@ -190,23 +175,11 @@ async def run_devserver(args: argparse.Namespace, remaining: list[str]) -> None:
if "://" not in origin: if "://" not in origin:
origin = f"https://{origin}" origin = f"https://{origin}"
caddy_origins.append(origin) caddy_origins.append(origin)
if not args.auth_host and not args.origins: if not caddy_origins:
caddy_origins.append(f"https://{args.rp_id}") caddy_origins.append(f"https://{args.rp_id}")
# Remove duplicates while preserving order seen: set = set()
seen = set()
caddy_origins = [x for x in caddy_origins if not (x in seen or seen.add(x))] 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)
# Set environment for subprocesses
os.environ["PASKIA_FRONTEND_URL"] = viteurl
os.environ["PASKIA_BACKEND_URL"] = backurl
os.environ["PASKIA_SITE_URL"] = caddy_origins[0] if args.caddy else viteurl
if args.auth_host:
os.environ["PASKIA_AUTH_HOST"] = args.auth_host
async with ProcessGroup() as pg:
# Start Caddy first if requested (needs to bind ports)
if args.caddy:
caddy_proc = await run_caddy(caddy_origins, vite_port)
pg._procs.append(caddy_proc) pg._procs.append(caddy_proc)
pg._cmds[caddy_proc.pid] = "caddy" pg._cmds[caddy_proc.pid] = "caddy"
@@ -222,9 +195,13 @@ def main():
parser.add_argument( parser.add_argument(
"-l", "-l",
"--listen", "--listen",
metavar="ENDPOINT", metavar="addr",
default=None, help=f"Vite (default: localhost:{DEFAULT_VITE_PORT})",
help="Vite frontend endpoint (default: localhost:4403)", )
parser.add_argument(
"--backend",
metavar="addr",
help=f"FastAPI (default: localhost:{DEFAULT_DEV_PORT})",
) )
parser.add_argument("--caddy", action="store_true", help="Run Caddy as HTTPS proxy") parser.add_argument("--caddy", action="store_true", help="Run Caddy as HTTPS proxy")
parser.add_argument("--rp-id", default="localhost", help="Relying Party ID") parser.add_argument("--rp-id", default="localhost", help="Relying Party ID")
@@ -238,5 +215,13 @@ def main():
asyncio.run(run_devserver(args, remaining)) asyncio.run(run_devserver(args, remaining))
HELP_EPILOG = """
Other options are forwarded to paskia [args]
JS_RUNTIME environment variable can be used to select the JS runtime:
npm, deno, bun, or full path to the runtime executable (node maps to npm).
"""
if __name__ == "__main__": if __name__ == "__main__":
main() main()
+57 -15
View File
@@ -9,10 +9,14 @@ Since we can't emulate WebAuthn passkeys, we create sessions directly
in the database to test authenticated endpoints. in the database to test authenticated endpoints.
""" """
from __future__ import annotations
import asyncio import asyncio
import os import os
import secrets
import tempfile import tempfile
from collections.abc import AsyncGenerator from collections.abc import AsyncGenerator
from datetime import UTC, datetime, timedelta
from uuid import UUID from uuid import UUID
import httpx import httpx
@@ -21,24 +25,28 @@ import pytest_asyncio
import paskia.db.operations as ops_db import paskia.db.operations as ops_db
from paskia import globals as paskia_globals from paskia import globals as paskia_globals
from paskia.authsession import expires, reset_expires from paskia.authsession import reset_expires
from paskia.config import SESSION_LIFETIME
from paskia.db import ( from paskia.db import (
Config,
Credential, Credential,
Org, Org,
Permission, Permission,
Role, Role,
User, User,
bootstrap,
create_credential, create_credential,
create_reset_token, create_reset_token,
create_role, create_role,
create_session,
create_user, create_user,
) )
from paskia.db.jsonl import JsonlStore from paskia.db.jsonl import JsonlStore
from paskia.db.operations import DB from paskia.db.operations import DB
from paskia.db.structs import Session
from paskia.fastapi.mainapp import app from paskia.fastapi.mainapp import app
from paskia.fastapi.session import AUTH_COOKIE_NAME from paskia.fastapi.session import AUTH_COOKIE_NAME
from paskia.sansio import Passkey from paskia.sansio import Passkey
from paskia.util.crypto import hash_secret
@pytest.fixture(scope="session") @pytest.fixture(scope="session")
@@ -58,16 +66,15 @@ async def test_db() -> AsyncGenerator[DB, None]:
- A default organization with Administration role - A default organization with Administration role
- An admin user with the Administration role - An admin user with the Administration role
""" """
with tempfile.NamedTemporaryFile(suffix=".jsonl", delete=True) as f: with tempfile.NamedTemporaryFile(suffix=".jsonl", delete=True) as f:
db = DB() db = DB(config=Config(rp_id="test.example.com"))
store = JsonlStore(db, f.name) store = JsonlStore(db, f.name)
db._store = store db._store = store
await store.load() await store.load()
ops_db._db = db ops_db._db = db
ops_db._store = store ops_db._store = store
# Bootstrap creates the initial permissions, org, role, and admin user # Bootstrap creates the initial permissions, org, role, and admin user
ops_db.bootstrap( bootstrap(
org_name="Test Organization", org_name="Test Organization",
admin_name="Test Admin", admin_name="Test Admin",
) )
@@ -177,14 +184,11 @@ async def session_token(
test_db: DB, test_user: User, test_credential: Credential test_db: DB, test_user: User, test_credential: Credential
) -> str: ) -> str:
"""Create a session for the admin user and return the token.""" """Create a session for the admin user and return the token."""
return create_session( _db_key, secret = create_test_session(
user_uuid=test_user.uuid, user_uuid=test_user.uuid,
credential_uuid=test_credential.uuid, credential_uuid=test_credential.uuid,
host="localhost",
ip="127.0.0.1",
user_agent="pytest",
expiry=expires(),
) )
return secret
@pytest_asyncio.fixture(scope="function") @pytest_asyncio.fixture(scope="function")
@@ -192,14 +196,11 @@ async def regular_session_token(
test_db: DB, regular_user: User, regular_credential: Credential test_db: DB, regular_user: User, regular_credential: Credential
) -> str: ) -> str:
"""Create a session for a regular user and return the token.""" """Create a session for a regular user and return the token."""
return create_session( _db_key, secret = create_test_session(
user_uuid=regular_user.uuid, user_uuid=regular_user.uuid,
credential_uuid=regular_credential.uuid, credential_uuid=regular_credential.uuid,
host="localhost",
ip="127.0.0.1",
user_agent="pytest",
expiry=expires(),
) )
return secret
@pytest_asyncio.fixture(scope="function") @pytest_asyncio.fixture(scope="function")
@@ -241,3 +242,44 @@ def auth_cookie(token: str) -> httpx.Cookies:
cookies = httpx.Cookies() cookies = httpx.Cookies()
cookies.set(AUTH_COOKIE_NAME, token, domain="localhost") cookies.set(AUTH_COOKIE_NAME, token, domain="localhost")
return cookies return cookies
def create_test_session(
user_uuid: UUID,
credential_uuid: UUID,
host: str = "localhost",
ip: str = "127.0.0.1",
user_agent: str = "pytest",
duration: timedelta | None = None,
) -> tuple[str, str]:
"""Create a test session. Returns (key, token) tuple.
- key: str used for session lookup (base64url encoded)
- token: stored in cookie/sent to client
"""
if duration is None:
duration = SESSION_LIFETIME
if user_uuid not in ops_db._db.users:
raise ValueError(f"User {user_uuid} not found")
if credential_uuid not in ops_db._db.credentials:
raise ValueError(f"Credential {credential_uuid} not found")
now = datetime.now(UTC)
# Generate token and derive key
token = secrets.token_urlsafe(12)
key = hash_secret("cookie", token)
session = Session.create(
user=user_uuid,
credential=credential_uuid,
key=key,
host=host,
ip=ip,
user_agent=user_agent,
validated=now,
)
if session.key in ops_db._db.sessions:
raise ValueError("Session already exists")
with ops_db._db.transaction("create_test_session"):
session.store(now)
return session.key, token
+47 -63
View File
@@ -22,7 +22,6 @@ import pytest_asyncio
import uuid7 import uuid7
from paskia import db from paskia import db
from paskia.authsession import expires
from paskia.db import ( from paskia.db import (
Credential, Credential,
Org, Org,
@@ -34,11 +33,11 @@ from paskia.db import (
create_org, create_org,
create_permission, create_permission,
create_role, create_role,
create_session,
create_user, create_user,
) )
from paskia.db.operations import DB from paskia.db.operations import DB
from tests.conftest import auth_headers from paskia.util.crypto import hash_secret
from tests.conftest import auth_headers, create_test_session
# -------------------- Additional Fixtures -------------------- # -------------------- Additional Fixtures --------------------
@@ -98,14 +97,11 @@ async def second_org_session_token(
test_db: DB, second_org_user: User, second_org_credential: Credential test_db: DB, second_org_user: User, second_org_credential: Credential
) -> str: ) -> str:
"""Create a session for the second org admin user.""" """Create a session for the second org admin user."""
return create_session( _db_key, secret = create_test_session(
user_uuid=second_org_user.uuid, user_uuid=second_org_user.uuid,
credential_uuid=second_org_credential.uuid, credential_uuid=second_org_credential.uuid,
host="localhost",
ip="127.0.0.1",
user_agent="pytest",
expiry=expires(),
) )
return secret
@pytest_asyncio.fixture(scope="function") @pytest_asyncio.fixture(scope="function")
@@ -155,14 +151,11 @@ async def org_admin_session_token(
test_db: DB, org_admin_user: User, org_admin_credential: Credential test_db: DB, org_admin_user: User, org_admin_credential: Credential
) -> str: ) -> str:
"""Create a session for the org admin user.""" """Create a session for the org admin user."""
return create_session( _db_key, secret = create_test_session(
user_uuid=org_admin_user.uuid, user_uuid=org_admin_user.uuid,
credential_uuid=org_admin_credential.uuid, credential_uuid=org_admin_credential.uuid,
host="localhost",
ip="127.0.0.1",
user_agent="pytest",
expiry=expires(),
) )
return secret
@pytest_asyncio.fixture(scope="function") @pytest_asyncio.fixture(scope="function")
@@ -185,7 +178,7 @@ class TestExceptionHandlers:
async def test_auth_exception_handler(self, client: httpx.AsyncClient): async def test_auth_exception_handler(self, client: httpx.AsyncClient):
"""AuthException should return proper JSON with auth info.""" """AuthException should return proper JSON with auth info."""
# Accessing admin without auth triggers AuthException # Accessing admin without auth triggers AuthException
response = await client.get("/auth/api/admin/orgs") response = await client.get("/auth/api/admin/info")
assert response.status_code == 401 assert response.status_code == 401
data = response.json() data = response.json()
assert "detail" in data assert "detail" in data
@@ -194,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 --------------------
@@ -222,7 +196,7 @@ class TestAdminOrganizations:
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_list_orgs_requires_auth(self, client: httpx.AsyncClient): async def test_list_orgs_requires_auth(self, client: httpx.AsyncClient):
"""List orgs without auth should return 401.""" """List orgs without auth should return 401."""
response = await client.get("/auth/api/admin/orgs") response = await client.get("/auth/api/admin/info")
assert response.status_code == 401 assert response.status_code == 401
@pytest.mark.asyncio @pytest.mark.asyncio
@@ -231,7 +205,7 @@ class TestAdminOrganizations:
): ):
"""List orgs without admin permission should return 403.""" """List orgs without admin permission should return 403."""
response = await client.get( response = await client.get(
"/auth/api/admin/orgs", "/auth/api/admin/info",
headers={ headers={
**auth_headers(regular_session_token), **auth_headers(regular_session_token),
"Host": "localhost:4401", "Host": "localhost:4401",
@@ -245,19 +219,24 @@ class TestAdminOrganizations:
): ):
"""Admin user should be able to list organizations.""" """Admin user should be able to list organizations."""
response = await client.get( response = await client.get(
"/auth/api/admin/orgs", "/auth/api/admin/info",
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
data = response.json() data = response.json()
assert isinstance(data, list) assert isinstance(data, dict)
assert len(data) >= 1 assert "orgs" in data
orgs_data = data["orgs"]
assert isinstance(orgs_data, dict)
assert len(orgs_data) >= 1
# Check org structure # Check org structure
org = data[0] org_data = list(orgs_data.values())[0]
assert "org" in org_data
org = org_data["org"]
assert "uuid" in org assert "uuid" in org
assert "display_name" in org assert "display_name" in org
assert "roles" in org assert "roles" in org_data
assert "users" in org assert "users" in org_data
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_list_orgs_with_org_admin( async def test_list_orgs_with_org_admin(
@@ -269,13 +248,13 @@ class TestAdminOrganizations:
): ):
"""Org admin should only see their own organization.""" """Org admin should only see their own organization."""
response = await client.get( response = await client.get(
"/auth/api/admin/orgs", "/auth/api/admin/info",
headers={**auth_headers(org_admin_session_token), "Host": "localhost:4401"}, headers={**auth_headers(org_admin_session_token), "Host": "localhost:4401"},
) )
assert response.status_code == 200 assert response.status_code == 200
data = response.json() data = response.json()
# Should only see their own org, not the second org # Should only see their own org, not the second org
org_uuids = [o["uuid"] for o in data] org_uuids = [org_data["org"]["uuid"] for org_data in data["orgs"].values()]
assert str(test_org.uuid) in org_uuids assert str(test_org.uuid) in org_uuids
@pytest.mark.asyncio @pytest.mark.asyncio
@@ -797,7 +776,8 @@ class TestAdminUsersInOrg:
) )
assert response.status_code == 200 assert response.status_code == 200
data = response.json() data = response.json()
assert "display_name" in data assert "user" in data
assert "display_name" in data["user"]
assert "credentials" in data assert "credentials" in data
assert "sessions" in data assert "sessions" in data
assert "aaguid_info" in data assert "aaguid_info" in data
@@ -848,7 +828,8 @@ class TestAdminUsersInOrg:
) )
assert response.status_code == 200 assert response.status_code == 200
data = response.json() data = response.json()
assert "display_name" in data assert "user" in data
assert "display_name" in data["user"]
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_update_user_display_name_in_org( async def test_update_user_display_name_in_org(
@@ -856,7 +837,7 @@ class TestAdminUsersInOrg:
): ):
"""Admin should be able to update user display name.""" """Admin should be able to update user display name."""
response = await client.patch( response = await client.patch(
f"/auth/api/admin/users/{test_user.uuid}/display-name", f"/auth/api/admin/users/{test_user.uuid}/info",
json={"display_name": "Updated Admin Name"}, json={"display_name": "Updated Admin Name"},
headers={**auth_headers(session_token), "Host": "localhost:4401"}, headers={**auth_headers(session_token), "Host": "localhost:4401"},
) )
@@ -869,7 +850,7 @@ class TestAdminUsersInOrg:
"""Updating non-existent user should return 404.""" """Updating non-existent user should return 404."""
fake_uuid = uuid7.create() fake_uuid = uuid7.create()
response = await client.patch( response = await client.patch(
f"/auth/api/admin/users/{fake_uuid}/display-name", f"/auth/api/admin/users/{fake_uuid}/info",
json={"display_name": "New Name"}, json={"display_name": "New Name"},
headers={**auth_headers(session_token), "Host": "localhost:4401"}, headers={**auth_headers(session_token), "Host": "localhost:4401"},
) )
@@ -887,7 +868,7 @@ class TestAdminUsersInOrg:
): ):
"""Org admin cannot update user from another org.""" """Org admin cannot update user from another org."""
response = await client.patch( response = await client.patch(
f"/auth/api/admin/users/{second_org_user.uuid}/display-name", f"/auth/api/admin/users/{second_org_user.uuid}/info",
json={"display_name": "New Name"}, json={"display_name": "New Name"},
headers={**auth_headers(org_admin_session_token), "Host": "localhost:4401"}, headers={**auth_headers(org_admin_session_token), "Host": "localhost:4401"},
) )
@@ -899,13 +880,13 @@ class TestAdminUsersInOrg:
): ):
"""Updating user with empty display name should fail.""" """Updating user with empty display name should fail."""
response = await client.patch( response = await client.patch(
f"/auth/api/admin/users/{test_user.uuid}/display-name", f"/auth/api/admin/users/{test_user.uuid}/info",
json={"display_name": " "}, json={"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
data = response.json() data = response.json()
assert "display_name required" in data["detail"] assert "display_name cannot be empty" in data["detail"]
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_update_user_display_name_too_long( async def test_update_user_display_name_too_long(
@@ -913,13 +894,13 @@ class TestAdminUsersInOrg:
): ):
"""Updating user with too long display name should fail.""" """Updating user with too long display name should fail."""
response = await client.patch( response = await client.patch(
f"/auth/api/admin/users/{test_user.uuid}/display-name", f"/auth/api/admin/users/{test_user.uuid}/info",
json={"display_name": "x" * 100}, json={"display_name": "x" * 65},
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
data = response.json() data = response.json()
assert "too long" in data["detail"] assert "display_name too long" in data["detail"]
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_update_user_role_in_org( async def test_update_user_role_in_org(
@@ -1293,17 +1274,16 @@ class TestAdminSessions:
): ):
"""Admin should be able to delete a user's session.""" """Admin should be able to delete a user's session."""
# Create an additional session to delete # Create an additional session to delete
extra_token = create_session( extra_db_key, _extra_secret = create_test_session(
user_uuid=test_user.uuid, user_uuid=test_user.uuid,
credential_uuid=test_credential.uuid, credential_uuid=test_credential.uuid,
host="other.host:4401", host="other.host:4401",
ip="192.168.1.1", ip="192.168.1.1",
user_agent="other-agent", user_agent="other-agent",
expiry=expires(),
) )
response = await client.delete( response = await client.delete(
f"/auth/api/admin/users/{test_user.uuid}/sessions/{extra_token}", f"/auth/api/admin/users/{test_user.uuid}/sessions/{extra_db_key}",
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
@@ -1320,8 +1300,9 @@ class TestAdminSessions:
test_user, test_user,
): ):
"""Admin can delete their own current session.""" """Admin can delete their own current session."""
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_token}", 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"},
) )
assert response.status_code == 200 assert response.status_code == 200
@@ -1398,14 +1379,17 @@ class TestAdminPermissions:
): ):
"""Admin should be able to list all permissions.""" """Admin should be able to list all permissions."""
response = await client.get( response = await client.get(
"/auth/api/admin/permissions", "/auth/api/admin/info",
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
data = response.json() data = response.json()
assert isinstance(data, list) assert isinstance(data, dict)
assert "permissions" in data
permissions_data = data["permissions"]
assert isinstance(permissions_data, dict)
# Should include at least auth:admin # Should include at least auth:admin
perm_scopes = [p["scope"] for p in data] perm_scopes = [p["scope"] for p in permissions_data.values()]
assert "auth:admin" in perm_scopes assert "auth:admin" in perm_scopes
@pytest.mark.asyncio @pytest.mark.asyncio
@@ -1418,13 +1402,13 @@ class TestAdminPermissions:
): ):
"""Org admin should only see permissions their org can grant.""" """Org admin should only see permissions their org can grant."""
response = await client.get( response = await client.get(
"/auth/api/admin/permissions", "/auth/api/admin/info",
headers={**auth_headers(org_admin_session_token), "Host": "localhost:4401"}, headers={**auth_headers(org_admin_session_token), "Host": "localhost:4401"},
) )
assert response.status_code == 200 assert response.status_code == 200
data = response.json() data = response.json()
# Should only see permissions the org can grant # Should only see permissions the org can grant
perm_scopes = [p["scope"] for p in data] perm_scopes = [p["scope"] for p in data["permissions"].values()]
assert grantable_permission.scope in perm_scopes assert grantable_permission.scope in perm_scopes
# test_org CAN grant auth:admin (it's in org.permissions), so org admin sees it # test_org CAN grant auth:admin (it's in org.permissions), so org admin sees it
assert "auth:admin" in perm_scopes assert "auth:admin" in perm_scopes
@@ -1708,7 +1692,7 @@ class TestOrgAdminAuthExceptions:
): ):
"""Regular user trying to update display name should get 403.""" """Regular user trying to update display name should get 403."""
response = await client.patch( response = await client.patch(
f"/auth/api/admin/users/{test_user.uuid}/display-name", f"/auth/api/admin/users/{test_user.uuid}/info",
json={"display_name": "New Name"}, json={"display_name": "New Name"},
headers={**auth_headers(regular_session_token), "Host": "localhost:4401"}, headers={**auth_headers(regular_session_token), "Host": "localhost:4401"},
) )
+30 -52
View File
@@ -16,10 +16,11 @@ from datetime import UTC, datetime, timedelta
import httpx import httpx
import pytest import pytest
from paskia import authcode
from paskia.authsession import EXPIRES from paskia.authsession import EXPIRES
from paskia.db import create_session, delete_session from paskia.db import delete_session
from paskia.util.passphrase import generate from paskia.util.passphrase import generate
from tests.conftest import auth_headers from tests.conftest import auth_headers, create_test_session
class TestSettingsEndpoint: class TestSettingsEndpoint:
@@ -229,12 +230,12 @@ class TestLogoutEndpoint:
class TestUserInfoEndpoint: class TestUserInfoEndpoint:
"""Tests for POST /auth/api/user-info""" """Tests for GET /auth/api/user-info"""
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_user_info_without_auth_returns_401(self, client: httpx.AsyncClient): async def test_user_info_without_auth_returns_401(self, client: httpx.AsyncClient):
"""User info without session should return 401.""" """User info without session should return 401."""
response = await client.post("/auth/api/user-info") response = await client.get("/auth/api/user-info")
assert response.status_code == 401 assert response.status_code == 401
@pytest.mark.asyncio @pytest.mark.asyncio
@@ -242,22 +243,22 @@ class TestUserInfoEndpoint:
self, client: httpx.AsyncClient, session_token: str, test_user self, client: httpx.AsyncClient, session_token: str, test_user
): ):
"""User info with valid session should return user data.""" """User info with valid session should return user data."""
response = await client.post( response = await client.get(
"/auth/api/user-info", "/auth/api/user-info",
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
data = response.json() data = response.json()
assert "ctx" in data assert "user" in data
assert data["ctx"]["user"]["uuid"] == str(test_user.uuid) assert data["user"]["uuid"] == str(test_user.uuid)
assert data["ctx"]["user"]["display_name"] == test_user.display_name assert data["user"]["display_name"] == test_user.display_name
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_user_info_includes_credentials( async def test_user_info_includes_credentials(
self, client: httpx.AsyncClient, session_token: str self, client: httpx.AsyncClient, session_token: str
): ):
"""User info should include user's credentials.""" """User info should include user's credentials."""
response = await client.post( response = await client.get(
"/auth/api/user-info", "/auth/api/user-info",
headers={**auth_headers(session_token), "Host": "localhost:4401"}, headers={**auth_headers(session_token), "Host": "localhost:4401"},
) )
@@ -271,7 +272,7 @@ class TestUserInfoEndpoint:
self, client: httpx.AsyncClient, session_token: str self, client: httpx.AsyncClient, session_token: str
): ):
"""User info should include user's active sessions.""" """User info should include user's active sessions."""
response = await client.post( response = await client.get(
"/auth/api/user-info", "/auth/api/user-info",
headers={**auth_headers(session_token), "Host": "localhost:4401"}, headers={**auth_headers(session_token), "Host": "localhost:4401"},
) )
@@ -285,36 +286,42 @@ class TestUserInfoEndpoint:
self, client: httpx.AsyncClient, session_token: str self, client: httpx.AsyncClient, session_token: str
): ):
"""User info should include user's permissions.""" """User info should include user's permissions."""
response = await client.post( response = await client.get(
"/auth/api/user-info", "/auth/api/user-info",
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
data = response.json() data = response.json()
assert "ctx" in data assert "permissions" in data
assert "permissions" in data["ctx"]
class TestSetSessionEndpoint: class TestSetSessionEndpoint:
"""Tests for POST /auth/api/set-session""" """Tests for POST /auth/api/set-session"""
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_set_session_without_bearer_returns_401( async def test_set_session_without_bearer_returns_400(
self, client: httpx.AsyncClient self, client: httpx.AsyncClient
): ):
"""Set session without bearer token should return 401.""" """Set session without bearer token should return 400."""
response = await client.post("/auth/api/set-session") response = await client.post("/auth/api/set-session")
assert response.status_code == 401 assert response.status_code == 400
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_set_session_with_valid_bearer_token( async def test_set_session_with_valid_bearer_token(
self, client: httpx.AsyncClient, session_token: str self, client: httpx.AsyncClient, session_token: str
): ):
"""Set session with valid bearer token should set cookie.""" """Set session with valid auth code as bearer should set cookie."""
code = authcode.store_cookie(
authcode.CookieCode(
session_key=session_token,
created=datetime.now(UTC),
)
)
response = await client.post( response = await client.post(
"/auth/api/set-session", "/auth/api/set-session",
headers={ headers={
"Authorization": f"Bearer {session_token}", "Authorization": f"Bearer {code}",
"Host": "localhost:4401", "Host": "localhost:4401",
}, },
) )
@@ -348,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
@@ -521,23 +500,22 @@ class TestValidateSessionRefresh:
): ):
"""Validate should return 401 if session disappears during refresh.""" """Validate should return 401 if session disappears during refresh."""
# Create a session with an old expiry time to trigger refresh # Create a session with a short remaining duration to trigger refresh
old_expiry = datetime.now(UTC) + EXPIRES - timedelta(minutes=10) db_key, secret = create_test_session(
token = create_session(
user_uuid=test_user.uuid, user_uuid=test_user.uuid,
credential_uuid=test_credential.uuid, credential_uuid=test_credential.uuid,
host="localhost", host="localhost",
ip="127.0.0.1", ip="127.0.0.1",
user_agent="pytest", user_agent="pytest",
expiry=old_expiry, duration=EXPIRES - timedelta(minutes=10),
) )
# Delete the session right before validate tries to refresh # Delete the session right before validate tries to refresh
delete_session(token) delete_session(db_key)
response = await client.post( response = await client.post(
"/auth/api/validate", "/auth/api/validate",
headers={**auth_headers(token), "Host": "localhost:4401"}, headers={**auth_headers(secret), "Host": "localhost:4401"},
) )
# Session was found initially but disappeared during refresh # Session was found initially but disappeared during refresh
assert response.status_code == 401 assert response.status_code == 401