Compare commits

..
119 Commits
Author SHA1 Message Date
LeoVasanko b9aec6bb58 Remove built-in database, replace with kanta package. No disk format changes. 2026-06-12 19:39:30 +00:00
LeoVasanko c79cb497ee Fix profile image path on OIDC. 2026-05-22 02:45:12 +00:00
LeoVasanko 9f50c8c20d Missing file 2026-05-22 01:40:53 +00:00
LeoVasanko 816c7a681e Update E2E tests for new database folder. 2026-05-22 01:40:09 +00:00
LeoVasanko d31c09084e Make rpid.paskiadb a folder containing the database and the files in one. Migrates existing old format rpid.paskiadb file to main.db. 2026-05-22 01:22:35 +00:00
LeoVasanko cc938dd306 Fix a call to loadOrgs when renaming a role, missed in earlier refactoring where we use loadAdminData() for refreshing. 2026-05-22 00:31:06 +00:00
LeoVasanko 36db1e7e56 Fix showing of admin reset link also in devserver where the logging configuration was eating the message. 2026-05-22 00:22:04 +00:00
LeoVasanko 95c163e37a Add profile picture support
- backend avatar storage and OIDC picture claims
- profile and admin UI components
- admin org cards, tests, and docs
2026-05-21 23:57:48 +00:00
LeoVasanko 2d0d17c307 fix remote auth: create session for requesting device host 2026-04-30 21:47:47 +00:00
LeoVasanko 10980ad39b fix type hints: update_session and set_session_host key type 2026-04-30 21:47:46 +00:00
LeoVasanko 42b54cf645 Release 1.4.0 2026-04-29 20:48:12 +00:00
LeoVasanko 232d0e1ae0 Added configurable timeout settings to paskia-js, used in our frontend as well. The default fetch timeout has been changed to 10s from prior 1s, but we maintain 1s for auth endpoints in internal use. 2026-04-29 20:23:38 +00:00
LeoVasanko e97a2b3291 Improved color compatibility across terminals that may have very different ideas of yellow shades. 2026-04-29 16:23:34 +00:00
LeoVasanko cde709e252 Print original METHOD /path on auth/api/forward access log entries. Previously the method was not printed, and nothing was printed for 401 without a session. 2026-04-29 15:47:37 +00:00
LeoVasanko 72d76df35d Log session id from handlers on selected auth routes. Adds request.state.log_extra for handlers to print access log extra. 2026-04-29 03:02:30 +00:00
LeoVasanko 1a742fc0e7 Cleaner websocket access log. 2026-04-29 02:45:20 +00:00
LeoVasanko 0b29654d6f Log original path on forward endpoint. Added logging extra argument for such additions on access logs. 2026-04-29 02:16:48 +00:00
LeoVasanko 76f24a755b Add GET /auth/api/check endpoint for unauthenticated user permission checks
Checks permissions for a user given by ?user=<UUID> query arg without
requiring a session cookie. No cookie is read or written, no DB writes.

- perm= query arg supported (same wildcard semantics as validate/forward)
- Returns valid bool + minimal ctx (user/org/role/permissions)
- Permissions are host-scoped via domain filtering, same as session_ctx
- 404 if UUID not found; valid=false if perm check fails (no 403)
- Add ApiCheckUserResponse struct to apistructs
- Add has_all_scopes() helper to permutil for scope-set-based checks
2026-04-26 05:45:59 +00:00
LeoVasanko 5c452f325a Better error messages on database loading errors. 2026-02-19 21:52:33 +00:00
LeoVasanko e9b6bc7a3d Implement migration for old format listen field in database (re: commit f746085) 2026-02-19 21:26:12 +00:00
LeoVasanko f5545b48f0 Remove dead code. 2026-02-19 21:10:16 +00:00
LeoVasanko c1b2bcf76c Correct alphabetical sort of names in Org Admin panel. Supports Last, First and First Last + variatioons. 2026-02-19 20:54:52 +00:00
LeoVasanko 1806bcab5c A bit more color for light theme; cleaner user badges in admin app. 2026-02-19 20:40:21 +00:00
LeoVasanko be177cbafc Add default value for a(ction) field in change records to keep support for very old versions. 2026-02-19 20:16:16 +00:00
LeoVasanko f5ccc204be Fix adminapp reference after refactoring. 2026-02-19 20:03:58 +00:00
LeoVasanko dd2031eef5 Fix runtime config update by Server Options panel. 2026-02-19 19:57:12 +00:00
LeoVasanko 3cb24bfee9 Refactor to separate admin app modules to subapps, required trailing slashes and plural changes on some of the URLs. 2026-02-19 19:49:13 +00:00
LeoVasanko 3f51d06f13 Admin Server Options panel added for configuring rp-name, auth-host and origins. 2026-02-19 18:53:53 +00:00
LeoVasanko 528a728eb8 README 2026-02-19 18:44:06 +00:00
LeoVasanko 727625ef4f Avoid storing IP and User Agent on OpenID Connect token renewals; preserves the user's information from authentication. 2026-02-19 16:34:44 +00:00
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
LeoVasanko 632230d05c Minor fixes to config handling. 2026-02-09 17:37:10 +00:00
LeoVasanko a37198bb4b README 2026-02-09 17:18:55 +00:00
LeoVasanko fb71ea1220 Storing config on database to simplify reloads by CLI. 2026-02-09 17:00:40 +00:00
LeoVasanko 25283eba9b Change database name to (rp-id).paskiadb (previously paskia.jsonl). Validate the rp-id in passkey init. 2026-02-09 16:42:12 +00:00
LeoVasanko 6a217978d6 Upgrade fastapi-vue-setup. 2026-02-09 16:29:24 +00:00
LeoVasanko 4d48c0720a README 2026-02-06 01:07:00 +00:00
LeoVasanko 083d20c8c9 README 2026-02-06 01:00:58 +00:00
LeoVasanko c662fbd39c README 2026-02-06 00:29:16 +00:00
LeoVasanko edf80aef52 README 2026-02-06 00:27:01 +00:00
LeoVasanko 8d5c53ab72 README 2026-02-06 00:25:38 +00:00
LeoVasanko f8a760ddfd README 2026-02-06 00:23:44 +00:00
LeoVasanko 430394a1e5 README 2026-02-06 00:22:03 +00:00
LeoVasanko 4c54ad5d1f Docs, screenshot of permissions view. 2026-02-06 00:16:29 +00:00
LeoVasanko 0b2be18b44 Docs 2026-02-06 00:08:27 +00:00
LeoVasanko 1c8be68811 README 2026-02-05 22:58:42 +00:00
LeoVasanko 116bc3c4ef README 2026-02-05 22:44:26 +00:00
LeoVasanko c641c721c7 README 2026-02-05 21:54:14 +00:00
LeoVasanko 8fc03ade04 Remove the reset subcommand that was broken and unnecessary. 2026-02-05 21:53:30 +00:00
LeoVasanko 6ad3aa7d8c Screenshots, README. 2026-02-05 21:38:57 +00:00
LeoVasanko d8444b0db8 New screenshot after theme changes. 2026-02-05 20:38:30 +00:00
LeoVasanko f515daeecd Release paskia-js 0.1.3 with minor updates on styling and metadata. 2026-02-05 19:49:39 +00:00
LeoVasanko 5deb57435b Remove paskia-migration script (SQL no longer supported). 2026-02-05 19:32:18 +00:00
LeoVasanko 250189dbe5 Update tests for the latest changes. 2026-02-05 19:26:20 +00:00
LeoVasanko 3469e6fa3f ruff check 2026-02-05 19:21:03 +00:00
LeoVasanko c3df6c318c Change host normalization to remove port numbers - sessions are per host, cookies don't respect port numbers. 2026-02-05 19:04:40 +00:00
LeoVasanko 615066a2a2 Upgrade by fastapi-vue-setup, devserver script entirely rewritten to use its facilities. 2026-02-05 18:50:08 +00:00
LeoVasanko 70c682b539 Improved client IP and UA handling. 2026-02-05 17:33:08 +00:00
LeoVasanko 8444d0399e Improved theme picker 2026-02-05 16:19:06 +00:00
LeoVasanko 3c5f8694b3 Load stylesheets directly from HTML to avoid flashing wrong background color first. 2026-02-05 15:39:51 +00:00
LeoVasanko 871eb149ab Styling updates, more robust dynamic/userpref light/dark switching. Sync with paskia-js. 2026-02-05 15:27:15 +00:00
LeoVasanko 291a665e21 Improved UI feedback on registration link creation. 2026-02-05 14:26:11 +00:00
LeoVasanko 0537b85085 Better UI for role deletions. 2026-02-05 14:05:36 +00:00
LeoVasanko af5a48f565 API/DB cleanup for flat URLs that don't include org where users etc. are referred to. Implement user deletion in admin app and API, UI improvement. Reset token DB factory function revised to create passphrase and key internally. Removed unneeded functions and args, using update_user_role instead of a separate deleted _in_organization function. 2026-02-05 13:57:07 +00:00
LeoVasanko 632278d4ce Change CLI to use --listen rather than positional hostport argument. Fix listening on all interfaces (runs lifespan twice, which needs to be OK). Update README with various CLI changes and more. 2026-02-05 02:54:07 +00:00
LeoVasanko 3faaeee7be Add missing __init__ that was causing ModuleFoundError trying to import paskia.util. 2026-02-05 02:33:05 +00:00
LeoVasanko 0f51f8501d Console startup box themed as well. 2026-01-31 00:30:14 +00:00
LeoVasanko a7e6eb7341 Missing theme file. Added favicon. 2026-01-31 00:25:38 +00:00
LeoVasanko 1800dc12ae Light/dark selection in user profile, if set this is preferred on the whole system, together with app overrides (the first one on the URL wins). 2026-01-30 23:31:06 +00:00
LeoVasanko cc55474e62 Light/dark override for in-app login dialogs to match app style e.g. light only 2026-01-30 22:59:52 +00:00
LeoVasanko 8ac2c8e5fa Screenshots on README. 2026-01-29 22:50:54 +00:00
142 changed files with 10283 additions and 5368 deletions
+2 -1
View File
@@ -5,7 +5,8 @@ dist/
*.lock
package-lock.json
paskia.sqlite
paskia.jsonl
*.paskiadb
*.data
/paskia/frontend-build
/paskia/_version.py
coverage-html/
+182 -21
View File
@@ -1,60 +1,221 @@
# Paskia
![Login dialog screenshot](https://git.zi.fi/leovasanko/paskia/raw/main/docs/screenshots/login-light.webp)
An easy to install passkey-based authentication service that protects any web application with strong passwordless login.
## What is Paskia?
- Easy to use fully featured auth&auth system (login and permissions)
- Organization and role-based access control (optional)
- Organization and role-based access control
* Org admins control their users and roles
* Master admin can create multiple independent orgs
* Master admin makes permissions available for orgs to assign
- User Profile and Administration by API and web interface.
under `/auth/` or `auth.example.com`
- Reset tokens and additional device linking via QR code or codewords.
- Pure Python, FastAPI, packaged with prebuilt Vue frontend
* Multiple independent orgs
* Master admin can do everything or delegate to org admins
- User Profile and Admin by API and web interface
- Implements login/reauth/forbidden flows for you
- Single Sign-On (SSO): Users register once and authenticate across your services
- Remote autentication by entering random keywords from another device (like 2fa)
- No CORS, NodeJS or anything extra needed.
## Authenticate to get to your app, or in your app
Two interfaces:
- API fetch: auth checks and login without leaving your app
- Forward-auth proxy: protect any unprotected site or service (Caddy, Nginx)
The API mode is useful for applications that can be customized to run with Paskia. Forward auth can also protect your javascript and other assets. Each provides fine-grained permission control and reauthentication requests where needed, and both can be mixed where needed.
Single Sign-On (SSO): Users register once and authenticate across all applications under your domain name (configured rp-id).
## Authentication flows already done
![Forbidden dialog, dark mode](https://git.zi.fi/leovasanko/paskia/raw/main/docs/screenshots/forbidden-dark.webp)
**Automatic light/dark mode switching with overrides by user profile and protected app's theme.**
Paskia includes set of login, reauthentication and forbidden dialogs that it can use to perform the needed flows. We never leave the URL, no redirections, and if you make use of API mode, we won't even interrupt whatever your app was doing but retry the blocked API fetch after login like nothing happened.
## Quick Start
Install [UV](https://docs.astral.sh/uv/getting-started/installation/) and run:
```fish
uvx paskia serve --rp-id example.com
```sh
uvx paskia --rp-id example.com
```
On the first run it downloads the software and prints a registration link for the Admin. The server will start up on [localhost:4401](http://localhost:4401) *for authentication required*, serving for `*.example.com`. If you are going to be connecting `localhost` directly, for testing, leave out the rp-id.
On the first run it downloads the software and prints a registration link for the Admin. The server starts on [localhost:4401](http://localhost:4401), serving authentication for `*.example.com`. For local testing, leave out `--rp-id`.
Otherwise you will need a web server such as [Caddy](https://caddyserver.com/) to serve HTTPS on your actual domain names and proxy requests to Paskia and your backend apps (see documentation below).
For production you need a web server such as [Caddy](https://caddyserver.com/) to serve HTTPS on your actual domain names and proxy requests to Paskia and your backend apps (see documentation below).
For a permanent install of `paskia` CLI command, not needing `uvx`:
```fish
```sh
uv tool install paskia
```
## Configuration
There is no config file. Pass only the options on CLI:
You will need to specify your main domain to which all passkeys will be tied as rp-id. Use your main domain even if Paskia is not running there. All other options are optional.
```text
paskia serve [options]
paskia [options]
```
| Option | Description | Default |
|--------|-------------|---------|
| Listen address | One of *host***:***port* (default all hosts, port 4401) or **unix:***path***/paskia.socket** (Unix socket) | **localhost:4401** |
| --rp-id *domain* | Main/top domain | **localhost** |
| --rp-name *"text"* | Name of your company or site | Same as rp-id |
| --origin *url* | Explicitly list the domain names served | **https://**_rp-id_ |
| --auth-host *domain* | Dedicated authentication site (e.g., **auth.example.com**) | **Unspecified:** we use **/auth/** on **every** site under rp-id.|
| -l, --listen *endpoint* | Listen address: *host*:*port*, :*port* (all interfaces), or */path.sock* | **localhost:4401** |
| --rp-id *domain* | Main/top domain for passkeys | **localhost** |
| --rp-name *"text"* | Branding name for the entire system (passkey auth, login dialog). | Same as rp-id |
| --origin *url* | Only sites listed can login (repeatable) | rp-id and all subdomains |
| --auth-host *url* | Dedicated authentication site, e.g. **auth.example.com** | Use **/auth/** path on each site |
| --save | Save current options to database | (only --rp-id required on further invocations) |
To clear a stored setting, pass an empty value like `--auth-host=`. The database is stored in `{rp-id}.paskiadb` folder in current directory. This can be overridden by environment `PASKIA_DB` if needed.
## Tutorial: From Local Testing to Production
This section walks you through a complete example, from running Paskia locally to protecting a real site in production.
### Step 1: Production Configuration
For a real deployment, configure Paskia with your domain name (rp-id). This enables SSO setup for that domain and any subdomains.
```sh
uvx paskia --rp-id=example.com --rp-name="Example Corp"
```
This binds passkeys to the rp-id, allowing them to be used there or on any subdomain of it. The `--rp-name` is the branding shown in UI and registered with passkeys for everything on your domain (rp id). On the first run, you'll see a registration link—use it to create your Admin account. You may enter your real name here for a more suitable account name.
### Step 2: Set Up Caddy
Install [Caddy](https://caddyserver.com/) and copy the [auth folder](caddy/auth) to `/etc/caddy/auth`. Say your current unprotected Caddyfile looks like this:
```caddyfile
app.example.com {
reverse_proxy :3000
}
```
Add Paskia full site protection:
```caddyfile
app.example.com {
import auth/setup
handle {
import auth/require perm=myapp:login
reverse_proxy :3000
}
}
```
Run `systemctl reload caddy`. Now `app.example.com` requires the `myapp:login` permission. Try accessing it and you'll land on a login dialog.
### Step 3: Assign Permissions via Admin Panel
![Admin panel permissions](https://git.zi.fi/leovasanko/paskia/raw/main/docs/screenshots/master-permissions.webp)
1. Go to `app.example.com/auth/admin/`
2. Create a permission, give it a name and scope `myapp:login`
3. Assign it to Organization
4. In that organization, assign it to the Administration role
Now you have granted yourself the new permission.
Permission scopes are text identifiers with colons as separators that we can use for permission checks. The `myapp:` prefix is a convention to namespace permissions per application—you but you can use other forms as you see fit (urlsafe characters, no spaces allowed).
### Step 4: Add API Authentication to Your App
Your backend already receives `Remote-*` headers from Caddy's forward-auth. For frontend API calls, we provide a [JS paskia module](https://www.npmjs.com/package/paskia):
```js
import { apiJson } from 'https://cdn.jsdelivr.net/npm/paskia@latest/dist/paskia.js'
const data = await apiJson('/api/sensitive', { method: 'POST' })
```
When a 401/403 occurs, the auth dialog appears automatically, and the request retries after authentication.
To protect the API path with a different permission, update your Caddyfile:
```caddyfile
app.example.com {
import auth/setup
@api path /api/*
handle @api {
import auth/require perm=myapp:api
reverse_proxy :3000
}
handle {
import auth/require perm=myapp:login
reverse_proxy :3000
}
}
```
Create the `myapp:api` permission in the admin panel, that will be required for all API access. Link to `/auth/` for the built-in profile page.
You may also remove the `myapp:login` protection from the rest of your site paths, unless you wish to keep all your assets behind a login page. Having this as the last entry in your config allows free access to everything not matched by other sections.
```Caddyfile
handle {
reverse_proxy :3000
}
```
### Step 5: Run Paskia as a Service
Create a system user paskia, install UV on the system, and create a systemd unit:
```sh
sudo useradd --system --home-dir /srv/paskia --create-home paskia
```
Install UV on the system (or arch btw `pacman -S uv`):
```sh
curl -LsSf https://astral.sh/uv/install.sh | sudo env UV_INSTALL_DIR=/usr/local/bin sh
```
Create a systemd unit:
```sh
sudo systemctl edit --force --full paskia@.service
```
Paste the following and save:
```ini
[Unit]
Description=Paskia for %i
[Service]
Type=simple
User=paskia
WorkingDirectory=/srv/paskia
ExecStart=uvx paskia@latest --rp-id=%i
[Install]
WantedBy=multi-user.target
```
Run the service and view log:
```sh
sudo systemctl enable --now paskia@example.com && sudo journalctl -n30 -ocat -fu paskia@example.com
```
### Optional: Dedicated Authentication Site
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. Your existing protected sites continue to work as before but they just forward to the dedicated site for user profile and other such functionality.
Enter your auth site domain on Admin / Server Options panel or use `--auth-host=auth.example.com` when starting the server.
## Further Documentation
+41 -3
View File
@@ -15,7 +15,7 @@ For integrating Paskia with your app frontend, see [integration](Integration.md)
| Method | Path | Used for | Notes |
|---:|---|---|---|
| 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/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) |
@@ -26,13 +26,17 @@ The `validate` and `forward` endpoints take query arguments `perm=` and `max_age
| Method | Path | Used for | Notes |
|---:|---|---|---|
| PUT | `/auth/api/user/display-name` | Update the users display name | Body: JSON `{ "display_name": "..." }` |
| PATCH | `/auth/api/user/display-name` | Update the users display name | Body: JSON `{ "display_name": "..." }` |
| GET | `/auth/api/user/{uuid}/profile.webp` | Canonical avatar image URL | Public on the auth host; serves `image/webp` with `ETag` and short-lived cache headers |
| PUT | `/auth/api/user/{uuid}/profile.webp` | Upload or replace a user avatar | Multipart form with `file`; upload must already be square WebP prepared in the browser |
| DELETE | `/auth/api/user/{uuid}/profile.webp` | Remove a user avatar | Allowed for the user, master admin, or org admin for users in the same org |
| POST | `/auth/api/user/logout-all` | Terminate all user sessions | Clears current host cookie |
| DELETE | `/auth/api/user/session/{session_id}` | Terminate one session | Session IDs are server-issued |
| DELETE | `/auth/api/user/credential/{uuid}` | Delete a credential | Requires recent authentication |
| POST | `/auth/api/user/create-link` | Create a device-add link | Requires recent authentication |
These are used mostly from the user profile panel and modify the current user.
These are used mostly from the user profile panel. The avatar route is also used by admins when managing other users.
`GET /auth/api/user-info` includes `user.avatar_url` when the user has an uploaded avatar, using the same canonical `/auth/api/user/{uuid}/profile.webp` path.
### Admin API: `/auth/api/admin/*`
@@ -40,6 +44,40 @@ Normally only used via admin panel, requires auth admin permissions and can modi
E.g. Org admin cannot see anything of the other orgs that he has no admin access to. Master admin `auth:admin` can see everything and create and manage orgs.
| Method | Path | Used for | Notes |
|---:|---|---|---|
| GET | `/auth/api/admin/info` | Admin overview | Returns orgs, permissions, OIDC clients info |
| POST | `/auth/api/admin/permissions/` | Create permission | Body: JSON with scope, display_name, domain |
| PATCH | `/auth/api/admin/permissions/{uuid}` | Update permission | Query params: display_name, scope, domain |
| DELETE | `/auth/api/admin/permissions/{uuid}` | Delete permission | |
| POST | `/auth/api/admin/orgs/` | Create organization | Body: JSON with display_name, permissions |
| GET | `/auth/api/admin/orgs/{uuid}` | Get organization details | |
| PATCH | `/auth/api/admin/orgs/{uuid}` | Update organization | Body: JSON with display_name |
| DELETE | `/auth/api/admin/orgs/{uuid}` | Delete organization | |
| POST | `/auth/api/admin/orgs/{uuid}/users` | Create user in org | Body: JSON with display_name, role_uuid |
| POST | `/auth/api/admin/orgs/{uuid}/roles` | Create role in org | Body: JSON with display_name, permissions |
| POST | `/auth/api/admin/orgs/{uuid}/permission` | Grant permission to org | Query param: permission_uuid |
| DELETE | `/auth/api/admin/orgs/{uuid}/permission` | Revoke permission from org | Query param: permission_uuid |
| PATCH | `/auth/api/admin/roles/{uuid}` | Update role | Body: JSON with display_name |
| POST | `/auth/api/admin/roles/{uuid}/permissions/{uuid}` | Add permission to role | |
| DELETE | `/auth/api/admin/roles/{uuid}/permissions/{uuid}` | Remove permission from role | |
| DELETE | `/auth/api/admin/roles/{uuid}` | Delete role | |
| PATCH | `/auth/api/admin/users/{uuid}/role` | Update user role | Body: JSON with role_uuid |
| PATCH | `/auth/api/admin/users/{uuid}/info` | Update user info | Body: JSON with display_name |
| GET | `/auth/api/admin/users/{uuid}` | Get user details | |
| DELETE | `/auth/api/admin/users/{uuid}` | Delete user | |
| POST | `/auth/api/admin/users/{uuid}/create-link` | Create device add link | |
| DELETE | `/auth/api/admin/users/{uuid}/credentials/{uuid}` | Delete user credential | |
| DELETE | `/auth/api/admin/users/{uuid}/sessions/{key}` | Delete user session | |
| POST | `/auth/api/admin/oidc-clients/` | Create OIDC client | Body: JSON with client_name, redirect_uris |
| PATCH | `/auth/api/admin/oidc-clients/{uuid}` | Update OIDC client | Body: JSON with client_name, redirect_uris |
| PATCH | `/auth/api/admin/oidc-clients/{uuid}/reset-secret` | Reset client secret | |
| DELETE | `/auth/api/admin/oidc-clients/{uuid}` | Delete OIDC client | |
| GET | `/auth/api/admin/server-config/` | Get server config | Returns rp_name, auth_host, origins |
| PATCH | `/auth/api/admin/server-config/` | Update server config | Body: JSON with rp_name, auth_host, origins |
Admins edit user avatars through the same canonical `/auth/api/user/{uuid}/profile.webp` PUT and DELETE endpoints.
### WebSockets: `/auth/ws/*`
| Path | Used for | Notes |
+188 -33
View File
@@ -1,43 +1,198 @@
# Integrating Paskia with your App
Protect API routes with forward-auth (see [Caddy configuration](Caddy.md)). Optionally protect your app assets and not just the API.
This guide covers frontend and backend integration with Paskia. For Caddy forward-auth setup, see [Caddy configuration](Caddy.md).
Catch response status 401/403 in fetch calls to protected endpoints and implement authentication flow in this case. The response is JSON and contains `detail` (an error message describing what is needed) and `auth.iframe` (a URL). Render that URL in an iframe and retry the request after authentication (see below).
## Frontend Integration
While the app is in (active) use, call `/auth/api/validate` occasionally to keep the session alive (session lifetime is 24h), otherwise the user will have to login every day. Max-age limits are unaffected by this and can be used on endpoints needing to reauthenticate with passkey more frequently.
### Using the paskia-js Module
Fetch `/auth/api/user-info` to display user/session details, or link to `/auth/` if you prefer using the built-in profile UI and not having to do anything more.
The [paskia](https://www.npmjs.com/package/paskia) JavaScript module provides utilities for API calls, session validation, and authentication overlays. Works with any framework or vanilla JS.
## Authentication Flow (iframe)
```js
// Show an authentication dialog
const iframe = document.createElement('iframe')
iframe.src = auth.url // from 401/403 response JSON
iframe.style.cssText = `
position: fixed;
inset: 0;
width: 100%;
height: 100%;
border: 0;
z-index: 9999;
background: transparent;
backdrop-filter: blur(0.1rem) brightness(0.7);
`
document.body.appendChild(iframe)
// Wait until user is finished with the dialog
const handler = ev => {
if (ev.origin !== location.origin) return
iframe.remove()
removeEventListener('message', handler)
if (ev.data?.type === 'auth-success') retry_original_fetch()
}
addEventListener('message', handler)
```html
<script type="module">
import { apiJson, apiFetch, SessionValidator } from 'https://cdn.jsdelivr.net/npm/paskia@latest/dist/paskia.js'
</script>
```
This describes the frontend flow for handling 401/403 responses from endpoints protected by Paskia forward-auth, without ever exiting your app.
Or install to your project:
When a protected request fails, the backend returns 401 (needs auth / reauth) or 403 (missing permission). For API requests, the response is JSON that includes an iframe URL. Your app should render that URL in a full-screen iframe overlay, and retry the request after the iframe reports success. If it reports `auth-cancel`, don't try again. The backdrop for the dialog is a stylistic choice, and you can style the background shown with the dialog any way you wish, and consider using CSS file with the iframe rather than inline styles as used in the example.
```sh
npm install paskia
```
Following this flow the user gets authenticated properly and after that your app keeps running as if nothing ever happened.
### API Fetch with Automatic Auth
Use `apiJson` or `apiFetch` for API calls. When a 401/403 response includes an auth URL, the authentication dialog appears automatically, then the request retries. The JSON variant is purely for convenience, doing JSON headers and conversions for you.
```js
import { apiJson, apiFetch, AuthCancelledError } from 'paskia'
// JSON API call (sets Content-Type, parses response)
try {
const data = await apiJson('/api/endpoint', { method: 'POST', body: { key: 'value' } })
} catch (e) {
if (e instanceof AuthCancelledError) {
// User cancelled auth dialog
}
}
// Raw fetch with auth handling (returns Response object)
const response = await apiFetch('/api/endpoint')
```
For requests that shouldn't trigger auth dialogs, use standard `fetch` or our `fetchJson`.
### Session Validation Polling
Keep sessions alive and detect when the user logs out or switches accounts:
```js
import { SessionValidator } from 'paskia'
const validator = new SessionValidator(
() => currentUser?.uuid, // getter for current user ID
(error) => handleSessionLost(error) // callback when session is lost or user changes
)
validator.start() // start polling (pauses on idle)
validator.stop() // stop polling
```
The validator calls `/auth/api/validate` periodically to:
- Renew the session cookie (24h lifetime)
- Detect if the user logged out or switched accounts
- Pause polling when the page is idle, allowing sessions to expire when not used
### Manual Auth Flow
If you need custom control, handle 401/403 responses manually:
```js
import { showAuthIframe, AuthCancelledError } from 'paskia'
const response = await fetch('/api/protected')
if (response.status === 401 || response.status === 403) {
const data = await response.json()
if (data.auth?.iframe) {
try {
await showAuthIframe(data.auth.iframe)
// Retry the original request
} catch (e) {
if (e instanceof AuthCancelledError) {
// User clicked Back
}
}
}
}
```
### User Info and Profile
Get current user details:
```js
const user = await apiJson('/auth/api/user-info', { method: 'GET' })
// Returns: { uuid, display_name, credentials, sessions, permissions, ... }
```
Or link to the built-in profile page: `/auth/`
## Backend Integration
### Using Forward-Auth Headers
When using Caddy forward-auth, your backend receives `Remote-*` headers on authenticated requests. See [Headers](Headers.md) for the full list.
```python
# Example: Python/FastAPI
@app.get("/api/data")
def get_data(request: Request):
user_id = request.headers.get("Remote-User")
org_id = request.headers.get("Remote-Org")
permissions = request.headers.get("Remote-Groups", "").split(",")
# ...
```
### Direct Validation from Backend
Your backend can validate sessions directly by calling Paskia's validate endpoint:
```python
import httpx
async def validate_session(request) -> dict:
"""Validate a session cookie and check permissions."""
authcookie = request.get("__Host-paskia")
response = await httpx.post(
"http://localhost:4401/auth/api/validate?perm=myapp:login+myapp:api",
headers={
"Host": request.headers["host"]
"X-Forwarded-For": request.client.host,
"Cookie": f"__Host-paskia={}",
},
)
if response.status_code != 200:
return response.json() # Return to client
# User authenticated... We are good to go!
ctx = response.json() # User and session information
```
This is useful for:
- WebSocket connections where headers aren't available after handshake
- Background jobs that need to verify a stored session
- APIs not behind forward-auth (auth/restrict)
### Validate Endpoint Parameters
`POST /auth/api/validate` accepts query parameters:
| Parameter | Description |
|-----------|-------------|
| `perm=scope:name` | Require this permission (repeatable) |
| `max_age=5min` | Require recent passkey use |
Returns 200 with user info on success, 401/403 on failure.
## Proxying /auth/ to Paskia
Your app server needs to proxy `/auth/` paths to Paskia. This can be done by your application but is much easier done by Caddy or Nginx.
### Caddy
This handles both HTTP and WebSocket connections. Caddy's `reverse_proxy` handles HTTP and WebSocket transparently. This is essentially what our Caddy [auth/setup](../caddy/auth/setup) snippet does: `reverse_proxy :4401`.
```caddyfile
app.example.com {
import auth/setup
# ... your routes in handle blocks
}
```
### Nginx
Certain headers need to be configured for correct host and WS support:
```nginx
location /auth/ {
proxy_pass http://localhost:4401;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
}
```
### Node.js / Express
Using `http-proxy-middleware`:
```js
import { createProxyMiddleware } from 'http-proxy-middleware'
app.use('/auth', createProxyMiddleware({ target: 'http://localhost:4401', ws: true, changeOrigin: false }))
```
### Python / FastAPI
You will need to process and handle `/auth/` for HTTP requests and `/auth/ws/` for WebSockets manually, which is beyond the scope of this documentation.
We highly recommend Caddy instead as the simpler and more production-worthy solution that Just Works.
Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

+38 -26
View File
@@ -10,7 +10,7 @@
"devDependencies": {
"@playwright/test": "^1.49.0",
"@simplewebauthn/browser": "^13.1.2",
"@types/bun": "^1.3.3",
"@types/node": "*",
"c8": "^10.1.3"
}
},
@@ -92,11 +92,13 @@
}
},
"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,
"license": "Apache-2.0",
"dependencies": {
"playwright": "1.57.0"
"playwright": "1.58.2"
},
"bin": {
"playwright": "cli.js"
@@ -107,17 +109,11 @@
},
"node_modules/@simplewebauthn/browser": {
"version": "13.2.2",
"resolved": "https://registry.npmjs.org/@simplewebauthn/browser/-/browser-13.2.2.tgz",
"integrity": "sha512-FNW1oLQpTJyqG5kkDg5ZsotvWgmBaC6jCHR7Ej0qUNep36Wl9tj2eZu7J5rP+uhXgHaLk+QQ3lqcw2vS5MX1IA==",
"dev": true,
"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": {
"version": "2.0.6",
"resolved": "https://registry.npmjs.org/@types/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.6.tgz",
@@ -126,7 +122,9 @@
"license": "MIT"
},
"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,
"license": "MIT",
"dependencies": {
@@ -176,14 +174,6 @@
"balanced-match": "^1.0.0"
}
},
"node_modules/bun-types": {
"version": "1.3.3",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/node": "*"
}
},
"node_modules/c8": {
"version": "10.1.3",
"resolved": "https://registry.npmjs.org/c8/-/c8-10.1.3.tgz",
@@ -412,6 +402,21 @@
"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": {
"version": "2.0.5",
"resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz",
@@ -426,6 +431,7 @@
"version": "10.5.0",
"resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz",
"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,
"license": "ISC",
"dependencies": {
@@ -674,11 +680,13 @@
}
},
"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,
"license": "Apache-2.0",
"dependencies": {
"playwright-core": "1.57.0"
"playwright-core": "1.58.2"
},
"bin": {
"playwright": "cli.js"
@@ -691,7 +699,9 @@
}
},
"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,
"license": "Apache-2.0",
"bin": {
@@ -712,9 +722,9 @@
}
},
"node_modules/semver": {
"version": "7.7.3",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz",
"integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==",
"version": "7.7.4",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz",
"integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==",
"dev": true,
"license": "ISC",
"bin": {
@@ -894,6 +904,8 @@
},
"node_modules/undici-types": {
"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,
"license": "MIT"
},
+8 -8
View File
@@ -5,18 +5,18 @@
"description": "E2E tests for Paskia using Playwright with Virtual Authenticator",
"type": "module",
"scripts": {
"test": "bunx playwright test",
"test:headed": "bunx playwright test --headed",
"test:debug": "bunx playwright test --debug",
"test:ui": "bunx playwright test --ui",
"test:coverage": "COVERAGE=1 bunx playwright test",
"report": "bunx playwright show-report",
"install:browsers": "bunx playwright install chromium"
"test": "npx playwright test",
"test:headed": "npx playwright test --headed",
"test:debug": "npx playwright test --debug",
"test:ui": "npx playwright test --ui",
"test:coverage": "COVERAGE=1 npx playwright test",
"report": "npx playwright show-report",
"install:browsers": "npx playwright install chromium"
},
"devDependencies": {
"@playwright/test": "^1.49.0",
"@simplewebauthn/browser": "^13.1.2",
"@types/bun": "^1.3.3",
"@types/node": "*",
"c8": "^10.1.3"
}
}
+1 -1
View File
@@ -4,7 +4,7 @@ import { defineConfig, devices } from '@playwright/test'
* Playwright configuration for Paskia E2E tests.
* Uses Chrome's Virtual Authenticator for automated passkey testing.
*
* Run with: bun run test
* Run with: npm test
*/
export default defineConfig({
+5 -5
View File
@@ -148,10 +148,10 @@ test.describe('Passkey Authentication E2E', () => {
const userInfo = await getUserInfo(page, baseUrl, sessionToken)
expect(userInfo.ctx.user.uuid).toBe(userUuid)
expect(userInfo.ctx.user.display_name).toBe('Admin User')
expect(userInfo.user.uuid).toBe(userUuid)
expect(userInfo.user.display_name).toBe('Admin User')
expect(userInfo.credentials).toBeDefined()
expect(userInfo.credentials.length).toBeGreaterThanOrEqual(1)
expect(Object.keys(userInfo.credentials).length).toBeGreaterThanOrEqual(1)
// Navigate to profile and take screenshot
const cookieName = getSessionCookieName()
@@ -169,8 +169,8 @@ test.describe('Passkey Authentication E2E', () => {
await page.screenshot({ path: '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(`✓ Credentials count: ${userInfo.credentials.length}`)
console.log(`✓ User info retrieved: ${userInfo.user.display_name}`)
console.log(`✓ Credentials count: ${Object.keys(userInfo.credentials).length}`)
})
test('should authenticate with existing passkey', async ({ page, virtualAuthenticator }) => {
+34 -18
View File
@@ -10,6 +10,12 @@ import {
logout,
} from './fixtures/passkey-helpers'
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.
@@ -55,8 +61,18 @@ async function clearSessionCookie(page: Page): Promise<void> {
/**
* Set up the test page using the examples page directly.
* 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> {
// 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
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.
*/
async function waitForAuthIframe(page: Page, timeout = 5000): Promise<Frame> {
await page.waitForSelector('#auth-iframe', { timeout })
const iframe = page.frameLocator('#auth-iframe')
await page.waitForSelector('#paskia-iframe', { timeout })
const iframe = page.frameLocator('#paskia-iframe')
// Wait for iframe content to load
await iframe.locator('.view-root').waitFor({ timeout })
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.
*/
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.
*/
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()
}
@@ -169,7 +185,7 @@ async function clickBackInIframe(page: Page): Promise<void> {
* Click Login button in auth iframe.
*/
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()
}
@@ -177,7 +193,7 @@ async function clickLoginInIframe(page: Page): Promise<void> {
* Click Verify button in auth iframe (for reauth mode).
*/
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()
}
@@ -185,7 +201,7 @@ async function clickVerifyInIframe(page: Page): Promise<void> {
* Click Logout button in auth iframe (for forbidden mode).
*/
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()
}
@@ -200,11 +216,11 @@ test.describe('API Mode - 401 Login Flow', () => {
await clearSessionCookie(page)
// Make API call that triggers 401 (don't await - it blocks until iframe resolves)
const apiCallPromise = makeApiCall(page, '/auth/api/user-info', 'POST').catch(e => e)
const apiCallPromise = makeApiCall(page, '/auth/api/user-info', 'GET').catch(e => e)
console.log('✓ Auth iframe appeared on 401')
// Verify it's in login mode (not reauth)
const iframe = page.frameLocator('#auth-iframe')
const iframe = page.frameLocator('#paskia-iframe')
await expect(iframe.locator('h1')).toContainText('🔐')
await expect(iframe.getByRole('button', { name: 'Login' })).toBeVisible()
@@ -252,7 +268,7 @@ test.describe('API Mode - 401 Login Flow', () => {
await setupTestHarness(page)
// Make API call that triggers 401
const apiCallPromise = makeApiCall(page, '/auth/api/user-info', 'POST')
const apiCallPromise = makeApiCall(page, '/auth/api/user-info', 'GET')
// Wait for auth iframe to appear
await waitForAuthIframe(page)
@@ -268,7 +284,7 @@ test.describe('API Mode - 401 Login Flow', () => {
// Wait for API call to complete and verify result
const result = await apiCallPromise
expect(result.status).toBe(200)
expect(result.data.ctx).toBeDefined()
expect(result.data.user).toBeDefined()
console.log('✓ API call succeeded after authentication')
// 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)')
// 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.getByRole('button', { name: 'Verify' })).toBeVisible()
@@ -362,7 +378,7 @@ test.describe('API Mode - 401 Reauth Flow', () => {
await waitForAuthIframe(page)
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')
// 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)
// 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) {
// 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
await page.waitForTimeout(500)
const iframe = page.frameLocator('#auth-iframe')
const iframe = page.frameLocator('#paskia-iframe')
const headingText = await iframe.locator('h1').textContent()
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)
// 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) {
const result = await apiCallPromise
@@ -470,7 +486,7 @@ test.describe('API Mode - 403 Forbidden Flow', () => {
}
await waitForAuthIframe(page)
const iframe = page.frameLocator('#auth-iframe')
const iframe = page.frameLocator('#paskia-iframe')
await page.waitForTimeout(500)
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.iframe).toBeDefined()
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}`)
})
+62 -12
View File
@@ -44,7 +44,7 @@ export interface UserInfo {
sign_count: number
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<{
id: string
credential: string
@@ -193,7 +193,8 @@ export async function registerPasskey(
baseUrl: string,
options: { resetToken?: string; displayName?: string } = {}
): 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
let wsUrl = `${baseUrl.replace('http', 'ws')}/auth/ws/register`
const params: string[] = []
@@ -203,6 +204,7 @@ export async function registerPasskey(
return new Promise<any>((resolve, reject) => {
const ws = new WebSocket(wsUrl)
let done = false
ws.onopen = () => {
console.log('WebSocket connected for registration')
@@ -213,15 +215,31 @@ export async function registerPasskey(
// Check for error response
if (data.detail) {
done = true
ws.close()
reject(new Error(data.detail))
return
}
// Check if this is the final success response
if (data.session_token) {
// Check if this is the final success response (exchange_code flow)
if (data.exchange_code) {
done = true
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
}
@@ -293,12 +311,21 @@ export async function registerPasskey(
}
ws.onclose = (event) => {
if (!event.wasClean && event.code !== 1000) {
if (!done && !event.wasClean && event.code !== 1000) {
reject(new Error(`WebSocket closed unexpectedly: ${event.code}`))
}
}
})
}, { 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,
baseUrl: string
): 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`
return new Promise<any>((resolve, reject) => {
const ws = new WebSocket(wsUrl)
let done = false
ws.onopen = () => {
console.log('WebSocket connected for authentication')
@@ -324,15 +353,27 @@ export async function authenticatePasskey(
// Check for error response
if (data.detail) {
done = true
ws.close()
reject(new Error(data.detail))
return
}
// Check if this is the final success response
if (data.session_token) {
// Check if this is the final success response (exchange_code flow)
if (data.exchange_code) {
done = true
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
}
@@ -395,12 +436,21 @@ export async function authenticatePasskey(
}
ws.onclose = (event) => {
if (!event.wasClean && event.code !== 1000) {
if (!done && !event.wasClean && event.code !== 1000) {
reject(new Error(`WebSocket closed unexpectedly: ${event.code}`))
}
}
})
}, { 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
): Promise<UserInfo> {
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: {
'Cookie': `${cookieName}=${sessionToken}`,
},
+16 -6
View File
@@ -1,6 +1,6 @@
import { spawn } from 'child_process'
import { execSync, spawn } from 'child_process'
import { join, dirname } from 'path'
import { existsSync, mkdirSync, writeFileSync } from 'fs'
import { existsSync, mkdirSync, rmSync, writeFileSync } from 'fs'
import { fileURLToPath } from 'url'
const __dirname = dirname(fileURLToPath(import.meta.url))
@@ -31,6 +31,11 @@ export default async function globalSetup() {
mkdirSync(testDataDir, { recursive: true })
}
// Build the package first
console.log(' Building package with uv build...')
execSync('uv build', { cwd: projectRoot, stdio: 'inherit' })
console.log(' ✅ Build complete\n')
console.log(' Starting server with in-memory database...')
if (COLLECT_COVERAGE) {
console.log(' 📊 Coverage collection enabled for Python backend')
@@ -42,16 +47,21 @@ export default async function globalSetup() {
const serverArgs = COLLECT_COVERAGE
? [
'run', 'coverage', 'run', '--parallel-mode',
'-m', 'paskia.fastapi', 'localhost:4404',
'-m', 'paskia', '-l', 'localhost:4404',
'--rp-id', 'localhost'
]
: [
'run', 'paskia', 'localhost:4404',
'run', 'paskia', '-l', 'localhost:4404',
'--rp-id', 'localhost'
]
// Use a temporary jsonl file for test database
const testDbFile = join(testDataDir, 'test-db.jsonl')
// Use a fresh database file for tests
const testDbFile = join(testDataDir, 'test.paskiadb')
if (existsSync(testDbFile)) {
console.log(' Removing stale test database...')
rmSync(testDbFile, { force: true, recursive: true })
}
// Start the server using Node's spawn
const serverProcess = spawn('uv', serverArgs, {
+2 -2
View File
@@ -60,10 +60,10 @@ export default async function globalTeardown() {
}
// Clean up test database
const testDbFile = join(testDataDir, 'test-db.jsonl')
const testDbFile = join(testDataDir, 'test.paskiadb')
if (existsSync(testDbFile)) {
console.log(' Removing test database...')
rmSync(testDbFile)
rmSync(testDbFile, { force: true, recursive: true })
}
// Generate Python coverage report if coverage was collected
+1 -1
View File
@@ -8,7 +8,7 @@
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"types": ["bun-types"]
"types": ["node"]
},
"include": ["tests/**/*.ts", "playwright.config.ts"],
"exclude": ["node_modules"]
+38 -107
View File
@@ -8,26 +8,6 @@
:root {
color-scheme: light dark; /* Automatic themes by browser */
}
/* Login/reauth/forbidden dialog will appear in this iframe */
#auth-iframe {
/* Full viewport overlay */
border: none;
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
z-index: 9999;
/* Optional transparent background with optional blur backdrop */
color-scheme: auto;
background: transparent;
backdrop-filter: blur(.1rem) brightness(0.7);
-webkit-backdrop-filter: blur(.1rem) brightness(0.7);
}
/* Prevent background scroll when auth-iframe is shown */
body:has(#auth-iframe) {
overflow: hidden;
}
</style>
</head>
<body>
@@ -47,7 +27,7 @@
<div class="section">
<h2>API Mode (not leaving the page)</h2>
<p>For SPAs and fetch() calls - shows auth in an iframe overlay:</p>
<button onclick="apiCall('/auth/api/user-info', 'POST')">📋 Get User Info</button>
<button onclick="apiCall('/auth/api/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?perm=auth:admin')">🛡️ Admin Only</button>
<button onclick="logout()">🚪 Logout</button>
@@ -65,99 +45,50 @@
</div>
</div>
<script>
<script type="module">
import { apiFetch, apiJson, AuthCancelledError } from '/paskia-js/dist/paskia.js'
const output = document.getElementById('output');
let pendingCall = null; // Stores the API call to retry after auth
// The auth iframe posts messages when authentication completes or is cancelled.
// Message types: 'auth-success' (proceed), 'auth-back' (user cancelled)
// Errors during auth stay in the dialog allowing retry, no message is sent.
window.addEventListener('message', (event) => {
const { type, message } = event.data || {};
if (type === 'auth-success') {
log('✓ Authentication successful, retrying...');
hideAuthIframe();
// Retry the original API call that triggered authentication
if (pendingCall) {
const { url, method } = pendingCall;
pendingCall = null;
apiCall(url, method);
}
} else if (type === 'auth-back') {
log(message || 'Authentication cancelled');
hideAuthIframe();
pendingCall = null;
}
});
// Make an API call, handling 401/403 by showing the auth iframe.
// The server returns JSON with auth.iframe URL when authentication is needed.
async function apiCall(url, method = 'GET') {
log(`${method} ${url}...`);
const response = await fetch(url, { method });
// Server returns 401 (login/reauth) or 403 (missing permissions)
// with a JSON body containing the iframe URL for authentication
if (response.status === 401 || response.status === 403) {
const data = await response.json();
if (data.auth?.iframe) {
const mode = data.auth.mode; // 'login' or 'reauth'
log(`${mode === 'reauth' ? 'Re-authentication' : 'Authentication'} required...`);
pendingCall = { url, method };
showAuthIframe(data.auth.iframe);
return;
}
log(`Error: ${response.status} - ${data.detail}`);
return;
}
// Forward endpoint returns 204 on success (Caddy then adds Remote-* headers)
if (response.status === 204) {
log('✓ Success (204 No Content)\nHeaders:\n' +
[...response.headers].filter(([k]) => k.startsWith('remote-'))
.map(([k, v]) => ` ${k}: ${v}`).join('\n'));
return;
}
if (!response.ok) {
log(`Error: ${response.status} ${response.statusText}`);
return;
}
const data = await response.json();
log('✓ Response:\n' + JSON.stringify(data, null, 2));
}
async function logout() {
await fetch('/auth/api/logout', { method: 'POST' });
log('Logged out');
}
// Create fullscreen iframe for authentication.
// The 'allow' attribute enables WebAuthn (passkey) API inside the iframe.
function showAuthIframe(url) {
hideAuthIframe();
const iframe = document.createElement('iframe');
iframe.id = 'auth-iframe';
iframe.src = url;
document.body.appendChild(iframe);
log("Authentication dialog open...")
}
function hideAuthIframe() {
document.getElementById('auth-iframe')?.remove();
}
function log(msg) {
output.textContent = msg;
}
// Make an API call using paskia module (handles 401/403 automatically)
window.apiCall = async function(url, method = 'GET') {
log(`${method} ${url}...`);
try {
const response = await apiFetch(url, { method });
// Forward endpoint returns 204 on success
if (response.status === 204) {
log('✓ Success (204 No Content)');
return;
}
if (!response.ok) {
log(`Error: ${response.status} ${response.statusText}`);
return;
}
const data = await response.json();
log('✓ Response:\n' + JSON.stringify(data, null, 2));
} catch (e) {
if (e instanceof AuthCancelledError) {
log('Authentication cancelled');
} else {
log(`Error: ${e.message}`);
}
}
}
window.logout = async function() {
await fetch('/auth/api/logout', { method: 'POST' });
log('Logged out');
}
// Browser mode: open the forward endpoint directly in a new window.
// When Accept: text/html, the server redirects to the login page if needed,
// then back to the original URL after authentication.
function browserNav(url) {
window.browserNav = function(url) {
log('Opening in new window...\nIf not authenticated, you\'ll see the login page.\nAfter auth, you\'ll see a 204 response (blank page = success).');
window.open(url, '_blank');
}
+33 -76
View File
@@ -13,8 +13,8 @@
<script setup>
import { computed, onMounted, onUnmounted, ref } from 'vue'
import { useAuthStore } from '@/stores/auth'
import { apiJson, SessionValidator, createAuthIframe, removeAuthIframe } from 'paskia'
import { getAuthIframeUrl } from '@/utils/api'
import { apiJson, SessionValidator, settings as paskiaSettings } from 'paskia'
import { updateThemeFromSession } from '@/utils/theme'
import StatusMessage from '@/components/StatusMessage.vue'
import ProfileView from '@/components/ProfileView.vue'
import HostProfileView from '@/components/HostProfileView.vue'
@@ -47,80 +47,50 @@ const isHostMode = computed(() => {
const configuredHost = normalizeHost(authHost)
return currentHost !== configuredHost
})
const userUuid = computed(() => store.userInfo?.ctx.user.uuid)
function terminateSession() {
function onSessionLost(e) {
store.userInfo = null
viewState.value = 'terminal'
store.ctx = null
if (e?.name === 'AuthCancelledError') {
viewState.value = 'terminal'
} else {
store.showMessage(e?.message || 'Session lost', 'error', 5000)
viewState.value = 'terminal'
}
}
const userUuidGetter = () => store.userInfo?.ctx.user.uuid
const sessionValidator = new SessionValidator(userUuidGetter, terminateSession)
const userUuidGetter = () => store.ctx?.user.uuid
const sessionValidator = new SessionValidator(userUuidGetter, onSessionLost)
onMounted(() => sessionValidator.start())
onUnmounted(() => sessionValidator.stop())
async function loadUserInfo() {
viewState.value = 'loading'
loadingMessage.value = 'Loading...'
try {
store.userInfo = await apiJson('/auth/api/user-info', { method: 'POST' })
// apiJson handles 401/403 with auth.iframe automatically:
// shows overlay iframe, waits for auth, retries the request.
const [validateData, userInfoData] = await Promise.all([
apiJson('/auth/api/validate', { method: 'POST', timeout: paskiaSettings.auth_ms }),
apiJson('/auth/api/user-info', { method: 'GET', timeout: paskiaSettings.auth_ms })
])
store.userInfo = userInfoData
store.ctx = validateData.ctx
updateThemeFromSession(store.userInfo)
// Verify that the user UUIDs match between user-info and validate responses
if (store.userInfo.user.uuid !== store.ctx.user.uuid) {
console.error('User UUID mismatch between user-info and validate responses')
window.location.reload()
return
}
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'
loadingMessage.value = 'Loading user profile...'
loadUserInfo()
break
case 'auth-error':
// Authentication failed - keep iframe open so user can retry
if (data.cancelled) {
console.log('Authentication cancelled by user')
} else {
store.showMessage(data.message || 'Authentication failed', 'error', 5000)
}
break
case 'auth-cancelled':
// Legacy support - treat as auth-error with cancelled flag
console.log('Authentication cancelled')
break
case 'auth-back':
// User clicked Back - show terminal state
removeAuthIframe()
terminateSession()
break
case 'auth-close-request':
// Legacy support - treat as back
removeAuthIframe()
break
} catch (e) {
onSessionLost(e)
}
}
onMounted(async () => {
// Listen for postMessage from auth iframe
window.addEventListener('message', handleAuthMessage)
// Load settings
await store.loadSettings()
@@ -134,20 +104,7 @@ onMounted(async () => {
document.title = inHostMode ? `${rpName} · Account summary` : rpName
}
// Try to load user info
const success = await loadUserInfo()
if (!success) {
// Need authentication - show login iframe
showAuthIframe()
}
})
onUnmounted(() => {
window.removeEventListener('message', handleAuthMessage)
removeAuthIframe()
// Load user info (apiJson handles auth iframe if needed)
await loadUserInfo()
})
</script>
<style scoped>
</style>
+356 -103
View File
@@ -9,10 +9,13 @@ import AccessDenied from '@/components/AccessDenied.vue'
import AdminOverview from '@/admin/AdminOverview.vue'
import AdminOrgDetail from '@/admin/AdminOrgDetail.vue'
import AdminUserDetail from '@/admin/AdminUserDetail.vue'
import AdminOidcDetail from '@/admin/AdminOidcDetail.vue'
import AdminDialogs from '@/admin/AdminDialogs.vue'
import { useAuthStore } from '@/stores/auth'
import { adminUiPath, makeUiHref } from '@/utils/settings'
import { apiJson, SessionValidator } from 'paskia'
import { apiJson, SessionValidator, settings as paskiaSettings } from 'paskia'
import { updateThemeFromSession } from '@/utils/theme'
import { uuidv7 } from 'uuidv7'
import { getDirection } from '@/utils/keynav'
import { goBack } from '@/utils/helpers'
@@ -24,9 +27,12 @@ const showBackMessage = ref(false)
const error = ref(null)
const orgs = ref([])
const permissions = ref([])
const oidcClients = ref([])
const currentOrgId = ref(null) // UUID of selected org for 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 editingOidcClient = ref(null) // OIDC client being edited (with local changes)
const authStore = useAuthStore()
const addingOrgForPermission = ref(null)
const PERMISSION_ID_PATTERN = '^[A-Za-z0-9:._~-]+$'
@@ -43,6 +49,7 @@ const breadcrumbsRef = ref(null)
const adminOverviewRef = ref(null)
const adminOrgDetailRef = ref(null)
const adminUserDetailRef = ref(null)
const adminOidcDetailRef = ref(null)
// Check if any modal/dialog is open (blocks arrow key navigation)
const hasActiveModal = computed(() => dialog.value.type !== null || showRegModal.value)
@@ -79,11 +86,12 @@ onUnmounted(() => {
const permissionSummary = computed(() => {
const summary = {}
for (const o of orgs.value) {
const orgBase = { uuid: o.uuid, display_name: o.display_name }
const orgPerms = new Set(o.permissions || [])
const orgBase = { uuid: o.uuid, display_name: o.org.display_name }
// 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
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].orgSet.has(o.uuid)) {
summary[pid].orgs.push(orgBase)
@@ -92,17 +100,18 @@ const permissionSummary = computed(() => {
}
// Role-based permissions (inheritance) - only count if org can grant them
for (const r of o.roles) {
for (const pid of r.permissions) {
for (const [roleUuid, r] of Object.entries(o.roles || {})) {
// r.permissions is dict[UUID, bool]
for (const pid of Object.keys(r.permissions || {})) {
// 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].orgSet.has(o.uuid)) {
summary[pid].orgs.push(orgBase)
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 || ''
currentOrgId.value = null
currentUserId.value = null
currentOidcId.value = null
editingOidcClient.value = null
if (h.startsWith('#org/')) {
currentOrgId.value = h.slice(5)
} else if (h.startsWith('#user/')) {
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() {
const data = await apiJson('/auth/api/admin/orgs')
orgs.value = data.map(o => {
const roles = o.roles.map(r => ({ ...r, org: o.uuid, users: [] }))
const roleMap = Object.fromEntries(roles.map(r => [r.display_name, r]))
for (const u of o.users || []) {
if (roleMap[u.role]) roleMap[u.role].users.push(u)
}
return { ...o, roles }
})
async function loadAdminData() {
const data = await apiJson('/auth/api/admin/info')
// Convert dicts to arrays with uuid added
orgs.value = Object.entries(data.orgs).map(([uuid, o]) => ({ uuid, ...o }))
permissions.value = Object.entries(data.permissions).map(([uuid, p]) => ({ uuid, ...p }))
oidcClients.value = Object.entries(data.oidc_clients).map(([uuid, c]) => ({ uuid, ...c }))
}
async function loadPermissions() {
permissions.value = await apiJson('/auth/api/admin/permissions')
// 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)
})
}
// Helper to count users in a role
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() {
const data = await apiJson('/auth/api/validate', { method: 'POST' })
const data = await apiJson('/auth/api/validate', { method: 'POST', timeout: paskiaSettings.auth_ms })
info.value = data
updateThemeFromSession(data.ctx)
authenticated.value = true
}
@@ -153,7 +206,9 @@ function clearSensitiveState() {
info.value = null
orgs.value = []
permissions.value = []
oidcClients.value = []
userDetail.value = null
editingOidcClient.value = null
authenticated.value = false
}
@@ -178,7 +233,7 @@ async function load() {
error.value = null
try {
// 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
await loadUserInfo()
@@ -186,7 +241,6 @@ async function load() {
if (!window.location.hash || window.location.hash === '#overview') {
currentOrgId.value = orgs.value[0].uuid
window.location.hash = `#org/${currentOrgId.value}`
authStore.showMessage(`Navigating to ${orgs.value[0].display_name} Administration`, 'info', 3000)
} else {
parseHash()
}
@@ -207,17 +261,17 @@ function editUserName(user) { openDialog('user-update-name', { user, name: user.
async function performOrgDeletion(orgUuid) {
await apiJson(`/auth/api/admin/orgs/${orgUuid}`, { method: 'DELETE' })
await Promise.all([loadOrgs(), loadPermissions()])
await Promise.all([loadAdminData()])
}
function deleteOrg(org) {
const userCount = org.roles.reduce((acc, r) => acc + r.users.length, 0)
const userCount = orgUserCount(org)
if (userCount === 0) {
// No users in the organization, safe to delete directly
performOrgDeletion(org.uuid)
.then(() => {
authStore.showMessage(`Organization "${org.display_name}" deleted.`, 'success', 2500)
authStore.showMessage(`Organization "${org.org.display_name}" deleted.`, 'success', 2500)
})
.catch(e => {
authStore.showMessage(e.message || 'Failed to delete organization', 'error')
@@ -226,50 +280,68 @@ function deleteOrg(org) {
}
// Build detailed breakdown of users by role
const roleParts = org.roles
.filter(r => r.users.length > 0)
.map(r => `${r.users.length} ${r.display_name}`)
const roleParts = Object.entries(org.roles)
.map(([uuid, r]) => ({ role: r, count: roleUserCount(org, uuid) }))
.filter(x => x.count > 0)
.map(x => `${x.count} ${x.role.display_name}`)
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)
} })
}
function createUserInRole(org, role) { openDialog('user-create', { org, role }) }
async function moveUserToRole(org, user, targetRoleDisplayName) {
if (user.role === targetRoleDisplayName) return
function deleteUser(user, userDetail) {
const credentialCount = userDetail?.credentials ? Object.keys(userDetail.credentials).length : 0
const userUuid = user.uuid
const userName = user.display_name
const orgUuid = user.org // org UUID is stored in selectedUser
if (credentialCount === 0) {
// No credentials, safe to delete directly
performUserDeletion(userUuid, userName, orgUuid)
return
}
const passkeys = credentialCount === 1 ? '1 passkey' : `${credentialCount} passkeys`
openDialog('confirm', {
message: `Delete user "${userName}" with ${passkeys}? This action cannot be undone.`,
action: async () => {
await performUserDeletion(userUuid, userName, orgUuid)
}
})
}
async function performUserDeletion(userUuid, userName, orgUuid) {
try {
await apiJson(`/auth/api/admin/orgs/${org.uuid}/users/${user.uuid}/role`, {
await apiJson(`/auth/api/admin/users/${userUuid}`, { method: 'DELETE' })
authStore.showMessage(`User "${userName}" deleted.`, 'success', 2500)
await loadAdminData()
window.location.hash = `#org/${orgUuid}`
} catch (e) {
authStore.showMessage(e.message || 'Failed to delete user', 'error')
}
}
async function moveUserToRole(userUuid, user, targetRoleUuid) {
if (user.role === targetRoleUuid) return
try {
await apiJson(`/auth/api/admin/users/${userUuid}/role`, {
method: 'PATCH',
body: { role: targetRoleDisplayName }
body: { role_uuid: targetRoleUuid }
})
await loadOrgs()
await loadAdminData()
} catch (e) {
authStore.showMessage(e.message || 'Failed to update user role')
}
}
function onUserDragStart(e, user, org) {
e.dataTransfer.effectAllowed = 'move'
e.dataTransfer.setData('text/plain', JSON.stringify({ user_uuid: user.uuid, org }))
}
function onRoleDragOver(e) {
e.preventDefault()
e.dataTransfer.dropEffect = 'move'
}
function onRoleDrop(e, org, role) {
e.preventDefault()
try {
const data = JSON.parse(e.dataTransfer.getData('text/plain'))
if (data.org !== org.uuid) return // only within same org
const user = org.roles.flatMap(r => r.users).find(u => u.uuid === data.user_uuid)
if (user) moveUserToRole(org, user, role.display_name)
} catch (_) { /* ignore */ }
function moveUserToRoleFromDrag(userUuid, newRoleUuid) {
const user = selectedOrg.value?.users?.[userUuid]
if (user) moveUserToRole(userUuid, user, newRoleUuid)
}
// Role actions
@@ -279,10 +351,10 @@ function updateRole(role) { openDialog('role-update', { role, name: role.display
function deleteRole(role) {
// UI only allows deleting empty roles, so no confirmation needed
apiJson(`/auth/api/admin/orgs/${role.org}/roles/${role.uuid}`, { method: 'DELETE' })
apiJson(`/auth/api/admin/roles/${role.uuid}`, { method: 'DELETE' })
.then(() => {
authStore.showMessage(`Role "${role.display_name}" deleted.`, 'success', 2500)
loadOrgs()
loadAdminData()
})
.catch(e => {
authStore.showMessage(e.message || 'Failed to delete role', 'error')
@@ -290,19 +362,22 @@ function deleteRole(role) {
}
async function toggleRolePermission(role, pid, checked) {
// Optimistic update
const prevPermissions = [...role.permissions]
const newPermissions = checked
? [...role.permissions, pid]
: role.permissions.filter(p => p !== pid)
// Optimistic update - role.permissions is dict[UUID, bool]
const prevPermissions = { ...role.permissions }
const newPermissions = { ...role.permissions }
if (checked) {
newPermissions[pid] = true
} else {
delete newPermissions[pid]
}
role.permissions = newPermissions
try {
const method = checked ? 'POST' : 'DELETE'
await apiJson(`/auth/api/admin/orgs/${role.org}/roles/${role.uuid}/permissions/${pid}`, {
await apiJson(`/auth/api/admin/roles/${role.uuid}/permissions/${pid}`, {
method
})
await loadOrgs()
await loadAdminData()
} catch (e) {
authStore.showMessage(e.message || 'Failed to update role permission')
role.permissions = prevPermissions // revert
@@ -313,7 +388,7 @@ async function toggleRolePermission(role, pid, checked) {
async function performPermissionDeletion(permissionUuid) {
const params = new URLSearchParams({ permission_uuid: permissionUuid })
await apiJson(`/auth/api/admin/permission?${params.toString()}`, { method: 'DELETE' })
await loadPermissions()
await loadAdminData()
}
function deletePermission(p) {
@@ -322,8 +397,8 @@ function deletePermission(p) {
// Count roles that have this permission
let roleCount = 0
for (const org of orgs.value) {
for (const role of org.roles) {
if (role.permissions.includes(p.uuid)) {
for (const role of Object.values(org.roles)) {
if (p.uuid in (role.permissions || {})) {
roleCount++
}
}
@@ -351,6 +426,104 @@ 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 })
}
async function openServerConfig() {
try {
const config = await apiJson('/auth/api/admin/server-config')
// Strip https:// scheme from stored origins and auth_host for editing
const origins = (config.origins || []).map(o => o.replace(/^https:\/\//, ''))
const auth_host = (config.auth_host || '').replace(/^https:\/\//, '')
openDialog('server-config', {
rp_name: config.rp_name || '',
auth_host,
origins,
originValidation: origins.map(() => null),
})
} catch (e) {
authStore.showMessage(e.message || 'Failed to load server configuration', 'error')
}
}
function deleteOidcClient(client) {
openDialog('confirm', {
message: `Delete OIDC client "${client.name}"? This will break any applications using this client.`,
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)
function openOrg(o) {
@@ -368,34 +541,41 @@ function openUser(u) {
const selectedUser = computed(() => {
if (!currentUserId.value) return null
for (const o of orgs.value) {
for (const r of o.roles) {
const u = r.users.find(x => x.uuid === currentUserId.value)
if (u) return { ...u, org: o.uuid, role_display_name: r.display_name }
const u = o.users[currentUserId.value]
if (u) {
const role = o.roles[u.role]
return { ...u, uuid: currentUserId.value, org: o.uuid, role_display_name: role?.display_name }
}
}
return null
})
const pageHeading = computed(() => {
if (selectedUser.value) return 'Admin: User'
if (selectedOrg.value) return 'Admin: Org'
return ((authStore.settings?.rp_name) || 'Master') + ' Admin'
})
// Breadcrumb entries for admin app.
const breadcrumbEntries = computed(() => {
const entries = [
{ label: 'Auth', href: makeUiHref() },
{ label: 'Admin', href: adminUiPath() }
{ label: 'My Profile', href: makeUiHref() }
]
// 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.
let orgForUser = null
if (selectedUser.value) {
orgForUser = orgs.value.find(o => o.uuid === selectedUser.value.org) || null
}
const orgToShow = selectedOrg.value || orgForUser
if (orgToShow) {
entries.push({ label: orgToShow.display_name, href: `#org/${orgToShow.uuid}` })
// Add org breadcrumb only if it's not already included in the Admin entry
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) {
entries.push({ label: selectedUser.value.display_name, href: `#user/${selectedUser.value.uuid}` })
@@ -406,7 +586,7 @@ const breadcrumbEntries = computed(() => {
watch(selectedUser, async (u) => {
if (!u) { userDetail.value = null; return }
try {
userDetail.value = await apiJson(`/auth/api/admin/orgs/${u.org}/users/${u.uuid}`)
userDetail.value = await apiJson(`/auth/api/admin/users/${u.uuid}`)
} catch (e) {
userDetail.value = { error: e.message }
}
@@ -418,18 +598,27 @@ function generateUserRegistrationLink(u) {
}
async function toggleOrgPermission(org, permId, checked) {
// Build next permission list
const has = org.permissions.includes(permId)
// org.permissions is dict[UUID, Permission]
const has = permId in org.permissions
if (checked && has) return
if (!checked && !has) return
const next = checked ? [...org.permissions, permId] : org.permissions.filter(p => p !== permId)
// Optimistic update
const prev = [...org.permissions]
org.permissions = next
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
}
try {
const params = new URLSearchParams({ permission_uuid: permId })
await apiJson(`/auth/api/admin/orgs/${org.uuid}/permission?${params.toString()}`, { method: checked ? 'POST' : 'DELETE' })
await loadOrgs()
await loadAdminData()
} catch (e) {
authStore.showMessage(e.message || 'Failed to update organization permission', 'error')
org.permissions = prev // revert
@@ -539,10 +728,10 @@ function handlePanelNavigateOut(direction) {
}
async function refreshUserDetail() {
await loadOrgs()
await loadAdminData()
if (selectedUser.value) {
try {
userDetail.value = await apiJson(`/auth/api/admin/orgs/${selectedUser.value.org}/users/${selectedUser.value.uuid}`)
userDetail.value = await apiJson(`/auth/api/admin/users/${selectedUser.value.uuid}`)
} catch (e) { authStore.showMessage(e.message || 'Failed to reload user', 'error') }
}
}
@@ -565,7 +754,7 @@ async function submitDialog() {
apiJson('/auth/api/admin/orgs', { method: 'POST', body: { display_name: name, permissions: [] } })
.then(() => {
authStore.showMessage(`Organization "${name}" created.`, 'success', 2500)
Promise.all([loadOrgs(), loadPermissions()])
loadAdminData()
})
.catch(e => {
authStore.showMessage(e.message || 'Failed to create organization', 'error')
@@ -579,7 +768,7 @@ async function submitDialog() {
apiJson(`/auth/api/admin/orgs/${org.uuid}`, { method: 'PATCH', body: { display_name: name } })
.then(() => {
authStore.showMessage(`Organization renamed to "${name}".`, 'success', 2500)
loadOrgs()
loadAdminData()
})
.catch(e => {
authStore.showMessage(e.message || 'Failed to update organization', 'error')
@@ -593,7 +782,7 @@ async function submitDialog() {
apiJson(`/auth/api/admin/orgs/${org.uuid}/roles`, { method: 'POST', body: { display_name: name, permissions: [] } })
.then(() => {
authStore.showMessage(`Role "${name}" created.`, 'success', 2500)
loadOrgs()
loadAdminData()
})
.catch(e => {
authStore.showMessage(e.message || 'Failed to create role', 'error')
@@ -604,10 +793,10 @@ async function submitDialog() {
// Close dialog immediately, then perform async operation
closeDialog()
apiJson(`/auth/api/admin/orgs/${role.org}/roles/${role.uuid}`, { method: 'PATCH', body: { display_name: name } })
apiJson(`/auth/api/admin/roles/${role.uuid}`, { method: 'PATCH', body: { display_name: name } })
.then(() => {
authStore.showMessage(`Role renamed to "${name}".`, 'success', 2500)
loadOrgs()
loadAdminData()
})
.catch(e => {
authStore.showMessage(e.message || 'Failed to update role', 'error')
@@ -621,7 +810,7 @@ async function submitDialog() {
apiJson(`/auth/api/admin/orgs/${org.uuid}/users`, { method: 'POST', body: { display_name: name, role: role.display_name } })
.then(() => {
authStore.showMessage(`User "${name}" added to ${role.display_name} role.`, 'success', 2500)
loadOrgs()
loadAdminData()
})
.catch(e => {
authStore.showMessage(e.message || 'Failed to add user', 'error')
@@ -632,7 +821,7 @@ async function submitDialog() {
// Close dialog immediately, then perform async operation
closeDialog()
apiJson(`/auth/api/admin/orgs/${user.org}/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(() => {
authStore.showMessage(`User renamed to "${name}".`, 'success', 2500)
onUserNameSaved()
@@ -667,7 +856,7 @@ async function submitDialog() {
apiJson(`/auth/api/admin/permission?${params.toString()}`, { method: 'PATCH' })
.then(() => {
authStore.showMessage(`Permission "${newDisplay}" updated.`, 'success', 2500)
loadPermissions()
loadAdminData()
})
.catch(e => {
authStore.showMessage(e.message || 'Failed to update permission', 'error')
@@ -683,12 +872,58 @@ async function submitDialog() {
apiJson('/auth/api/admin/permissions', { method: 'POST', body: { scope, display_name, domain: domain || undefined } })
.then(() => {
authStore.showMessage(`Permission "${display_name}" created.`, 'success', 2500)
loadPermissions()
loadAdminData()
})
.catch(e => {
authStore.showMessage(e.message || 'Failed to create permission', 'error')
})
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 === 'server-config') {
const rp_name = dialog.value.data.rp_name?.trim() || ''
const auth_host = dialog.value.data.auth_host?.trim() || ''
// Origins are stored as-is (hostnames); backend normalizes with https://
const origins = dialog.value.data.origins
.map(o => o.trim())
.filter(o => o)
closeDialog()
apiJson('/auth/api/admin/server-config', { method: 'PATCH', body: { rp_name, auth_host, origins } })
.then(() => {
authStore.showMessage('Server configuration updated.', 'success', 2500)
// Reload settings to reflect rp_name changes
authStore.loadSettings().then(() => {
if (authStore.settings?.rp_name) document.title = authStore.settings.rp_name + ' Admin'
})
})
.catch(e => {
authStore.showMessage(e.message || 'Failed to update server configuration', 'error')
})
return // Don't call closeDialog() again
} else if (t === 'confirm') {
const action = dialog.value.data.action
// Close dialog first, then perform action (errors shown via showMessage)
@@ -728,7 +963,6 @@ async function submitDialog() {
/>
<section v-else-if="authenticated && (isMasterAdmin || isOrgAdmin)" class="view-root view-root--wide view-admin">
<header class="view-header">
<h1>{{ pageHeading }}</h1>
<Breadcrumbs ref="breadcrumbsRef" :entries="breadcrumbEntries" @keydown="handleBreadcrumbKeydown" />
</header>
@@ -736,11 +970,12 @@ async function submitDialog() {
<div class="section-body admin-section-body">
<div class="admin-panels">
<AdminOverview
v-if="!selectedUser && !selectedOrg && (isMasterAdmin || isOrgAdmin)"
v-if="!selectedUser && !selectedOrg && !currentOidcId && (isMasterAdmin || isOrgAdmin)"
ref="adminOverviewRef"
:info="info"
:orgs="orgs"
:permissions="permissions"
:oidc-clients="oidcClients"
:navigation-disabled="hasActiveModal"
:permission-summary="permissionSummary"
@create-org="createOrg"
@@ -751,6 +986,10 @@ async function submitDialog() {
@open-dialog="openDialog"
@delete-permission="deletePermission"
@rename-permission-display="renamePermissionDisplay"
@create-oidc-client="createOidcClient"
@open-oidc-client="openOidcClient"
@delete-oidc-client="deleteOidcClient"
@open-server-config="openServerConfig"
@navigate-out="handlePanelNavigateOut"
/>
@@ -771,6 +1010,7 @@ async function submitDialog() {
@edit-user-name="editUserName"
@close-reg-modal="showRegModal = false"
@navigate-out="handlePanelNavigateOut"
@delete-user="deleteUser(selectedUser, userDetail)"
/>
<AdminOrgDetail
v-else-if="selectedOrg"
@@ -785,10 +1025,23 @@ async function submitDialog() {
@create-user-in-role="createUserInRole"
@open-user="openUser"
@toggle-role-permission="toggleRolePermission"
@on-role-drag-over="onRoleDragOver"
@move-user-to-role="moveUserToRoleFromDrag"
@navigate-out="handlePanelNavigateOut"
/>
<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"
@on-role-drop="onRoleDrop"
@on-user-drag-start="onUserDragStart"
/>
</div>
@@ -802,14 +1055,14 @@ async function submitDialog() {
:settings="authStore.settings"
@submit-dialog="submitDialog"
@close-dialog="closeDialog"
@reset-oidc-secret="resetOidcSecret"
@create-permission-for-client="createPermissionForClient"
/>
</div>
</template>
<style scoped>
.view-admin { padding-bottom: var(--space-3xl); }
.view-header { display: flex; flex-direction: column; gap: var(--space-sm); }
.admin-section { margin-top: var(--space-xl); }
.admin-section-body { display: flex; flex-direction: column; gap: var(--space-xl); }
.admin-panels { display: flex; flex-direction: column; gap: var(--space-xl); }
</style>
+3 -1
View File
@@ -3,7 +3,9 @@
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Admin</title>
<title>Admin</title>
<script>(localStorage.getItem('paskia-theme')==='dark'||localStorage.getItem('paskia-theme')!=='light'&&matchMedia('(prefers-color-scheme:dark)').matches)&&document.documentElement.classList.add('dark')</script>
<link rel="stylesheet" href="/src/assets/style.css">
</head>
<body>
<div id="admin-app"></div>
+2 -1
View File
@@ -1,4 +1,5 @@
import '@/assets/style.css'
import { initThemeFromCache } from '@/utils/theme'
initThemeFromCache()
import { createApp } from 'vue'
import { createPinia } from 'pinia'
+2
View File
@@ -4,6 +4,8 @@
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Auth Profile</title>
<script>(localStorage.getItem('paskia-theme')==='dark'||localStorage.getItem('paskia-theme')!=='light'&&matchMedia('(prefers-color-scheme:dark)').matches)&&document.documentElement.classList.add('dark')</script>
<link rel="stylesheet" href="/src/assets/style.css">
</head>
<body>
<div id="app"></div>
+2 -1
View File
@@ -1,4 +1,5 @@
import '@/assets/style.css'
import { initThemeFromCache } from '@/utils/theme'
initThemeFromCache()
import { createApp } from 'vue'
import { createPinia } from 'pinia'
+25 -10
View File
@@ -2,19 +2,23 @@
<RestrictedAuth
:mode="authMode"
:remote-auth-token="remoteAuthToken"
:oidc-query-string="oidcQueryString"
@authenticated="handleAuthenticated"
@back="handleBack"
/>
</template>
<script setup>
import { computed, onMounted, ref } from 'vue'
import { onMounted, ref } from 'vue'
import RestrictedAuth from '@/components/RestrictedAuth.vue'
// Check if this is a remote auth URL: /auth/{token}
// The token is a 5-word passphrase like "word1.word2.word3.word4.word5"
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() {
const path = window.location.pathname
// Match /auth/{token} where token is a passphrase with dots
@@ -30,14 +34,20 @@ function extractRemoteToken() {
return null
}
// Detect mode from URL hash fragment
const authMode = computed(() => {
const params = new URLSearchParams(window.location.hash.slice(1))
const mode = params.get('mode')
if (mode === 'reauth') return 'reauth'
if (mode === 'forbidden') return 'forbidden'
return 'login'
})
// Parse URL hash fragment
const hashParams = new URLSearchParams(window.location.hash.slice(1))
// 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) {
if (window.parent && window.parent !== window) {
@@ -46,10 +56,15 @@ function postToParent(message) {
}
function handleAuthenticated(result) {
if (result.redirect_url) {
// OIDC flow: redirect to client with auth code
window.location.href = result.redirect_url
return
}
postToParent({
type: 'auth-success',
authenticated: true,
sessionToken: result.session_token
exchangeCode: result.exchange_code
})
}
+3 -1
View File
@@ -1,8 +1,10 @@
<!DOCTYPE html>
<html lang="en" style="background: transparent">
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<script>{let t=localStorage.getItem('paskia-theme');if(!t){let p=new URLSearchParams(location.hash.slice(1)).get('theme');if(p==='light'||p==='dark')t=p}(t==='dark'||t!=='light'&&matchMedia('(prefers-color-scheme:dark)').matches)&&document.documentElement.classList.add('dark');if(window.location.pathname==='/auth/restricted/iframe')document.documentElement.style.background='transparent'}</script>
<link rel="stylesheet" href="/src/assets/style.css">
</head>
<body>
<div id="app"></div>
+1 -1
View File
@@ -1,6 +1,6 @@
import './theme.js'
import { createApp } from 'vue'
import RestrictedApi from './RestrictedApi.vue'
import '@/assets/style.css'
import { initKeyboardNavigation } from '@/utils/keynav'
createApp(RestrictedApi).mount('#app')
+11
View File
@@ -0,0 +1,11 @@
// Early theme for restricted app - user preference (localStorage) wins, then URL param
import { applyTheme, getCachedTheme } from '@/utils/theme.js'
function getTheme() {
const params = new URLSearchParams(location.hash.slice(1))
return getCachedTheme() || params.get('theme') || ''
}
// Apply theme class to document root
applyTheme(getTheme())
addEventListener('hashchange', () => applyTheme(getTheme()))
+1
View File
@@ -4,6 +4,7 @@
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<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>
<body>
<div id="app"></div>
+21 -19
View File
@@ -1,14 +1,14 @@
<template>
<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]">
{{ status.message }}
</div>
</div>
<main class="view-root">
<div class="surface surface--tight" style="max-width: 560px; margin: 0 auto; width: 100%;">
<header class="view-header" style="text-align: center;">
<div class="surface surface--tight reset-container">
<header class="view-header center">
<h1>🔑 Registration</h1>
<p class="view-lede">
{{ subtitleMessage }}
@@ -23,7 +23,7 @@
<section class="section-block" v-else-if="!canRegister">
<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>
</div>
</div>
@@ -59,7 +59,8 @@
import { computed, onMounted, reactive, ref } from 'vue'
import passkey from '@/utils/passkey'
import { getSettings, uiBasePath } from '@/utils/settings'
import { apiJson, ApiError, getUserFriendlyErrorMessage } from 'paskia'
import { apiJson, ApiError, getUserFriendlyErrorMessage, settings as paskiaSettings } from 'paskia'
import { updateThemeFromSession } from '@/utils/theme'
const status = reactive({
show: false,
@@ -80,7 +81,7 @@ const sessionDescriptor = computed(() => tokenInfo.value?.token_type || 'your en
const subtitleMessage = computed(() => {
if (initializing.value) return 'Preparing your secure enrollment…'
if (!canRegister.value) return 'This authentication link is no longer valid.'
return `Finish up ${sessionDescriptor.value}. You may edit the name below if needed, and it will be saved to your passkey.`
return `Finish up ${sessionDescriptor.value}. The name entered will be stored on your passkey and on our system.`
})
const basePath = computed(() => uiBasePath())
@@ -117,6 +118,7 @@ async function fetchTokenInfo() {
headers: { 'Authorization': `Bearer ${token.value}` },
})
displayName.value = tokenInfo.value.display_name
if (tokenInfo.value.theme) updateThemeFromSession({ user: { theme: tokenInfo.value.theme } })
} catch (error) {
console.error('Failed to load token info', error)
const message = error instanceof ApiError
@@ -144,7 +146,7 @@ async function registerPasskey() {
}
try {
await setSessionCookie(result)
await exchangeCode(result)
} catch (error) {
loading.value = false
const message = error?.message || 'Failed to establish session'
@@ -156,15 +158,14 @@ async function registerPasskey() {
setTimeout(() => { loading.value = false; goHome() }, 800)
}
async function setSessionCookie(result) {
if (!result?.session_token) {
throw new Error('Registration response missing session_token')
async function exchangeCode(result) {
if (!result?.exchange_code) {
throw new Error('Registration response missing exchange_code')
}
return await apiJson('/auth/api/set-session', {
method: 'POST',
headers: {
Authorization: `Bearer ${result.session_token}`
}
headers: { 'Authorization': `Bearer ${result.exchange_code}` },
timeout: paskiaSettings.auth_ms,
})
}
@@ -203,13 +204,14 @@ onMounted(async () => {
</script>
<style scoped>
.center {
text-align: center;
}
.button-row.center {
main.view-root { min-height: 100vh; align-items: center; justify-content: center; padding: 2rem 1rem; }
.reset-container {
max-width: 520px;
margin: 0 auto;
width: 100%;
display: flex;
justify-content: center;
flex-direction: column;
gap: 1.75rem;
}
.section-body {
+1
View File
@@ -4,6 +4,7 @@
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<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>
<body>
<div id="app"></div>
+3 -1
View File
@@ -14,7 +14,9 @@
"pinia": "^3.0.3",
"qrcode": "^1.5.4",
"sirv": "^3.0.2",
"vue": "^3.5.17"
"uuidv7": "^1.1.0",
"vue": "^3.5.17",
"vuedraggable": "^4.1.0"
},
"devDependencies": {
"@vitejs/plugin-vue": "^6.0.0",
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 43 KiB

+226 -9
View File
@@ -2,6 +2,7 @@
import { computed } from 'vue'
import Modal from '@/components/Modal.vue'
import NameEditForm from '@/components/NameEditForm.vue'
import { useAuthStore } from '@/stores/auth'
const props = defineProps({
dialog: Object,
@@ -9,10 +10,164 @@ const props = defineProps({
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 NO_SUBMIT_TYPES = new Set([])
const rpId = computed(() => props.settings?.rp_id || 'the configured domain')
const discoveryUrl = computed(() => `${window.location.origin}/.well-known/openid-configuration`)
// Initialize validation properties
if (props.dialog?.data && props.dialog.type === 'server-config') {
if (!('authHostValidation' in props.dialog.data)) {
props.dialog.data.authHostValidation = null
}
}
const isValidationInvalid = computed(() => {
if (props.dialog?.type !== 'server-config') return false
const d = props.dialog.data
if (d.authHostValidation?.startsWith('invalid') || d.authHostValidation === 'validating') return true
if (d.originValidation?.some(v => v === 'invalid' || v === 'validating')) return true
return false
})
// Copy-to-clipboard helper
const authStore = useAuthStore()
function copyText(value, label) {
navigator.clipboard.writeText(value).then(() => {
authStore.showMessage(`${label} copied to clipboard`, 'success', 1500)
})
}
function addOrigin() {
const d = props.dialog?.data
if (d) {
d.origins.push(rpId.value)
d.originValidation.push(null)
validateOrigin(d.origins[d.origins.length - 1], d.origins.length - 1)
}
}
function removeOrigin(i) {
const d = props.dialog?.data
if (d) {
d.origins.splice(i, 1)
d.originValidation.splice(i, 1)
}
}
function stripScheme(val, i) {
const d = props.dialog?.data
if (d) d.origins[i] = val.replace(/^https:\/\//, '').replace(/\/+$/, '')
}
function stripSchemeAuthHost() {
const d = props.dialog?.data
if (d && d.auth_host) d.auth_host = d.auth_host.replace(/^https:\/\//, '').replace(/\/+$/, '')
}
function focusOriginStart(e) {
e.target.setSelectionRange(0, 0)
}
function validateOriginDomain(origin, rpId) {
if (!origin.trim()) return false
try {
const url = origin.startsWith('http') ? new URL(origin) : new URL('https://' + origin)
const hostname = url.hostname
return hostname === rpId || hostname.endsWith('.' + rpId)
} catch {
return false
}
}
async function validateOriginConnectivity(origin, i) {
const d = props.dialog?.data
if (!d) return
d.originValidation[i] = 'validating'
try {
const cleanOrigin = origin.replace(/\/+$/, '')
const testUrl = cleanOrigin.startsWith('http') ? cleanOrigin : 'https://' + cleanOrigin
const response = await fetch(testUrl + '/auth/api/settings', {
method: 'GET',
headers: { 'Accept': 'application/json' }
})
if (response.ok) {
const data = await response.json()
// Check if it returns valid settings (has rp_id and matches current rp_id)
const result = (data.rp_id && data.rp_id === rpId.value) ? 'valid' : 'invalid'
// Only update if the origin hasn't changed
if (d.origins[i] === origin) {
d.originValidation[i] = result
}
} else {
if (d.origins[i] === origin) {
d.originValidation[i] = 'invalid'
}
}
} catch (e) {
if (d.origins[i] === origin) {
d.originValidation[i] = 'invalid'
}
}
}
function validateOrigin(origin, i) {
const d = props.dialog?.data
if (!d) return
const id = rpId.value
if (validateOriginDomain(origin, id)) {
validateOriginConnectivity(origin, i)
} else {
d.originValidation[i] = 'invalid'
}
}
async function validateAuthHostConnectivity(authHost) {
const d = props.dialog?.data
if (!d) return
d.authHostValidation = 'validating'
try {
const cleanAuthHost = authHost.replace(/\/+$/, '')
const testUrl = cleanAuthHost.startsWith('http') ? cleanAuthHost : 'https://' + cleanAuthHost
const response = await fetch(testUrl + '/auth/api/settings', {
method: 'GET',
headers: { 'Accept': 'application/json' }
})
if (response.ok) {
const data = await response.json()
// Check if it returns valid settings (has rp_id and matches current rp_id)
const result = (data.rp_id && data.rp_id === rpId.value) ? 'valid' : 'invalid'
// Only update if the auth_host hasn't changed
if (d.auth_host === authHost) {
d.authHostValidation = result
}
} else {
if (d.auth_host === authHost) {
d.authHostValidation = 'invalid-connectivity'
}
}
} catch (e) {
if (d.auth_host === authHost) {
d.authHostValidation = 'invalid-connectivity'
}
}
}
function validateAuthHost() {
const d = props.dialog?.data
if (!d || !d.auth_host?.trim()) {
d.authHostValidation = null // Allow empty
return
}
const id = rpId.value
if (validateOriginDomain(d.auth_host, id)) {
validateAuthHostConnectivity(d.auth_host)
} else {
d.authHostValidation = 'invalid-domain'
}
}
</script>
<template>
@@ -25,6 +180,8 @@ 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-update-name'">Edit User Name</template>
<template v-else-if="dialog.type==='perm-create' || dialog.type==='perm-display'">{{ dialog.type === 'perm-create' ? 'Create Permission' : 'Edit Permission' }}</template>
<template v-else-if="dialog.type==='oidc-edit'">{{ dialog.data?.isNew ? 'New OIDC Client' : 'OIDC Client' }}</template>
<template v-else-if="dialog.type==='server-config'">Server Options</template>
<template v-else-if="dialog.type==='confirm'">Confirm</template>
</h3>
<form @submit.prevent="$emit('submitDialog')" class="modal-form">
@@ -76,19 +233,51 @@ const rpId = computed(() => props.settings?.rp_id || 'the configured domain')
<input ref="displayNameInput" v-model="dialog.data.display_name" required />
</label>
<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>
<p class="small muted">E.g. yourapp:reports. Changing the scope name may break deployed applications.</p>
<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>
<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 v-else-if="dialog.type==='server-config'">
<label>Site Branding (rp-name)
<input v-model="dialog.data.rp_name" :placeholder="rpId" />
</label>
<label>Dedicated Authentication Site (auth-host)
<input v-model="dialog.data.auth_host" @input="validateAuthHost()" :class="{ 'input-error': dialog.data.authHostValidation?.startsWith('invalid') }" />
</label>
<p v-if="dialog.data.authHostValidation === 'validating'" class="small muted">Validating...</p>
<p v-else-if="dialog.data.authHostValidation === 'valid'" class="small muted">Valid</p>
<p v-else-if="dialog.data.authHostValidation === 'invalid-domain'" class="small muted">Invalid domain</p>
<p v-else-if="dialog.data.authHostValidation === 'invalid-connectivity'" class="small muted">Well-formed but unreachable</p>
<p v-else-if="dialog.data.authHostValidation === 'invalid'" class="small muted">Invalid configuration</p>
<p v-else-if="dialog.data.authHostValidation === 'invalid'" class="small muted">Enter {{ rpId }} or any subdomain of it.</p>
<div class="origin-label">
Allowed Origins
<button type="button" class="icon-btn origin-add-btn" @click="addOrigin" aria-label="Add origin" title="Add origin"></button>
</div>
<div v-if="dialog.data.origins.length" class="origin-list">
<div v-for="(_, i) in dialog.data.origins" :key="i" class="origin-row">
<input
:value="dialog.data.origins[i]"
@input="e => { dialog.data.origins[i] = e.target.value; validateOrigin(e.target.value, i) }"
@focus="focusOriginStart"
class="origin-input"
:class="{ 'input-error': dialog.data.originValidation[i] === 'invalid' }"
/>
<button type="button" class="icon-btn delete-icon" @click="removeOrigin(i)" aria-label="Remove origin" title="Remove origin"></button>
</div>
</div>
<p v-if="!dialog.data.origins.length" class="small muted">{{ rpId }} and all subdomains allowed.</p>
<p v-else class="small muted">Only the above sites are allowed to authenticate.</p>
</template>
<template v-else-if="dialog.type==='confirm'">
<p>{{ dialog.data.message }}</p>
</template>
<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
type="button"
class="btn-secondary"
@@ -100,18 +289,46 @@ const rpId = computed(() => props.settings?.rp_id || 'the configured domain')
<button
type="submit"
class="btn-primary"
:disabled="dialog.busy"
:disabled="dialog.busy || isValidationInvalid"
>
{{ dialog.type==='confirm' ? 'OK' : 'Save' }}
</button>
</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>
</Modal>
</template>
<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; }
.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; }
/* Server config origins */
.origin-label { font-weight: 600; font-size: 0.95rem; margin-top: var(--space-sm); display: flex; align-items: center; gap: var(--space-sm); }
.origin-list { display: flex; flex-direction: column; gap: 0.4rem; }
.origin-row { display: flex; align-items: center; gap: var(--space-xs); }
.origin-input { flex: 1; min-width: 8rem; font-family: var(--font-mono, monospace); }
.origin-row .delete-icon { flex-shrink: 0; }
.origin-add-btn { font-size: 1.2rem; }
.input-error {
border-color: var(--color-error);
background: var(--color-error-bg, rgba(239, 68, 68, 0.05));
}
</style>
+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>
+100 -61
View File
@@ -1,5 +1,7 @@
<script setup>
import { computed, ref } from 'vue'
import draggable from 'vuedraggable'
import ProfilePicture from '@/components/ProfilePicture.vue'
import { getDirection, navigateButtonRow, focusPreferred } from '@/utils/keynav'
const props = defineProps({
@@ -8,7 +10,7 @@ const props = defineProps({
navigationDisabled: { type: Boolean, default: false }
})
const emit = defineEmits(['updateOrg', 'createRole', 'updateRole', 'deleteRole', 'createUserInRole', 'openUser', 'toggleRolePermission', 'onRoleDragOver', 'onRoleDrop', 'onUserDragStart', 'navigateOut'])
const emit = defineEmits(['updateOrg', 'createRole', 'updateRole', 'deleteRole', 'createUserInRole', 'openUser', 'toggleRolePermission', 'moveUserToRole', 'navigateOut'])
// Template refs for navigation
const orgTitleRef = ref(null)
@@ -16,7 +18,8 @@ const permMatrixRef = ref(null)
const rolesGridRef = ref(null)
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 nameB = b.display_name.toLowerCase()
if (nameA !== nameB) {
@@ -28,12 +31,45 @@ const sortedRoles = computed(() => {
// Get org's grantable permissions as full permission objects (with UUIDs)
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))
})
function permissionDisplayName(scope) {
return props.permissions.find(p => p.scope === scope)?.display_name || scope
// Get users for a role as sorted array of { uuid, ...user }
function getNormalizedName(name) {
let cleaned = name.replace(/\([^)]*\)/g, '').trim();
if (cleaned.includes(',')) {
return cleaned.toLowerCase();
} else {
const parts = cleaned.split(/\s+/);
const last = parts.pop();
const first = parts.join(' ');
return `${last}, ${first}`.toLowerCase();
}
}
function roleUsers(roleUuid) {
return Object.entries(props.selectedOrg.users)
.filter(([_, u]) => u.role === roleUuid)
.map(([uuid, u]) => ({ uuid, ...u }))
.sort((a, b) => {
const normA = getNormalizedName(a.display_name);
const normB = getNormalizedName(b.display_name);
return normA.localeCompare(normB);
})
}
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 toggleRolePermission(role, pid, checked) {
@@ -83,7 +119,7 @@ function handleMatrixKeydown(event) {
// Calculate grid dimensions
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 currentCol = currentIndex % cols
@@ -219,7 +255,7 @@ function handleRoleHeaderKeydown(event, roleIndex) {
if (checkboxes?.length) {
// Focus the checkbox in the corresponding column
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
if (checkboxes[targetIndex]) checkboxes[targetIndex].focus()
else checkboxes[checkboxes.length - 1].focus()
@@ -287,7 +323,7 @@ defineExpose({ focusFirstElement })
<template>
<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>
</h2>
@@ -317,7 +353,7 @@ defineExpose({ focusFirstElement })
>
<input
type="checkbox"
:checked="r.permissions.includes(p.uuid)"
:checked="p.uuid in (r.permissions || {})"
@change="e => toggleRolePermission(r, p.uuid, e.target.checked)"
/>
</div>
@@ -332,84 +368,87 @@ defineExpose({ focusFirstElement })
v-for="(r, roleIndex) in sortedRoles"
:key="r.uuid"
class="role-column"
@dragover="$emit('onRoleDragOver', $event)"
@drop="e => $emit('onRoleDrop', e, selectedOrg, r)"
>
<div class="role-header" @keydown="e => handleRoleHeaderKeydown(e, roleIndex)">
<strong class="role-name" :title="r.uuid">
<span>{{ r.display_name }}</span>
<button @click="$emit('updateRole', r)" class="icon-btn" aria-label="Edit role" title="Edit 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>
<div class="role-actions">
<button @click="$emit('createUserInRole', selectedOrg, r)" class="plus-btn" aria-label="Add user" title="Add user"></button>
</div>
</div>
<template v-if="r.users.length > 0">
<ul class="user-list" @keydown="handleUserListKeydown">
<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"
tabindex="0"
draggable="true"
@dragstart="e => $emit('onUserDragStart', e, u, selectedOrg.uuid)"
@click="$emit('openUser', u)"
@keydown.enter="$emit('openUser', u)"
:title="u.uuid"
>
<span class="name">{{ u.display_name }}</span>
<span class="meta">{{ u.last_seen ? new Date(u.last_seen).toLocaleDateString() : '—' }}</span>
</li>
</ul>
</template>
<div v-else class="empty-role" @keydown="e => handleEmptyRoleKeydown(e, roleIndex)">
<p class="empty-text muted">No members</p>
<button @click="$emit('deleteRole', r)" class="icon-btn delete-icon" aria-label="Delete empty role" title="Delete role"></button>
<div class="user-list-wrapper">
<draggable
:list="roleUsers(r.uuid)"
group="users"
item-key="uuid"
tag="ul"
class="user-list"
@change="evt => onUserChange(evt, r.uuid)"
@keydown="handleUserListKeydown"
>
<template #item="{ element: u }">
<li
class="user-chip"
tabindex="0"
@click="$emit('openUser', u)"
@keydown.enter="$emit('openUser', u)"
:title="u.uuid"
>
<ProfilePicture
class="user-chip-picture"
:src="u.avatar_url"
:title="u.display_name"
width="3.25rem"
height="100%"
radius="0"
fallback-size="1.3rem"
/>
<span class="user-chip-body">
<span class="name">{{ u.display_name }}</span>
<span class="meta">{{ u.last_seen ? new Date(u.last_seen).toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' }) : '—' }}</span>
</span>
</li>
</template>
</draggable>
<div v-if="roleUserCount(r.uuid) === 0" class="empty-role">
<p class="empty-text muted">No members</p>
</div>
</div>
</div>
</div>
<p v-if="sortedRoles.length >= 2" class="roles-hint muted">Members can be drag&dropped to different roles.</p>
</template>
<style scoped>
.card.surface { padding: var(--space-lg); }
.org-title { display: flex; align-items: center; gap: var(--space-sm); margin-bottom: var(--space-lg); }
.org-name { font-size: 1.5rem; font-weight: 600; color: var(--color-heading); }
.icon-btn { background: none; border: none; color: var(--color-text-muted); padding: 0.2rem; border-radius: var(--radius-sm); cursor: pointer; transition: background 0.2s ease, color 0.2s ease; }
.icon-btn:hover { color: var(--color-heading); background: var(--color-surface-muted); }
.matrix-wrapper { margin: var(--space-md) 0; padding: var(--space-lg); }
.matrix-scroll { overflow-x: auto; }
.matrix-hint { font-size: 0.8rem; color: var(--color-text-muted); }
.perm-matrix-grid { display: inline-grid; gap: 0.25rem; align-items: stretch; }
.perm-matrix-grid > * { padding: 0.35rem 0.45rem; font-size: 0.75rem; }
.perm-matrix-grid .grid-head { color: var(--color-text-muted); text-transform: uppercase; font-weight: 600; letter-spacing: 0.05em; }
.perm-matrix-grid .perm-head { display: flex; align-items: flex-end; justify-content: flex-start; padding: 0.35rem 0.45rem; font-size: 0.75rem; }
.org-title { display: flex; align-items: center; gap: var(--space-sm); margin-bottom: var(--space-lg); font-size: 1.65rem; }
.org-name { font-weight: 600; color: var(--color-heading); }
.perm-matrix-grid .role-head { display: flex; align-items: flex-end; justify-content: center; }
.perm-matrix-grid .role-head span { writing-mode: vertical-rl; transform: rotate(180deg); font-size: 0.65rem; }
.perm-matrix-grid .add-role-head { cursor: pointer; }
.perm-name { font-weight: 600; color: var(--color-heading); padding: 0.35rem 0.45rem; font-size: 0.75rem; }
.roles-grid { display: flex; gap: var(--space-lg); margin-top: var(--space-lg); }
.role-column { flex: 1; min-width: 200px; border: 1px solid var(--color-border); border-radius: var(--radius-md); padding: var(--space-md); }
.roles-grid { display: flex; flex-wrap: wrap; gap: 0; margin-top: var(--space-lg); justify-content: flex-start; align-items: stretch; }
.role-column { flex: 0 0 17em; border-radius: var(--radius-md); padding: var(--space-md); display: flex; flex-direction: column; }
.role-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: var(--space-md); }
.role-name { display: flex; align-items: center; gap: var(--space-xs); font-size: 1.1rem; color: var(--color-heading); }
.role-actions { display: flex; gap: var(--space-xs); }
.plus-btn { background: 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); }
.user-list { list-style: none; padding: 0; margin: 0; display: flex; flex-direction: column; gap: var(--space-xs); }
.user-chip { background: var(--color-surface); border: 1px solid var(--color-border); border-radius: var(--radius-md); padding: 0.45rem 0.6rem; display: flex; justify-content: space-between; gap: var(--space-sm); cursor: grab; }
.user-list-wrapper { position: relative; flex: 1; display: flex; flex-direction: column; min-height: 5.5rem; }
.user-list { list-style: none; padding: 0; margin: 0; display: flex; flex-direction: column; gap: var(--space-xs); flex: 1; }
.user-chip { background: var(--color-accent-strong); color: var(--color-accent-contrast); border: none; border-radius: var(--radius-md); padding: 0; display: grid; grid-template-columns: 3.25rem minmax(0, 1fr); align-items: stretch; gap: 0; cursor: grab; overflow: hidden; min-height: 3.25rem; }
.user-chip:focus { outline: 2px solid var(--color-accent); outline-offset: 1px; }
.user-chip .meta { font-size: 0.7rem; color: var(--color-text-muted); }
.empty-role { border: 1px dashed var(--color-border-strong); border-radius: var(--radius-md); padding: var(--space-sm); display: flex; flex-direction: column; gap: var(--space-xs); align-items: flex-start; }
.user-chip-picture { align-self: stretch; }
.user-chip-body { display: flex; min-width: 0; flex-direction: column; justify-content: center; gap: 0.1rem; padding: 0.45rem 0.6rem; }
.user-chip .name { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.user-chip .meta { font-size: 0.7rem; opacity: 0.85; }
.user-chip.sortable-ghost { opacity: 0.5; }
.user-chip.sortable-chosen { box-shadow: 0 4px 12px rgba(0, 0, 0, 0.2); }
.empty-role { position: absolute; inset: 0; border: 1px dashed var(--color-border-strong); border-radius: var(--radius-md); display: flex; align-items: center; justify-content: center; pointer-events: none; }
.user-list:has(.sortable-ghost) + .empty-role { display: none; }
.empty-text { margin: 0; }
.delete-icon { color: var(--color-danger); }
.delete-icon:hover { background: var(--color-danger-bg); color: var(--color-danger-text); }
.muted { color: var(--color-text-muted); }
@media (max-width: 720px) {
.roles-grid { flex-direction: column; }
+109 -30
View File
@@ -1,19 +1,20 @@
<script setup>
import { computed, ref } from 'vue'
import { getDirection, navigateButtonRow, focusPreferred, focusAtIndex } from '@/utils/keynav'
import { formatDate } from '@/utils/helpers'
const props = defineProps({
info: Object,
orgs: Array,
permissions: Array,
oidcClients: Array,
permissionSummary: Object,
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', 'openServerConfig', 'navigateOut'])
// Template refs for navigation
const orgSection = ref(null)
const orgActionsRef = ref(null)
const orgTableRef = ref(null)
const permMatrixRef = ref(null)
@@ -21,27 +22,56 @@ const permActionsRef = ref(null)
const permTableRef = ref(null)
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)
}))
// 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)))
// Derive admin status from permissions (info contains ctx from validate response)
const isMasterAdmin = computed(() => props.info?.ctx.permissions.includes('auth:admin'))
const isOrgAdmin = computed(() => props.info?.ctx.permissions.includes('auth:org:admin'))
function permissionDisplayName(scope) {
return props.permissions.find(p => p.scope === scope)?.display_name || scope
}
function getRoleNames(org) {
return org.roles
// org.roles is dict[UUID, Role]
return Object.values(org.roles)
.slice()
.sort((a, b) => a.display_name.localeCompare(b.display_name))
.map(r => r.display_name)
.join(', ')
}
function orgUserCount(org) {
return Object.keys(org.users).length
}
// Table navigation for both org and permissions tables
function handleTableKeydown(event, tableType) {
if (props.navigationDisabled) return
@@ -265,11 +295,11 @@ defineExpose({ focusFirstElement })
<tbody>
<tr v-for="o in sortedOrgs" :key="o.uuid">
<td>
<a href="#org/{{o.uuid}}" @click.prevent="$emit('openOrg', o)">{{ o.display_name }}</a>
<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>
</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">
<button @click="$emit('deleteOrg', o)" class="icon-btn delete-icon" aria-label="Delete organization" title="Delete organization"></button>
</td>
@@ -291,9 +321,9 @@ defineExpose({ focusFirstElement })
v-for="o in sortedOrgs"
:key="'head-' + o.uuid"
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>
<template v-for="p in sortedPermissions" :key="p.uuid">
@@ -307,7 +337,7 @@ defineExpose({ focusFirstElement })
>
<input
type="checkbox"
:checked="o.permissions.includes(p.uuid)"
:checked="p.uuid in o.permissions"
@change="e => $emit('toggleOrgPermission', o, p.uuid, e.target.checked)"
/>
</div>
@@ -339,7 +369,7 @@ defineExpose({ focusFirstElement })
<span class="id-text">{{ p.scope }}</span>
</div>
</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-actions center">
<button @click="$emit('deletePermission', p)" class="icon-btn delete-icon" aria-label="Delete permission" title="Delete permission"></button>
@@ -348,13 +378,67 @@ defineExpose({ focusFirstElement })
</tbody>
</table>
</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>
<div v-if="isMasterAdmin" class="server-options-section">
<div class="section-header">
<h2>Server</h2>
<p class="section-description">
Configure core server settings such as the display name, authentication host, and allowed origins.
</p>
</div>
<button @click="$emit('openServerConfig')"> Server Options</button>
</div>
</template>
<style scoped>
.permissions-section { margin-bottom: var(--space-xl); }
.permissions-section h2 { margin-bottom: var(--space-md); }
.actions { display: flex; flex-wrap: wrap; gap: var(--space-sm); align-items: center; }
.actions button { width: auto; }
.org-table a { text-decoration: none; color: var(--color-link); }
.org-table a:hover { text-decoration: underline; }
.org-table .center { width: 6rem; min-width: 6rem; }
@@ -363,24 +447,19 @@ defineExpose({ focusFirstElement })
.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-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 span { writing-mode: vertical-rl; transform: rotate(180deg); font-size: 0.65rem; }
.perm-name { font-weight: 600; color: var(--color-heading); padding: 0.35rem 0.45rem; font-size: 0.75rem; }
.display-text { margin-right: var(--space-xs); }
.edit-display-btn { padding: 0.1rem 0.2rem; font-size: 0.8rem; }
.edit-org-btn { padding: 0.1rem 0.2rem; font-size: 0.8rem; margin-left: var(--space-xs); }
.perm-actions { text-align: center; }
.center { text-align: center; }
.muted { color: var(--color-text-muted); }
/* 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); }
/* Server Options Section */
.server-options-section { margin-top: var(--space-2xl); }
.server-options-section .section-header { display: flex; flex-direction: column; gap: 0.4rem; margin-bottom: var(--space-md); }
</style>
+90 -41
View File
@@ -2,6 +2,7 @@
import { ref, computed } from 'vue'
import UserBasicInfo from '@/components/UserBasicInfo.vue'
import CredentialList from '@/components/CredentialList.vue'
import ProfilePictureEditorModal from '@/components/ProfilePictureEditorModal.vue'
import RegistrationLinkModal from '@/components/RegistrationLinkModal.vue'
import SessionList from '@/components/SessionList.vue'
import { useAuthStore } from '@/stores/auth'
@@ -17,12 +18,19 @@ const props = defineProps({
navigationDisabled: { type: Boolean, default: false }
})
const emit = defineEmits(['generateUserRegistrationLink', 'goOverview', 'openOrg', 'onUserNameSaved', 'closeRegModal', 'editUserName', 'refreshUserDetail', 'navigateOut'])
const emit = defineEmits(['generateUserRegistrationLink', 'goOverview', 'openOrg', 'onUserNameSaved', 'closeRegModal', 'editUserName', 'refreshUserDetail', 'navigateOut', 'deleteUser'])
const authStore = useAuthStore()
const terminatingSessions = ref({})
const hoveredCredentialUuid = ref(null)
const hoveredSession = ref(null)
const showPictureDialog = ref(false)
const avatarRenderVersion = ref(0)
// Convert credentials dict to array with uuid attached as 'credential'
const credentials = computed(() =>
Object.entries(props.userDetail?.credentials || {}).map(([uuid, c]) => ({ ...c, credential: uuid }))
)
// Template refs for navigation
const userInfoRef = ref(null)
@@ -43,9 +51,23 @@ function handleEditName() {
emit('editUserName', props.selectedUser)
}
function openPictureDialog() {
if (!props.userDetail || props.userDetail.error) return
showPictureDialog.value = true
}
function closePictureDialog() {
showPictureDialog.value = false
}
function handlePictureUpdated() {
avatarRenderVersion.value += 1
emit('refreshUserDetail')
}
async function handleDelete(credential) {
try {
const data = await apiJson(`/auth/api/admin/orgs/${props.selectedUser.org}/users/${props.selectedUser.uuid}/credentials/${credential.credential}`, { method: 'DELETE' })
const data = await apiJson(`/auth/api/admin/users/${props.selectedUser.uuid}/credentials/${credential.credential}`, { method: 'DELETE' })
if (data.status === 'ok') {
emit('onUserNameSaved') // Reuse to refresh user detail
} else {
@@ -57,11 +79,11 @@ async function handleDelete(credential) {
}
async function handleTerminateSession(session) {
const sessionId = session?.id
if (!sessionId) return
terminatingSessions.value = { ...terminatingSessions.value, [sessionId]: true }
const sessionKey = session?.key
if (!sessionKey) return
terminatingSessions.value = { ...terminatingSessions.value, [sessionKey]: true }
try {
const data = await apiJson(`/auth/api/admin/orgs/${props.selectedUser.org}/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.current_session_terminated) {
sessionStorage.clear()
@@ -78,11 +100,15 @@ async function handleTerminateSession(session) {
authStore.showMessage(err.message || 'Failed to terminate session', 'error')
} finally {
const next = { ...terminatingSessions.value }
delete next[sessionId]
delete next[sessionKey]
terminatingSessions.value = next
}
}
async function handleDeleteUser() {
emit('deleteUser')
}
// Handle user info section keynav
function handleUserInfoKeydown(event) {
if (hasActiveModal.value || props.navigationDisabled) return
@@ -93,7 +119,7 @@ function handleUserInfoKeydown(event) {
event.preventDefault()
if (direction === 'left' || direction === 'right') {
navigateButtonRow(userInfoRef.value, event.target, direction, { itemSelector: '.mini-btn' })
navigateButtonRow(userInfoRef.value, event.target, direction, { itemSelector: '.user-picture-btn, .mini-btn' })
} else if (direction === 'up') {
emit('navigateOut', 'up')
} else if (direction === 'down') {
@@ -115,7 +141,7 @@ function handleRegActionsKeydown(event) {
navigateButtonRow(regActionsRef.value, event.target, direction, { itemSelector: 'button' })
} else if (direction === 'up') {
// Move to user info edit button
focusPreferred(userInfoRef.value, { itemSelector: '.mini-btn' })
focusPreferred(userInfoRef.value, { itemSelector: '.user-picture-btn, .mini-btn' })
} else if (direction === 'down') {
// Move to credential list
credentialListRef.value?.$el?.focus()
@@ -165,10 +191,22 @@ function handleBackButtonKeydown(event) {
// Focus helper for external navigation
function focusFirstElement() {
focusPreferred(userInfoRef.value, { itemSelector: '.mini-btn' })
focusPreferred(userInfoRef.value, { itemSelector: '.user-picture-btn, .mini-btn' })
}
defineExpose({ focusFirstElement })
const currentPictureEndpoint = computed(() => {
if (!props.selectedUser?.uuid) return null
return `/auth/api/user/${props.selectedUser.uuid}/profile.webp`
})
const adminPictureTitle = computed(() => {
const username = props.userDetail?.user?.preferred_username || props.selectedUser?.preferred_username
const displayName = props.userDetail?.user?.display_name || props.selectedUser?.display_name
const label = username || displayName || 'User'
return `Profile Picture for ${label}`
})
</script>
<template>
@@ -176,31 +214,41 @@ defineExpose({ focusFirstElement })
<div ref="userInfoRef" @keydown="handleUserInfoKeydown">
<UserBasicInfo
v-if="userDetail && !userDetail.error"
:name="userDetail.display_name || selectedUser.display_name"
:visits="userDetail.visits"
:created-at="userDetail.created_at"
:last-seen="userDetail.last_seen"
:name="userDetail.user.display_name || selectedUser.display_name"
:avatar-url="userDetail.user.avatar_url"
:avatar-render-version="avatarRenderVersion"
avatar-clickable
:visits="userDetail.user.visits"
:created-at="userDetail.user.created_at"
:last-seen="userDetail.user.last_seen"
:email="userDetail.user.email"
:telephone="userDetail.user.telephone"
:loading="loading"
:org-display-name="userDetail.org.display_name"
:role-name="userDetail.role"
:update-endpoint="`/auth/api/admin/orgs/${selectedUser.org}/users/${selectedUser.uuid}/display-name`"
:role-name="userDetail.role.display_name"
:update-endpoint="`/auth/api/admin/users/${selectedUser.uuid}/info`"
@saved="$emit('onUserNameSaved')"
@edit-name="handleEditName"
/>
@avatar-click="openPictureDialog"
@edit="handleEditName"
>
<div class="admin-actions">
<button
class="btn-primary"
@click="$emit('generateUserRegistrationLink', selectedUser)"
:disabled="loading"
title="Generate a one-time link for this user"
>{{ userDetail?.credentials && Object.keys(userDetail.credentials).length > 0 ? 'Recovery Link' : 'Registration Link' }}</button>
<button
class="btn-danger"
@click="handleDeleteUser"
:disabled="loading"
title="Delete this user"
>Delete User</button>
</div>
</UserBasicInfo>
</div>
<div v-if="userDetail?.error" class="error small">{{ userDetail.error }}</div>
<template v-if="userDetail && !userDetail.error">
<div class="registration-actions" ref="regActionsRef" @keydown="handleRegActionsKeydown">
<button
class="btn-secondary reg-token-btn"
@click="$emit('generateUserRegistrationLink', selectedUser)"
:disabled="loading"
>Generate Registration Token</button>
<p class="matrix-hint muted">
Generate a one-time registration link so this user can register or add another passkey.
Copy the link from the dialog and send it to the user, or have the user scan the QR code on their device.
</p>
</div>
<section class="section-block" data-section="registered-passkeys">
<div class="section-header">
<h2>Registered Passkeys</h2>
@@ -208,7 +256,7 @@ defineExpose({ focusFirstElement })
<div class="section-body">
<CredentialList
ref="credentialListRef"
:credentials="userDetail.credentials"
:credentials="credentials"
:aaguid-info="userDetail.aaguid_info"
:allow-delete="true"
:hovered-credential-uuid="hoveredCredentialUuid"
@@ -222,7 +270,7 @@ defineExpose({ focusFirstElement })
</section>
<SessionList
ref="sessionListRef"
:sessions="userDetail.sessions || []"
:sessions="userDetail.sessions || {}"
:terminating-sessions="terminatingSessions"
:hovered-credential-uuid="hoveredCredentialUuid"
:navigation-disabled="hasActiveModal"
@@ -238,24 +286,25 @@ defineExpose({ focusFirstElement })
</div>
<RegistrationLinkModal
v-if="showRegModal"
:endpoint="`/auth/api/admin/orgs/${selectedUser.org}/users/${selectedUser.uuid}/create-link`"
:endpoint="`/auth/api/admin/users/${selectedUser.uuid}/create-link`"
:user-name="userDetail?.display_name || selectedUser.display_name"
@close="$emit('closeRegModal')"
@copied="onLinkCopied"
/>
<ProfilePictureEditorModal
v-if="showPictureDialog && currentPictureEndpoint"
:endpoint="currentPictureEndpoint"
:picture-url="userDetail?.user?.avatar_url"
:render-version="avatarRenderVersion"
:title="adminPictureTitle"
@close="closePictureDialog"
@updated="handlePictureUpdated"
/>
</div>
</template>
<style scoped>
.user-detail { display: flex; flex-direction: column; gap: var(--space-lg); }
.actions { display: flex; flex-wrap: wrap; gap: var(--space-sm); align-items: center; }
.admin-actions { display: flex; gap: 0.5rem; }
.ancillary-actions { margin-top: -0.5rem; }
.reg-token-btn { align-self: flex-start; }
.registration-actions { display: flex; flex-direction: column; gap: 0.5rem; }
.icon-btn { background: none; border: none; color: var(--color-text-muted); padding: 0.2rem; border-radius: var(--radius-sm); cursor: pointer; transition: background 0.2s ease, color 0.2s ease; }
.icon-btn:hover { color: var(--color-heading); background: var(--color-surface-muted); }
.matrix-hint { font-size: 0.8rem; color: var(--color-text-muted); }
.error { color: var(--color-danger-text); }
.small { font-size: 0.9rem; }
.muted { color: var(--color-text-muted); }
</style>
+5
View File
@@ -0,0 +1,5 @@
<?xml version="1.0"?>
<svg xmlns="http://www.w3.org/2000/svg" width="340" height="340">
<path fill="#DDD" d="m169,.5a169,169 0 1,0 2,0zm0,86a76,76 0 1
1-2,0zM57,287q27-35 67-35h92q40,0 67,35a164,164 0 0,1-226,0"/>
</svg>

After

Width:  |  Height:  |  Size: 220 B

+288 -87
View File
@@ -1,68 +1,90 @@
@property --hue {
syntax: '<angle>';
inherits: true;
initial-value: 0.72turn;
}
:root {
--font-sans: "Inter", "Inter var", "Segoe UI", system-ui, -apple-system, "Helvetica Neue", sans-serif;
--font-mono: "DM Mono", "JetBrains Mono", "SFMono-Regular", Menlo, Monaco, Consolas, "Liberation Mono", monospace;
--color-canvas: #ffffff;
--color-surface: #eff6ff;
--color-surface-subtle: #dbeafe;
--color-border: #2563eb;
--color-border-strong: #1e40af;
--color-heading: #1e3a8a;
--color-text: #1e293b;
--color-text-muted: #475569;
--color-link: #1d4ed8;
--color-link-hover: #1e40af;
--color-accent: #2563eb;
--color-accent-strong: #1e40af;
--color-accent-contrast: #ffffff;
--color-success-text: #166534;
--color-success-bg: #dcfce7;
--color-error-text: #b91c1c;
--color-error-bg: #fee2e2;
--color-info-text: #1e40af;
--color-info-bg: #dbeafe;
--color-danger: #dc2626;
--shadow-soft: 0 10px 30px rgba(30, 64, 175, 0.15);
--radius-none: 0;
--color-canvas: white;
--color-surface: #def;
--color-surface-subtle: #bcf;
--color-surface-hover: oklab(0.97 -0.01 -0.02);
--color-dialog: oklab(0.96 -0.01 -0.03);
--color-border: oklab(0.82 -0.02 -0.06);
--color-border-strong: oklab(0.55 -0.05 -0.14);
--color-heading: oklab(0.22 -0.03 -0.09);
--color-text: oklab(0.2 -0.02 -0.05);
--color-text-muted: oklab(0.42 -0.02 -0.06);
--color-link: oklab(0.5 -0.06 -0.17);
--color-link-hover: oklab(0.45 -0.06 -0.19);
--color-accent: oklab(0.55 -0.06 -0.19);
--color-accent-strong: #46f;
--color-accent-contrast: white;
--color-secondary: oklab(0.55 -0.02 -0.05);
--color-secondary-strong: oklab(0.45 -0.02 -0.05);
--color-success-text: oklab(0.4 -0.12 0.09);
--color-success-bg: oklab(0.95 -0.02 0.02);
--color-error-text: oklab(0.45 0.18 0.09);
--color-error-bg: oklab(0.95 0.03 0.01);
--color-info-text: oklab(0.45 -0.05 -0.14);
--color-info-bg: oklab(0.95 -0.01 -0.02);
--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-xl: 0 10px 40px rgba(0, 0, 0, 0.15);
--radius-sm: 4px;
--radius-md: 6px;
--radius-lg: 10px;
--space-xxs: 0.25rem;
--space-xs: 0.5rem;
--space-sm: 0.75rem;
--space-md: 1rem;
--space-lg: 1.5rem;
--space-xl: 2.25rem;
--space-xxl: 3.5rem;
--space-3xl: 5rem;
--layout-padding: clamp(1.5rem, 3vw + 1rem, 3.25rem);
--transition-base: 160ms ease;
--focus-ring: 0 0 0 2px var(--color-accent);
}
@media (prefers-color-scheme: dark) {
:root {
--color-canvas: #0f172a;
--color-surface: #141b2f;
--color-surface-subtle: #1b243b;
--color-border: #25304a;
--color-border-strong: #3d4d6b;
--color-heading: #fff;
--color-text: #e2e8f0;
--color-text-muted: #94a3b8;
--color-link: #60a5fa;
--color-link-hover: #93c5fd;
--color-accent: #60a5fa;
--color-accent-strong: #3b82f6;
--color-accent-contrast: #0b1120;
--color-success-text: #34d399;
--color-success-bg: #1a4d2e;
--color-error-text: #fca5a5;
--color-error-bg: #4a1f1f;
--color-info-text: #bae6fd;
--color-info-bg: #1e3a5f;
--color-danger: #f87171;
--shadow-soft: 0 0 0 #000000;
}
:root.dark {
--color-canvas: oklab(0.17 -0.02 -0.05);
--color-surface: oklab(0.22 -0.02 -0.05);
--color-surface-subtle: oklab(0.25 -0.02 -0.05);
--color-surface-hover: oklab(0.28 -0.02 -0.05);
--color-dialog: oklab(0.22 -0.02 -0.05);
--color-border: oklab(0.3 -0.02 -0.05);
--color-border-strong: oklab(0.4 -0.02 -0.05);
--color-heading: white;
--color-text: oklab(0.9 0.00 -0.01);
--color-text-muted: oklab(0.7 -0.01 -0.02);
--color-link: oklab(0.7 -0.05 -0.14);
--color-link-hover: oklab(0.8 -0.04 -0.11);
--color-accent: oklab(0.7 -0.05 -0.14);
--color-accent-strong: oklab(0.6 -0.06 -0.17);
--color-accent-contrast: oklab(0.12 -0.01 -0.03);
--color-secondary: oklab(0.6 -0.02 -0.05);
--color-secondary-strong: oklab(0.5 -0.02 -0.05);
--color-success-text: oklab(0.75 -0.12 0.09);
--color-success-bg: oklab(0.3 -0.07 0.05);
--color-error-text: oklab(0.8 0.11 0.05);
--color-error-bg: oklab(0.3 0.07 0.03);
--color-info-text: oklab(0.8 -0.03 -0.10);
--color-info-bg: oklab(0.3 -0.02 -0.05);
--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-xl: 0 10px 40px rgba(0, 0, 0, 0.4);
}
*,
@@ -78,7 +100,6 @@ html {
}
body {
color-scheme: light dark;
overflow: auto;
scrollbar-gutter: stable;
height: 100%;
@@ -132,6 +153,7 @@ a:focus-visible {
}
.view-root {
position: relative;
flex: 1;
width: 100%;
display: flex;
@@ -150,6 +172,25 @@ a:focus-visible {
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 {
display: flex;
flex-direction: column;
@@ -197,6 +238,7 @@ a:focus-visible {
flex-wrap: nowrap;
gap: 0.75rem;
justify-content: flex-start;
width: 100%;
}
.button-row button {
@@ -229,6 +271,7 @@ button {
gap: 0.4rem;
background: var(--color-surface);
color: var(--color-text);
transition: box-shadow var(--transition-base), background var(--transition-base), border-color var(--transition-base);
}
button:disabled {
@@ -236,49 +279,53 @@ button:disabled {
filter: opacity(0.6);
}
output[title="Click to copy"] {
cursor: pointer;
}
.btn-primary {
background: var(--color-accent);
background: linear-gradient(to bottom, oklab(1 0 0 / 0.15), transparent 60%) var(--color-accent);
color: var(--color-accent-contrast);
border-color: var(--color-accent);
}
.btn-primary:hover:not(:disabled),
.btn-primary:focus-visible {
background: linear-gradient(to bottom, oklab(1 0 0 / 0.15), transparent 60%) var(--color-accent-strong);
border-color: var(--color-accent-strong);
box-shadow: var(--shadow-soft);
}
.btn-primary:hover:not(:disabled) {
background: var(--color-accent-strong);
border-color: var(--color-accent-strong);
}
.btn-secondary {
background: transparent;
color: var(--color-text);
border-color: var(--color-border);
}
.btn-secondary:hover:not(:disabled) {
border-color: var(--color-border-strong);
background: var(--color-surface-subtle);
}
.btn-danger {
background: var(--color-danger);
background: linear-gradient(to bottom, oklab(1 0 0 / 0.15), transparent 60%) var(--color-secondary);
color: var(--color-accent-contrast);
border-color: transparent;
}
.btn-danger:hover:not(:disabled) {
filter: brightness(0.92);
.btn-secondary:hover:not(:disabled),
.btn-secondary:focus-visible {
background: linear-gradient(to bottom, oklab(1 0 0 / 0.15), transparent 60%) var(--color-secondary-strong);
box-shadow: var(--shadow-soft);
}
/* Focus-visible outlines for buttons */
.btn-primary:focus-visible,
.btn-secondary:focus-visible,
.btn-danger {
background: linear-gradient(to bottom, oklab(1 0 0 / 0.15), transparent 60%) var(--color-danger);
color: var(--color-accent-contrast);
border-color: transparent;
}
.btn-danger:hover:not(:disabled),
.btn-danger:focus-visible {
outline: 1px solid var(-webkit-focus-ring-color);
background: linear-gradient(to bottom, oklab(1 0 0 / 0.15), transparent 60%) var(--color-danger);
filter: brightness(0.92);
box-shadow: var(--shadow-soft);
}
input:not([type]),
input[type="text"],
input[type="search"],
input[type="email"],
input[type="tel"],
textarea,
select {
font: inherit;
@@ -291,6 +338,18 @@ select {
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 {
display: flex;
flex-direction: column;
@@ -331,10 +390,118 @@ th {
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 {
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 {
display: inline-flex;
align-items: center;
@@ -353,13 +520,17 @@ th {
left: 0;
right: 0;
margin: 0 auto;
z-index: 1200;
z-index: 2000;
width: fit-content;
min-width: min(520px, calc(100% - 2rem));
max-width: calc(100% - 2rem);
display: none;
}
.global-status.show {
display: block;
}
.global-status .status {
display: flex;
align-items: center;
@@ -407,7 +578,7 @@ th {
.device-dialog,
.modal {
background: var(--color-surface);
background: var(--color-dialog);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
width: min(520px, 100%);
@@ -429,8 +600,7 @@ th {
.qr-code {
padding: 1rem;
background: #fff;
box-shadow: var(--shadow-soft);
background: white;
}
.link-container,
@@ -487,9 +657,18 @@ th {
.record-item.is-current,
.credential-item.current-session,
.session-item.is-current {
border-color: var(--color-accent);
background-color: var(--color-surface-subtle);
}
.credential-item.is-hovered,
.session-item.is-current,
.session-item.is-hovered { border-color: var(--color-accent); background-color: var(--color-surface-subtle); }
.session-item.is-hovered {
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,
.session-item.is-linked-credential { border-color: var(--color-accent); background-color: var(--color-surface-subtle); }
@@ -567,7 +746,7 @@ th {
}
.btn-card-delete { background: transparent; border: none; color: var(--color-danger); padding: 0.35rem 0.5rem; font-size: 1.05rem; line-height: 1; border-radius: var(--radius-sm); cursor: pointer; display: inline-flex; align-items: center; justify-content: center; }
.btn-card-delete:hover:not(:disabled) { background: #fee; }
.btn-card-delete:hover:not(:disabled) { filter: brightness(0.85); }
.btn-card-delete:disabled { filter: opacity(0.4); cursor: not-allowed; }
@@ -644,9 +823,6 @@ th {
.user-info {
display: grid;
border-radius: var(--radius-md);
background: var(--color-surface);
padding: 1.1rem 1.25rem;
}
.user-details {
@@ -692,8 +868,8 @@ th {
width: 100vw;
height: 100vh;
background: transparent;
backdrop-filter: blur(.1rem) brightness(0.7);
-webkit-backdrop-filter: blur(.1rem) brightness(0.7);
backdrop-filter: blur(.2rem) brightness(0.7);
-webkit-backdrop-filter: blur(.2rem) brightness(0.7);
display: flex;
align-items: center;
justify-content: center;
@@ -711,7 +887,7 @@ th {
width: 100%;
max-width: 480px;
padding: 2rem;
background: var(--color-surface);
background: var(--color-dialog);
border-radius: var(--radius-lg);
box-shadow: 0 20px 60px #1e293b;
border: 1px solid var(--color-border);
@@ -733,7 +909,7 @@ th {
.slot-machine {
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-radius: var(--radius-sm);
font-family: 'SF Mono', Monaco, 'Cascadia Code', 'Roboto Mono', Consolas, 'Courier New', monospace;
@@ -754,3 +930,28 @@ th {
height: 1.8em;
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]
if (!info) return null
const isDarkMode = window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches
const iconKey = isDarkMode ? 'icon_dark' : 'icon_light'
return info[iconKey] || null
// Fall back to icon if icon_dark is not available
return (isDarkMode && info.icon_dark) || info.icon || null
}
</script>
+10 -16
View File
@@ -1,5 +1,5 @@
<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">
<h1>{{ headingTitle }}</h1>
<p class="view-lede">{{ subheading }}</p>
@@ -10,9 +10,12 @@
<UserBasicInfo
v-if="ctx"
:name="ctx.user.display_name"
:visits="authStore.userInfo?.visits || 0"
:created-at="authStore.userInfo?.created_at"
:last-seen="authStore.userInfo?.last_seen"
:avatar-url="authStore.userInfo.user.avatar_url"
:visits="authStore.userInfo.user.visits"
:created-at="authStore.userInfo.user.created_at"
:last-seen="authStore.userInfo.user.last_seen"
:email="ctx.user.email"
:telephone="ctx.user.telephone"
:org-display-name="orgDisplayName"
:role-name="roleDisplayName"
:can-edit="false"
@@ -78,9 +81,9 @@ const currentHost = window.location.host
const userInfoSection = ref(null)
const buttonRow = ref(null)
const ctx = computed(() => authStore.userInfo?.ctx || null)
const orgDisplayName = computed(() => ctx.value?.org.display_name ?? '')
const roleDisplayName = computed(() => ctx.value?.role.display_name ?? '')
const ctx = computed(() => authStore.userInfo || null)
const orgDisplayName = computed(() => ctx.value?.org?.display_name ?? '')
const roleDisplayName = computed(() => ctx.value?.role?.display_name ?? '')
const headingTitle = computed(() => {
const service = authStore.settings?.rp_name
@@ -125,12 +128,3 @@ const handleButtonRowKeydown = (event) => {
// Down does nothing (no elements below to navigate to)
}
</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 {
width: 40px;
height: 40px;
border: 4px solid var(--color-border);
border-top: 4px solid var(--color-primary);
border: 3px solid var(--color-border);
border-top-color: var(--color-primary);
border-radius: 50%;
animation: spin 1s linear infinite;
}
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
.loading-container p {
color: var(--color-text-muted);
margin: 0;
+30 -45
View File
@@ -1,12 +1,15 @@
<template>
<dialog ref="dialog" @close="$emit('close')" @keydown="handleDialogKeydown">
<slot />
</dialog>
<div class="dialog-overlay" @click="$emit('close')">
<div ref="dialog" :class="['modal-panel', panelClass]" @keydown="handleDialogKeydown" @click.stop>
<slot />
</div>
</div>
</template>
<script setup>
import { ref, onMounted, onUnmounted, nextTick } from 'vue'
import { navigateButtonRow, getDirection, focusPreferred, focusDialogDefault } from '@/utils/keynav'
import { holdGlobalBackdrop, releaseGlobalBackdrop } from 'paskia'
const props = defineProps({
// Optional: provide a fallback element to focus if original element is gone
@@ -14,10 +17,12 @@ const props = defineProps({
// Optional: index to help find next sibling when item is deleted
focusIndex: { type: Number, default: -1 },
// Optional: selector for finding siblings when restoring focus
focusSiblingSelector: { type: String, default: '' }
focusSiblingSelector: { type: String, default: '' },
// Optional: extra class name(s) for the modal panel
panelClass: { type: [String, Array, Object], default: '' }
})
defineEmits(['close'])
const emit = defineEmits(['close'])
// Dialog element reference
const dialog = ref(null)
@@ -76,6 +81,13 @@ const restoreFocus = () => {
}
const handleDialogKeydown = (event) => {
// ESC to close (previously handled by <dialog> natively)
if (event.key === 'Escape') {
event.preventDefault()
emit('close')
return
}
const direction = getDirection(event)
if (!direction) return
@@ -111,11 +123,11 @@ onMounted(() => {
// Save currently focused element before modal takes focus
previouslyFocusedElement.value = document.activeElement
// Show the dialog as a modal
holdGlobalBackdrop()
// Focus the most appropriate element
nextTick(() => {
if (dialog.value) {
dialog.value.showModal()
// Autofocus the most appropriate element:
// - For form dialogs (rename, edit): focus first input and select text
// - For other dialogs: focus primary button (or fallback)
@@ -131,14 +143,16 @@ onMounted(() => {
})
onUnmounted(() => {
releaseGlobalBackdrop()
// Restore focus when modal closes
restoreFocus()
})
</script>
<style scoped>
dialog {
background: var(--color-surface);
.modal-panel {
background: var(--color-dialog);
color: var(--color-text);
border: 1px solid var(--color-border);
border-radius: var(--radius-lg);
box-shadow: var(--shadow-xl);
@@ -147,65 +161,36 @@ dialog {
width: min(500px, 90vw);
max-height: 90vh;
overflow-y: auto;
position: fixed;
inset: 0;
margin: auto;
height: fit-content;
}
dialog::backdrop {
background: transparent;
backdrop-filter: blur(.1rem) brightness(0.7);
-webkit-backdrop-filter: blur(.1rem) brightness(0.7);
}
dialog :deep(.modal-title),
dialog :deep(h3) {
.modal-panel :deep(.modal-title),
.modal-panel :deep(h3) {
margin: 0 0 var(--space-md);
font-size: 1.25rem;
font-weight: 600;
color: var(--color-heading);
}
dialog :deep(form) {
.modal-panel :deep(form) {
display: flex;
flex-direction: column;
gap: var(--space-md);
}
dialog :deep(.modal-form) {
.modal-panel :deep(.modal-form) {
display: flex;
flex-direction: column;
gap: var(--space-md);
}
dialog :deep(.modal-form label) {
.modal-panel :deep(.modal-form label) {
display: flex;
flex-direction: column;
gap: var(--space-xs);
font-weight: 500;
}
dialog :deep(.modal-form input),
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) {
.modal-panel :deep(.modal-actions) {
display: flex;
justify-content: flex-end;
gap: var(--space-sm);
-8
View File
@@ -84,12 +84,4 @@ function handleCancel() {
flex-direction: column;
gap: var(--space-md);
}
.error {
color: var(--color-danger-text);
}
.small {
font-size: 0.9rem;
}
</style>
+126
View File
@@ -0,0 +1,126 @@
<template>
<component
:is="rootTag"
v-bind="rootAttrs"
class="profile-picture"
:class="{ 'profile-picture-btn': clickable }"
:style="pictureStyle"
@click="handleClick"
>
<img
v-if="showPicture"
:key="`${src || 'none'}:${renderVersion}`"
:src="src"
alt=""
class="profile-picture-image"
@error="handleError"
/>
<img
v-else
:src="profileGeneric"
alt=""
class="profile-picture-fallback"
/>
</component>
</template>
<script setup>
import profileGeneric from '@/assets/profile-generic.svg'
import { computed, ref, watch } from 'vue'
const props = defineProps({
src: { type: String, default: null },
clickable: { type: Boolean, default: false },
loading: { type: Boolean, default: false },
title: { type: String, default: '' },
renderVersion: { type: [Number, String], default: 0 },
width: { type: String, default: '3rem' },
height: { type: String, default: '3rem' },
radius: { type: String, default: '0.9rem' },
fit: { type: String, default: 'cover' },
filter: { type: String, default: 'none' },
fallbackSize: { type: String, default: '2em' }
})
const emit = defineEmits(['click'])
const pictureAvailable = ref(true)
const rootTag = computed(() => (props.clickable ? 'button' : 'div'))
const showPicture = computed(() => !!props.src && pictureAvailable.value)
const pictureStyle = computed(() => ({
'--profile-picture-width': props.width,
'--profile-picture-height': props.height,
'--profile-picture-radius': props.radius,
'--profile-picture-fit': props.fit,
'--profile-picture-filter': props.filter,
'--profile-picture-fallback-size': props.fallbackSize
}))
const rootAttrs = computed(() => {
if (!props.clickable) return { title: props.title || undefined }
return {
type: 'button',
disabled: props.loading,
title: props.title || undefined
}
})
watch(() => props.src, () => {
pictureAvailable.value = true
})
const handleError = () => {
pictureAvailable.value = false
}
const handleClick = () => {
if (!props.clickable || props.loading) return
emit('click')
}
</script>
<style scoped>
.profile-picture {
display: flex;
align-items: center;
justify-content: center;
width: var(--profile-picture-width);
height: var(--profile-picture-height);
font-size: var(--profile-picture-fallback-size);
line-height: 1;
overflow: hidden;
border-radius: var(--profile-picture-radius);
background: transparent;
flex-shrink: 0;
}
.profile-picture-btn {
padding: 0;
border: 0;
transition: transform 0.12s ease, box-shadow 0.12s ease;
cursor: pointer;
}
.profile-picture-btn:hover:not(:disabled) {
transform: translateY(-1px);
box-shadow: inset 0 0 0 1px var(--color-accent);
}
.profile-picture-btn:disabled {
cursor: progress;
}
.profile-picture-image {
width: 100%;
height: 100%;
object-fit: var(--profile-picture-fit);
display: block;
filter: var(--profile-picture-filter);
}
.profile-picture-fallback {
width: 100%;
height: 100%;
object-fit: contain;
display: block;
}
</style>
@@ -0,0 +1,489 @@
<template>
<Modal panel-class="modal-panel--avatar" @close="closeEditor">
<h3>{{ title }}</h3>
<input
ref="pictureInput"
type="file"
accept="image/*"
class="profile-picture-editor-input"
:disabled="saving"
@change="handlePictureSelected"
/>
<div ref="picturePreview" class="profile-picture-editor-preview" :style="previewStyle">
<img
v-if="editorImageUrl && displayMetrics"
:src="editorImageUrl"
alt=""
class="profile-picture-editor-image"
:style="editorImageStyle"
/>
<img
v-if="editorImageUrl && displayMetrics"
:src="editorImageUrl"
alt=""
class="profile-picture-editor-image profile-picture-editor-image--overlay"
:style="editorOverlayStyle"
/>
<div
v-if="editorImageUrl && displayMetrics"
class="profile-picture-editor-crop"
:style="cropBoxStyle"
@pointerdown="startMove"
>
<div class="profile-picture-editor-guides" aria-hidden="true">
<div class="profile-picture-editor-guide profile-picture-editor-guide--circle"></div>
<div class="profile-picture-editor-guide profile-picture-editor-guide--eyes"></div>
<div class="profile-picture-editor-guide profile-picture-editor-guide--cheek-left"></div>
<div class="profile-picture-editor-guide profile-picture-editor-guide--cheek-right"></div>
</div>
<button
type="button"
class="profile-picture-editor-handle profile-picture-editor-handle--nw"
@pointerdown.stop="startResize($event, 'nw')"
></button>
<button
type="button"
class="profile-picture-editor-handle profile-picture-editor-handle--ne"
@pointerdown.stop="startResize($event, 'ne')"
></button>
<button
type="button"
class="profile-picture-editor-handle profile-picture-editor-handle--sw"
@pointerdown.stop="startResize($event, 'sw')"
></button>
<button
type="button"
class="profile-picture-editor-handle profile-picture-editor-handle--se"
@pointerdown.stop="startResize($event, 'se')"
></button>
</div>
<ProfilePicture
v-else
class="profile-picture-editor-trigger"
:src="pictureUrl"
:render-version="renderVersion"
clickable
:loading="saving"
title="Choose profile picture"
width="100%"
height="100%"
radius="0"
fit="contain"
fallback-size="5rem"
@click="triggerPictureSelect"
/>
</div>
<div v-if="errorMessage" class="error small">{{ errorMessage }}</div>
<div class="modal-actions">
<button type="button" class="btn-secondary" :disabled="saving" @click="closeEditor">Back</button>
<button
v-if="!editorImageUrl && pictureUrl"
type="button"
class="btn-danger"
:disabled="saving"
@click="removePicture"
>Delete</button>
<button
v-if="editorImageUrl"
type="button"
class="btn-primary"
:disabled="saving"
@click="savePicture"
>Save</button>
</div>
</Modal>
</template>
<script setup>
import { computed, nextTick, onMounted, onUnmounted, reactive, ref, watch } from 'vue'
import { apiJson } from 'paskia'
import { useAuthStore } from '@/stores/auth'
import Modal from '@/components/Modal.vue'
import ProfilePicture from '@/components/ProfilePicture.vue'
const AVATAR_UPLOAD_SIZE = 720
const MIN_CROP_SIZE = 36
const props = defineProps({
endpoint: { type: String, required: true },
pictureUrl: { type: String, default: null },
renderVersion: { type: [Number, String], default: 0 },
title: { type: String, default: 'Profile Picture' }
})
const emit = defineEmits(['close', 'updated'])
const authStore = useAuthStore()
const pictureInput = ref(null)
const picturePreview = ref(null)
const editorImage = ref(null)
const editorImageUrl = ref('')
const previewObjectUrl = ref(null)
const saving = ref(false)
const errorMessage = ref('')
const cropRect = reactive({ x: 0, y: 0, size: 0 })
const previewRect = reactive({ width: 0, height: 0 })
const viewportSize = reactive({ width: 0, height: 0 })
let dragState = null
let previewObserver = null
onMounted(async () => {
viewportSize.width = window.innerWidth
viewportSize.height = window.innerHeight
window.addEventListener('pointermove', handlePointerMove)
window.addEventListener('pointerup', endPointerInteraction)
window.addEventListener('resize', syncPreviewRect)
await nextTick()
syncPreviewRect()
if (picturePreview.value && typeof ResizeObserver !== 'undefined') {
previewObserver = new ResizeObserver(() => syncPreviewRect())
previewObserver.observe(picturePreview.value)
}
})
onUnmounted(() => {
window.removeEventListener('pointermove', handlePointerMove)
window.removeEventListener('pointerup', endPointerInteraction)
window.removeEventListener('resize', syncPreviewRect)
previewObserver?.disconnect()
clearPreviewObjectUrl()
})
watch(editorImage, async (image) => {
if (!image) return
await nextTick()
syncPreviewRect()
initializeCrop()
})
const clearPreviewObjectUrl = () => {
if (!previewObjectUrl.value) return
URL.revokeObjectURL(previewObjectUrl.value)
previewObjectUrl.value = null
}
const resetEditor = () => {
clearPreviewObjectUrl()
editorImage.value = null
editorImageUrl.value = ''
cropRect.x = 0
cropRect.y = 0
cropRect.size = 0
errorMessage.value = ''
if (pictureInput.value) pictureInput.value.value = ''
}
const syncPreviewRect = () => {
viewportSize.width = window.innerWidth
viewportSize.height = window.innerHeight
const element = picturePreview.value
if (!element) return
previewRect.width = element.clientWidth
previewRect.height = element.clientHeight
}
const previewStyle = computed(() => {
const image = editorImage.value
if (!image) {
const size = Math.min(viewportSize.width * 0.72, viewportSize.height * 0.42, 352)
return {
width: `${Math.max(160, Math.round(size))}px`,
height: `${Math.max(160, Math.round(size))}px`
}
}
const maxWidth = Math.min(viewportSize.width * 0.88, 928)
const maxHeight = Math.min(viewportSize.height * 0.62, 620)
const scale = Math.min(maxWidth / image.naturalWidth, maxHeight / image.naturalHeight)
return {
width: `${Math.max(1, Math.round(image.naturalWidth * scale))}px`,
height: `${Math.max(1, Math.round(image.naturalHeight * scale))}px`
}
})
const displayMetrics = computed(() => {
const image = editorImage.value
if (!image || !previewRect.width || !previewRect.height) return null
const scale = Math.min(previewRect.width / image.naturalWidth, previewRect.height / image.naturalHeight)
const width = image.naturalWidth * scale
const height = image.naturalHeight * scale
return {
x: (previewRect.width - width) / 2,
y: (previewRect.height - height) / 2,
width,
height
}
})
const editorImageStyle = computed(() => {
const metrics = displayMetrics.value
if (!metrics) return null
return {
width: `${metrics.width}px`,
height: `${metrics.height}px`,
left: `${metrics.x}px`,
top: `${metrics.y}px`
}
})
const editorOverlayStyle = computed(() => {
const metrics = displayMetrics.value
if (!metrics || !cropRect.size) return editorImageStyle.value
const left = cropRect.x
const top = cropRect.y
const right = cropRect.x + cropRect.size
const bottom = cropRect.y + cropRect.size
return {
...editorImageStyle.value,
clipPath: `polygon(evenodd, 0 0, 100% 0, 100% 100%, 0 100%, 0 0, ${left}px ${top}px, ${left}px ${bottom}px, ${right}px ${bottom}px, ${right}px ${top}px, ${left}px ${top}px)`
}
})
const cropBoxStyle = computed(() => {
const metrics = displayMetrics.value
if (!metrics || !cropRect.size) return null
return {
left: `${metrics.x + cropRect.x}px`,
top: `${metrics.y + cropRect.y}px`,
width: `${cropRect.size}px`,
height: `${cropRect.size}px`
}
})
const initializeCrop = () => {
const metrics = displayMetrics.value
if (!metrics) return
const size = Math.min(metrics.width, metrics.height)
cropRect.size = size
cropRect.x = (metrics.width - size) / 2
cropRect.y = (metrics.height - size) / 2
}
const triggerPictureSelect = () => {
pictureInput.value?.click()
}
const handlePictureSelected = async (event) => {
const nextFile = event.target.files?.[0] || null
resetEditor()
if (!nextFile) return
previewObjectUrl.value = URL.createObjectURL(nextFile)
editorImageUrl.value = previewObjectUrl.value
const image = new Image()
image.decoding = 'async'
image.src = editorImageUrl.value
try {
await image.decode()
editorImage.value = image
} catch {
errorMessage.value = 'Failed to load image'
resetEditor()
}
}
const startMove = (event) => {
if (!displayMetrics.value || saving.value) return
event.preventDefault()
dragState = {
mode: 'move',
startX: event.clientX,
startY: event.clientY,
initialX: cropRect.x,
initialY: cropRect.y,
initialSize: cropRect.size
}
}
const startResize = (event, handle) => {
if (!displayMetrics.value || saving.value) return
event.preventDefault()
dragState = {
mode: 'resize',
handle,
startX: event.clientX,
startY: event.clientY,
initialX: cropRect.x,
initialY: cropRect.y,
initialSize: cropRect.size
}
}
const handlePointerMove = (event) => {
if (!dragState) return
const metrics = displayMetrics.value
if (!metrics) return
const dx = event.clientX - dragState.startX
const dy = event.clientY - dragState.startY
if (dragState.mode === 'move') {
cropRect.x = Math.max(0, Math.min(metrics.width - dragState.initialSize, dragState.initialX + dx))
cropRect.y = Math.max(0, Math.min(metrics.height - dragState.initialSize, dragState.initialY + dy))
return
}
const directionMap = {
nw: { deltaX: -1, deltaY: -1 },
ne: { deltaX: 1, deltaY: -1 },
sw: { deltaX: -1, deltaY: 1 },
se: { deltaX: 1, deltaY: 1 }
}
const direction = directionMap[dragState.handle]
if (!direction) return
const delta = Math.max(dx * direction.deltaX, dy * direction.deltaY)
const nextSize = Math.max(
MIN_CROP_SIZE,
Math.min(getResizeLimit(metrics, dragState), dragState.initialSize + delta)
)
applyResize(dragState, nextSize)
}
const endPointerInteraction = () => {
dragState = null
}
const getResizeLimit = (metrics, state) => {
const { initialX, initialY, initialSize, handle } = state
if (handle === 'nw') return Math.min(initialX + initialSize, initialY + initialSize)
if (handle === 'ne') return Math.min(metrics.width - initialX, initialY + initialSize)
if (handle === 'sw') return Math.min(initialX + initialSize, metrics.height - initialY)
return Math.min(metrics.width - initialX, metrics.height - initialY)
}
const applyResize = (state, size) => {
const { initialX, initialY, initialSize, handle } = state
if (handle === 'nw') {
cropRect.x = initialX + initialSize - size
cropRect.y = initialY + initialSize - size
cropRect.size = size
return
}
if (handle === 'ne') {
cropRect.x = initialX
cropRect.y = initialY + initialSize - size
cropRect.size = size
return
}
if (handle === 'sw') {
cropRect.x = initialX + initialSize - size
cropRect.y = initialY
cropRect.size = size
return
}
cropRect.x = initialX
cropRect.y = initialY
cropRect.size = size
}
const renderPictureBlob = async () => {
const image = editorImage.value
if (!image) throw new Error('No image selected')
const metrics = displayMetrics.value
if (!metrics || !cropRect.size) throw new Error('Crop selection unavailable')
const canvas = document.createElement('canvas')
canvas.width = AVATAR_UPLOAD_SIZE
canvas.height = AVATAR_UPLOAD_SIZE
const context = canvas.getContext('2d')
if (!context) throw new Error('Canvas unavailable')
const sourceScale = image.naturalWidth / metrics.width
const sourceX = cropRect.x * sourceScale
const sourceY = cropRect.y * sourceScale
const sourceSize = cropRect.size * sourceScale
context.drawImage(image, sourceX, sourceY, sourceSize, sourceSize, 0, 0, AVATAR_UPLOAD_SIZE, AVATAR_UPLOAD_SIZE)
return await new Promise((resolve, reject) => {
canvas.toBlob((blob) => {
if (!blob) {
reject(new Error('Failed to export cropped picture'))
return
}
resolve(blob)
}, 'image/webp', 0.9)
})
}
const reloadPictureFromCache = async () => {
const response = await fetch(props.endpoint, {
method: 'GET',
credentials: 'same-origin',
cache: 'reload'
})
if (!response.ok) throw new Error('Failed to refresh profile picture')
}
const savePicture = async () => {
try {
saving.value = true
errorMessage.value = ''
const blob = await renderPictureBlob()
const formData = new FormData()
formData.append('file', blob, 'profile.webp')
await apiJson(props.endpoint, { method: 'PUT', body: formData })
await reloadPictureFromCache()
authStore.showMessage('Profile picture updated.', 'success', 3000)
emit('updated')
closeEditor()
} catch (error) {
errorMessage.value = error.message || 'Failed to update profile picture'
} finally {
saving.value = false
}
}
const removePicture = async () => {
try {
saving.value = true
errorMessage.value = ''
await apiJson(props.endpoint, { method: 'DELETE' })
authStore.showMessage('Profile picture removed.', 'success', 3000)
emit('updated')
closeEditor()
} catch (error) {
errorMessage.value = error.message || 'Failed to remove profile picture'
} finally {
saving.value = false
}
}
const closeEditor = () => {
resetEditor()
emit('close')
}
</script>
<style scoped>
.profile-picture-editor-input { display: none; }
.profile-picture-editor-preview { position: relative; display: flex; justify-content: center; align-items: center; width: auto; max-width: min(58rem, 88vw); min-height: 0; margin: 0 auto; overflow: visible; }
.profile-picture-editor-trigger { min-width: 0; }
.profile-picture-editor-image { position: absolute; user-select: none; pointer-events: none; object-fit: contain; }
.profile-picture-editor-image--overlay { filter: grayscale(0.45) saturate(0.7) brightness(0.68); }
.profile-picture-editor-crop { position: absolute; border: 2px solid white; cursor: move; touch-action: none; }
.profile-picture-editor-guides { position: absolute; inset: 0; pointer-events: none; }
.profile-picture-editor-guide { position: absolute; border-color: rgba(255, 255, 255, 0.52); }
.profile-picture-editor-guide--circle { inset: 0; border: 1.5px solid rgba(255, 255, 255, 0.62); border-radius: 999px; box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.18); }
.profile-picture-editor-guide--eyes { left: 18%; right: 18%; top: 38%; border-top: 1.5px solid rgba(255, 255, 255, 0.56); }
.profile-picture-editor-guide--cheek-left { top: 24%; bottom: 18%; left: 24%; border-left: 1.5px solid rgba(255, 255, 255, 0.48); }
.profile-picture-editor-guide--cheek-right { top: 24%; bottom: 18%; right: 24%; border-right: 1.5px solid rgba(255, 255, 255, 0.48); }
.profile-picture-editor-handle { position: absolute; width: 1.1rem; height: 1.1rem; border-radius: 999px; border: 2px solid white; background: var(--color-accent); padding: 0; }
.profile-picture-editor-handle--nw { left: -0.55rem; top: -0.55rem; cursor: nwse-resize; }
.profile-picture-editor-handle--ne { right: -0.55rem; top: -0.55rem; cursor: nesw-resize; }
.profile-picture-editor-handle--sw { left: -0.55rem; bottom: -0.55rem; cursor: nesw-resize; }
.profile-picture-editor-handle--se { right: -0.55rem; bottom: -0.55rem; cursor: nwse-resize; }
:deep(.modal-panel--avatar) { width: fit-content; max-width: min(58rem, 94vw); }
@media (max-width: 720px) {
.profile-picture-editor-preview { max-width: 100%; }
}
</style>
+183 -69
View File
@@ -1,23 +1,36 @@
<template>
<section class="view-root" data-view="profile">
<header class="view-header">
<h1>User Profile</h1>
<Breadcrumbs ref="breadcrumbs" :entries="breadcrumbEntries" @keydown="handleBreadcrumbKeydown" />
<p class="view-lede">Account dashboard for managing credentials and authenticating with other devices.</p>
</header>
<section class="view-root view-root--profile" data-view="profile">
<div class="view-header-wrapper">
<div class="theme-toggle">
<ThemeSelector />
</div>
<header class="view-header">
<Breadcrumbs ref="breadcrumbs" :entries="breadcrumbEntries" @keydown="handleBreadcrumbKeydown" />
<p class="view-lede">Account dashboard to manage your profile and authentications.</p>
</header>
</div>
<section class="section-block" ref="userInfoSection">
<section class="section-block section-block--constrained" ref="userInfoSection">
<UserBasicInfo
v-if="authStore.userInfo?.ctx"
v-if="authStore.userInfo?.user"
ref="userBasicInfo"
:name="authStore.userInfo.ctx.user.display_name"
:visits="authStore.userInfo.visits"
:created-at="authStore.userInfo.created_at"
:last-seen="authStore.userInfo.last_seen"
:name="authStore.userInfo.user.display_name"
:avatar-url="authStore.userInfo.user.avatar_url"
:avatar-render-version="avatarRenderVersion"
avatar-clickable
:email="authStore.userInfo.user.email"
:preferred_username="authStore.userInfo.user.preferred_username"
:telephone="authStore.userInfo.user.telephone"
:visits="authStore.userInfo.user.visits"
:created-at="authStore.userInfo.user.created_at"
:last-seen="authStore.userInfo.user.last_seen"
: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()"
@edit-name="openNameDialog"
@avatar-click="openAvatarDialog"
@edit="openEditDialog"
@keydown="handleUserInfoKeydown"
>
<div class="remote-auth-inline">
@@ -31,11 +44,11 @@
@device-info-visible="showDeviceInfo = $event"
/>
</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>
</section>
<section class="section-block">
<section :class="['section-block', { 'section-block--constrained': !useWideLayout }]">
<div class="section-header">
<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>
@@ -43,8 +56,8 @@
<div class="section-body">
<CredentialList
ref="credentialList"
:credentials="authStore.userInfo?.credentials || []"
:aaguid-info="authStore.userInfo?.aaguid_info || {}"
:credentials="credentials"
:aaguid-info="authStore.userInfo.aaguid_info"
:loading="authStore.isLoading"
:hovered-credential-uuid="hoveredCredentialUuid"
:hovered-session-credential-uuid="hoveredSession?.credential"
@@ -67,25 +80,14 @@
:terminating-sessions="terminatingSessions"
:hovered-credential-uuid="hoveredCredentialUuid"
:navigation-disabled="hasActiveModal"
:section-class="useWideLayout ? '' : 'section-block--constrained'"
@terminate="terminateSession"
@session-hover="hoveredSession = $event"
@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."
/>
<Modal v-if="showNameDialog" @close="showNameDialog = false">
<h3>Edit Display Name</h3>
<form @submit.prevent="saveName" class="modal-form">
<NameEditForm
label="Display Name"
v-model="newName"
:busy="saving"
@cancel="showNameDialog = false"
/>
</form>
</Modal>
<section class="section-block">
<section :class="['section-block', { 'section-block--constrained': !useWideLayout }]">
<div class="button-row" ref="logoutButtons">
<button
type="button"
@@ -101,9 +103,47 @@
<button @click="logoutEverywhere" class="btn-danger" :disabled="authStore.isLoading" @keydown="handleLogoutButtonKeydown">All</button>
</template>
</div>
<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>
<div class="logout-footer">
<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 }}.</p>
<a class="paskia-version" href="https://git.zi.fi/leovasanko/paskia" target="_blank" rel="noopener noreferrer">Paskia {{ paskiaVersion }}</a>
</div>
</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>
<ProfilePictureEditorModal
v-if="showAvatarDialog && currentAvatarEndpoint"
:endpoint="currentAvatarEndpoint"
:picture-url="authStore.userInfo?.user?.avatar_url"
:render-version="avatarRenderVersion"
@close="closeAvatarDialog"
@updated="handleProfilePictureUpdated"
/>
<RegistrationLinkModal
v-if="showRegLink"
endpoint="/auth/api/user/create-link"
@@ -117,9 +157,10 @@
import { ref, onMounted, onUnmounted, computed, watch } from 'vue'
import Breadcrumbs from '@/components/Breadcrumbs.vue'
import CredentialList from '@/components/CredentialList.vue'
import ProfilePictureEditorModal from '@/components/ProfilePictureEditorModal.vue'
import ThemeSelector from '@/components/ThemeSelector.vue'
import UserBasicInfo from '@/components/UserBasicInfo.vue'
import Modal from '@/components/Modal.vue'
import NameEditForm from '@/components/NameEditForm.vue'
import SessionList from '@/components/SessionList.vue'
import RegistrationLinkModal from '@/components/RegistrationLinkModal.vue'
import RemoteAuthPermit from '@/components/RemoteAuthPermit.vue'
@@ -132,10 +173,16 @@ import { navigateButtonRow, focusPreferred, focusAtIndex, getDirection } from '@
const authStore = useAuthStore()
const updateInterval = ref(null)
const showNameDialog = ref(false)
const showEditDialog = ref(false)
const showAvatarDialog = ref(false)
const showRegLink = ref(false)
const newName = ref('')
const editName = ref('')
const editEmail = ref('')
const editUsername = ref('')
const editTelephone = ref('')
const avatarRenderVersion = ref(0)
const saving = ref(false)
const editError = ref('')
const hoveredCredentialUuid = ref(null)
const hoveredSession = ref(null)
const showDeviceInfo = ref(false)
@@ -145,19 +192,49 @@ const credentialButtons = ref(null)
const sessionList = ref(null)
const logoutButtons = ref(null)
const breadcrumbs = ref(null)
const userBasicInfo = ref(null)
const userInfoSection = ref(null)
// Check if any modal/dialog is open (blocks arrow key navigation)
const hasActiveModal = computed(() => showNameDialog.value || showRegLink.value)
const hasActiveModal = computed(() => showEditDialog.value || showAvatarDialog.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(() => {
updateInterval.value = setInterval(() => { if (authStore.userInfo) authStore.userInfo = { ...authStore.userInfo } }, 60000)
})
onUnmounted(() => { if (updateInterval.value) clearInterval(updateInterval.value) })
onUnmounted(() => {
if (updateInterval.value) clearInterval(updateInterval.value)
})
const currentAvatarEndpoint = computed(() => {
const userUuid = authStore.userInfo?.user?.uuid
if (!userUuid) return null
return `/auth/api/user/${userUuid}/profile.webp`
})
const openAvatarDialog = () => {
showAvatarDialog.value = true
}
const closeAvatarDialog = () => {
showAvatarDialog.value = false
}
const handleProfilePictureUpdated = async () => {
await authStore.loadUserInfo()
avatarRenderVersion.value += 1
}
const addNewCredential = async () => {
try {
@@ -206,7 +283,7 @@ const handleBreadcrumbKeydown = (event) => {
if (direction === 'down') {
event.preventDefault()
// Move to user info section - always focus edit button first
focusPreferred(userInfoSection.value, { primarySelector: '.mini-btn', itemSelector: '.mini-btn, .pairing-input' })
focusPreferred(userInfoSection.value, { primarySelector: '.mini-btn', itemSelector: '.user-picture-btn, .mini-btn, .pairing-input' })
}
// ArrowUp at the top does nothing
}
@@ -218,7 +295,7 @@ const handleUserInfoKeydown = (event) => {
if (!direction) return
event.preventDefault()
const itemSelector = '.mini-btn, .pairing-input'
const itemSelector = '.user-picture-btn, .mini-btn, .pairing-input'
if (direction === 'left' || direction === 'right') {
navigateButtonRow(userInfoSection.value, event.target, direction, { itemSelector })
@@ -239,7 +316,7 @@ const handleCredentialNavigateOut = (direction) => {
focusPreferredButton(credentialButtons.value)
} else if (direction === 'up' || direction === 'left') {
// Focus user info section - always focus edit button first
focusPreferred(userInfoSection.value, { primarySelector: '.mini-btn', itemSelector: '.mini-btn, .pairing-input' })
focusPreferred(userInfoSection.value, { primarySelector: '.mini-btn', itemSelector: '.user-picture-btn, .mini-btn, .pairing-input' })
}
}
@@ -301,59 +378,96 @@ const handleDelete = async (credential) => {
}
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 currentSession = sessions.value.find(session => session.is_current)
const currentSession = Object.values(sessions.value).find(session => session.is_current)
return currentSession?.host || 'this host'
})
const terminatingSessions = ref({})
const terminateSession = async (session) => {
const sessionId = session?.id
if (!sessionId) return
terminatingSessions.value = { ...terminatingSessions.value, [sessionId]: true }
try { await authStore.terminateSession(sessionId) }
const sessionKey = session?.key
if (!sessionKey) return
terminatingSessions.value = { ...terminatingSessions.value, [sessionKey]: true }
try { await authStore.terminateSession(sessionKey) }
catch (error) { authStore.showMessage(error.message || 'Failed to terminate session', 'error', 5000) }
finally {
const next = { ...terminatingSessions.value }
delete next[sessionId]
delete next[sessionKey]
terminatingSessions.value = next
}
}
const logoutEverywhere = async () => { await authStore.logoutEverywhere() }
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 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')
})
const hasMultipleSessions = computed(() => sessions.value.length > 1)
const breadcrumbEntries = computed(() => { const entries = [{ label: 'Auth', href: makeUiHref() }]; if (isAdmin.value) entries.push({ label: 'Admin', href: adminUiPath() }); return entries })
const hasMultipleSessions = computed(() => Object.keys(sessions.value).length > 1)
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 () => {
const name = newName.value.trim()
if (!name) { authStore.showMessage('Name cannot be empty', 'error'); return }
// Check if passkeys exceed 8
const hasManyCredentials = credentials.value.length > 8
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 {
editError.value = ''
saving.value = true
await apiJson('/auth/api/user/display-name', { method: 'PATCH', body: { display_name: name } })
showNameDialog.value = false
await authStore.loadUserInfo()
authStore.showMessage('Name updated successfully!', 'success', 3000)
} catch (e) { authStore.showMessage(e.message || 'Failed to update name', 'error') }
finally { saving.value = false }
let changed = false
const body = {}
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 })
changed = true
}
if (changed) {
await authStore.loadUserInfo()
authStore.showMessage('Profile updated!', 'success', 3000)
}
showEditDialog.value = false
} catch (e) {
editError.value = e.message || 'Failed to update profile'
} finally { saving.value = false }
}
</script>
<style scoped>
.view-lede { margin: 0; color: var(--color-text-muted); font-size: 1rem; }
.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; }
.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-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); }
.profile-edit-form { display: flex; flex-direction: column; gap: var(--space-md); }
</style>
@@ -1,16 +1,17 @@
<template>
<dialog ref="dialog" @close="$emit('close')" @keydown="handleDialogKeydown">
<div class="device-dialog" role="dialog" aria-modal="true" aria-labelledby="regTitle">
<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="reg-header-row">
<h2 id="regTitle" class="reg-title">
📱 <span v-if="userName">Registration for {{ userName }}</span><span v-else>Add Another Device</span>
📱 <span v-if="userName">{{ tokenType === 'account recovery' ? 'Recovery' : 'Registration' }} for {{ userName }}</span><span v-else>Add Another Device</span>
</h2>
<button class="icon-btn" @click="$emit('close')" aria-label="Close" tabindex="-1"></button>
</div>
<div class="device-link-section">
<p class="reg-help">
Scan this QR code on the new device, or copy the link and open it there.
{{ helpText }}
</p>
<QRCodeDisplay
@@ -28,14 +29,15 @@
<div class="reg-actions" ref="actionsRow" @keydown="handleActionsKeydown">
<button class="btn-secondary" @click="$emit('close')">Close</button>
</div>
</div>
</div>
</dialog>
</div>
</template>
<script setup>
import { ref, onMounted, onUnmounted, nextTick } from 'vue'
import { ref, computed, onMounted, onUnmounted, nextTick } from 'vue'
import QRCodeDisplay from '@/components/QRCodeDisplay.vue'
import { apiJson } from 'paskia'
import { apiJson, AuthCancelledError, holdGlobalBackdrop, releaseGlobalBackdrop } from 'paskia'
import { formatDate } from '@/utils/helpers'
import { getDirection } from '@/utils/keynav'
import { useAuthStore } from '@/stores/auth'
@@ -51,32 +53,46 @@ const authStore = useAuthStore()
const dialog = ref(null)
const linkUrl = ref(null)
const expiresAt = ref(null)
const tokenType = ref(null)
const actionsRow = ref(null)
// Store the element that had focus before modal opened
const previouslyFocusedElement = ref(null)
// Determine if this is an admin action for another user
const isAdminAction = computed(() => !!props.userName)
// Compute the help text based on token type and context
const helpText = computed(() => {
if (!isAdminAction.value) {
// User adding their own device
return 'Scan this QR code on the new device, or copy the link and open it there.'
}
// Admin action for another user
return `Send this link to ${props.userName}, or have them scan the QR code.`
})
async function generateLink() {
try {
const data = await apiJson(props.endpoint, { method: 'POST' })
if (data.url) {
linkUrl.value = data.url
expiresAt.value = data.expires ? new Date(data.expires) : null
tokenType.value = data.token_type || null
// Show the dialog as modal
holdGlobalBackdrop()
// Focus primary button (or first button if no primary) after content renders
await nextTick()
if (dialog.value) {
dialog.value.showModal()
// Focus primary button (or first button if no primary) after content renders
const actions = actionsRow.value
const target = actions?.querySelector('.btn-primary') || actions?.querySelector('button')
target?.focus()
}
const actions = actionsRow.value
const target = actions?.querySelector('.btn-primary') || actions?.querySelector('button')
target?.focus()
} else {
emit('close')
}
} catch (e) {
authStore.showMessage(e.message || 'Failed to generate link', 'error')
if (!(e instanceof AuthCancelledError)) {
authStore.showMessage(e.message || 'Failed to generate link', 'error')
}
emit('close')
}
}
@@ -86,7 +102,12 @@ function onCopied() {
}
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
const direction = getDirection(event)
if (!direction) return
@@ -132,6 +153,7 @@ onMounted(() => {
})
onUnmounted(() => {
if (linkUrl.value) releaseGlobalBackdrop()
// Restore focus when modal closes
const prev = previouslyFocusedElement.value
if (prev && document.body.contains(prev) && !prev.disabled) {
@@ -141,23 +163,6 @@ onUnmounted(() => {
</script>
<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:hover { opacity: 1; }
.reg-header-row { display: flex; justify-content: space-between; align-items: center; gap: .75rem; margin-bottom: .75rem; }
+14 -12
View File
@@ -53,17 +53,16 @@
<!-- Device info display (shown when 3 words match a request) -->
<div v-else-if="deviceInfo" class="device-info">
<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
type="button"
class="btn-secondary"
:disabled="loading"
@click="deny"
style="flex: 1;"
>
Deny
</button>
@@ -72,7 +71,6 @@
type="submit"
:disabled="loading"
class="btn-primary"
style="flex: 1;"
>
{{ loading ? 'Authenticating…' : 'Authorize' }}
</button>
@@ -773,7 +771,6 @@ defineExpose({ reset, deny, code, handleInput, loading, error })
.input-wrapper {
position: relative;
display: flex;
width: 280px;
max-width: 100%;
}
@@ -921,10 +918,6 @@ defineExpose({ reset, deny, code, handleInput, loading, error })
animation: spin 0.8s linear infinite;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
.device-info {
display: flex;
flex-direction: column;
@@ -945,9 +938,18 @@ defineExpose({ reset, deny, code, handleInput, loading, error })
}
.error-message {
margin: 0;
margin: 0.5rem 0 0;
font-size: 0.875rem;
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>
@@ -8,7 +8,7 @@
<!-- Error state -->
<div v-else-if="error" class="error-section">
<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>
<!-- Connecting phase -->
@@ -163,6 +163,9 @@ async function startRemoteAuth() {
// PoW challenge
const powChallenge = await ws.receive_json()
if (powChallenge.status) {
throw new Error(powChallenge.detail || `Failed to connect: ${powChallenge.status}`)
}
if (powChallenge.pow) {
const challenge = b64dec(powChallenge.pow.challenge)
const nonces = await solvePoW(challenge, powChallenge.pow.work)
@@ -196,7 +199,7 @@ async function startRemoteAuth() {
} else if (msg.status === 'authenticated') {
// Success
completed.value = true
emit('authenticated', { session_token: msg.session_token })
emit('authenticated', { exchange_code: msg.exchange_code })
break
} else if (msg.status === 'denied') {
// Explicitly denied by the authenticating device
@@ -293,10 +296,6 @@ defineExpose({ retry, cancel })
animation: spin 0.8s linear infinite;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
.auth-display {
display: flex;
flex-direction: column;
@@ -499,6 +498,10 @@ defineExpose({ retry, cancel })
color: var(--color-error, #ef4444);
}
.error-section button {
margin-top: 0.75rem;
}
/* Responsive adjustments */
@media (max-width: 640px) {
.auth-content {
+32 -16
View File
@@ -1,6 +1,6 @@
<template>
<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]">
{{ status.message }}
</div>
@@ -18,7 +18,7 @@
<div class="section-body center">
<!-- Local passkey authentication 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"
:loading="loading"
:can-authenticate="canAuthenticate"
@@ -58,15 +58,20 @@
import { computed, nextTick, onMounted, onUnmounted, reactive, ref, watch } from 'vue'
import passkey from '@/utils/passkey'
import { getSettings, uiBasePath } from '@/utils/settings'
import { fetchJson, getUserFriendlyErrorMessage } from 'paskia'
import { fetchJson, getUserFriendlyErrorMessage, settings as paskiaSettings } from 'paskia'
import RemoteAuthRequest from '@/components/RemoteAuthRequest.vue'
import { focusDialogButton } from '@/utils/keynav'
import { updateThemeFromSession } from '@/utils/theme'
const props = defineProps({
mode: {
type: String,
default: 'login',
validator: (value) => ['login', 'reauth', 'forbidden'].includes(value)
validator: (value) => ['login', 'reauth', 'forbidden', 'oidc'].includes(value)
},
oidcQueryString: {
type: String,
default: null
}
})
@@ -142,7 +147,8 @@ async function fetchSettings() {
async function validateSession() {
try {
session.value = await fetchJson('/auth/api/validate', { method: 'POST' })
session.value = await fetchJson('/auth/api/validate', { method: 'POST', timeout: paskiaSettings.auth_ms })
updateThemeFromSession(session.value?.ctx)
if (isAuthenticated.value && props.mode !== 'reauth') {
currentView.value = 'forbidden'
emit('forbidden', session.value)
@@ -163,7 +169,7 @@ async function authenticateUser() {
loading.value = true
showMessage('Starting authentication…', 'info')
let result
try { result = await passkey.authenticate() } catch (error) {
try { result = await passkey.authenticate(props.oidcQueryString) } catch (error) {
loading.value = false
const message = error?.message || 'Passkey authentication cancelled'
const cancelled = message === 'Passkey authentication cancelled'
@@ -171,7 +177,13 @@ async function authenticateUser() {
emit('auth-error', { message, cancelled })
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
const message = error?.message || 'Failed to establish session'
showMessage(message, 'error', 4000)
@@ -186,7 +198,7 @@ async function logoutUser() {
if (loading.value) return
loading.value = true
try {
await fetchJson('/auth/api/logout', { method: 'POST' })
await fetchJson('/auth/api/logout', { method: 'POST', timeout: paskiaSettings.auth_ms })
session.value = null
currentView.value = 'login'
showMessage('Logged out. You can sign in with a different account.', 'info', 3000)
@@ -202,13 +214,13 @@ function openProfile() {
if (profileWindow) profileWindow.focus()
}
async function setSessionCookie(result) {
if (!result?.session_token) {
console.error('setSessionCookie called with missing session_token:', result)
throw new Error('Authentication response missing session_token')
async function exchangeCode(result) {
if (!result?.exchange_code) {
console.error('exchangeCode called with missing exchange_code:', result)
throw new Error('Authentication response missing exchange_code')
}
return await fetchJson('/auth/api/set-session', {
method: 'POST', headers: { Authorization: `Bearer ${result.session_token}` }
method: 'POST', headers: { 'Authorization': `Bearer ${result.exchange_code}` }, timeout: paskiaSettings.auth_ms
})
}
@@ -223,7 +235,7 @@ function switchToLocal() {
async function handleRemoteAuthenticated(result) {
showMessage('Authenticated from another device!', 'success', 2000)
try {
await setSessionCookie(result)
await exchangeCode(result)
} catch (error) {
const message = error?.message || 'Failed to establish session'
showMessage(message, 'error', 4000)
@@ -265,7 +277,12 @@ watch(initializing, (newVal) => {
onMounted(async () => {
await fetchSettings()
await validateSession()
// OIDC mode doesn't depend on session state - skip validation
if (props.mode !== 'oidc') {
await validateSession()
} else {
currentView.value = 'login'
}
initializing.value = false
// Add click handler for inline links
@@ -284,7 +301,6 @@ defineExpose({
</script>
<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); }
main.view-root { min-height: 100vh; align-items: center; justify-content: center; padding: 2rem 1rem; }
.surface.surface--tight {
+34 -34
View File
@@ -1,25 +1,26 @@
<template>
<section class="section-block" data-component="session-list-section">
<section :class="['section-block', sectionClass]" data-component="session-list-section">
<div class="section-header">
<h2>Active Sessions</h2>
<p class="section-description">{{ sectionDescription }}</p>
</div>
<div class="section-body">
<div>
<template v-if="Array.isArray(sessions) && sessions.length">
<div v-for="(group, host) in groupedSessions" :key="host" class="session-group" tabindex="0" @keydown="handleGroupKeydown($event, host)">
<template v-if="sessionsArray.length">
<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-icon">🌐</span>
<a v-if="host" :href="hostUrl(host)" tabindex="-1" target="_blank" rel="noopener noreferrer">{{ host }}</a>
<span class="session-group-icon">{{ group.isOIDC ? '🪪' : '🌐' }}</span>
<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>
</span>
<div class="session-list">
<div
v-for="session in group.sessions"
:key="session.id"
:key="session.key"
:class="['session-item', {
'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
}]"
tabindex="-1"
@@ -30,17 +31,17 @@
@keydown="handleItemKeydown($event, session)"
>
<div class="item-top">
<h4 class="item-title">{{ session.user_agent }}</h4>
<h4 class="item-title">{{ session.user_agent || '—' }}</h4>
<div class="item-actions">
<span v-if="session.is_current && !hoveredIp && !hoveredCredentialUuid" class="badge badge-current">Current</span>
<span v-else-if="hoveredSession?.id === session.id" class="badge badge-current">Selected</span>
<span v-else-if="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 && isSameHost(session.ip)" class="badge">Same IP</span>
<button
@click="$emit('terminate', session)"
class="btn-card-delete"
:disabled="isTerminating(session.id)"
:title="isTerminating(session.id) ? 'Terminating...' : 'Terminate session'"
:disabled="isTerminating(session.key)"
:title="isTerminating(session.key) ? 'Terminating...' : 'Terminate session'"
tabindex="-1"
></button>
</div>
@@ -69,12 +70,13 @@ import { hostIP } from '@/utils/helpers'
import { navigateGrid, handleDeleteKey, handleEscape, getDirection } from '@/utils/keynav'
const props = defineProps({
sessions: { type: Array, default: () => [] },
sessions: { type: Object, default: () => ({}) },
emptyMessage: { type: String, default: 'You currently have no other active sessions.' },
sectionDescription: { type: String, default: "Review where you're signed in and end any sessions you no longer recognize." },
terminatingSessions: { type: Object, default: () => ({}) },
hoveredCredentialUuid: { type: String, default: null },
navigationDisabled: { type: Boolean, default: false },
sectionClass: { type: String, default: '' },
})
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 group = event.currentTarget
@@ -149,7 +151,7 @@ const handleGroupKeydown = (event, host) => {
const handleItemKeydown = (event, session) => {
// Handle delete (always allowed even with modal)
handleDeleteKey(event, () => {
if (!isTerminating(session.id)) emit('terminate', session)
if (!isTerminating(session.key)) emit('terminate', session)
})
if (event.defaultPrevented) return
@@ -208,9 +210,14 @@ const copyIp = async (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(() => {
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
})
@@ -218,27 +225,20 @@ const isSameHost = ip => currentHostIP.value && hostIP(ip) === currentHostIP.val
const groupedSessions = computed(() => {
const groups = {}
for (const session of props.sessions) {
const host = session.host || ''
if (!groups[host]) {
groups[host] = { sessions: [], isCurrentSite: false }
}
groups[host].sessions.push(session)
if (session.is_current_host) {
groups[host].isCurrentSite = true
for (const session of sessionsArray.value) {
const groupKey = session.client || session.host || ''
if (!groups[groupKey]) {
groups[groupKey] = { sessions: [], isCurrentSite: false, isOIDC: !!session.client, displayName: session.client_name || groupKey }
}
groups[groupKey].sessions.push(session)
if (session.is_current_host) groups[groupKey].isCurrentSite = true
}
// Sort sessions within each group by last_renewed descending
for (const host in groups) {
groups[host].sessions.sort((a, b) => new Date(b.last_renewed) - new Date(a.last_renewed))
}
// Sort groups by host name (natural sort)
for (const groupKey in groups) groups[groupKey].sessions.sort((a, b) => new Date(b.last_renewed) - new Date(a.last_renewed))
const collator = new Intl.Collator(undefined, { numeric: true, sensitivity: 'base' })
const sortedHosts = Object.keys(groups).sort(collator.compare)
const sortedGroups = {}
for (const host of sortedHosts) {
sortedGroups[host] = groups[host]
}
return sortedGroups
const sorted = Object.entries(groups).sort(([, a], [, b]) => {
if (a.isOIDC !== b.isOIDC) return a.isOIDC ? 1 : -1
return collator.compare(a.displayName, b.displayName) || collator.compare(a.sessions[0]?.client || '', b.sessions[0]?.client || '')
})
return Object.fromEntries(sorted)
})
</script>
+1 -1
View File
@@ -1,5 +1,5 @@
<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]">
{{ authStore.status.message }}
</div>
+48
View File
@@ -0,0 +1,48 @@
<template>
<div class="theme-selector" @click.stop>
<button v-for="t in themes" :key="t.value" class="theme-icon" :class="{ hidden: isHidden(t.value) }"
:style="{ top: getPos(t.value).y + 'px', left: getPos(t.value).x + 'px' }" :title="t.title"
@click="handleClick(t.value)">{{ t.icon }}</button>
</div>
</template>
<script setup>
import { ref, onMounted, onUnmounted } from 'vue'
import { apiJson } from 'paskia'
import { updateThemeFromSession, getCachedTheme } from '@/utils/theme'
const open = ref(false), closing = ref(false), closingValue = ref(null), selected = ref(getCachedTheme())
const themes = [{ value: '', icon: '🌓', title: 'Auto' }, { value: 'light', icon: '☀️', title: 'Light' }, { value: 'dark', icon: '🌙', title: 'Dark' }]
const center = { x: 16, y: 16 }
const expanded = { '': { x: 16, y: 0 }, light: { x: 0, y: 28 }, dark: { x: 32, y: 28 } }
const getPos = v => closing.value ? (v === closingValue.value ? center : expanded[v]) : open.value ? expanded[v] : (v === selected.value ? center : expanded[v])
const isHidden = v => closing.value ? v !== closingValue.value : !open.value && v !== selected.value
function close(v) {
closingValue.value = v
closing.value = true
setTimeout(() => { open.value = closing.value = false; closingValue.value = null }, 200)
}
function handleClick(v) {
if (!open.value) { open.value = true; return }
close(v)
setTimeout(() => {
selected.value = v
updateThemeFromSession({ user: { theme: v } }, true)
apiJson('/auth/api/user/theme', { method: 'PATCH', body: { theme: v } }).catch(() => {})
}, 200)
}
function onOutside(e) { if (open.value && !closing.value && !e.target.closest('.theme-selector')) close(selected.value) }
onMounted(() => document.addEventListener('click', onOutside))
onUnmounted(() => document.removeEventListener('click', onOutside))
</script>
<style scoped>
.theme-selector { position: relative; width: 2rem; height: 2rem; }
.theme-icon { position: absolute; transform: translate(-50%, -50%); background: none; border: none; font-size: 1.25rem; cursor: pointer; padding: 0.25rem; transition: top 0.2s, left 0.2s, opacity 0.15s; }
.theme-icon:hover, .theme-icon:focus-visible { transform: translate(-50%, -50%) scale(1.15); }
.theme-icon.hidden { opacity: 0; pointer-events: none; }
</style>
+91 -60
View File
@@ -1,23 +1,49 @@
<template>
<div v-if="userLoaded" class="user-info" :class="{ 'has-extra': $slots.default }">
<h3 class="user-name-heading">
<span class="icon">👤</span>
<span class="user-name-row">
<span class="display-name" :title="name">{{ name }}</span>
<button v-if="canEdit && updateEndpoint" class="mini-btn" @click="emit('editName')" title="Edit name"></button>
</span>
</h3>
<div v-if="orgDisplayName || roleName" class="org-role-sub">
<div class="org-line" v-if="orgDisplayName">{{ orgDisplayName }}</div>
<div class="role-line" v-if="roleName">{{ roleName }}</div>
</div>
<div class="user-details">
<span class="date-label"><strong>Visits:</strong></span>
<span class="date-value">{{ visits || 0 }}</span>
<span class="date-label"><strong>Registered:</strong></span>
<span class="date-value">{{ formatDate(createdAt) }}</span>
<span class="date-label"><strong>Last seen:</strong></span>
<span class="date-value">{{ formatDate(lastSeen) }}</span>
<div class="user-info-content">
<ProfilePicture
:src="avatarUrl"
:render-version="avatarRenderVersion"
:clickable="avatarClickable"
:loading="loading"
:title="avatarClickable ? 'Change profile picture' : ''"
width="5.25rem"
height="5.25rem"
radius="var(--radius-sm)"
fallback-size="2.8em"
class="user-picture"
:class="avatarClickable ? 'user-picture-btn' : ''"
@click="emit('avatar-click')"
/>
<h3 class="user-name-heading">
<span class="user-name-row">
<span class="display-name" :title="name">{{ name }}</span>
<button v-if="canEdit && updateEndpoint" class="mini-btn" @click="emit('edit')" title="Edit profile"></button>
</span>
</h3>
<div v-if="orgDisplayName || roleName" class="org-role-sub">
<div class="org-line" v-if="orgDisplayName">{{ orgDisplayName }}</div>
<div class="role-line" v-if="roleName">{{ roleName }}</div>
</div>
<div class="info-fields-block">
<div v-if="preferred_username" class="contact-item">🆔 {{ preferred_username }}</div>
<a v-if="email" :href="`mailto:${email}`" class="contact-link"> {{ email }}</a>
<a v-if="telephone" :href="`tel:${telephone}`" class="contact-link">📞 {{ telephone }}</a>
</div>
<div class="info-line">
<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 v-if="$slots.default" class="user-info-extra">
<slot></slot>
@@ -26,80 +52,85 @@
</template>
<script setup>
import { ref, computed, watch } from 'vue'
import { useAuthStore } from '@/stores/auth'
import { computed } from 'vue'
import ProfilePicture from '@/components/ProfilePicture.vue'
import { formatDate } from '@/utils/helpers'
const props = defineProps({
name: { type: String, required: true },
avatarUrl: { type: String, default: null },
avatarRenderVersion: { type: [Number, String], default: 0 },
email: { type: String, default: null },
preferred_username: { type: String, default: null },
telephone: { type: String, default: null },
visits: { type: [Number, String], default: 0 },
createdAt: { type: [String, Number, Date], default: null },
lastSeen: { type: [String, Number, Date], default: null },
updateEndpoint: { type: String, default: null },
canEdit: { type: Boolean, default: true },
avatarClickable: { type: Boolean, default: false },
loading: { type: Boolean, default: false },
orgDisplayName: { type: String, default: '' },
roleName: { type: String, default: '' }
})
const emit = defineEmits(['saved', 'editName'])
const authStore = useAuthStore()
const emit = defineEmits(['saved', 'edit', 'avatar-click'])
const userLoaded = computed(() => !!props.name)
</script>
<style scoped>
.user-info.has-extra {
grid-template-columns: auto 1fr 2fr;
grid-template-columns: minmax(0, 1fr) 14rem;
grid-template-areas:
"heading heading extra"
"org org extra"
"label1 value1 extra"
"label2 value2 extra"
"label3 value3 extra";
"content extra";
gap: 1.5rem;
}
.user-info:not(.has-extra) {
grid-template-columns: auto 1fr;
grid-template-columns: minmax(0, 1fr);
grid-template-areas:
"heading heading"
"org org"
"label1 value1"
"label2 value2"
"label3 value3";
"content";
}
@media (max-width: 720px) {
.user-info.has-extra {
grid-template-columns: auto 1fr;
grid-template-columns: 1fr;
grid-template-areas:
"heading heading"
"org org"
"label1 value1"
"label2 value2"
"label3 value3"
"extra extra";
"content"
"extra";
}
}
.user-name-heading { grid-area: heading; display: flex; align-items: center; flex-wrap: wrap; margin: 0 0 0.25rem 0; }
.org-role-sub { grid-area: org; display:flex; flex-direction:column; margin: -0.15rem 0 0.25rem; }
.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; }
.info-label:nth-of-type(1) { grid-area: label1; }
.info-value:nth-of-type(2) { grid-area: value1; }
.info-label:nth-of-type(3) { grid-area: label2; }
.info-value:nth-of-type(4) { grid-area: value2; }
.info-label:nth-of-type(5) { grid-area: label3; }
.info-value:nth-of-type(6) { grid-area: value3; }
.user-info-extra { grid-area: extra; padding-left: 2rem; border-left: 1px solid var(--color-border); }
.user-name-row { display: inline-flex; align-items: center; gap: 0.35rem; max-width: 100%; }
.user-name-row.editing { flex: 1 1 auto; }
.display-name { font-weight: 600; font-size: 1.05em; line-height: 1.2; max-width: 14ch; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.name-input { width: auto; flex: 1 1 140px; min-width: 120px; padding: 6px 8px; font-size: 0.9em; border: 1px solid var(--color-border-strong); border-radius: 6px; background: var(--color-surface); color: var(--color-text); }
.user-name-heading .name-input { width: auto; }
.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; }
.user-info-content {
grid-area: content;
display: grid;
grid-template-columns: auto minmax(0, 1fr) minmax(0, 1fr);
grid-template-areas:
"picture heading fields"
"picture org fields"
"picture info info";
gap: 0 1rem;
min-width: 0;
}
:deep(.user-picture) { grid-area: picture; align-self: stretch; }
.user-name-heading { grid-area: heading; display: flex; align-items: center; flex-wrap: wrap; margin: 0 0 0.25rem 0; min-width: 0; }
.org-role-sub { grid-area: org; display: flex; flex-direction: column; min-width: 0; }
.org-line { font-size: .7rem; font-weight: 600; line-height: 1.1; color: var(--color-text-muted); text-transform: uppercase; letter-spacing: 0.05em; }
.role-line { font-size: .65rem; color: var(--color-text-muted); line-height: 1.1; }
.info-fields-block { grid-area: fields; display: flex; flex-direction: column; gap: 0.25rem; min-width: 0; }
.contact-item { display: block; color: var(--color-text); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.contact-link { color: var(--color-text); text-decoration: none; display: block; transition: transform 0.1s ease; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.contact-link:hover { transform: scale(1.01); }
.info-line { grid-area: info; line-height: 1.4; font-size: 0.9em; }
.info-date { color: var(--color-text) !important; }
.info-label { color: var(--color-text) !important; }
.info-punct { color: var(--color-text-muted) !important; }
.info-count { color: var(--color-text-muted) !important; }
.user-info-extra { grid-area: extra; padding-left: 1rem; border-left: 1px solid var(--color-border); flex-shrink: 0; }
.user-name-row { display: inline-flex; align-items: center; gap: 0.35rem; max-width: 100%; min-width: 0; }
.display-name { font-weight: 600; font-size: 1.05em; line-height: 1.2; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; min-width: 0; }
.mini-btn { width: auto; padding: 4px 6px; margin: 0; font-size: 0.75em; line-height: 1; cursor: pointer; background: transparent; }
.mini-btn:hover:not(:disabled) { background: var(--color-accent-soft); color: var(--color-accent); }
.mini-btn:active:not(:disabled) { transform: translateY(1px); }
.mini-btn:disabled { opacity: 0.5; cursor: not-allowed; }
+10 -6
View File
@@ -1,12 +1,14 @@
import { defineStore } from 'pinia'
import { register, authenticate } from '@/utils/passkey'
import { getSettings } from '@/utils/settings'
import { apiJson } from 'paskia'
import { apiJson, settings as paskiaSettings } from 'paskia'
import { updateThemeFromSession } from '@/utils/theme'
export const useAuthStore = defineStore('auth', {
state: () => ({
// Auth State
userInfo: null, // Contains the full user info response: {user, credentials, aaguid_info}
ctx: null, // Session context from validate
isLoading: false,
// Settings
@@ -48,6 +50,7 @@ export const useAuthStore = defineStore('auth', {
return await apiJson('/auth/api/set-session', {
method: 'POST',
headers: {'Authorization': `Bearer ${result.session_token}`},
timeout: paskiaSettings.auth_ms,
})
},
async register() {
@@ -85,7 +88,8 @@ export const useAuthStore = defineStore('auth', {
},
async loadUserInfo() {
try {
this.userInfo = await apiJson('/auth/api/user-info', { method: 'POST' })
this.userInfo = await apiJson('/auth/api/user-info', { method: 'GET', timeout: paskiaSettings.auth_ms })
updateThemeFromSession(this.userInfo)
console.log('User info loaded:', this.userInfo)
} catch (error) {
// Suppress toast for 401/403 errors - the auth iframe will handle these
@@ -101,9 +105,9 @@ export const useAuthStore = defineStore('auth', {
await apiJson(`/auth/api/user/credential/${uuid}`, { method: 'DELETE' })
await this.loadUserInfo()
},
async terminateSession(sessionId) {
async terminateSession(sessionKey) {
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) {
sessionStorage.clear()
location.reload()
@@ -118,7 +122,7 @@ export const useAuthStore = defineStore('auth', {
},
async logout() {
try {
await apiJson('/auth/api/logout', {method: 'POST'})
await apiJson('/auth/api/logout', {method: 'POST', timeout: paskiaSettings.auth_ms})
sessionStorage.clear()
location.reload()
} catch (error) {
@@ -131,7 +135,7 @@ export const useAuthStore = defineStore('auth', {
},
async logoutEverywhere() {
try {
await apiJson('/auth/api/user/logout-all', {method: 'POST'})
await apiJson('/auth/api/user/logout-all', {method: 'POST', timeout: paskiaSettings.auth_ms})
sessionStorage.clear()
location.reload()
} catch (error) {
-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() {
const ws = await aWebSocket(await makeUrl('/auth/ws/authenticate'))
export async function authenticate(queryString = null) {
// 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 {
let res = await ws.receive_json()
if (res.status >= 400) throw new Error(res.detail || `Authentication failed: ${res.status}`)
+46
View File
@@ -0,0 +1,46 @@
// Theme override utilities - shared across apps
// User preference or URL hash can force light/dark mode
const TRANSITION_ID = 'theme-transition'
const STORAGE_KEY = 'paskia-theme'
/** Apply theme by setting class on documentElement */
export function applyTheme(theme, element = document.documentElement, animate = false) {
// Add temporary transition for smooth theme change
if (animate) {
let transitionStyle = document.getElementById(TRANSITION_ID)
if (!transitionStyle) {
transitionStyle = document.createElement('style')
transitionStyle.id = TRANSITION_ID
transitionStyle.textContent = '*, *::before, *::after { transition: background-color 0.3s, color 0.3s, border-color 0.3s, box-shadow 0.3s !important; }'
document.head.appendChild(transitionStyle)
}
setTimeout(() => document.getElementById(TRANSITION_ID)?.remove(), 350)
}
// If no explicit theme, check system preference
const isDark = theme === 'dark' || (theme !== 'light' && matchMedia('(prefers-color-scheme:dark)').matches)
element.classList.toggle('dark', isDark)
}
/** Get theme from localStorage cache */
export function getCachedTheme() {
return localStorage.getItem(STORAGE_KEY) || ''
}
/** Cache theme in localStorage */
export function setCachedTheme(theme) {
if (theme) localStorage.setItem(STORAGE_KEY, theme)
else localStorage.removeItem(STORAGE_KEY)
}
/** Initialize theme from user preference (with localStorage cache for fast load) */
export function initThemeFromCache() {
applyTheme(getCachedTheme())
}
/** Update theme from session context (call after login/session load) */
export function updateThemeFromSession(ctx, animate = false) {
const theme = ctx?.user?.theme || ''
setCachedTheme(theme)
applyTheme(theme, document.documentElement, animate)
}
+6 -5
View File
@@ -1,17 +1,18 @@
/**
* FastAPI-Vue Vite Plugin
* auto-upgrade@fastapi-vue-setup -- remove this if you edit the plugin
*
* Configures Vite for FastAPI backend integration:
* - Proxies /api/* requests to the FastAPI backend
* - Builds to the Python module's frontend-build directory
*
* Environment variables (with defaults):
* FASTAPI_VUE_BACKEND_URL=http://localhost:5180 - Backend API URL for proxying
* Options:
* paths - Array of paths to proxy (default: ["/api"])
*/
const backendUrl = process.env.FASTAPI_VUE_BACKEND_URL || "http://localhost:5180"
export default function fastapiVue({ paths = ["/api"] } = {}) {
const backendUrl = process.env.PASKIA_BACKEND_URL || "http://localhost:4402"
// Build proxy configuration for each path
const proxy = {}
for (const path of paths) {
@@ -23,7 +24,7 @@ export default function fastapiVue({ paths = ["/api"] } = {}) {
}
return {
name: "fastapi-vite",
name: "vite-plugin-fastapi-paskia",
config: () => ({
server: { proxy },
build: {
+13
View File
@@ -16,6 +16,7 @@ export default defineConfig(({ command }) => ({
fastapiVue({ paths: [
"/auth/api",
"/auth/ws",
"/.well-known/openid-configuration",
// Passphrase links: /auth/word1.word2.word3.word4.word5
"^/auth/[a-z]+\\.[a-z]+\\.[a-z]+\\.[a-z]+\\.[a-z]+$",
// 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',
configureServer(server) {
+92
View File
@@ -0,0 +1,92 @@
# 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, includes `picture` when `profile` scope is granted and avatar exists)
- `POST /auth/oidc/backchannel-logout` — Logout
- `POST /auth/api/exchange` — Native auth code → cookie
## Claims
- `profile` scope may include `name`, `preferred_username`, and `picture`
- `email` scope may include `email`
- `groups` is emitted from client-scoped permissions
## Files
**Created:** [paskia/authcode.py](paskia/authcode.py), [paskia/util/crypto.py](paskia/util/crypto.py), [paskia/fastapi/oid.py](paskia/fastapi/oid.py)
**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)
+26 -2
View File
@@ -1,6 +1,6 @@
# Paskia
![Paskia](https://git.zi.fi/leovasanko/paskia/raw/main/docs/screenshots/forbidden-light.webp)
![Screenshot](https://git.zi.fi/leovasanko/paskia/raw/main/docs/screenshots/forbidden-light.webp)
JavaScript utilities for [Paskia authentication system](https://git.zi.fi/leovasanko/paskia) integration into web apps.
@@ -64,11 +64,35 @@ When a 401/403 response includes an auth iframe URL, the request automatically p
The JSON variants set headers automatically, with body and response in JSON.
### Timeout Settings
Paskia exports a mutable settings object for defaults used by fetch/auth/session validation timers. Default values shown below.
```js
import { settings } from 'paskia'
// General fetch timeout used by apiFetch/apiJson/fetchJson when no timeout is passed
settings.fetch_ms = 10000
// Fetch timeout used by SessionValidator (/auth/api/validate is fast)
settings.auth_ms = 1000
// SessionValidator polling and idle timers
settings.poll_ms = 60000
settings.idle_ms = 300000
```
You can still override timeout per request:
```js
await apiJson('/api/upload', { method: 'POST', body: data, timeout: 30000 })
```
### Authentication Overlay
Normally you use apiJson/apiFetch and they handle this automatically. If you need to wire it yourself, on a 401/403 response that includes `auth.iframe`, call `showAuthIframe(...)` and then retry the original request.
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
import { showAuthIframe, AuthCancelledError } from 'paskia'
+13 -4
View File
@@ -1,7 +1,15 @@
{
"name": "paskia",
"version": "0.1.2",
"version": "1.4.0",
"description": "Paskia authentication utilities for JavaScript",
"author": "Leo Vasanko",
"license": "Unlicense",
"homepage": "https://git.zi.fi/LeoVasanko/paskia",
"repository": {
"type": "git",
"url": "https://github.com/LeoVasanko/paskia",
"directory": "paskia-js"
},
"type": "module",
"main": "./dist/paskia.js",
"types": "./dist/paskia.d.ts",
@@ -26,7 +34,8 @@
"keywords": [
"auth",
"authentication",
"paskia"
],
"license": "Unlicense"
"paskia",
"passkey",
"webauthn"
]
}
+2 -3
View File
@@ -1,9 +1,8 @@
import { showAuthIframe, AuthCancelledError } from './overlay'
import settings from './settings'
export { AuthCancelledError }
const DEFAULT_TIMEOUT_MS = 1000
export interface ApiFetchOptions extends RequestInit {
timeout?: number
}
@@ -40,7 +39,7 @@ export class NetworkError extends Error {
}
export async function apiFetch(url: string, options: ApiFetchOptions = {}): Promise<Response> {
const { timeout = DEFAULT_TIMEOUT_MS, ...fetchOptions } = options
const { timeout = settings.fetch_ms, ...fetchOptions } = options
fetchOptions.credentials = fetchOptions.credentials || 'include'
while (true) {
+2 -2
View File
@@ -12,14 +12,14 @@ export {
export type { ApiFetchOptions, FetchJsonOptions } from './fetch'
export { default as settings } from './settings'
export {
holdGlobalBackdrop,
releaseGlobalBackdrop,
isAuthIframeOpen,
hideAuthIframe,
showAuthIframe,
createAuthIframe,
removeAuthIframe,
} from './overlay'
export { SessionValidator } from './validate'
+3 -2
View File
@@ -14,8 +14,8 @@ body::before {
transition: all 0.2s ease-out;
}
body.paskia-backdrop::before {
-webkit-backdrop-filter: blur(.2rem) brightness(0.5);
backdrop-filter: blur(.2rem) brightness(0.5);
backdrop-filter: blur(.2rem) brightness(0.7);
-webkit-backdrop-filter: blur(.2rem) brightness(0.7);
visibility: visible;
}
body.paskia-backdrop {
@@ -57,6 +57,7 @@ export class AuthCancelledError extends Error {
}
export function holdGlobalBackdrop(): void {
injectStyles()
backdropHolders++
document.body.classList.add('paskia-backdrop')
}
+6
View File
@@ -0,0 +1,6 @@
export default {
fetch_ms: 10000,
auth_ms: 1000,
poll_ms: 60000,
idle_ms: 300000,
}
+4 -6
View File
@@ -1,7 +1,5 @@
import { apiJson } from './fetch'
const POLL_INTERVAL = 60 * 1000
const IDLE_TIMEOUT = 5 * 60 * 1000
import settings from './settings'
export class SessionValidator {
private userUuidGetter: () => string | undefined
@@ -19,12 +17,12 @@ export class SessionValidator {
resetIdleTimer(): void {
if (this.idleTimer) clearTimeout(this.idleTimer)
if (!this.active) this.startPolling()
this.idleTimer = setTimeout(() => this.stopPolling(), IDLE_TIMEOUT)
this.idleTimer = setTimeout(() => this.stopPolling(), settings.idle_ms)
}
async validate(): Promise<void> {
try {
const data = await apiJson<{ ctx?: { user?: { uuid?: string } } }>('/auth/api/validate', { method: 'POST' })
const data = await apiJson<{ ctx?: { user?: { uuid?: string } } }>('/auth/api/validate', { method: 'POST', timeout: settings.auth_ms })
const newUuid = data.ctx?.user?.uuid
if (newUuid !== this.userUuidGetter()) {
window.location.reload()
@@ -40,7 +38,7 @@ export class SessionValidator {
startPolling(): void {
if (this.active) return
this.active = true
this.pollTimer = setInterval(() => this.validate(), POLL_INTERVAL)
this.pollTimer = setInterval(() => this.validate(), settings.poll_ms)
}
stopPolling(): void {
+148
View File
@@ -0,0 +1,148 @@
import argparse
import logging
import os
import sys
import msgspec
from fastapi_vue import server
from fastapi_vue.hostutil import parse_endpoints
from paskia._version import __version__
from paskia.db.jsonl import load_readonly
from paskia.db.paths import db_file_path
from paskia.util import startupbox
from paskia.util.hostutil import (
normalize_auth_host_and_origins,
normalize_origin,
validate_auth_host,
)
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 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 = db_file_path(rp_id=args.rp_id, create_root=True)
try:
config = load_readonly(str(db_path), rp_id=args.rp_id).config
except SystemExit as e:
print(f"🛑 Paskia {__version__} could not load")
sys.exit(str(e))
# 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 and origins
try:
validate_auth_host(config.auth_host, config.rp_id) if config.auth_host else None
except ValueError as e:
raise SystemExit(str(e))
if config.origins:
config.origins = [normalize_origin(o) for o in config.origins]
config.auth_host, config.origins = normalize_auth_host_and_origins(
config.auth_host, config.origins
)
# Parse first endpoint for 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
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.config import RESET_LIFETIME, SESSION_LIFETIME
from paskia.db.structs import ResetToken
from paskia.util import hostutil
if TYPE_CHECKING:
@@ -22,6 +23,11 @@ if TYPE_CHECKING:
EXPIRES = SESSION_LIFETIME
def session_ctx(auth: str, host: str | None = None):
"""Get session context with normalized host."""
return db.data().session_ctx(auth, hostutil.normalize_host(host))
def expires() -> datetime:
return datetime.now(UTC) + EXPIRES
@@ -33,7 +39,7 @@ def reset_expires() -> datetime:
def get_reset(token: str) -> "ResetToken":
"""Validate a credential reset token."""
record = db.get_reset_token(token)
record = ResetToken.by_passphrase(token)
if record:
return record
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):
"""Delete a specific credential for the current user."""
ctx = db.data().session_ctx(auth, hostutil.normalize_host(host))
ctx = session_ctx(auth, host)
if not ctx:
raise ValueError("Session expired")
db.delete_credential(credential_uuid, ctx.user.uuid)
+49 -37
View File
@@ -6,42 +6,58 @@ including creating default admin user, organization, permissions, and
generating a reset link for initial admin setup.
"""
import asyncio
import logging
from paskia import authsession, db, globals
from paskia.util import hostutil, passphrase
from paskia import authsession, db
from paskia.db.structs import Config
from paskia.util import hostutil
logger = logging.getLogger(__name__)
# Shared log message template for admin reset links
ADMIN_RESET_MESSAGE = """\
%s
def _configure_logger() -> None:
if logger.handlers:
return
handler = logging.StreamHandler()
handler.setFormatter(logging.Formatter("%(message)s"))
logger.addHandler(handler)
logger.setLevel(logging.INFO)
logger.propagate = False
_configure_logger()
# Shared log message template for admin reset links
ADMIN_RESET_MESSAGE = """
👤 Admin %s
- Use this link to register a Passkey for the admin user!
"""
def _log_reset_link(message: str, passphrase: str) -> str:
def _log_reset_link(passphrase: str, message: str | None = None) -> str:
"""Log a reset link message and return the URL."""
reset_link = hostutil.reset_link_url(passphrase)
logger.info(ADMIN_RESET_MESSAGE, message, reset_link)
if message:
logger.info(message)
logger.info(ADMIN_RESET_MESSAGE, 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.
Uses db.bootstrap() which performs all operations in a single transaction.
The transaction log will show a single "bootstrap" action with all changes.
Args:
config: Configuration to store (rp_id, rp_name, origins, etc.)
"""
# Call the single-transaction bootstrap function
reset_passphrase = db.bootstrap()
reset_passphrase = db.bootstrap(config=config)
# Log the reset link (this is separate from the transaction log)
_log_reset_link("✅ Bootstrap completed!", reset_passphrase)
_log_reset_link(reset_passphrase, "✅ Bootstrap completed!")
async def check_admin_credentials() -> bool:
@@ -52,17 +68,24 @@ async def check_admin_credentials() -> bool:
bool: True if a reset link was created, False if admin already has credentials
"""
try:
# Get permission organizations to find admin users
# Find the auth:admin permission
p = next(
(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
# Get users from the first organization with admin permission
first_org_uuid = next(iter(p.orgs))
org_users = db.get_organization_users(first_org_uuid)
admin_users = [user for user, role in org_users if role == "Administration"]
perm_uuid = p.uuid
# Find all roles that have the auth:admin permission
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:
return False
@@ -70,18 +93,17 @@ async def check_admin_credentials() -> bool:
# Check first admin user for credentials
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
logger.info("⚠️ Admin user has no credentials!")
token = passphrase.generate()
expiry = authsession.reset_expires()
db.create_reset_token(
token = db.create_reset_token(
user_uuid=admin_user.uuid,
passphrase=token,
expiry=expiry,
token_type="admin registration",
)
_log_reset_link("⚠️ Admin user has no credentials!", token)
_log_reset_link(token)
return True
return False
@@ -90,10 +112,13 @@ async def check_admin_credentials() -> bool:
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.
Args:
config: Configuration to store during bootstrap (rp_id, rp_name, origins, etc.)
Returns:
bool: True if bootstrapping was performed, False if system was already set up
"""
@@ -106,18 +131,5 @@ async def bootstrap_if_needed() -> bool:
# No admin permission found, need to bootstrap
# Bootstrap creates the admin user AND the reset link, so no need to check credentials after
await bootstrap_system()
await bootstrap_system(config=config)
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
# Shared configuration constants for session management.
@@ -6,19 +5,3 @@ SESSION_LIFETIME = timedelta(hours=24)
# Lifetime for reset links created by admins
RESET_LIFETIME = timedelta(days=14)
@dataclass
class PaskiaConfig:
"""Runtime configuration for the Paskia authentication server."""
rp_id: str
rp_name: str | None
origins: list[str] | None
auth_host: str | None
site_url: str # Base URL without trailing path (e.g. https://example.com)
site_path: str # Path to auth UI: "/" if auth_host, else "/auth/"
# Listen address (one of host:port or uds)
host: str | None = None
port: int | None = None
uds: str | None = None
+26 -24
View File
@@ -1,7 +1,7 @@
"""
Database module for WebAuthn passkey authentication.
Read: Access data() directly, use build_* to convert to public structs.
Read: Access data() directly for structs.
CTX: data().session_ctx(key) returns SessionContext with effective permissions.
Write: Functions validate and commit, or raise ValueError.
@@ -10,7 +10,6 @@ Usage:
# Read (after init)
user_data = db.data().users[user_uuid]
user = db.build_user(user_uuid)
# Context
ctx = db.data().session_ctx(session_key)
@@ -26,20 +25,22 @@ from paskia.db.background import (
stop_background,
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 (
add_permission_to_org,
add_permission_to_role,
bootstrap,
cleanup_expired,
create_credential,
create_credential_session,
create_oid_client,
create_org,
create_permission,
create_reset_token,
create_role,
create_session,
create_user,
delete_credential,
delete_oid_client,
delete_org,
delete_permission,
delete_reset_token,
@@ -47,26 +48,28 @@ from paskia.db.operations import (
delete_session,
delete_sessions_for_user,
delete_user,
get_organization_users,
get_reset_token,
get_user_credential_ids,
get_user_organization,
init,
is_username_taken,
login,
oidc_login,
remove_permission_from_org,
remove_permission_from_role,
reset_oid_client_secret,
set_session_host,
update_config,
update_credential_sign_count,
update_oid_client,
update_org_name,
update_permission,
update_role_name,
update_session,
update_user_display_name,
update_user_info,
update_user_role,
update_user_role_in_organization,
)
from paskia.db.structs import (
DB,
Client,
Config,
Credential,
Org,
Permission,
@@ -85,8 +88,10 @@ def data() -> DB:
__all__ = [
# Types
"Config",
"Credential",
"DB",
"Client",
"Org",
"Permission",
"ResetToken",
@@ -97,23 +102,13 @@ __all__ = [
# Instance
"data",
"init",
"load_readonly",
# Background
"start_background",
"stop_background",
"start_cleanup",
"stop_cleanup",
# Builders
"build_credential",
"build_permission",
"build_reset_token",
"build_role",
"build_session",
"build_user",
# Read ops
"get_organization_users",
"get_reset_token",
"get_user_credential_ids",
"get_user_organization",
# Write ops
"add_permission_to_org",
"add_permission_to_role",
@@ -125,7 +120,6 @@ __all__ = [
"create_permission",
"create_reset_token",
"create_role",
"create_session",
"create_user",
"delete_credential",
"delete_org",
@@ -136,15 +130,23 @@ __all__ = [
"delete_sessions_for_user",
"delete_user",
"login",
"oidc_login",
"remove_permission_from_org",
"remove_permission_from_role",
"set_session_host",
"update_config",
"update_credential_sign_count",
"update_org_name",
"update_permission",
"update_role_name",
"update_session",
"update_user_display_name",
"update_user_info",
"update_user_role",
"update_user_role_in_organization",
"is_username_taken",
# OIDC
"create_oid_client",
"update_oid_client",
"reset_oid_client_secret",
"delete_oid_client",
]
+39 -38
View File
@@ -1,62 +1,61 @@
"""
Background task for database maintenance.
Periodically flushes pending changes to disk and cleans up expired items.
Kanta handles periodic flushing to disk. This module keeps a small
companion task that periodically cleans up expired sessions/tokens.
"""
import asyncio
import logging
from datetime import UTC, datetime
import os
import signal
from paskia.db.operations import _store, cleanup_expired
from kanta.exceptions import DatabaseError
import paskia.db.operations as _ops
from paskia.db.lifecycle import cleanup_expired
FLUSH_INTERVAL = 0.1 # Flush to disk
CLEANUP_INTERVAL = 1 # Expired item cleanup
_logger = logging.getLogger(__name__)
_background_task: asyncio.Task | None = None
def _sigterm_on_error(error: DatabaseError) -> None:
"""Exit the server when a database write fails."""
_logger.error("Fatal database error: %s", error)
os.kill(os.getpid(), signal.SIGTERM)
async def flush() -> None:
"""Write all pending database changes to disk."""
if _store is None:
store = _ops._store
if store is None:
_logger.warning("flush() called but _store is None")
return
await _store.flush()
try:
await store.flush()
except DatabaseError as e:
_sigterm_on_error(e)
async def _background_loop():
"""Background task that periodically flushes changes and cleans up."""
"""Background task that periodically cleans up expired items."""
# Run cleanup immediately on startup to clear old expired items
cleanup_expired()
await flush()
last_cleanup = datetime.now(UTC)
while True:
try:
await asyncio.sleep(FLUSH_INTERVAL)
# Flush pending changes to disk
await flush()
# Run cleanup periodically
now = datetime.now(UTC)
if (now - last_cleanup).total_seconds() >= CLEANUP_INTERVAL:
cleanup_expired()
await flush() # Flush cleanup changes
last_cleanup = now
await asyncio.sleep(CLEANUP_INTERVAL)
cleanup_expired()
except asyncio.CancelledError:
# Final flush before exit
await flush()
break
except Exception:
_logger.debug("Error in database background loop", exc_info=True)
async def start_background():
"""Start the background flush/cleanup task."""
"""Start the background cleanup task."""
global _background_task
# Check if task exists but is no longer running (e.g., after uvicorn reload)
@@ -70,29 +69,25 @@ async def start_background():
# Check if task is in current event loop
loop = asyncio.get_running_loop()
task_loop = _background_task.get_loop()
if loop is not task_loop:
_logger.debug("Background task in different event loop, restarting")
_background_task = None
else:
# Task is running in the same event loop - this is an error
raise RuntimeError(
"Background task is already running. "
"start_background() must not be called multiple times in the same event loop."
if loop is task_loop:
# Task is already running in same loop - idempotent, just return
# This happens with dual IPv4+IPv6 endpoints sharing the same process
_logger.debug(
"Background task already running in same loop, skipping"
)
except RuntimeError:
raise # Re-raise RuntimeError from above
return
_logger.debug("Background task in different event loop, restarting")
_background_task = None
except Exception as e:
_logger.debug("Error checking background task loop: %s, restarting", e)
_background_task = None
if _background_task is None:
_background_task = asyncio.create_task(_background_loop())
else:
_logger.debug("Background task already running: %s", _background_task)
async def stop_background():
"""Stop the background task and flush any pending changes."""
"""Stop the background cleanup task and close kanta."""
global _background_task
if _background_task:
_background_task.cancel()
@@ -101,6 +96,12 @@ async def stop_background():
except asyncio.CancelledError:
pass
_background_task = None
store = _ops._store
if store is not None:
try:
await store.close()
except DatabaseError as e:
_sigterm_on_error(e)
# 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
+31 -261
View File
@@ -1,282 +1,52 @@
"""
JSONL persistence layer for the database.
JSONL read-only loader using kanta.
"""
import copy
import logging
from collections import deque
from contextlib import contextmanager
from datetime import UTC, datetime
from pathlib import Path
from typing import Any
from uuid import UUID
import aiofiles
import jsondiff
import msgspec
from kanta import replay as replay_jsonl
from kanta.migrate import MigrationRegistry
from paskia.db.logging import log_change
from paskia.db.migrations import DBVER, apply_all_migrations
from paskia.db.structs import DB, SessionContext
from paskia.db.migrations import MigrationCtx
from paskia.db.structs import DB, Config
_logger = logging.getLogger(__name__)
# Default database path
DB_PATH_DEFAULT = "paskia.jsonl"
def load_readonly(db_path: str, *, rp_id: str = "localhost") -> DB:
"""Replay JSONL and apply migrations to produce a DB, without writing anything.
class _ChangeRecord(msgspec.Struct, omit_defaults=True):
"""A single change record in the JSONL file."""
ts: datetime
a: str # action - describes the operation (e.g., "migrate", "login", "create_user")
v: int # schema version after this change
u: str | None = None # user UUID who performed the action (None for system)
diff: dict = {}
# msgspec encoder for change records
_change_encoder = msgspec.json.Encoder()
def compute_diff(previous: dict, current: dict) -> dict | None:
"""Compute JSON diff between two states.
Args:
previous: Previous state (JSON-compatible dict)
current: Current state (JSON-compatible dict)
Returns:
The diff, or None if no changes
This is suitable for reading settings before the server starts.
Migrations are applied in-memory only; nothing is queued or flushed.
"""
diff = jsondiff.diff(previous, current, marshal=True)
return diff if diff else None
def create_change_record(
action: str, version: int, diff: dict, user: str | None = None
) -> _ChangeRecord:
"""Create a change record for persistence."""
return _ChangeRecord(
ts=datetime.now(UTC),
a=action,
v=version,
u=user,
diff=diff,
)
# Actions that are allowed to create a new database file
_BOOTSTRAP_ACTIONS = frozenset({"bootstrap", "migrate:sql"})
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 or migrate can create a new database",
first_action,
)
pending_changes.clear()
return False
changes_to_write = list(pending_changes)
pending_changes.clear()
path = Path(db_path)
if not path.exists():
return DB(config=Config(rp_id=rp_id))
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:
"""JSONL persistence layer for a DB instance."""
def __init__(self, db: DB, db_path: str = DB_PATH_DEFAULT):
self.db: DB = db
self.db_path = Path(db_path)
self._previous_builtins: dict[str, Any] = {}
self._pending_changes: deque[_ChangeRecord] = deque()
self._current_action: str = "system"
self._current_user: str | None = None
self._in_transaction: bool = False
self._transaction_snapshot: dict[str, Any] | None = None
self._current_version: int = DBVER # Schema version for new databases
async def load(self, db_path: str | None = None) -> None:
"""Load data from JSONL change log."""
if db_path is not None:
self.db_path = Path(db_path)
if not self.db_path.exists():
return
# Replay change log to reconstruct state
data_dict: dict = {}
try:
async with aiofiles.open(self.db_path, "rb") as f:
content = await f.read()
for line_num, line in enumerate(content.split(b"\n"), 1):
line = line.strip()
if not line:
continue
try:
change = msgspec.json.decode(line)
data_dict = jsondiff.patch(data_dict, change["diff"], marshal=True)
self._current_version = change.get("v", 0)
except Exception as e:
raise ValueError(f"Error parsing line {line_num}: {e}")
except (OSError, ValueError, msgspec.DecodeError) as e:
raise ValueError(f"Failed to load database: {e}")
content = path.read_bytes()
rr = replay_jsonl(content)
data_dict = rr.state
version = rr.version
if not data_dict:
return
return DB(config=Config(rp_id=rp_id))
# Set previous state for diffing (will be updated by _queue_change)
self._previous_builtins = copy.deepcopy(data_dict)
# Callback to persist each migration
async def persist_migration(
action: str, new_version: int, current: dict
) -> None:
self._current_version = new_version
self._queue_change(action, new_version, current)
# Apply schema migrations one at a time
await apply_all_migrations(data_dict, self._current_version, persist_migration)
# Decode to msgspec struct
decoder = msgspec.json.Decoder(DB)
self.db = decoder.decode(msgspec.json.encode(data_dict))
self.db._store = self
# Normalize via msgspec round-trip (handles omit_defaults etc.)
# This ensures _previous_builtins matches what msgspec would produce
normalized_dict = msgspec.to_builtins(self.db)
await persist_migration(
"migrate:msgspec", self._current_version, normalized_dict
# Apply migrations in-memory (no persistence)
registry = MigrationRegistry.from_module("paskia.db.migrations")
version = registry.apply(
data_dict, version, MigrationCtx(rp_id=rp_id), silent=True
)
def _queue_change(
self, action: str, version: int, current: dict, user: str | None = None
) -> None:
"""Queue a change record and log it.
Args:
action: The action name for the change record
version: The schema version for the change record
current: The current state as a plain dict
user: Optional user UUID who performed the action
"""
diff = compute_diff(self._previous_builtins, current)
if not diff:
return
self._pending_changes.append(create_change_record(action, version, diff, user))
# Log the change with user display name if available
user_display = None
if user:
try:
user_uuid = UUID(user)
if user_uuid in self.db.users:
user_display = self.db.users[user_uuid].display_name
except (ValueError, KeyError):
user_display = user
log_change(action, diff, user_display, self._previous_builtins)
self._previous_builtins = copy.deepcopy(current)
@contextmanager
def transaction(
self,
action: str,
ctx: SessionContext | None = None,
*,
user: str | None = None,
):
"""Wrap writes in transaction. Queues change on successful exit.
Args:
action: Describes the operation (e.g., "Created user", "Login")
ctx: Session context of user performing the action (None for system operations)
user: User UUID string (alternative to ctx when full context unavailable)
"""
if self._in_transaction:
raise RuntimeError("Nested transactions are not supported")
# Check for out-of-transaction modifications
current_state = msgspec.to_builtins(self.db)
if current_state != self._previous_builtins:
# Allow bootstrap/migrate to create a new database from empty state
is_bootstrap = action in _BOOTSTRAP_ACTIONS or action.startswith("migrate:")
if is_bootstrap and not self._previous_builtins:
pass # Expected: creating database from scratch
else:
diff = compute_diff(self._previous_builtins, current_state)
diff_json = msgspec.json.encode(diff).decode()
_logger.critical(
"Database state modified outside of transaction! "
"This indicates a bug where DB changes occurred without a transaction wrapper.\n"
f"Changes detected:\n{diff_json}"
)
raise SystemExit(1)
old_action = self._current_action
old_user = self._current_user
self._current_action = action
# Prefer ctx.user.uuid if ctx provided, otherwise use user param
self._current_user = str(ctx.user.uuid) if ctx else user
self._in_transaction = True
self._transaction_snapshot = current_state
try:
yield
current = msgspec.to_builtins(self.db)
self._queue_change(
self._current_action, self._current_version, current, self._current_user
)
except Exception:
# Rollback on error: restore from snapshot
_logger.warning("Transaction '%s' failed, rolling back changes", action)
if self._transaction_snapshot is not None:
decoder = msgspec.json.Decoder(DB)
self.db = decoder.decode(
msgspec.json.encode(self._transaction_snapshot)
)
self.db._store = self
raise
finally:
self._current_action = old_action
self._current_user = old_user
self._in_transaction = False
self._transaction_snapshot = None
async def flush(self) -> bool:
"""Write all pending changes to disk."""
return await flush_changes(self.db_path, self._pending_changes)
# Decode to msgspec struct
return msgspec.json.decode(msgspec.json.encode(data_dict), type=DB)
except OSError as e:
_logger.exception("Failed to load database")
raise SystemExit(f"{e}")
except (ValueError, msgspec.DecodeError) as e:
raise SystemExit(f"{e}")
except Exception as e:
_logger.exception("Unexpected error loading database")
raise SystemExit(f"{e}")
+70
View File
@@ -0,0 +1,70 @@
"""
Database lifecycle: initialization and maintenance.
"""
import logging
import os
import signal
from datetime import UTC, datetime
from kanta import Kanta
from kanta.exceptions import DatabaseError
import paskia.db.operations as _ops
from paskia import oidc_notify
from paskia.authsession import EXPIRES
from paskia.db.migrations import MigrationCtx
from paskia.db.paths import db_file_path
from paskia.db.structs import DB
_logger = logging.getLogger(__name__)
def _fatal_error(error: DatabaseError) -> None:
"""Fatal error callback: terminate the process on background write failures."""
_logger.error("Fatal database error: %s", error)
os.kill(os.getpid(), signal.SIGTERM)
async def init(rp_id: str, *args, **kwargs):
"""Load database from JSONL file using kanta."""
if _ops._store is not None:
_logger.debug("Database already initialized, skipping reload")
return
db_path = db_file_path(rp_id=rp_id, create_root=True)
db = DB()
kanta = Kanta(
str(db_path),
db,
migrations="paskia.db.migrations",
migration_ctx=MigrationCtx(rp_id=rp_id),
fatal_error=_fatal_error,
)
try:
await kanta.open()
except DatabaseError as e:
raise SystemExit(f"{e}") from e
_ops._store = kanta
_ops._db = db
_ops._db._store = kanta
# Request a snapshot after successful startup
kanta.request_snapshot()
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
+226 -74
View File
@@ -3,15 +3,29 @@ Database change logging with pretty-printed diffs.
Provides a logger for JSONL database changes that formats diffs
in a human-readable path.notation style with color coding.
UUIDs are replaced with display names where available, or the full UUID string
for types without display names.
"""
import logging
import re
import sys
from typing import Any
from typing import TYPE_CHECKING, Any
from uuid import UUID
from kanta.logging import configure_logging as configure_kanta_logging
if TYPE_CHECKING:
from paskia.db.structs import DB
logger = logging.getLogger("paskia.db")
# UUID regex pattern (8-4-4-4-12 hex format)
_UUID_PATTERN = re.compile(
r"^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$"
)
# Pattern to match control characters and bidirectional overrides
_UNSAFE_CHARS = re.compile(
r"[\x00-\x1f\x7f-\x9f" # C0 and C1 control characters
@@ -23,22 +37,123 @@ _UNSAFE_CHARS = re.compile(
# ANSI color codes (matching FastAPI logging style)
_RESET = "\033[0m"
_DIM = "\033[2m"
_PATH_PREFIX = "\033[1;30m" # Dark grey for path prefix (like host in access log)
_PATH_FINAL = "\033[0m" # Default for final element (like path in access log)
_SEP = "\033[38;5;242m" # Dark grey for separators (like host/timing in access log)
_PATH_PREFIX = "\033[38;5;242m" # Dark grey for path prefix (like host 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
_ADD = "\033[0;32m" # Green for additions
_ACTION = "\033[1;34m" # Bold blue for action name
_USER = "\033[0;34m" # Blue for user display
def _use_color() -> bool:
"""Check if we should use color output."""
return sys.stderr.isatty()
def _is_uuid(value: str) -> bool:
"""Check if a string is a UUID."""
return bool(_UUID_PATTERN.match(value))
def _format_value(value: Any, use_color: bool, max_len: int = 60) -> str:
"""Format a value for display, truncating if needed."""
class UuidResolver:
"""Resolve UUIDs to display names or short suffixes.
Uses the previous state for lookups to show the name before any changes.
"""
def __init__(self, db: "DB | None" = None, previous: dict | None = None):
self._db = db
self._previous = previous
def resolve(self, uuid_str: str) -> str:
"""Resolve a UUID to its display name or the full UUID string."""
display = self._get_display_name(uuid_str)
if display:
return display
return uuid_str
def _get_display_name(self, uuid_str: str) -> str | None:
"""Look up display name for a UUID.
First checks the previous state (to show names before changes),
then falls back to the current database.
"""
# Try previous state first (for showing name before a change)
name = self._lookup_in_previous(uuid_str)
if name:
return name
# Fall back to current database
return self._lookup_in_db(uuid_str)
def _lookup_in_previous(self, uuid_str: str) -> str | None:
"""Look up display name in the previous state dict."""
if not self._previous:
return None
# Check users
if "users" in self._previous and uuid_str in self._previous["users"]:
user_data = self._previous["users"][uuid_str]
if isinstance(user_data, dict) and "display_name" in user_data:
return user_data["display_name"]
# Check orgs
if "orgs" in self._previous and uuid_str in self._previous["orgs"]:
org_data = self._previous["orgs"][uuid_str]
if isinstance(org_data, dict) and "display_name" in org_data:
return org_data["display_name"]
# Check roles
if "roles" in self._previous and uuid_str in self._previous["roles"]:
role_data = self._previous["roles"][uuid_str]
if isinstance(role_data, dict) and "display_name" in role_data:
return role_data["display_name"]
# Check permissions
if (
"permissions" in self._previous
and uuid_str in self._previous["permissions"]
):
perm_data = self._previous["permissions"][uuid_str]
if isinstance(perm_data, dict) and "display_name" in perm_data:
return perm_data["display_name"]
return None
def _lookup_in_db(self, uuid_str: str) -> str | None:
"""Look up display name in the current database."""
if not self._db:
return None
try:
uuid_obj = UUID(uuid_str)
except ValueError:
return None
# Check users
if uuid_obj in self._db.users:
return self._db.users[uuid_obj].display_name
# Check orgs
if uuid_obj in self._db.orgs:
return self._db.orgs[uuid_obj].display_name
# Check roles
if uuid_obj in self._db.roles:
return self._db.roles[uuid_obj].display_name
# Check permissions
if uuid_obj in self._db.permissions:
return self._db.permissions[uuid_obj].display_name
return None
def _format_value(
value: Any,
max_len: int = 60,
resolver: UuidResolver | None = None,
) -> str:
"""Format a value for display, truncating if needed.
If resolver is provided, UUIDs are replaced with display names or short suffixes.
"""
if value is None:
return "null"
@@ -49,6 +164,9 @@ def _format_value(value: Any, use_color: bool, max_len: int = 60) -> str:
return str(value)
if isinstance(value, str):
# Check if it's a UUID and resolve to display name
if resolver and _is_uuid(value):
return resolver.resolve(value)
# Filter out control characters and bidirectional overrides
value = _UNSAFE_CHARS.sub("", value)
# Truncate long strings
@@ -59,18 +177,24 @@ def _format_value(value: Any, use_color: bool, max_len: int = 60) -> str:
if isinstance(value, dict):
if not value:
return "{}"
# For small dicts, show inline
if len(value) == 1:
k, v = next(iter(value.items()))
return "{" + f"{k}: {_format_value(v, use_color, max_len=30)}" + "}"
return f"{{...{len(value)} keys}}"
# Check if all values are True - render as set-like {key1, key2}
all_true = all(v is True for v in value.values())
parts = []
for k, v in value.items():
# Replace UUID keys with display names
key_display = resolver.resolve(k) if resolver and _is_uuid(k) else k
if all_true:
parts.append(key_display)
else:
val_display = _format_value(v, max_len=30, resolver=resolver)
parts.append(f"{key_display}: {val_display}")
return "{" + ", ".join(parts) + "}"
if isinstance(value, list):
if not value:
return "[]"
if len(value) == 1:
return "[" + _format_value(value[0], use_color, max_len=30) + "]"
return f"[...{len(value)} items]"
parts = [_format_value(v, max_len=30, resolver=resolver) for v in value]
return "[" + ", ".join(parts) + "]"
# Fallback for other types
text = str(value)
@@ -79,12 +203,18 @@ def _format_value(value: Any, use_color: bool, max_len: int = 60) -> str:
return text
def _format_path(path: list[str], use_color: bool) -> str:
"""Format a path as dot notation with prefix in dark grey, final in default."""
def _format_path(path: list[str], resolver: UuidResolver | None = None) -> str:
"""Format a path as dot notation with prefix in dark grey, final in default.
If resolver is provided, UUIDs in the path are replaced with display names.
"""
if not path:
return ""
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:
return f"{_PATH_FINAL}{path[0]}{_RESET}"
prefix = ".".join(path[:-1])
@@ -176,16 +306,35 @@ def _collect_changes(
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]:
"""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 not use_color:
return [f" {'.'.join(path)}"]
if len(path) == 1:
return [f" {_DELETE}{path[0]}{_RESET}"]
prefix = ".".join(path[:-1])
final = path[-1]
if len(formatted_path) == 1:
return [f" {_DELETE}{formatted_path[0]}{_RESET}"]
prefix = ".".join(formatted_path[:-1])
final = formatted_path[-1]
return [f" {_PATH_PREFIX}{prefix}.{_RESET}{_DELETE}{final}{_RESET}"]
if change_type == "add":
@@ -194,87 +343,83 @@ def _format_change_lines(
if isinstance(value, dict) and value:
lines = []
# First line: path with green final element and grey =
if not use_color:
lines.append(f" {'.'.join(path)} =")
elif len(path) == 1:
lines.append(f" {_ADD}{path[0]}{_RESET} {_DIM}={_RESET}")
if len(formatted_path) == 1:
lines.append(f" {_ADD}{formatted_path[0]}{_RESET} {_SEP}={_RESET}")
else:
prefix = ".".join(path[:-1])
final = path[-1]
prefix = ".".join(formatted_path[:-1])
final = formatted_path[-1]
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
max_key_len = max(len(k) for k in value.keys())
field_width = max(max_key_len, 12) # minimum 12 chars
# Format keys (may contain UUIDs)
formatted_items = []
for k, v in value.items():
v_str = _format_value(v, use_color)
padding = " " * (field_width - len(k))
if use_color:
lines.append(f" {k}{_DIM}:{_RESET}{padding} {v_str}")
else:
lines.append(f" {k}:{padding} {v_str}")
k_display = resolver.resolve(k) if resolver and _is_uuid(k) else k
v_str = fmt_value(v, path + [k])
formatted_items.append((k_display, v_str))
max_key_len = max(len(k) for k, _ in formatted_items)
field_width = max(max_key_len, 12) # minimum 12 chars
for k_display, v_str in formatted_items:
padding = " " * (field_width - len(k_display))
lines.append(f" {k_display}{_SEP}:{_RESET}{padding} {v_str}")
return lines
else:
value_str = _format_value(value, use_color)
if not use_color:
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]
value_str = fmt_value(value, path)
if len(formatted_path) == 1:
return [
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} {_DIM}={_RESET} {value_str}"
f" {_PATH_PREFIX}{prefix}.{_RESET}{_ADD}{final}{_RESET} {_SEP}={_RESET} {value_str}"
]
# update: Existing item being updated - normal path colors
value_str = _format_value(value, use_color)
path_str = _format_path(path, use_color)
if use_color:
return [f" {path_str} {_DIM}={_RESET} {value_str}"]
return [f" {path_str} = {value_str}"]
value_str = fmt_value(value, path)
path_str = _format_path(path, resolver=resolver)
return [f" {path_str} {_SEP}={_RESET} {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.
Args:
diff: The JSON diff dict
previous: The previous state dict (for determining add vs update)
db: Optional database for looking up display names
Returns a list of formatted lines (without newlines).
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]] = []
_collect_changes(diff, [], changes, previous)
if not changes:
return []
# Create resolver for UUID replacement (uses previous state for lookups)
resolver = UuidResolver(db, previous)
# Format each change
lines = []
for change_type, path, value in changes:
lines.extend(_format_change_lines(change_type, path, value, use_color))
lines.extend(_format_change_lines(change_type, path, value, resolver))
return lines
def format_action_header(action: str, user_display: str | None = None) -> str:
"""Format the action header line."""
use_color = _use_color()
if use_color:
action_str = f"{_ACTION}{action}{_RESET}"
if user_display:
user_str = f"{_USER}{user_display}{_RESET}"
return f"{action_str} by {user_str}"
return action_str
else:
if user_display:
return f"{action} by {user_display}"
return action
action_str = f"{_ACTION}{action}{_RESET}"
if user_display:
user_str = f"{_USER}{user_display}{_RESET}"
return f"{action_str} by {user_str}"
return action_str
def log_change(
@@ -282,18 +427,23 @@ def log_change(
diff: dict,
user_display: str | None = None,
previous: dict | None = None,
db: "DB | None" = None,
) -> None:
"""
Log a database change with pretty-printed diff.
UUIDs are replaced with display names for readability. For types without
display names, the full UUID string is used.
Args:
action: The action name (e.g., "login", "admin:delete_user")
diff: The JSON diff dict
user_display: Optional display name of the user who performed the action
previous: The previous state dict (for determining add vs update)
db: Optional database for looking up display names
"""
header = format_action_header(action, user_display)
diff_lines = format_diff(diff, previous)
diff_lines = format_diff(diff, previous, db)
if not diff_lines:
logger.info(header)
@@ -316,3 +466,5 @@ def configure_db_logging() -> None:
logger.addHandler(handler)
logger.setLevel(logging.INFO)
logger.propagate = False
# Kanta logs changes through its own logger; wire it to the same output.
configure_kanta_logging()
+37 -17
View File
@@ -5,29 +5,49 @@ Migrations are applied during database load based on the version field.
Each migration should be idempotent and only run when needed.
"""
from collections.abc import Awaitable, Callable
import base64
from paskia.util.crypto import secret_key
def migrate_v1(d: dict) -> None:
class MigrationCtx:
"""Context passed to each migration function."""
def __init__(self, rp_id: str):
self.rp_id = rp_id
def migrate_v1(d: dict, ctx: MigrationCtx) -> None:
"""Remove Org.created_at fields."""
for org_data in d["orgs"].values():
org_data.pop("created_at", None)
migrations = sorted(
[f for n, f in globals().items() if n.startswith("migrate_v")],
key=lambda f: int(f.__name__.removeprefix("migrate_v")),
)
DBVER = len(migrations) # Used by bootstrap and migrate:sql to set initial version
def migrate_v2(d: dict, ctx: MigrationCtx) -> None:
"""Add config field if missing."""
if "config" not in d:
d["config"] = {"rp_id": ctx.rp_id}
async def apply_all_migrations(
data_dict: dict,
current_version: int,
persist: Callable[[str, int, dict], Awaitable[None]],
) -> None:
while current_version < DBVER:
migrations[current_version](data_dict)
current_version += 1
await persist(f"migrate:v{current_version}", current_version, data_dict)
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(),
}
def migrate_v5(d: dict, ctx: MigrationCtx) -> None:
"""Convert config.listen from str to list[str] if needed."""
listen = d["config"].get("listen")
if listen and isinstance(listen, str):
d["config"]["listen"] = [listen]
+285 -334
View File
@@ -6,22 +6,20 @@ Context lookup: _db.session_ctx() returns full SessionContext with effective per
Write operations: Functions that validate and commit, or raise ValueError.
"""
import hashlib
import logging
import os
import secrets
from datetime import UTC, datetime
from datetime import UTC, datetime, timedelta
from uuid import UUID
import uuid7
from kanta import Kanta
from paskia import oidc_notify
from paskia.config import SESSION_LIFETIME
from paskia.db.jsonl import (
DB_PATH_DEFAULT,
JsonlStore,
)
from paskia.db.structs import (
DB,
Client,
Config,
Credential,
Org,
Permission,
@@ -31,94 +29,28 @@ from paskia.db.structs import (
SessionContext,
User,
)
from paskia.util.passphrase import generate as generate_passphrase
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__)
# Sentinel for distinguishing "not provided" from None
_UNSET = object()
# Global database instance (empty until init() loads data)
_db = DB()
_store = JsonlStore(_db)
_db._store = _store
_initialized = False
_db = DB(config=Config(rp_id="uninitialized.invalid"))
_store: Kanta[DB] | None = None
async def init(*args, **kwargs):
"""Load database from JSONL file."""
global _db, _initialized
if _initialized:
_logger.debug("Database already initialized, skipping reload")
return
db_path = os.environ.get("PASKIA_DB", DB_PATH_DEFAULT)
if db_path.startswith("json:"):
db_path = db_path[5:]
await _store.load(db_path)
_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:
- update_user_role_in_organization: org only
- 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
"""
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)
def is_username_taken(username: str, exclude_uuid: UUID | None = None) -> bool:
"""Check if a preferred_username is already taken by another user."""
if not username:
return False
for user in _db.users.values():
if user.preferred_username == username:
if exclude_uuid is None or user.uuid != exclude_uuid:
return True
return False
# -------------------------------------------------------------------------
@@ -126,12 +58,18 @@ def get_reset_token(passphrase: str) -> ResetToken | None:
# -------------------------------------------------------------------------
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:
"""Create a new permission."""
if perm.uuid in _db.permissions:
raise ValueError(f"Permission {perm.uuid} already exists")
with _db.transaction("admin:create_permission", ctx):
_db.permissions[perm.uuid] = perm
perm.store()
def update_permission(
@@ -159,10 +97,7 @@ def delete_permission(uuid: UUID, *, ctx: SessionContext | None = None) -> None:
if uuid not in _db.permissions:
raise ValueError(f"Permission {uuid} not found")
with _db.transaction("admin:delete_permission", ctx):
# Remove this permission from all roles
for role in _db.roles.values():
role.permissions.pop(uuid, None)
del _db.permissions[uuid]
_db.permissions[uuid].delete()
def create_org(org: Org, *, ctx: SessionContext | None = None) -> None:
@@ -172,13 +107,14 @@ def create_org(org: Org, *, ctx: SessionContext | None = None) -> None:
"""
if org.uuid in _db.orgs:
raise ValueError(f"Organization {org.uuid} already exists")
now = datetime.now(UTC)
with _db.transaction("admin:create_org", ctx):
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
_db.orgs[org.uuid] = new_org
new_org.store()
# 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
org_admin_perm_uuid = None
for pid, p in _db.permissions.items():
@@ -192,7 +128,7 @@ def create_org(org: Org, *, ctx: SessionContext | None = None) -> None:
permissions=role_permissions,
)
admin_role.uuid = admin_role_uuid
_db.roles[admin_role_uuid] = admin_role
admin_role.store()
def update_org_name(
@@ -213,16 +149,7 @@ def delete_org(uuid: UUID, *, ctx: SessionContext | None = None) -> None:
if uuid not in _db.orgs:
raise ValueError(f"Organization {uuid} not found")
with _db.transaction("admin:delete_org", ctx):
org = _db.orgs[uuid]
# 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]
_db.orgs[uuid].delete()
def add_permission_to_org(
@@ -266,7 +193,7 @@ def create_role(role: Role, *, ctx: SessionContext | None = None) -> None:
if role.org_uuid not in _db.orgs:
raise ValueError(f"Organization {role.org_uuid} not found")
with _db.transaction("admin:create_role", ctx):
_db.roles[role.uuid] = role
role.store()
def update_role_name(
@@ -319,7 +246,7 @@ def delete_role(uuid: UUID, *, ctx: SessionContext | None = None) -> None:
if role.users:
raise ValueError(f"Cannot delete role {uuid}: users still assigned")
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:
@@ -329,7 +256,7 @@ def create_user(new_user: User, *, ctx: SessionContext | None = None) -> None:
if new_user.role_uuid not in _db.roles:
raise ValueError(f"Role {new_user.role_uuid} not found")
with _db.transaction("admin:create_user", ctx):
_db.users[new_user.uuid] = new_user
new_user.store()
def update_user_display_name(
@@ -343,13 +270,103 @@ def update_user_display_name(
The acting user should be logged via ctx.
For self-service (user updating own name), pass user'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):
uuid = UUID(uuid)
if uuid not in _db.users:
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):
_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_info(
uuid: UUID,
*,
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,
) -> None:
"""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):
uuid = UUID(uuid)
if uuid not in _db.users:
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"):
raise ValueError(f"Invalid 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(
@@ -367,45 +384,12 @@ def update_user_role(
_db.users[uuid].role_uuid = role_uuid
def update_user_role_in_organization(
user_uuid: UUID,
role_name: str,
*,
ctx: SessionContext | None = None,
) -> None:
"""Update user's role by role name within their current organization."""
if user_uuid not in _db.users:
raise ValueError(f"User {user_uuid} not found")
user = _db.users[user_uuid]
org = user.org
# Find role by name in the same org
new_role_uuid = None
for r in org.roles:
if r.display_name == role_name:
new_role_uuid = r.uuid
break
if new_role_uuid is None:
raise ValueError(f"Role '{role_name}' not found in organization")
with _db.transaction("admin:update_user_role", ctx):
_db.users[user_uuid].role_uuid = new_role_uuid
def delete_user(uuid: UUID, *, ctx: SessionContext | None = None) -> None:
"""Delete user and their credentials/sessions."""
if uuid not in _db.users:
raise ValueError(f"User {uuid} not found")
user = _db.users[uuid]
with _db.transaction("admin:delete_user", ctx):
# Delete credentials
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
for token in user.reset_tokens:
del _db.reset_tokens[token.key]
del _db.users[uuid]
_db.users[uuid].delete()
def create_credential(cred: Credential, *, ctx: SessionContext | None = None) -> None:
@@ -415,7 +399,7 @@ def create_credential(cred: Credential, *, ctx: SessionContext | None = None) ->
if cred.user_uuid not in _db.users:
raise ValueError(f"User {cred.user_uuid} not found")
with _db.transaction("create_credential", ctx):
_db.credentials[cred.uuid] = cred
cred.store()
def update_credential_sign_count(
@@ -451,41 +435,7 @@ def delete_credential(
if cred.user_uuid != user_uuid:
raise ValueError(f"Credential {uuid} does not belong to user {user_uuid}")
with _db.transaction("delete_credential", ctx):
# Delete all sessions using this credential
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
cred.delete()
def update_session(
@@ -493,7 +443,7 @@ def update_session(
host: str | None = None,
ip: str | None = None,
user_agent: str | None = None,
expiry: datetime | None = None,
validated: datetime | None = None,
*,
ctx: SessionContext | None = None,
) -> None:
@@ -508,8 +458,8 @@ def update_session(
s.ip = ip
if user_agent is not None:
s.user_agent = user_agent
if expiry is not None:
s.expiry = expiry
if validated is not None:
s.validated = validated
def set_session_host(key: str, host: str, *, ctx: SessionContext | None = None) -> None:
@@ -528,8 +478,10 @@ def delete_session(
"""
if key not in _db.sessions:
raise ValueError("Session not found")
oidc_notify.schedule_notifications([key])
with _db.transaction(action, ctx):
del _db.sessions[key]
_db.sessions[key].delete()
def delete_sessions_for_user(
@@ -544,37 +496,43 @@ def delete_sessions_for_user(
user = _db.users.get(user_uuid)
if not user:
return
keys = [s.key for s in user.sessions]
oidc_notify.schedule_notifications(keys)
with _db.transaction("admin:delete_sessions_for_user", ctx):
for sess in user.sessions:
del _db.sessions[sess.key]
sess.delete()
def create_reset_token(
passphrase: str,
user_uuid: UUID,
expiry: datetime,
token_type: str,
*,
ctx: SessionContext | None = None,
user: str | None = None,
) -> None:
"""Create a reset token from a passphrase.
) -> str:
"""Create a reset token and return the passphrase.
The acting user should be logged via ctx.
For self-service (user creating own recovery link), pass user's ctx.
For admin operations, pass admin's ctx.
For system operations (bootstrap), pass neither to log no user.
For API operations where ctx is not available but user is known, pass user.
Returns:
The passphrase to give to the user.
"""
key = _reset_key(passphrase)
if key in _db.reset_tokens:
raise ValueError("Reset token already exists")
if user_uuid not in _db.users:
raise ValueError(f"User {user_uuid} not found")
token, passphrase = ResetToken.create(
user=user_uuid, expiry=expiry, token_type=token_type
)
if token.key in _db.reset_tokens:
raise ValueError("Reset token already exists")
with _db.transaction("create_reset_token", ctx, user=user):
_db.reset_tokens[key] = ResetToken(
user_uuid=user_uuid, expiry=expiry, token_type=token_type
)
token.store()
return passphrase
def delete_reset_token(key: bytes, *, ctx: SessionContext | None = None) -> None:
@@ -582,28 +540,7 @@ def delete_reset_token(key: bytes, *, ctx: SessionContext | None = None) -> None
if key not in _db.reset_tokens:
raise ValueError("Reset token not found")
with _db.transaction("delete_reset_token", ctx):
del _db.reset_tokens[key]
# -------------------------------------------------------------------------
# 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
_db.reset_tokens[key].delete()
# -------------------------------------------------------------------------
@@ -611,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(
user_uuid: UUID,
credential_uuid: UUID,
@@ -623,7 +555,7 @@ def login(
host: str,
ip: str,
user_agent: str,
expiry: datetime,
duration: timedelta = SESSION_LIFETIME,
) -> str:
"""Update user/credential on login and create session in a single transaction.
@@ -643,25 +575,51 @@ def login(
if credential_uuid not in _db.credentials:
raise ValueError(f"Credential {credential_uuid} not found")
# Generate token and derive key
token = secrets.token_urlsafe(12)
session = Session.create(
user=user_uuid,
credential=credential_uuid,
key=hash_secret("cookie", token),
host=host,
ip=ip,
user_agent=user_agent,
expiry=expiry,
validated=now,
)
user_str = str(user_uuid)
with _db.transaction("login", user=user_str):
# Update user
_db.users[user_uuid].last_seen = now
_db.users[user_uuid].visits += 1
session.store(now)
# Update credential
_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
_db.credentials[credential_uuid].sign_count = sign_count
_db.credentials[credential_uuid].last_used = now
# Create session
_db.sessions[session.key] = session
return session.key
def create_credential_session(
@@ -685,18 +643,22 @@ def create_credential_session(
"""
now = datetime.now(UTC)
expiry = now + SESSION_LIFETIME
if user_uuid not in _db.users:
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(
user=user_uuid,
credential=credential.uuid,
key=key,
host=host,
ip=ip,
user_agent=user_agent,
expiry=expiry,
validated=now,
)
user_str = str(user_uuid)
with _db.transaction("create_credential_session", user=user_str):
@@ -704,126 +666,115 @@ def create_credential_session(
if display_name:
_db.users[user_uuid].display_name = display_name
# Create credential
_db.credentials[credential.uuid] = credential
# Align credential timestamps with transaction time
credential.created_at = now
credential.last_used = now
credential.last_verified = now
# Create session
_db.sessions[session.key] = session
# Create credential
credential.store()
# Store session and record visit
session.store(now)
# Delete reset token if provided
if reset_key:
if reset_key in _db.reset_tokens:
del _db.reset_tokens[reset_key]
return session.key
reset_token = _db.reset_tokens.get(reset_key)
if reset_token:
reset_token.delete()
return token
# -------------------------------------------------------------------------
# Bootstrap (single transaction for initial system setup)
# OIDC Provider operations
# -------------------------------------------------------------------------
def bootstrap(
org_name: str = "Organization",
admin_name: str = "Admin",
reset_passphrase: str | None = None,
reset_expiry: datetime | None = None,
) -> str:
"""Bootstrap the entire system in a single transaction.
def create_oid_client(client: Client, *, ctx: SessionContext | None = None) -> None:
"""Create a new OIDC client."""
if client.uuid in _db.oidc.clients:
raise ValueError(f"OIDC client {client.uuid} already exists")
with _db.transaction("admin:create_oid_client", ctx):
_db.oidc.clients[client.uuid] = client
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 (besides migrate).
All data is created atomically - if any step fails, nothing is written.
def update_oid_client(
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:
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)
client = _db.oidc.clients[client_uuid]
changes = {}
Returns:
The reset passphrase for admin registration.
"""
if name is not None and name != client.name:
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
for p in _db.permissions.values():
if p.scope == "auth:admin":
raise ValueError(
"System already bootstrapped (auth:admin permission exists)"
)
if not changes:
return # No changes to make
# Generate UUIDs upfront
perm_admin_uuid = uuid7.create()
perm_org_admin_uuid = uuid7.create()
org_uuid = uuid7.create()
role_uuid = uuid7.create()
user_uuid = uuid7.create()
new_logout_uri = (
backchannel_logout_uri
if backchannel_logout_uri is not _UNSET
else client.backchannel_logout_uri
)
# Generate reset token components
if reset_passphrase is None:
reset_passphrase = generate_passphrase()
if reset_expiry is None:
from paskia.authsession import reset_expires # noqa: PLC0415
reset_expiry = reset_expires()
reset_key = _reset_key(reset_passphrase)
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
with _db.transaction("admin:update_oid_client", ctx):
# Create updated client with new values
updated_client = Client(
client_secret_hash=secret_hash
if secret_hash is not None
else client.client_secret_hash,
name=name if name is not None else client.name,
redirect_uris=redirect_uris
if redirect_uris is not None
else client.redirect_uris,
backchannel_logout_uri=new_logout_uri,
)
perm_admin.uuid = perm_admin_uuid
_db.permissions[perm_admin_uuid] = perm_admin
updated_client.uuid = client.uuid
_db.oidc.clients[client_uuid] = updated_client
# Create auth:org:admin permission
perm_org_admin = Permission(
scope="auth:org:admin",
display_name="Org Admin",
orgs={org_uuid: True}, # Grant to org
def reset_oid_client_secret(
client_uuid: UUID,
new_secret_hash: bytes,
*,
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
_db.permissions[perm_org_admin_uuid] = perm_org_admin
updated.uuid = client.uuid
_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
_db.reset_tokens[reset_key] = ResetToken(
user_uuid=user_uuid,
expiry=reset_expiry,
token_type="admin bootstrap",
)
return reset_passphrase
def delete_oid_client(client_uuid: UUID, *, ctx: SessionContext | None = None) -> None:
"""Delete an OIDC client."""
if client_uuid not in _db.oidc.clients:
raise ValueError(f"OIDC client {client_uuid} not found")
with _db.transaction("admin:delete_oid_client", ctx):
del _db.oidc.clients[client_uuid]
+47
View File
@@ -0,0 +1,47 @@
from __future__ import annotations
import os
import shutil
from pathlib import Path
def db_root_path(*, rp_id: str = "localhost") -> Path:
"""Return the configured persistence root directory."""
return Path(os.environ.get("PASKIA_DB", f"{rp_id}.paskiadb"))
def db_file_path(*, rp_id: str = "localhost", create_root: bool = False) -> Path:
"""Return the JSONL database file path under the persistence root."""
root = db_root_path(rp_id=rp_id)
if root.is_file():
_migrate_legacy_db_file(root)
if create_root:
root.mkdir(parents=True, exist_ok=True)
return root / "main.db"
def users_root_path(*, rp_id: str = "localhost", create_root: bool = False) -> Path:
"""Return the filesystem root for persisted user files."""
root = db_root_path(rp_id=rp_id)
if root.is_file():
_migrate_legacy_db_file(root)
if create_root:
root.mkdir(parents=True, exist_ok=True)
return root / "users"
def _migrate_legacy_db_file(legacy_path: Path) -> None:
"""Upgrade a legacy single-file database path into a directory root."""
temp_root = legacy_path.parent / f".{legacy_path.name}.migrating"
shutil.rmtree(temp_root, ignore_errors=True)
temp_root.unlink(missing_ok=True)
temp_root.mkdir(parents=True)
legacy_path.replace(temp_root / "main.db")
temp_root.rename(legacy_path)
+303 -32
View File
@@ -1,5 +1,6 @@
from __future__ import annotations
import hashlib
import secrets
from datetime import UTC, datetime
from uuid import UUID
@@ -8,7 +9,9 @@ import msgspec
import uuid7
from paskia import db
from paskia.util.hostutil import normalize_host
from paskia.db.logging import UuidResolver
from paskia.util import passphrase as passphrase_util
from paskia.util.crypto import hash_secret
# Sentinel for uuid fields before they are set by create() or DB post init
_UUID_UNSET = UUID(int=0)
@@ -45,20 +48,36 @@ class Permission(msgspec.Struct, dict=True, omit_defaults=True):
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
def create(
cls,
scope: str,
display_name: str,
domain: str | None = None,
created_at: datetime | None = None,
) -> Permission:
"""Create a new Permission with auto-generated uuid7."""
now = created_at or datetime.now(UTC)
perm = cls(
scope=scope,
display_name=display_name,
domain=domain,
)
perm.uuid = uuid7.create()
perm.uuid = uuid7.create(now)
return perm
@@ -81,11 +100,30 @@ class Org(msgspec.Struct, dict=True):
"""Get all permissions that this organization can grant."""
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
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."""
now = created_at or datetime.now(UTC)
org = cls(display_name=display_name)
org.uuid = uuid7.create()
org.uuid = uuid7.create(now)
return org
@@ -129,28 +167,38 @@ class Role(msgspec.Struct, dict=True, omit_defaults=True):
"""Get all users that have this role."""
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
def create(
cls,
org: UUID | Org,
display_name: str,
permissions: set[UUID] | None = None,
created_at: datetime | None = None,
) -> Role:
"""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
role = cls(
org_uuid=org_uuid,
display_name=display_name,
permissions={p: True for p in (permissions or set())},
)
role.uuid = uuid7.create()
role.uuid = uuid7.create(now)
return role
class User(msgspec.Struct, dict=True):
class User(msgspec.Struct, dict=True, omit_defaults=True, kw_only=True):
"""User data structure.
Mutable fields: display_name, role_uuid, last_seen, visits
Mutable fields: display_name, role_uuid, last_seen, visits, theme, email, preferred_username
Immutable fields: created_at (set at creation, never modified)
uuid is derived from created_at using uuid7.
"""
@@ -158,8 +206,12 @@ class User(msgspec.Struct, dict=True):
display_name: str
role_uuid: UUID = msgspec.field(name="role")
created_at: datetime
visits: int
last_seen: datetime | None = None
visits: int = 0
theme: str = ""
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):
if not hasattr(self, "uuid"):
@@ -180,6 +232,11 @@ class User(msgspec.Struct, dict=True):
"""Get all credentials for this user."""
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
def sessions(self) -> list[Session]:
"""Get all sessions for this user."""
@@ -190,6 +247,24 @@ class User(msgspec.Struct, dict=True):
"""Get all reset tokens for this user."""
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
def create(
cls,
@@ -203,6 +278,9 @@ class User(msgspec.Struct, dict=True):
display_name=display_name,
role_uuid=role_uuid,
created_at=created_at or datetime.now(UTC),
last_seen=None,
visits=0,
theme="",
)
user.uuid = uuid7.create(user.created_at)
return user
@@ -241,6 +319,20 @@ class Credential(msgspec.Struct, dict=True):
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
def create(
cls,
@@ -268,12 +360,17 @@ class Credential(msgspec.Struct, dict=True):
return cred
class Session(msgspec.Struct, dict=True):
class Session(msgspec.Struct, dict=True, omit_defaults=True):
"""Session data structure.
Mutable fields: expiry (updated on session refresh)
Immutable fields: user_uuid, credential_uuid, host, ip, user_agent
key is stored in the dict key, not in the struct.
Mutable fields: validated (updated on session refresh)
Immutable fields: user_uuid, credential_uuid, host, ip, user_agent, client_uuid
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")
@@ -281,7 +378,8 @@ class Session(msgspec.Struct, dict=True):
host: str
ip: str
user_agent: str
expiry: datetime
validated: datetime
client_uuid: UUID | None = msgspec.field(name="client", default=None)
def __post_init__(self):
if not hasattr(self, "key"):
@@ -302,33 +400,60 @@ class Session(msgspec.Struct, dict=True):
return {
"ip": self.ip,
"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
def create(
cls,
user: UUID | User,
credential: UUID | Credential,
key: str,
host: str,
ip: str,
user_agent: str,
expiry: datetime,
validated: datetime,
client: UUID | None = None,
) -> 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
credential_uuid = (
credential if isinstance(credential, UUID) else credential.uuid
)
session = cls(
user_uuid=user_uuid,
credential_uuid=credential_uuid,
host=host,
ip=ip,
user_agent=user_agent,
expiry=expiry,
validated=validated,
client_uuid=client,
)
session.key = secrets.token_urlsafe(12)
session.key = key
return session
@@ -345,13 +470,122 @@ class ResetToken(msgspec.Struct, dict=True):
def __post_init__(self):
if not hasattr(self, "key"):
self.key: bytes = b""
self.key: str = ""
@property
def user(self) -> User:
"""Get the User object for this reset token."""
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
def create(
cls,
user: UUID | User,
expiry: datetime,
token_type: str,
passphrase: str | None = None,
) -> tuple[ResetToken, str]:
"""Create a new ResetToken with auto-generated or provided passphrase.
Args:
user: User UUID or User object
expiry: Token expiration datetime
token_type: Type of token (e.g., "device addition", "account recovery")
passphrase: Optional passphrase to use (auto-generated if not provided)
Returns:
Tuple of (token, passphrase) where passphrase is the human-readable
code to give to the user.
"""
if passphrase is None:
passphrase = passphrase_util.generate()
key = cls.hash(passphrase)
user_uuid = user if isinstance(user, UUID) else user.uuid
token = cls(
user_uuid=user_uuid,
expiry=expiry,
token_type=token_type,
)
token.key = key
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):
session: Session
@@ -362,6 +596,21 @@ class SessionContext(msgspec.Struct):
permissions: list[Permission] = []
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."""
rp_id: str
rp_name: str | None = None
auth_host: str | None = None
origins: list[str] | None = None
listen: list[str] | None = None
# -------------------------------------------------------------------------
# Database storage structure
# -------------------------------------------------------------------------
@@ -370,13 +619,16 @@ class SessionContext(msgspec.Struct):
class DB(msgspec.Struct, dict=True, omit_defaults=False):
"""In-memory database. Access fields directly for reads."""
config: Config = msgspec.field(default_factory=lambda: Config(rp_id="localhost"))
permissions: dict[UUID, Permission] = {}
orgs: dict[UUID, Org] = {}
roles: dict[UUID, Role] = {}
users: dict[UUID, User] = {}
credentials: dict[UUID, Credential] = {}
sessions: dict[str, Session] = {}
reset_tokens: dict[bytes, ResetToken] = {}
reset_tokens: dict[str, ResetToken] = {}
# OIDC provider data
oidc: OIDC = msgspec.field(default_factory=lambda: OIDC())
def __post_init__(self):
# Store reference for persistence (not serialized)
@@ -396,30 +648,53 @@ class DB(msgspec.Struct, dict=True, omit_defaults=False):
session.key = key
for key, token in self.reset_tokens.items():
token.key = key
# OIDC
for uuid, client in self.oidc.clients.items():
client.uuid = uuid
def transaction(self, action, ctx=None, *, user=None):
"""Wrap writes in transaction. Delegates to JsonlStore."""
return self._store.transaction(action, ctx, user=user)
"""Wrap writes in transaction. Delegates to Kanta."""
user_id = str(ctx.user.uuid) if ctx else user
user_display = None
if user_id:
try:
user_uuid = UUID(user_id)
if user_uuid in self.users:
user_display = self.users[user_uuid].display_name
except (ValueError, KeyError):
user_display = user_id
previous_state = msgspec.to_builtins(self)
resolver = UuidResolver(self, previous_state).resolve
return self._store.transaction(
action, user=user_id, user_display=user_display, resolver=resolver
)
def session_ctx(
self, session_key: str, host: str | None = None
self, session_secret: str, host: str | None = None
) -> SessionContext | None:
"""Get full session context with effective permissions.
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
Returns:
SessionContext if valid, None if session not found, expired, or host mismatch
"""
key = hash_secret("cookie", session_secret)
try:
s = self.sessions[session_key]
s = self.sessions[key]
except KeyError:
return None
# OIDC sessions (client_uuid set) are not valid for cookie-based auth
if s.client_uuid is not None:
return None
# Validate host matches (sessions are always created with a host)
if s.host != host:
normalized_input = host
if s.host != normalized_input:
# Session bound to different host
return None
@@ -434,10 +709,6 @@ class DB(msgspec.Struct, dict=True, omit_defaults=False):
# Effective permissions: role's permissions that the org can grant
# Also filter by domain if host is provided
org_perm_uuids = {p.uuid for p in org.permissions}
normalized_host = normalize_host(host)
host_without_port = (
normalized_host.rsplit(":", 1)[0] if normalized_host else None
)
effective_perms = []
for perm_uuid in role.permission_set:
@@ -447,8 +718,8 @@ class DB(msgspec.Struct, dict=True, omit_defaults=False):
p = self.permissions[perm_uuid]
except KeyError:
continue
# Check domain restriction
if p.domain is not None and p.domain != host_without_port:
# Check domain restriction (normalized_input already has port stripped)
if p.domain is not None and p.domain != normalized_input:
continue
effective_perms.append(p)
-242
View File
@@ -1,242 +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, 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.background import flush
from paskia.fastapi import reset as reset_cmd
from paskia.util import startupbox
from paskia.util.hostutil import normalize_origin
DEFAULT_PORT = 4401
EPILOG = """\
Examples:
paskia # localhost:4401
paskia :8080 # All interfaces, port 8080
paskia unix:/tmp/paskia.sock
paskia reset [user] # Generate passkey reset link
"""
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 host (optionally with scheme/port) to serve the auth UI at the root,"
" e.g. auth.example.com or https://auth.example.com"
),
)
def main():
# 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,
)
# Primary argument: either host:port or "reset" subcommand
parser.add_argument(
"hostport",
nargs="?",
help=(
"Endpoint (default: localhost:4401). Forms: host[:port] | :port | "
"[ipv6][:port] | ipv6 | unix:/path.sock | 'reset' for credential reset"
),
)
parser.add_argument(
"reset_query",
nargs="?",
help="For 'reset' command: user UUID or substring of display name",
)
add_common_options(parser)
args = parser.parse_args()
# Detect "reset" subcommand (first positional is "reset")
is_reset = args.hostport == "reset"
if is_reset:
endpoints = []
else:
# Parse endpoint using fastapi_vue.hostutil
endpoints = parse_endpoint(args.hostport, 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)
devmode = bool(os.environ.get("FASTAPI_VUE_FRONTEND_URL"))
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 is_reset:
exit_code = reset_cmd.run(args.reset_query)
raise SystemExit(exit_code)
if len(endpoints) > 1:
async with asyncio.TaskGroup() as tg:
for ep in endpoints:
tg.create_task(
Server(
Config(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(
Config(app="paskia.fastapi:app", **run_kwargs, **endpoints[0])
)
await server.serve()
try:
asyncio.run(async_main())
except KeyboardInterrupt:
pass
if __name__ == "__main__":
main()
-909
View File
@@ -1,909 +0,0 @@
import logging
from uuid import UUID
from fastapi import Body, FastAPI, HTTPException, Query, Request, Response
from fastapi.responses import JSONResponse
from paskia import aaguid as aaguid_mod
from paskia import db
from paskia.authsession import EXPIRES, reset_expires
from paskia.db import Org as OrgDC
from paskia.db import Permission as PermDC
from paskia.db import Role as RoleDC
from paskia.db import User as UserDC
from paskia.fastapi import authz
from paskia.fastapi.response import MsgspecResponse
from paskia.fastapi.session import AUTH_COOKIE
from paskia.globals import passkey
from paskia.util import (
hostutil,
passphrase,
permutil,
querysafe,
vitedev,
)
from paskia.util.apistructs import ApiPermission, ApiSession, format_datetime
from paskia.util.hostutil import normalize_host
app = FastAPI(docs_url=None, redoc_url=None, openapi_url=None)
def master_admin(ctx) -> bool:
return any(p.scope == "auth:admin" for p in ctx.permissions)
def org_admin(ctx, org_uuid: UUID) -> bool:
return ctx.org.uuid == org_uuid and any(
p.scope == "auth:org:admin" for p in ctx.permissions
)
def can_manage_org(ctx, org_uuid: UUID) -> bool:
return master_admin(ctx) or org_admin(ctx, org_uuid)
@app.exception_handler(ValueError)
async def value_error_handler(_request, exc: ValueError): # pragma: no cover - simple
return JSONResponse(status_code=400, content={"detail": str(exc)})
@app.exception_handler(authz.AuthException)
async def auth_exception_handler(_request, exc: authz.AuthException):
"""Handle AuthException with auth info for UI."""
return JSONResponse(
status_code=exc.status_code,
content=await authz.auth_error_content(exc),
)
@app.exception_handler(Exception)
async def general_exception_handler(_request, exc: Exception): # pragma: no cover
logging.exception("Unhandled exception in admin app")
return JSONResponse(status_code=500, content={"detail": "Internal server error"})
@app.get("/")
async def adminapp(request: Request, auth=AUTH_COOKIE):
return Response(*await vitedev.read("/auth/admin/index.html"))
# -------------------- 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,
"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")
async def admin_create_org(
request: Request, payload: dict = Body(...), auth=AUTH_COOKIE
):
ctx = await authz.verify(
auth, ["auth:admin"], host=request.headers.get("host"), match=permutil.has_all
)
display_name = payload.get("display_name") or "New Organization"
permissions = payload.get("permissions") or []
org = OrgDC.create(display_name=display_name)
db.create_org(org, ctx=ctx)
# Grant requested permissions to the new org
for perm in permissions:
db.add_permission_to_org(str(org.uuid), perm, ctx=ctx)
return {"uuid": str(org.uuid)}
@app.patch("/orgs/{org_uuid}")
async def admin_update_org_name(
org_uuid: UUID,
request: Request,
payload: dict = Body(...),
auth=AUTH_COOKIE,
):
"""Update organization display name only."""
ctx = await authz.verify(
auth,
["auth:admin", "auth:org:admin"],
match=permutil.has_any,
host=request.headers.get("host"),
)
if not can_manage_org(ctx, org_uuid):
raise authz.AuthException(
status_code=403, detail="Insufficient permissions", mode="forbidden"
)
display_name = payload.get("display_name")
if not display_name:
raise ValueError("display_name is required")
db.update_org_name(org_uuid, display_name, ctx=ctx)
return {"status": "ok"}
@app.delete("/orgs/{org_uuid}")
async def admin_delete_org(org_uuid: UUID, request: Request, auth=AUTH_COOKIE):
ctx = await authz.verify(
auth,
["auth:admin", "auth:org:admin"],
match=permutil.has_any,
host=request.headers.get("host"),
max_age="5m",
)
if not can_manage_org(ctx, org_uuid):
raise authz.AuthException(
status_code=403, detail="Insufficient permissions", mode="forbidden"
)
if ctx.org.uuid == org_uuid:
raise ValueError("Cannot delete the organization you belong to")
# Delete organization-specific permissions
org_perm_pattern = f"org:{str(org_uuid).lower()}"
all_permissions = list(db.data().permissions.values())
for perm in all_permissions:
perm_scope_lower = perm.scope.lower()
# Check if permission contains "org:{uuid}" separated by colons or at boundaries
if (
f":{org_perm_pattern}:" in perm_scope_lower
or perm_scope_lower.startswith(f"{org_perm_pattern}:")
or perm_scope_lower.endswith(f":{org_perm_pattern}")
or perm_scope_lower == org_perm_pattern
):
db.delete_permission(perm.uuid, ctx=ctx)
db.delete_org(org_uuid, ctx=ctx)
return {"status": "ok"}
@app.post("/orgs/{org_uuid}/permission")
async def admin_add_org_permission(
org_uuid: UUID,
request: Request,
permission_uuid: UUID = Query(...),
auth=AUTH_COOKIE,
):
ctx = await authz.verify(
auth, ["auth:admin"], host=request.headers.get("host"), match=permutil.has_all
)
db.add_permission_to_org(org_uuid, permission_uuid, ctx=ctx)
return {"status": "ok"}
@app.delete("/orgs/{org_uuid}/permission")
async def admin_remove_org_permission(
org_uuid: UUID,
request: Request,
permission_uuid: UUID = Query(...),
auth=AUTH_COOKIE,
):
ctx = await authz.verify(
auth, ["auth:admin"], host=request.headers.get("host"), match=permutil.has_all
)
db.remove_permission_from_org(org_uuid, permission_uuid, ctx=ctx)
# Guard rail: prevent removing auth:admin from your own org if it would lock you out
perm = db.data().permissions.get(permission_uuid)
if perm and perm.scope == "auth:admin" and ctx.org.uuid == org_uuid:
# Check if any other org grants auth:admin that we're a member of
# (we only know our current org, so this effectively means we can't remove it from our own org)
raise ValueError(
"Cannot remove auth:admin from your own organization. "
"This would lock you out of admin access."
)
db.remove_permission_from_org(org_uuid, permission_uuid, ctx=ctx)
return {"status": "ok"}
# -------------------- Roles --------------------
@app.post("/orgs/{org_uuid}/roles")
async def admin_create_role(
org_uuid: UUID,
request: Request,
payload: dict = Body(...),
auth=AUTH_COOKIE,
):
ctx = await authz.verify(
auth,
["auth:admin", "auth:org:admin"],
match=permutil.has_any,
host=request.headers.get("host"),
)
if not can_manage_org(ctx, org_uuid):
raise authz.AuthException(
status_code=403, detail="Insufficient permissions", mode="forbidden"
)
display_name = payload.get("display_name") or "New Role"
perms = payload.get("permissions") or []
if org_uuid not in db.data().orgs:
raise HTTPException(status_code=404, detail="Organization not found")
org = db.data().orgs[org_uuid]
grantable = {p.uuid for p in org.permissions}
# Normalize permission IDs to UUIDs
permission_uuids: set[UUID] = set()
for pid in perms:
perm = db.data().permissions.get(UUID(pid))
if not perm:
raise ValueError(f"Permission {pid} not found")
if perm.uuid not in grantable:
raise ValueError(f"Permission not grantable by org: {pid}")
permission_uuids.add(perm.uuid)
role = RoleDC.create(
org=org_uuid,
display_name=display_name,
permissions=permission_uuids,
)
db.create_role(role, ctx=ctx)
return {"uuid": str(role.uuid)}
@app.patch("/orgs/{org_uuid}/roles/{role_uuid}")
async def admin_update_role_name(
org_uuid: UUID,
role_uuid: UUID,
request: Request,
payload: dict = Body(...),
auth=AUTH_COOKIE,
):
"""Update role display name only."""
ctx = await authz.verify(
auth,
["auth:admin", "auth:org:admin"],
match=permutil.has_any,
host=request.headers.get("host"),
)
if not can_manage_org(ctx, org_uuid):
raise authz.AuthException(
status_code=403, detail="Insufficient permissions", mode="forbidden"
)
role = db.data().roles.get(role_uuid)
if not role or role.org_uuid != org_uuid:
raise HTTPException(status_code=404, detail="Role not found in organization")
display_name = payload.get("display_name")
if not display_name:
raise ValueError("display_name is required")
db.update_role_name(role_uuid, display_name, ctx=ctx)
return {"status": "ok"}
@app.post("/orgs/{org_uuid}/roles/{role_uuid}/permissions/{permission_uuid}")
async def admin_add_role_permission(
org_uuid: UUID,
role_uuid: UUID,
permission_uuid: UUID,
request: Request,
auth=AUTH_COOKIE,
):
"""Add a permission to a role (intent-based API)."""
ctx = await authz.verify(
auth,
["auth:admin", "auth:org:admin"],
match=permutil.has_any,
host=request.headers.get("host"),
)
if not can_manage_org(ctx, org_uuid):
raise authz.AuthException(
status_code=403, detail="Insufficient permissions", mode="forbidden"
)
role = db.data().roles.get(role_uuid)
if not role or role.org_uuid != org_uuid:
raise HTTPException(status_code=404, detail="Role not found in organization")
# Verify permission exists and org can grant it
perm = db.data().permissions.get(permission_uuid)
if not perm:
raise HTTPException(status_code=404, detail="Permission not found")
if org_uuid not in perm.orgs:
raise ValueError("Permission not grantable by organization")
db.add_permission_to_role(role_uuid, permission_uuid, ctx=ctx)
return {"status": "ok"}
@app.delete("/orgs/{org_uuid}/roles/{role_uuid}/permissions/{permission_uuid}")
async def admin_remove_role_permission(
org_uuid: UUID,
role_uuid: UUID,
permission_uuid: UUID,
request: Request,
auth=AUTH_COOKIE,
):
"""Remove a permission from a role (intent-based API)."""
ctx = await authz.verify(
auth,
["auth:admin", "auth:org:admin"],
match=permutil.has_any,
host=request.headers.get("host"),
)
if not can_manage_org(ctx, org_uuid):
raise authz.AuthException(
status_code=403, detail="Insufficient permissions", mode="forbidden"
)
role = db.data().roles.get(role_uuid)
if not role or role.org_uuid != org_uuid:
raise HTTPException(status_code=404, detail="Role not found in organization")
# Sanity check: prevent admin from removing their own access
perm = db.data().permissions.get(permission_uuid)
if ctx.org.uuid == org_uuid and ctx.role.uuid == role_uuid:
if perm and perm.scope in ["auth:admin", "auth:org:admin"]:
# Check if removing this permission would leave no admin access
remaining_perms = role.permission_set - {permission_uuid}
has_admin = False
for rp_uuid in remaining_perms:
rp = db.data().permissions.get(rp_uuid)
if rp and rp.scope in ["auth:admin", "auth:org:admin"]:
has_admin = True
break
if not has_admin:
raise ValueError("Cannot remove your own admin permissions")
db.remove_permission_from_role(role_uuid, permission_uuid, ctx=ctx)
return {"status": "ok"}
@app.delete("/orgs/{org_uuid}/roles/{role_uuid}")
async def admin_delete_role(
org_uuid: UUID,
role_uuid: UUID,
request: Request,
auth=AUTH_COOKIE,
):
ctx = await authz.verify(
auth,
["auth:admin", "auth:org:admin"],
match=permutil.has_any,
host=request.headers.get("host"),
max_age="5m",
)
if not can_manage_org(ctx, org_uuid):
raise authz.AuthException(
status_code=403, detail="Insufficient permissions", mode="forbidden"
)
role = db.data().roles.get(role_uuid)
if not role or role.org_uuid != org_uuid:
raise HTTPException(status_code=404, detail="Role not found in organization")
# Sanity check: prevent admin from deleting their own role
if ctx.role.uuid == role_uuid:
raise ValueError("Cannot delete your own role")
db.delete_role(role_uuid, ctx=ctx)
return {"status": "ok"}
# -------------------- Users --------------------
@app.post("/orgs/{org_uuid}/users")
async def admin_create_user(
org_uuid: UUID,
request: Request,
payload: dict = Body(...),
auth=AUTH_COOKIE,
):
ctx = await authz.verify(
auth,
["auth:admin", "auth:org:admin"],
match=permutil.has_any,
host=request.headers.get("host"),
)
if not can_manage_org(ctx, org_uuid):
raise authz.AuthException(
status_code=403, detail="Insufficient permissions", mode="forbidden"
)
display_name = payload.get("display_name")
role_name = payload.get("role")
if not display_name or not role_name:
raise ValueError("display_name and role are required")
org = db.data().orgs[org_uuid]
role_obj = next(
(r for r in org.roles if r.display_name == role_name),
None,
)
if not role_obj:
raise ValueError("Role not found in organization")
user = UserDC.create(
display_name=display_name,
role=role_obj.uuid,
)
db.create_user(user, ctx=ctx)
return {"uuid": str(user.uuid)}
@app.patch("/orgs/{org_uuid}/users/{user_uuid}/role")
async def admin_update_user_role(
org_uuid: UUID,
user_uuid: UUID,
request: Request,
payload: dict = Body(...),
auth=AUTH_COOKIE,
):
ctx = await authz.verify(
auth,
["auth:admin", "auth:org:admin"],
match=permutil.has_any,
host=request.headers.get("host"),
)
if not can_manage_org(ctx, org_uuid):
raise authz.AuthException(
status_code=403, detail="Insufficient permissions", mode="forbidden"
)
new_role = payload.get("role")
if not new_role:
raise ValueError("role is required")
try:
user_org, _current_role = db.get_user_organization(user_uuid)
except ValueError:
raise ValueError("User not found")
if user_org.uuid != org_uuid:
raise ValueError("User does not belong to this organization")
roles = user_org.roles
if not any(r.display_name == new_role for r in roles):
raise ValueError("Role not found in organization")
# Sanity check: prevent admin from removing their own access
if ctx.user.uuid == user_uuid:
new_role_obj = next((r for r in roles if r.display_name == new_role), None)
if new_role_obj: # pragma: no branch - always true, role validated above
# Check if any permission in the new role is an admin permission
has_admin_access = False
for perm_uuid in new_role_obj.permissions:
perm = db.data().permissions.get(perm_uuid)
if perm and perm.scope in ["auth:admin", "auth:org:admin"]:
has_admin_access = True
break
if not has_admin_access:
raise ValueError(
"Cannot change your own role to one without admin permissions"
)
db.update_user_role_in_organization(user_uuid, new_role, ctx=ctx)
return {"status": "ok"}
@app.post("/orgs/{org_uuid}/users/{user_uuid}/create-link")
async def admin_create_user_registration_link(
org_uuid: UUID,
user_uuid: UUID,
request: Request,
auth=AUTH_COOKIE,
):
try:
user_org, _role_name = db.get_user_organization(user_uuid)
except ValueError:
raise HTTPException(status_code=404, detail="User not found")
if user_org.uuid != org_uuid:
raise HTTPException(status_code=404, detail="User not found in organization")
ctx = await authz.verify(
auth,
["auth:admin", "auth:org:admin"],
match=permutil.has_any,
host=request.headers.get("host"),
max_age="5m",
)
if not can_manage_org(ctx, org_uuid):
raise authz.AuthException(
status_code=403, detail="Insufficient permissions", mode="forbidden"
)
# Check if user has existing credentials
has_credentials = db.get_user_credential_ids(user_uuid)
token_type = "user registration" if not has_credentials else "account recovery"
token = passphrase.generate()
expiry = reset_expires()
db.create_reset_token(
user_uuid=user_uuid,
passphrase=token,
expiry=expiry,
token_type=token_type,
ctx=ctx,
)
url = hostutil.reset_link_url(token)
return {
"url": url,
"expires": format_datetime(expiry),
}
@app.get("/orgs/{org_uuid}/users/{user_uuid}")
async def admin_get_user_detail(
org_uuid: UUID,
user_uuid: UUID,
request: Request,
auth=AUTH_COOKIE,
):
try:
user_org, role_name = db.get_user_organization(user_uuid)
except ValueError:
raise HTTPException(status_code=404, detail="User not found")
if user_org.uuid != org_uuid:
raise HTTPException(status_code=404, detail="User not found in organization")
ctx = await authz.verify(
auth,
["auth:admin", "auth:org:admin"],
match=permutil.has_any,
host=request.headers.get("host"),
)
if not can_manage_org(ctx, org_uuid):
raise authz.AuthException(
status_code=403, detail="Insufficient permissions", mode="forbidden"
)
user = db.data().users.get(user_uuid)
normalized_host = hostutil.normalize_host(request.headers.get("host"))
return MsgspecResponse(
{
"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,
current_key=auth,
normalized_host=normalized_host,
expires_delta=EXPIRES,
)
for s in user.sessions
],
}
)
@app.patch("/orgs/{org_uuid}/users/{user_uuid}/display-name")
async def admin_update_user_display_name(
org_uuid: UUID,
user_uuid: UUID,
request: Request,
payload: dict = Body(...),
auth=AUTH_COOKIE,
):
try:
user_org, _role_name = db.get_user_organization(user_uuid)
except ValueError:
raise HTTPException(status_code=404, detail="User not found")
if user_org.uuid != org_uuid:
raise HTTPException(status_code=404, detail="User not found in organization")
ctx = await authz.verify(
auth,
["auth:admin", "auth:org:admin"],
match=permutil.has_any,
host=request.headers.get("host"),
)
if not can_manage_org(ctx, org_uuid):
raise authz.AuthException(
status_code=403, detail="Insufficient permissions", mode="forbidden"
)
new_name = (payload.get("display_name") or "").strip()
if not new_name:
raise HTTPException(status_code=400, detail="display_name required")
if len(new_name) > 64:
raise HTTPException(status_code=400, detail="display_name too long")
db.update_user_display_name(user_uuid, new_name, ctx=ctx)
return {"status": "ok"}
@app.delete("/orgs/{org_uuid}/users/{user_uuid}/credentials/{credential_uuid}")
async def admin_delete_user_credential(
org_uuid: UUID,
user_uuid: UUID,
credential_uuid: UUID,
request: Request,
auth=AUTH_COOKIE,
):
try:
user_org, _role_name = db.get_user_organization(user_uuid)
except ValueError:
raise HTTPException(status_code=404, detail="User not found")
if user_org.uuid != org_uuid:
raise HTTPException(status_code=404, detail="User not found in organization")
ctx = await authz.verify(
auth,
["auth:admin", "auth:org:admin"],
match=permutil.has_any,
host=request.headers.get("host"),
max_age="5m",
)
if not can_manage_org(ctx, org_uuid):
raise authz.AuthException(
status_code=403, detail="Insufficient permissions", mode="forbidden"
)
db.delete_credential(credential_uuid, user_uuid, ctx=ctx)
return {"status": "ok"}
@app.delete("/orgs/{org_uuid}/users/{user_uuid}/sessions/{session_id}")
async def admin_delete_user_session(
org_uuid: UUID,
user_uuid: UUID,
session_id: str,
request: Request,
auth=AUTH_COOKIE,
):
try:
user_org, _role_name = db.get_user_organization(user_uuid)
except ValueError:
raise HTTPException(status_code=404, detail="User not found")
if user_org.uuid != org_uuid:
raise HTTPException(status_code=404, detail="User not found in organization")
ctx = await authz.verify(
auth,
["auth:admin", "auth:org:admin"],
match=permutil.has_any,
host=request.headers.get("host"),
)
if not can_manage_org(ctx, org_uuid):
raise authz.AuthException(
status_code=403, detail="Insufficient permissions", mode="forbidden"
)
target_session = db.data().sessions.get(session_id)
if not target_session or target_session.user_uuid != user_uuid:
raise HTTPException(status_code=404, detail="Session not found")
db.delete_session(session_id, ctx=ctx, action="admin:delete_session")
# Check if admin terminated their own session
current_terminated = session_id == auth
return {"status": "ok", "current_session_terminated": current_terminated}
# -------------------- Permissions (global) --------------------
def _validate_permission_domain(domain: str | None) -> None:
"""Validate that domain is rp_id or a subdomain of it."""
if domain is None:
return
rp_id = passkey.instance.rp_id
if domain == rp_id or domain.endswith(f".{rp_id}"):
return
raise ValueError(f"Domain '{domain}' must be '{rp_id}' or its subdomain")
def _check_admin_lockout(
perm_uuid: str, new_domain: str | None, current_host: str | None
) -> None:
"""Check if setting domain on auth:admin would lock out the admin.
Raises ValueError if this change would result in no auth:admin permissions
being accessible from the current host.
"""
normalized_host = normalize_host(current_host)
host_without_port = normalized_host.rsplit(":", 1)[0] if normalized_host else None
# Get all auth:admin permissions
all_perms = list(db.data().permissions.values())
admin_perms = [p for p in all_perms if p.scope == "auth:admin"]
# Check if at least one auth:admin would remain accessible
for p in admin_perms:
# If this is the permission being modified, use the new domain
domain = new_domain if str(p.uuid) == perm_uuid else p.domain
# No domain restriction = accessible from anywhere
if domain is None:
return
# Domain matches current host
if host_without_port and domain == host_without_port:
return
# No accessible auth:admin permission would remain
raise ValueError(
"Cannot set this domain restriction: it would lock you out of admin access. "
"Ensure at least one auth:admin permission remains accessible from your current host."
)
def _check_admin_lockout_on_delete(perm_uuid: str, current_host: str | None) -> None:
"""Check if deleting an auth:admin permission would lock out the admin.
Raises ValueError if this deletion would result in no auth:admin permissions
being accessible from the current host.
"""
normalized_host = normalize_host(current_host)
host_without_port = normalized_host.rsplit(":", 1)[0] if normalized_host else None
# Get all auth:admin permissions
all_perms = list(db.data().permissions.values())
admin_perms = [p for p in all_perms if p.scope == "auth:admin"]
# Check if at least one auth:admin would remain accessible after deletion
for p in admin_perms:
# Skip the permission being deleted
if str(p.uuid) == perm_uuid:
continue
# No domain restriction = accessible from anywhere
if p.domain is None:
return
# Domain matches current host
if host_without_port and p.domain == host_without_port:
return
# No accessible auth:admin permission would remain
raise ValueError(
"Cannot delete this permission: it would lock you out of admin access. "
"Ensure at least one auth:admin permission remains accessible from your current host."
)
@app.get("/permissions")
async def admin_list_permissions(request: Request, auth=AUTH_COOKIE):
ctx = await authz.verify(
auth,
["auth:admin", "auth:org:admin"],
match=permutil.has_any,
host=request.headers.get("host"),
)
perms = db.data().permissions.values() if master_admin(ctx) else ctx.org.permissions
return MsgspecResponse([ApiPermission.from_db(p) for p in perms])
@app.post("/permissions")
async def admin_create_permission(
request: Request,
payload: dict = Body(...),
auth=AUTH_COOKIE,
):
ctx = await authz.verify(
auth,
["auth:admin"],
host=request.headers.get("host"),
match=permutil.has_all,
max_age="5m",
)
scope = payload.get("scope") or payload.get(
"id"
) # Support both for backwards compat
display_name = payload.get("display_name")
domain = payload.get("domain") or None # Treat empty string as None
if not scope or not display_name:
raise ValueError("scope and display_name are required")
querysafe.assert_safe(scope, field="scope")
_validate_permission_domain(domain)
db.create_permission(
PermDC.create(scope=scope, display_name=display_name, domain=domain),
ctx=ctx,
)
return {"status": "ok"}
@app.patch("/permission")
async def admin_update_permission(
request: Request,
auth=AUTH_COOKIE,
permission_uuid: UUID = Query(...),
display_name: str | None = Query(None),
scope: str | None = Query(None),
domain: str | None = Query(None),
):
ctx = await authz.verify(
auth, ["auth:admin"], host=request.headers.get("host"), match=permutil.has_all
)
# Get existing permission
perm = db.data().permissions.get(permission_uuid)
# Update fields that were provided
new_scope = scope if scope is not None else perm.scope
new_display_name = display_name if display_name is not None else perm.display_name
domain_value = domain if domain else None
# Sanity check: prevent changing the auth:admin permission scope
if perm.scope == "auth:admin" and new_scope != "auth:admin":
raise ValueError("Cannot rename the master admin permission")
if not new_display_name:
raise ValueError("display_name is required")
querysafe.assert_safe(new_scope, field="scope")
_validate_permission_domain(domain_value)
# Safety check: prevent admin lockout when setting domain on auth:admin
if perm.scope == "auth:admin" or new_scope == "auth:admin":
_check_admin_lockout(str(perm.uuid), domain_value, request.headers.get("host"))
db.update_permission(
uuid=perm.uuid,
scope=new_scope,
display_name=new_display_name,
domain=domain_value,
ctx=ctx,
)
return {"status": "ok"}
@app.delete("/permission")
async def admin_delete_permission(
request: Request,
permission_uuid: UUID = Query(...),
auth=AUTH_COOKIE,
):
ctx = await authz.verify(
auth,
["auth:admin"],
host=request.headers.get("host"),
match=permutil.has_all,
max_age="5m",
)
# Get the permission to check its scope
perm = db.data().permissions.get(permission_uuid)
# Sanity check: prevent deleting critical permissions if it would lock out admin
if perm.scope == "auth:admin":
_check_admin_lockout_on_delete(str(perm.uuid), request.headers.get("host"))
db.delete_permission(permission_uuid, ctx=ctx)
return {"status": "ok"}
+3
View File
@@ -0,0 +1,3 @@
from paskia.fastapi.admin.adminapp import app
__all__ = ["app"]
+122
View File
@@ -0,0 +1,122 @@
from uuid import UUID
from fastapi import FastAPI, Request
from paskia import db
from paskia.fastapi import authz
from paskia.fastapi.admin import (
oidc_clients,
orgs,
permissions,
roles,
server_config,
users,
)
from paskia.fastapi.admin.errors import install_error_handlers
from paskia.fastapi.front import frontend
from paskia.fastapi.response import MsgspecResponse
from paskia.fastapi.session import AUTH_COOKIE
from paskia.util import (
avatar,
permutil,
vitedev,
)
from paskia.util.apistructs import (
ApiAdminInfo,
ApiOidcClient,
ApiOrg,
ApiOrgResponse,
ApiPermission,
ApiUser,
)
app = FastAPI(docs_url=None, redoc_url=None, openapi_url=None)
install_error_handlers(app)
app.mount("/oidc-clients", oidc_clients.app)
app.mount("/orgs", orgs.app)
app.mount("/roles", roles.app)
app.mount("/users", users.app)
app.mount("/permissions", permissions.app)
app.mount("/server-config", server_config.app)
def master_admin(ctx) -> bool:
return any(p.scope == "auth:admin" for p in ctx.permissions)
def org_admin(ctx, org_uuid: UUID) -> bool:
return ctx.org.uuid == org_uuid and any(
p.scope == "auth:org:admin" for p in ctx.permissions
)
def can_manage_org(ctx, org_uuid: UUID) -> bool:
return master_admin(ctx) or org_admin(ctx, org_uuid)
@app.get("/")
async def adminapp(request: Request, auth=AUTH_COOKIE):
return await vitedev.handle(request, frontend, "/auth/admin/")
@app.get("/info")
async def admin_info(request: Request, auth=AUTH_COOKIE):
ctx = await authz.verify(
auth,
["auth:admin", "auth:org:admin"],
match=permutil.has_any,
host=request.headers.get("host"),
)
# Orgs
orgs = list(db.data().orgs.values())
if not master_admin(ctx):
# Org admins can only see their own organization
orgs = [o for o in orgs if o.uuid == ctx.org.uuid]
def org_to_dict(o):
roles = o.roles
return ApiOrgResponse(
org=ApiOrg.from_db(o),
permissions={p.uuid: p for p in o.permissions},
roles={r.uuid: r for r in roles},
users={
u.uuid: ApiUser.from_db(u, avatar_url=avatar.avatar_browser_url(u.uuid))
for r in roles
for u in r.users
},
)
orgs_dict = {o.uuid: org_to_dict(o) for o in orgs}
# Permissions
perms = db.data().permissions.values() if master_admin(ctx) else ctx.org.permissions
perms_dict = {p.uuid: ApiPermission.from_db(p) for p in perms}
# OIDC Clients (master admin only)
oidc_clients_dict = {}
if master_admin(ctx):
clients = sorted(db.data().oidc.clients.values(), key=lambda c: c.uuid)
sessions = db.data().sessions
# Count active sessions per client
client_session_counts = {}
for session in sessions.values():
if session.client_uuid:
client_session_counts[session.client_uuid] = (
client_session_counts.get(session.client_uuid, 0) + 1
)
oidc_clients_dict = {
client.uuid: ApiOidcClient.from_db(
client, client_session_counts.get(client.uuid, 0)
)
for client in clients
}
return MsgspecResponse(
ApiAdminInfo(
orgs=orgs_dict,
permissions=perms_dict,
oidc_clients=oidc_clients_dict,
)
)
+30
View File
@@ -0,0 +1,30 @@
"""Shared exception handlers for admin sub-apps."""
import logging
from fastapi import FastAPI
from fastapi.responses import JSONResponse
from paskia.fastapi import authz
def install_error_handlers(app: FastAPI) -> None:
"""Register standard exception handlers on *app*."""
@app.exception_handler(ValueError)
async def value_error_handler(_request, exc: ValueError):
return JSONResponse(status_code=400, content={"detail": str(exc)})
@app.exception_handler(authz.AuthException)
async def auth_exception_handler(_request, exc: authz.AuthException):
return JSONResponse(
status_code=exc.status_code,
content=await authz.auth_error_content(exc),
)
@app.exception_handler(Exception)
async def general_exception_handler(_request, exc: Exception): # pragma: no cover
logging.exception("Unhandled exception in admin app")
return JSONResponse(
status_code=500, content={"detail": "Internal server error"}
)
+237
View File
@@ -0,0 +1,237 @@
from uuid import UUID
from fastapi import Body, FastAPI, HTTPException, Request
from paskia import db
from paskia.db.operations import _UNSET
from paskia.db.structs import Client
from paskia.fastapi import authz
from paskia.fastapi.admin.errors import install_error_handlers
from paskia.fastapi.session import AUTH_COOKIE
from paskia.util import permutil
app = FastAPI(docs_url=None, redoc_url=None, openapi_url=None)
install_error_handlers(app)
def master_admin(ctx) -> bool:
return any(p.scope == "auth:admin" for p in ctx.permissions)
@app.post("/")
async def admin_create_oidc_client(
request: Request,
payload: dict = Body(...),
auth=AUTH_COOKIE,
):
"""Create a new OIDC client (master admin only)."""
ctx = await authz.verify(
auth,
["auth:admin"],
host=request.headers.get("host"),
match=permutil.has_all,
max_age="5m",
)
if not master_admin(ctx):
raise authz.AuthException(
status_code=403,
detail="Only master admin can manage OIDC clients",
mode="forbidden",
)
# Client ID and secret hash are generated client-side
client_id = payload.get("client_id", "").strip()
secret_hash_hex = payload.get("secret_hash", "").strip()
name = payload.get("name", "").strip()
redirect_uris = payload.get("redirect_uris", [])
backchannel_logout_uri = payload.get("backchannel_logout_uri")
if isinstance(backchannel_logout_uri, str):
backchannel_logout_uri = backchannel_logout_uri.strip() or None
if not client_id or not secret_hash_hex:
raise ValueError("client_id and secret_hash are required")
try:
client_uuid = UUID(client_id)
except (ValueError, AttributeError):
raise ValueError("client_id must be a valid UUID")
try:
secret_hash = bytes.fromhex(secret_hash_hex)
except ValueError:
raise ValueError("secret_hash must be a hex-encoded SHA-256 hash")
if len(secret_hash) != 32:
raise ValueError("secret_hash must be a SHA-256 hash (32 bytes)")
if not isinstance(redirect_uris, list):
raise ValueError("redirect_uris must be a list")
# Validate redirect URIs
for uri in redirect_uris:
if not isinstance(uri, str) or not uri.startswith("http"):
raise ValueError(f"Invalid redirect URI: {uri}")
if backchannel_logout_uri and not backchannel_logout_uri.startswith("http"):
raise ValueError("backchannel_logout_uri must be an HTTP(S) URL")
client = Client(
client_secret_hash=secret_hash,
name=name,
redirect_uris=redirect_uris,
backchannel_logout_uri=backchannel_logout_uri,
)
client.uuid = client_uuid
db.create_oid_client(client, ctx=ctx)
return {"status": "ok", "client_id": str(client.uuid)}
@app.patch("/{client_uuid}")
async def admin_update_oidc_client(
client_uuid: UUID,
request: Request,
payload: dict = Body(...),
auth=AUTH_COOKIE,
):
"""Update an OIDC client's name and redirect URIs (master admin only)."""
ctx = await authz.verify(
auth,
["auth:admin"],
host=request.headers.get("host"),
match=permutil.has_all,
max_age="5m",
)
if not master_admin(ctx):
raise authz.AuthException(
status_code=403,
detail="Only master admin can manage OIDC clients",
mode="forbidden",
)
name = payload.get("name", "").strip() if "name" in payload else None
redirect_uris = payload.get("redirect_uris") if "redirect_uris" in payload else None
secret_hash_hex = (
payload.get("secret_hash", "").strip() if "secret_hash" in payload else None
)
backchannel_logout_uri = (
payload.get("backchannel_logout_uri")
if "backchannel_logout_uri" in payload
else _UNSET
)
if isinstance(backchannel_logout_uri, str):
backchannel_logout_uri = backchannel_logout_uri.strip() or None
if name is not None and not name:
raise ValueError("Client name cannot be empty")
if redirect_uris is not None:
if not isinstance(redirect_uris, list):
raise ValueError("redirect_uris must be a list")
# Validate redirect URIs
for uri in redirect_uris:
if not isinstance(uri, str) or not uri.startswith("http"):
raise ValueError(f"Invalid redirect URI: {uri}")
if (
backchannel_logout_uri is not _UNSET
and backchannel_logout_uri
and not backchannel_logout_uri.startswith("http")
):
raise ValueError("backchannel_logout_uri must be an HTTP(S) URL")
secret_hash = None
if secret_hash_hex:
try:
secret_hash = bytes.fromhex(secret_hash_hex)
except ValueError:
raise ValueError("secret_hash must be a hex-encoded SHA-256 hash")
if len(secret_hash) != 32:
raise ValueError("secret_hash must be a SHA-256 hash (32 bytes)")
try:
db.update_oid_client(
client_uuid,
name=name,
redirect_uris=redirect_uris,
secret_hash=secret_hash,
backchannel_logout_uri=backchannel_logout_uri,
ctx=ctx,
)
except ValueError as e:
raise HTTPException(status_code=404, detail=str(e))
return {"status": "ok"}
@app.post("/{client_uuid}/reset-secret")
async def admin_reset_oidc_client_secret(
client_uuid: UUID,
request: Request,
payload: dict = Body(...),
auth=AUTH_COOKIE,
):
"""Reset an OIDC client's secret (master admin only).
The new secret is generated client-side; only the SHA-256 hash is sent.
"""
ctx = await authz.verify(
auth,
["auth:admin"],
host=request.headers.get("host"),
match=permutil.has_all,
max_age="5m",
)
if not master_admin(ctx):
raise authz.AuthException(
status_code=403,
detail="Only master admin can manage OIDC clients",
mode="forbidden",
)
secret_hash_hex = payload.get("secret_hash", "").strip()
if not secret_hash_hex:
raise ValueError("secret_hash is required")
try:
secret_hash = bytes.fromhex(secret_hash_hex)
except ValueError:
raise ValueError("secret_hash must be a hex-encoded SHA-256 hash")
if len(secret_hash) != 32:
raise ValueError("secret_hash must be a SHA-256 hash (32 bytes)")
try:
db.reset_oid_client_secret(client_uuid, secret_hash, ctx=ctx)
except ValueError as e:
raise HTTPException(status_code=404, detail=str(e))
return {"status": "ok"}
@app.delete("/{client_uuid}")
async def admin_delete_oidc_client(
client_uuid: UUID,
request: Request,
auth=AUTH_COOKIE,
):
"""Delete an OIDC client (master admin only)."""
ctx = await authz.verify(
auth,
["auth:admin"],
host=request.headers.get("host"),
match=permutil.has_all,
max_age="5m",
)
if not master_admin(ctx):
raise authz.AuthException(
status_code=403,
detail="Only master admin can manage OIDC clients",
mode="forbidden",
)
try:
db.delete_oid_client(client_uuid, ctx=ctx)
except ValueError as e:
raise HTTPException(status_code=404, detail=str(e))
return {"status": "ok"}
+234
View File
@@ -0,0 +1,234 @@
from uuid import UUID
from fastapi import Body, FastAPI, HTTPException, Query, Request
from paskia import db
from paskia.db import Org as OrgDC
from paskia.db import Role as RoleDC
from paskia.db import User as UserDC
from paskia.fastapi import authz
from paskia.fastapi.admin.errors import install_error_handlers
from paskia.fastapi.response import MsgspecResponse
from paskia.fastapi.session import AUTH_COOKIE
from paskia.util import permutil
from paskia.util.apistructs import ApiUuidResponse
app = FastAPI(docs_url=None, redoc_url=None, openapi_url=None)
install_error_handlers(app)
def master_admin(ctx) -> bool:
return any(p.scope == "auth:admin" for p in ctx.permissions)
def org_admin(ctx, org_uuid: UUID) -> bool:
return ctx.org.uuid == org_uuid and any(
p.scope == "auth:org:admin" for p in ctx.permissions
)
def can_manage_org(ctx, org_uuid: UUID) -> bool:
return master_admin(ctx) or org_admin(ctx, org_uuid)
@app.post("/")
async def admin_create_org(
request: Request, payload: dict = Body(...), auth=AUTH_COOKIE
):
ctx = await authz.verify(
auth, ["auth:admin"], host=request.headers.get("host"), match=permutil.has_all
)
display_name = payload.get("display_name") or "New Organization"
permissions = payload.get("permissions") or []
org = OrgDC.create(display_name=display_name)
db.create_org(org, ctx=ctx)
# Grant requested permissions to the new org
for perm in permissions:
db.add_permission_to_org(str(org.uuid), perm, ctx=ctx)
return MsgspecResponse(ApiUuidResponse(uuid=str(org.uuid)))
@app.patch("/{org_uuid}")
async def admin_update_org_name(
org_uuid: UUID,
request: Request,
payload: dict = Body(...),
auth=AUTH_COOKIE,
):
"""Update organization display name only."""
ctx = await authz.verify(
auth,
["auth:admin", "auth:org:admin"],
match=permutil.has_any,
host=request.headers.get("host"),
)
if not can_manage_org(ctx, org_uuid):
raise authz.AuthException(
status_code=403, detail="Insufficient permissions", mode="forbidden"
)
display_name = payload.get("display_name")
if not display_name:
raise ValueError("display_name is required")
db.update_org_name(org_uuid, display_name, ctx=ctx)
return {"status": "ok"}
@app.delete("/{org_uuid}")
async def admin_delete_org(org_uuid: UUID, request: Request, auth=AUTH_COOKIE):
ctx = await authz.verify(
auth,
["auth:admin", "auth:org:admin"],
match=permutil.has_any,
host=request.headers.get("host"),
max_age="5m",
)
if not can_manage_org(ctx, org_uuid):
raise authz.AuthException(
status_code=403, detail="Insufficient permissions", mode="forbidden"
)
if ctx.org.uuid == org_uuid:
raise ValueError("Cannot delete the organization you belong to")
# Delete organization-specific permissions
org_perm_pattern = f"org:{str(org_uuid).lower()}"
all_permissions = list(db.data().permissions.values())
for perm in all_permissions:
perm_scope_lower = perm.scope.lower()
# Check if permission contains "org:{uuid}" separated by colons or at boundaries
if (
f":{org_perm_pattern}:" in perm_scope_lower
or perm_scope_lower.startswith(f"{org_perm_pattern}:")
or perm_scope_lower.endswith(f":{org_perm_pattern}")
or perm_scope_lower == org_perm_pattern
):
db.delete_permission(perm.uuid, ctx=ctx)
db.delete_org(org_uuid, ctx=ctx)
return {"status": "ok"}
@app.post("/{org_uuid}/permission")
async def admin_add_org_permission(
org_uuid: UUID,
request: Request,
permission_uuid: UUID = Query(...),
auth=AUTH_COOKIE,
):
ctx = await authz.verify(
auth, ["auth:admin"], host=request.headers.get("host"), match=permutil.has_all
)
db.add_permission_to_org(org_uuid, permission_uuid, ctx=ctx)
return {"status": "ok"}
@app.delete("/{org_uuid}/permission")
async def admin_remove_org_permission(
org_uuid: UUID,
request: Request,
permission_uuid: UUID = Query(...),
auth=AUTH_COOKIE,
):
ctx = await authz.verify(
auth, ["auth:admin"], host=request.headers.get("host"), match=permutil.has_all
)
db.remove_permission_from_org(org_uuid, permission_uuid, ctx=ctx)
# Guard rail: prevent removing auth:admin from your own org if it would lock you out
perm = db.data().permissions.get(permission_uuid)
if perm and perm.scope == "auth:admin" and ctx.org.uuid == org_uuid:
# Check if any other org grants auth:admin that we're a member of
# (we only know our current org, so this effectively means we can't remove it from our own org)
raise ValueError(
"Cannot remove auth:admin from your own organization. "
"This would lock you out of admin access."
)
db.remove_permission_from_org(org_uuid, permission_uuid, ctx=ctx)
return {"status": "ok"}
@app.post("/{org_uuid}/roles")
async def admin_create_role(
org_uuid: UUID,
request: Request,
payload: dict = Body(...),
auth=AUTH_COOKIE,
):
ctx = await authz.verify(
auth,
["auth:admin", "auth:org:admin"],
match=permutil.has_any,
host=request.headers.get("host"),
)
if not can_manage_org(ctx, org_uuid):
raise authz.AuthException(
status_code=403, detail="Insufficient permissions", mode="forbidden"
)
display_name = payload.get("display_name") or "New Role"
perms = payload.get("permissions") or []
if org_uuid not in db.data().orgs:
raise HTTPException(status_code=404, detail="Organization not found")
org = db.data().orgs[org_uuid]
grantable = {p.uuid for p in org.permissions}
# Normalize permission IDs to UUIDs
permission_uuids: set[UUID] = set()
for pid in perms:
perm = db.data().permissions.get(UUID(pid))
if not perm:
raise ValueError(f"Permission {pid} not found")
if perm.uuid not in grantable:
raise ValueError(f"Permission not grantable by org: {pid}")
permission_uuids.add(perm.uuid)
role = RoleDC.create(
org=org_uuid,
display_name=display_name,
permissions=permission_uuids,
)
db.create_role(role, ctx=ctx)
return MsgspecResponse(ApiUuidResponse(uuid=str(role.uuid)))
@app.post("/{org_uuid}/users")
async def admin_create_user(
org_uuid: UUID,
request: Request,
payload: dict = Body(...),
auth=AUTH_COOKIE,
):
ctx = await authz.verify(
auth,
["auth:admin", "auth:org:admin"],
match=permutil.has_any,
host=request.headers.get("host"),
)
if not can_manage_org(ctx, org_uuid):
raise authz.AuthException(
status_code=403, detail="Insufficient permissions", mode="forbidden"
)
display_name = payload.get("display_name")
role_name = payload.get("role")
if not display_name or not role_name:
raise ValueError("display_name and role are required")
org = db.data().orgs[org_uuid]
role_obj = next(
(r for r in org.roles if r.display_name == role_name),
None,
)
if not role_obj:
raise ValueError("Role not found in organization")
user = UserDC.create(
display_name=display_name,
role=role_obj.uuid,
)
db.create_user(user, ctx=ctx)
return MsgspecResponse(ApiUuidResponse(uuid=str(user.uuid)))
+215
View File
@@ -0,0 +1,215 @@
from uuid import UUID
from fastapi import Body, FastAPI, Query, Request
from paskia import db
from paskia.db import Permission as PermDC
from paskia.fastapi import authz
from paskia.fastapi.admin.errors import install_error_handlers
from paskia.fastapi.session import AUTH_COOKIE
from paskia.globals import passkey
from paskia.util import hostutil, permutil, querysafe
app = FastAPI(docs_url=None, redoc_url=None, openapi_url=None)
install_error_handlers(app)
def _validate_permission_domain(domain: str | None) -> None:
"""Validate that domain is rp_id, a subdomain of it, or an OIDC client UUID."""
if domain is None:
return
# Allow OIDC client UUIDs (used for groups claim)
try:
client_uuid = UUID(domain)
if client_uuid in db.data().oidc.clients:
return
except ValueError:
pass
rp_id = passkey.instance.rp_id
if domain == rp_id or domain.endswith(f".{rp_id}"):
return
raise ValueError(
f"Domain '{domain}' must be '{rp_id}', its subdomain, or an OIDC client UUID"
)
def _check_admin_lockout(
perm_uuid: str, new_domain: str | None, current_host: str | None
) -> None:
"""Check if setting domain on auth:admin would lock out the admin.
Raises ValueError if this change would result in no auth:admin permissions
being accessible from the current host.
"""
normalized_host = hostutil.normalize_host(current_host)
host_without_port = normalized_host.rsplit(":", 1)[0] if normalized_host else None
# Get all auth:admin permissions
all_perms = list(db.data().permissions.values())
admin_perms = [p for p in all_perms if p.scope == "auth:admin"]
# Check if at least one auth:admin would remain accessible
for p in admin_perms:
# If this is the permission being modified, use the new domain
domain = new_domain if str(p.uuid) == perm_uuid else p.domain
# No domain restriction = accessible from anywhere
if domain is None:
return
# Check if domain matches current host
if domain == normalized_host or domain == host_without_port:
return
# Check if domain is a subdomain of current host or vice versa
if normalized_host and normalized_host.endswith(f".{domain}"):
return
if host_without_port and host_without_port.endswith(f".{domain}"):
return
raise ValueError(
f"Setting domain '{new_domain}' on auth:admin permission would lock you out of "
f"admin access from current host '{current_host}'"
)
def _check_admin_lockout_on_delete(perm_uuid: str, current_host: str | None) -> None:
"""Check if deleting an auth:admin permission would lock out the admin.
Raises ValueError if this deletion would result in no auth:admin permissions
being accessible from the current host.
"""
normalized_host = hostutil.normalize_host(current_host)
host_without_port = normalized_host.rsplit(":", 1)[0] if normalized_host else None
# Get all auth:admin permissions except the one being deleted
all_perms = list(db.data().permissions.values())
admin_perms = [
p for p in all_perms if p.scope == "auth:admin" and str(p.uuid) != perm_uuid
]
# Check if at least one auth:admin would remain accessible
for p in admin_perms:
domain = p.domain
# No domain restriction = accessible from anywhere
if domain is None:
return
# Check if domain matches current host
if domain == normalized_host or domain == host_without_port:
return
# Check if domain is a subdomain of current host or vice versa
if normalized_host and normalized_host.endswith(f".{domain}"):
return
if host_without_port and host_without_port.endswith(f".{domain}"):
return
raise ValueError(
f"Deleting this auth:admin permission would lock you out of "
f"admin access from current host '{current_host}'"
)
@app.post("/")
async def admin_create_permission(
request: Request,
payload: dict = Body(...),
auth=AUTH_COOKIE,
):
ctx = await authz.verify(
auth,
["auth:admin"],
host=request.headers.get("host"),
match=permutil.has_all,
max_age="5m",
)
scope = payload.get("scope") or payload.get(
"id"
) # Support both for backwards compat
display_name = payload.get("display_name")
domain = payload.get("domain") or None # Treat empty string as None
if not scope or not display_name:
raise ValueError("scope and display_name are required")
querysafe.assert_safe(scope, field="scope")
_validate_permission_domain(domain)
db.create_permission(
PermDC.create(scope=scope, display_name=display_name, domain=domain),
ctx=ctx,
)
return {"status": "ok"}
@app.patch("/{permission_uuid}")
async def admin_update_permission(
permission_uuid: UUID,
request: Request,
auth=AUTH_COOKIE,
display_name: str | None = Query(None),
scope: str | None = Query(None),
domain: str | None = Query(None),
):
ctx = await authz.verify(
auth, ["auth:admin"], host=request.headers.get("host"), match=permutil.has_all
)
# Get existing permission
perm = db.data().permissions.get(permission_uuid)
# Update fields that were provided
new_scope = scope if scope is not None else perm.scope
new_display_name = display_name if display_name is not None else perm.display_name
domain_value = domain if domain else None
# Sanity check: prevent changing the auth:admin permission scope
if perm.scope == "auth:admin" and new_scope != "auth:admin":
raise ValueError("Cannot rename the master admin permission")
if not new_display_name:
raise ValueError("display_name is required")
querysafe.assert_safe(new_scope, field="scope")
_validate_permission_domain(domain_value)
# Safety check: prevent admin lockout when setting domain on auth:admin
if perm.scope == "auth:admin" or new_scope == "auth:admin":
_check_admin_lockout(str(perm.uuid), domain_value, request.headers.get("host"))
db.update_permission(
uuid=perm.uuid,
scope=new_scope,
display_name=new_display_name,
domain=domain_value,
ctx=ctx,
)
return {"status": "ok"}
@app.delete("/{permission_uuid}")
async def admin_delete_permission(
permission_uuid: UUID,
request: Request,
auth=AUTH_COOKIE,
):
ctx = await authz.verify(
auth,
["auth:admin"],
host=request.headers.get("host"),
match=permutil.has_all,
max_age="5m",
)
# Get the permission to check its scope
perm = db.data().permissions.get(permission_uuid)
# Sanity check: prevent deleting critical permissions if it would lock out admin
if perm.scope == "auth:admin":
_check_admin_lockout_on_delete(str(perm.uuid), request.headers.get("host"))
db.delete_permission(permission_uuid, ctx=ctx)
return {"status": "ok"}
+160
View File
@@ -0,0 +1,160 @@
from uuid import UUID
from fastapi import Body, FastAPI, HTTPException, Request
from paskia import db
from paskia.fastapi import authz
from paskia.fastapi.admin.errors import install_error_handlers
from paskia.fastapi.session import AUTH_COOKIE
from paskia.util import permutil
app = FastAPI(docs_url=None, redoc_url=None, openapi_url=None)
install_error_handlers(app)
def master_admin(ctx) -> bool:
return any(p.scope == "auth:admin" for p in ctx.permissions)
def org_admin(ctx, org_uuid: UUID) -> bool:
return ctx.org.uuid == org_uuid and any(
p.scope == "auth:org:admin" for p in ctx.permissions
)
def can_manage_org(ctx, org_uuid: UUID) -> bool:
return master_admin(ctx) or org_admin(ctx, org_uuid)
@app.patch("/{role_uuid}")
async def admin_update_role_name(
role_uuid: UUID,
request: Request,
payload: dict = Body(...),
auth=AUTH_COOKIE,
):
"""Update role display name only."""
role = db.data().roles.get(role_uuid)
if not role:
raise HTTPException(status_code=404, detail="Role not found")
ctx = await authz.verify(
auth,
["auth:admin", "auth:org:admin"],
match=permutil.has_any,
host=request.headers.get("host"),
)
if not can_manage_org(ctx, role.org_uuid):
raise authz.AuthException(
status_code=403, detail="Insufficient permissions", mode="forbidden"
)
display_name = payload.get("display_name")
if not display_name:
raise ValueError("display_name is required")
db.update_role_name(role_uuid, display_name, ctx=ctx)
return {"status": "ok"}
@app.post("/{role_uuid}/permissions/{permission_uuid}")
async def admin_add_role_permission(
role_uuid: UUID,
permission_uuid: UUID,
request: Request,
auth=AUTH_COOKIE,
):
"""Add a permission to a role (intent-based API)."""
role = db.data().roles.get(role_uuid)
if not role:
raise HTTPException(status_code=404, detail="Role not found")
ctx = await authz.verify(
auth,
["auth:admin", "auth:org:admin"],
match=permutil.has_any,
host=request.headers.get("host"),
)
if not can_manage_org(ctx, role.org_uuid):
raise authz.AuthException(
status_code=403, detail="Insufficient permissions", mode="forbidden"
)
# Verify permission exists and org can grant it
perm = db.data().permissions.get(permission_uuid)
if not perm:
raise HTTPException(status_code=404, detail="Permission not found")
if role.org_uuid not in perm.orgs:
raise ValueError("Permission not grantable by organization")
db.add_permission_to_role(role_uuid, permission_uuid, ctx=ctx)
return {"status": "ok"}
@app.delete("/{role_uuid}/permissions/{permission_uuid}")
async def admin_remove_role_permission(
role_uuid: UUID,
permission_uuid: UUID,
request: Request,
auth=AUTH_COOKIE,
):
"""Remove a permission from a role (intent-based API)."""
role = db.data().roles.get(role_uuid)
if not role:
raise HTTPException(status_code=404, detail="Role not found")
ctx = await authz.verify(
auth,
["auth:admin", "auth:org:admin"],
match=permutil.has_any,
host=request.headers.get("host"),
)
if not can_manage_org(ctx, role.org_uuid):
raise authz.AuthException(
status_code=403, detail="Insufficient permissions", mode="forbidden"
)
# Sanity check: prevent admin from removing their own access
perm = db.data().permissions.get(permission_uuid)
if ctx.org.uuid == role.org_uuid and ctx.role.uuid == role_uuid:
if perm and perm.scope in ["auth:admin", "auth:org:admin"]:
# Check if removing this permission would leave no admin access
remaining_perms = role.permission_set - {permission_uuid}
has_admin = False
for rp_uuid in remaining_perms:
rp = db.data().permissions.get(rp_uuid)
if rp and rp.scope in ["auth:admin", "auth:org:admin"]:
has_admin = True
break
if not has_admin:
raise ValueError("Cannot remove your own admin permissions")
db.remove_permission_from_role(role_uuid, permission_uuid, ctx=ctx)
return {"status": "ok"}
@app.delete("/{role_uuid}")
async def admin_delete_role(
role_uuid: UUID,
request: Request,
auth=AUTH_COOKIE,
):
role = db.data().roles.get(role_uuid)
if not role:
raise HTTPException(status_code=404, detail="Role not found")
ctx = await authz.verify(
auth,
["auth:admin", "auth:org:admin"],
match=permutil.has_any,
host=request.headers.get("host"),
max_age="5m",
)
if not can_manage_org(ctx, role.org_uuid):
raise authz.AuthException(
status_code=403, detail="Insufficient permissions", mode="forbidden"
)
# Sanity check: prevent admin from deleting their own role
if ctx.role.uuid == role_uuid:
raise ValueError("Cannot delete your own role")
db.delete_role(role_uuid, ctx=ctx)
return {"status": "ok"}
+85
View File
@@ -0,0 +1,85 @@
from fastapi import Body, FastAPI, HTTPException, Request
from paskia import db
from paskia.db.structs import Config
from paskia.fastapi import authz
from paskia.fastapi.admin.errors import install_error_handlers
from paskia.fastapi.session import AUTH_COOKIE
from paskia.globals import passkey
from paskia.sansio import Passkey
from paskia.util import hostutil
from paskia.util.runtime import update_runtime_config
app = FastAPI(docs_url=None, redoc_url=None, openapi_url=None)
install_error_handlers(app)
@app.get("/")
async def admin_get_server_config(
request: Request,
auth=AUTH_COOKIE,
):
"""Get current server configuration (master admin only)."""
await authz.verify(auth, ["auth:admin"], host=request.headers.get("host"))
pk = passkey.instance
config = db.data().config
return {
"rp_name": pk.rp_name,
"auth_host": config.auth_host or "",
"origins": list(pk.allowed_origins) if pk.allowed_origins else [],
}
@app.patch("/")
async def admin_update_server_config(
request: Request,
payload: dict = Body(...),
auth=AUTH_COOKIE,
):
"""Update server configuration (master admin only).
Updates rp_name, auth_host, and origins in both the runtime Passkey
instance and the persisted database config.
"""
await authz.verify(
auth, ["auth:admin"], host=request.headers.get("host"), max_age="5m"
)
config = db.data().config
pk = passkey.instance
rp_name = payload.get("rp_name", "").strip() or None
auth_host = payload.get("auth_host", "").strip() or None
raw_origins = payload.get("origins", [])
origins = [
hostutil.normalize_origin(o.strip()) for o in raw_origins if o.strip()
] or None
# Normalize auth_host and origins (matching CLI startup behavior)
if auth_host:
try:
hostutil.validate_auth_host(auth_host, config.rp_id)
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
auth_host, origins = hostutil.normalize_auth_host_and_origins(auth_host, origins)
# Validate origins against the current rp_id
if origins:
for o in origins:
Passkey(rp_id=config.rp_id, origins=[o]) # validates or raises
# Update runtime Passkey instance
pk.rp_name = rp_name or config.rp_id
pk.allowed_origins = set(origins) if origins else None
# Persist to database
new_config = Config(
rp_id=config.rp_id,
rp_name=rp_name,
auth_host=auth_host,
origins=origins,
listen=config.listen,
)
db.update_config(new_config)
update_runtime_config(new_config)
return {"status": "ok"}
+318
View File
@@ -0,0 +1,318 @@
from uuid import UUID
from fastapi import Body, FastAPI, HTTPException, Request
from paskia import aaguid as aaguid_mod
from paskia import db
from paskia.authsession import reset_expires
from paskia.fastapi import authz
from paskia.fastapi.admin.errors import install_error_handlers
from paskia.fastapi.response import MsgspecResponse
from paskia.fastapi.session import AUTH_COOKIE
from paskia.util import avatar, hostutil, permutil
from paskia.util.apistructs import (
ApiAaguidInfo,
ApiCreateLinkResponse,
ApiOrg,
ApiRole,
ApiUser,
ApiUserDetail,
ApiUserSession,
)
app = FastAPI(docs_url=None, redoc_url=None, openapi_url=None)
install_error_handlers(app)
def master_admin(ctx) -> bool:
return any(p.scope == "auth:admin" for p in ctx.permissions)
def org_admin(ctx, org_uuid: UUID) -> bool:
return ctx.org.uuid == org_uuid and any(
p.scope == "auth:org:admin" for p in ctx.permissions
)
def can_manage_org(ctx, org_uuid: UUID) -> bool:
return master_admin(ctx) or org_admin(ctx, org_uuid)
@app.patch("/{user_uuid}/role")
async def admin_update_user_role(
user_uuid: UUID,
request: Request,
payload: dict = Body(...),
auth=AUTH_COOKIE,
):
try:
user = db.data().users[user_uuid]
except KeyError:
raise HTTPException(status_code=404, detail="User not found")
ctx = await authz.verify(
auth,
["auth:admin", "auth:org:admin"],
match=permutil.has_any,
host=request.headers.get("host"),
)
if not can_manage_org(ctx, user.org.uuid):
raise authz.AuthException(
status_code=403, detail="Insufficient permissions", mode="forbidden"
)
role_uuid_str = payload.get("role_uuid")
if not role_uuid_str:
raise ValueError("role_uuid is required")
try:
new_role_uuid = UUID(role_uuid_str)
except (ValueError, TypeError):
raise ValueError("Invalid role UUID")
new_role = db.data().roles.get(new_role_uuid)
if not new_role or new_role.org_uuid != user.org.uuid:
raise ValueError("Role not found in organization")
# Sanity check: prevent admin from removing their own access
if ctx.user.uuid == user_uuid:
# Check if any permission in the new role is an admin permission
has_admin_access = False
for perm_uuid in new_role.permissions:
perm = db.data().permissions.get(perm_uuid)
if perm and perm.scope in ["auth:admin", "auth:org:admin"]:
has_admin_access = True
break
if not has_admin_access:
raise ValueError(
"Cannot change your own role to one without admin permissions"
)
db.update_user_role(user_uuid, new_role_uuid, ctx=ctx)
return {"status": "ok"}
@app.post("/{user_uuid}/create-link")
async def admin_create_user_registration_link(
user_uuid: UUID,
request: Request,
auth=AUTH_COOKIE,
):
try:
user = db.data().users[user_uuid]
except KeyError:
raise HTTPException(status_code=404, detail="User not found")
ctx = await authz.verify(
auth,
["auth:admin", "auth:org:admin"],
match=permutil.has_any,
host=request.headers.get("host"),
max_age="5m",
)
if not can_manage_org(ctx, user.org.uuid):
raise authz.AuthException(
status_code=403, detail="Insufficient permissions", mode="forbidden"
)
# Check if user has existing credentials
has_credentials = db.data().users[user_uuid].credential_ids
token_type = "user registration" if not has_credentials else "account recovery"
expiry = reset_expires()
token = db.create_reset_token(
user_uuid=user_uuid,
expiry=expiry,
token_type=token_type,
ctx=ctx,
)
url = hostutil.reset_link_url(token)
return MsgspecResponse(
ApiCreateLinkResponse(
url=url,
expires=expiry,
token_type=token_type,
)
)
@app.get("/{user_uuid}")
async def admin_get_user_detail(
user_uuid: UUID,
request: Request,
auth=AUTH_COOKIE,
):
try:
user = db.data().users[user_uuid]
except KeyError:
raise HTTPException(status_code=404, detail="User not found")
ctx = await authz.verify(
auth,
["auth:admin", "auth:org:admin"],
match=permutil.has_any,
host=request.headers.get("host"),
)
if not can_manage_org(ctx, user.org.uuid):
raise authz.AuthException(
status_code=403, detail="Insufficient permissions", mode="forbidden"
)
normalized_host = hostutil.normalize_host(request.headers.get("host"))
sessions = {
s.key: ApiUserSession.from_db(
s,
current_key=ctx.session.key,
normalized_host=normalized_host,
)
for s in user.sessions
}
return MsgspecResponse(
ApiUserDetail(
user=ApiUser.from_db(user, avatar_url=avatar.avatar_browser_url(user.uuid)),
credentials={c.uuid: c for c in user.credentials},
aaguid_info={
k: ApiAaguidInfo(**v)
for k, v in aaguid_mod.filter(
c.aaguid for c in user.credentials
).items()
},
sessions=sessions,
org=ApiOrg.from_db(user.org),
role=ApiRole.from_db(user.role),
)
)
@app.patch("/{user_uuid}/info")
async def admin_update_user_info(
user_uuid: UUID,
request: Request,
payload: dict = Body(...),
auth=AUTH_COOKIE,
):
"""Update user profile info (display_name, email, preferred_username, telephone).
Pass only the fields you want to update. Use null to clear optional fields.
"""
try:
user = db.data().users[user_uuid]
except KeyError:
raise HTTPException(status_code=404, detail="User not found")
ctx = await authz.verify(
auth,
["auth:admin", "auth:org:admin"],
match=permutil.has_any,
host=request.headers.get("host"),
)
if not can_manage_org(ctx, user.org.uuid):
raise authz.AuthException(
status_code=403, detail="Insufficient permissions", mode="forbidden"
)
kwargs = {}
if "display_name" in payload:
name = (payload["display_name"] or "").strip()
if not name:
raise HTTPException(status_code=400, detail="display_name cannot be empty")
if len(name) > 64:
raise HTTPException(status_code=400, detail="display_name too long")
kwargs["display_name"] = name
if "email" in payload:
kwargs["email"] = payload["email"]
if "preferred_username" in payload:
kwargs["preferred_username"] = payload["preferred_username"]
if "telephone" in payload:
kwargs["telephone"] = payload["telephone"]
if not kwargs:
raise HTTPException(status_code=400, detail="No fields to update")
db.update_user_info(user_uuid, **kwargs, ctx=ctx)
return {"status": "ok"}
@app.delete("/{user_uuid}")
async def admin_delete_user(
user_uuid: UUID,
request: Request,
auth=AUTH_COOKIE,
):
"""Delete a user and all their credentials/sessions."""
try:
user = db.data().users[user_uuid]
except KeyError:
raise HTTPException(status_code=404, detail="User not found")
ctx = await authz.verify(
auth,
["auth:admin", "auth:org:admin"],
match=permutil.has_any,
host=request.headers.get("host"),
max_age="5m",
)
if not can_manage_org(ctx, user.org.uuid):
raise authz.AuthException(
status_code=403, detail="Insufficient permissions", mode="forbidden"
)
# Prevent admin from deleting themselves
if ctx.user.uuid == user_uuid:
raise ValueError("Cannot delete your own account")
db.delete_user(user_uuid, ctx=ctx)
return {"status": "ok"}
@app.delete("/{user_uuid}/credentials/{credential_uuid}")
async def admin_delete_user_credential(
user_uuid: UUID,
credential_uuid: UUID,
request: Request,
auth=AUTH_COOKIE,
):
try:
user = db.data().users[user_uuid]
except KeyError:
raise HTTPException(status_code=404, detail="User not found")
ctx = await authz.verify(
auth,
["auth:admin", "auth:org:admin"],
match=permutil.has_any,
host=request.headers.get("host"),
max_age="5m",
)
if not can_manage_org(ctx, user.org.uuid):
raise authz.AuthException(
status_code=403, detail="Insufficient permissions", mode="forbidden"
)
db.delete_credential(credential_uuid, user_uuid, ctx=ctx)
return {"status": "ok"}
@app.delete("/{user_uuid}/sessions/{session_id}")
async def admin_delete_user_session(
user_uuid: UUID,
session_id: str,
request: Request,
auth=AUTH_COOKIE,
):
try:
user = db.data().users[user_uuid]
except KeyError:
raise HTTPException(status_code=404, detail="User not found")
ctx = await authz.verify(
auth,
["auth:admin", "auth:org:admin"],
match=permutil.has_any,
host=request.headers.get("host"),
)
if not can_manage_org(ctx, user.org.uuid):
raise authz.AuthException(
status_code=403, detail="Insufficient permissions", mode="forbidden"
)
session_key = session_id
target_session = db.data().sessions.get(session_key)
if not target_session or target_session.user_uuid != user_uuid:
raise HTTPException(status_code=404, detail="Session not found")
db.delete_session(session_key, ctx=ctx, action="admin:delete_session")
# Check if admin terminated their own session
current_terminated = session_key == ctx.session.key
return {"status": "ok", "current_session_terminated": current_terminated}
+169 -64
View File
@@ -1,6 +1,7 @@
import logging
from contextlib import suppress
from datetime import UTC, datetime, timedelta
from uuid import UUID
from fastapi import (
Depends,
@@ -13,15 +14,27 @@ from fastapi import (
from fastapi.responses import JSONResponse
from fastapi.security import HTTPBearer
from paskia import db
from paskia.authsession import EXPIRES, expires, get_reset
from paskia import authcode, db
from paskia._version import __version__
from paskia.authsession import EXPIRES, get_reset, session_ctx
from paskia.fastapi import authz, session, user
from paskia.fastapi.response import MsgspecResponse
from paskia.fastapi.session import AUTH_COOKIE, AUTH_COOKIE_NAME
from paskia.fastapi.session import AUTH_COOKIE, AUTH_COOKIE_NAME, get_client_ip
from paskia.globals import passkey as global_passkey
from paskia.util import hostutil, htmlutil, passphrase, userinfo, vitedev
from paskia.util import hostutil, htmlutil, passphrase, permutil, userinfo
from paskia.util.apistructs import (
ApiCheckUserResponse,
ApiOrgContext,
ApiRoleContext,
ApiSessionContext,
ApiSettings,
ApiTokenInfo,
ApiUserContext,
ApiValidateResponse,
)
from paskia.util.crypto import hash_secret
bearer_auth = HTTPBearer(auto_error=True)
bearer_auth = HTTPBearer(auto_error=False)
app = FastAPI(docs_url=None, redoc_url=None, openapi_url=None)
@@ -44,6 +57,12 @@ async def http_exception_handler(_request: Request, exc: HTTPException):
_REFRESH_INTERVAL = timedelta(minutes=5)
def _set_log_extra(request: Request, *parts: str) -> None:
values = [part for part in parts if part]
if values:
request.state.log_extra = " ".join(values)
@app.exception_handler(ValueError)
async def value_error_handler(_request: Request, exc: ValueError):
return JSONResponse(status_code=400, content={"detail": str(exc)})
@@ -87,26 +106,81 @@ async def validate_token(
raise
renewed = False
if auth:
consumed = EXPIRES - (ctx.session.expiry - datetime.now(UTC))
consumed = datetime.now(UTC) - ctx.session.validated
if not timedelta(0) < consumed < _REFRESH_INTERVAL:
db.update_session(
auth,
ip=request.client.host if request.client else "",
ctx.session.key,
ip=get_client_ip(request),
user_agent=request.headers.get("user-agent") or "",
expiry=expires(),
validated=datetime.now(UTC),
ctx=ctx,
)
session.set_session_cookie(response, auth)
renewed = True
_set_log_extra(request, ctx.session.key)
return MsgspecResponse(
{
"valid": True,
"renewed": renewed,
"ctx": userinfo.build_session_context(ctx),
}
ApiValidateResponse(
valid=True,
renewed=renewed,
ctx=userinfo.build_session_context(ctx),
)
)
@app.get("/check")
async def check_user(
request: Request,
user_uuid: UUID = Query(..., alias="user"),
perm: list[str] = Query([]),
):
"""Check permissions for a user by UUID without requiring a session.
Query Params:
- user: UUID of the user to check.
- perm: repeated permission scope the user must possess (ALL required).
Returns 200 with valid=True/False and the user's effective permissions,
scoped to the requesting host (domain-restricted permissions are filtered).
Returns 404 if the user UUID does not exist.
No session cookie is read or written. Caller authentication is not required.
"""
data = db.data()
try:
u = data.users[user_uuid]
role = u.role
org = role.org
except KeyError:
raise HTTPException(status_code=404, detail="User not found")
host = hostutil.normalize_host(request.headers.get("host"))
org_perm_uuids = {p.uuid for p in org.permissions}
effective_perms = []
for perm_uuid in role.permission_set:
if perm_uuid not in org_perm_uuids:
continue
try:
p = data.permissions[perm_uuid]
except KeyError:
continue
if p.domain is not None and p.domain != host:
continue
effective_perms.append(p)
required = " ".join(perm).split()
effective_scopes = {p.scope for p in effective_perms}
valid = permutil.has_all_scopes(effective_scopes, required)
ctx = ApiSessionContext(
user=ApiUserContext(uuid=u.uuid, display_name=u.display_name, theme=u.theme),
org=ApiOrgContext(uuid=org.uuid, display_name=org.display_name),
role=ApiRoleContext(uuid=role.uuid, display_name=role.display_name),
permissions=sorted(effective_scopes),
)
return MsgspecResponse(ApiCheckUserResponse(valid=valid, ctx=ctx))
@app.get("/forward")
async def forward_authentication(
request: Request,
@@ -127,8 +201,17 @@ async def forward_authentication(
- If Accept header contains "text/html": HTML page for authentication
with data attributes for mode and other metadata.
- Otherwise: JSON response with error details and an `iframe` field
pointing to /auth/restricted/?mode=... for iframe-based authentication.
pointing to /auth/restricted/iframe#mode=... for iframe-based authentication.
"""
forwarded_method = request.headers.get("x-forwarded-method", "").strip()
forwarded_uri = request.headers.get("x-forwarded-uri", "").strip()
forwarded = (
f"{forwarded_method} {forwarded_uri}"
if forwarded_method and forwarded_uri
else ""
)
_set_log_extra(request, forwarded)
try:
ctx = await authz.verify(
auth,
@@ -136,6 +219,7 @@ async def forward_authentication(
host=request.headers.get("host"),
max_age=max_age,
)
_set_log_extra(request, forwarded, ctx.session.key)
# Build permission scopes for Remote-Groups header
role_permissions = (
{p.scope for p in ctx.permissions} if ctx.permissions else set()
@@ -150,11 +234,7 @@ async def forward_authentication(
"Remote-Role": str(ctx.role.uuid),
"Remote-Role-Name": ctx.role.display_name,
"Remote-Session-Expires": (
ctx.session.expiry.astimezone(UTC).isoformat().replace("+00:00", "Z")
if ctx.session.expiry.tzinfo
else ctx.session.expiry.replace(tzinfo=UTC)
.isoformat()
.replace("+00:00", "Z")
(ctx.session.validated + EXPIRES).isoformat().replace("+00:00", "Z")
),
"Remote-Credential": str(ctx.session.credential),
}
@@ -163,42 +243,36 @@ async def forward_authentication(
# Clear cookie only if session is invalid (not for reauth)
if e.clear_session:
session.clear_session_cookie(response)
# Check Accept header to decide response format
accept = request.headers.get("accept", "")
wants_html = "text/html" in accept
if wants_html:
# Browser request - return full-page HTML with metadata
data_attrs = {"mode": e.mode, **e.metadata}
html = (await vitedev.read("/int/forward/index.html"))[0]
html = htmlutil.patch_html_data_attrs(html, **data_attrs)
return Response(
html, status_code=e.status_code, media_type="text/html; charset=UTF-8"
)
else:
# API request - return JSON with iframe srcdoc HTML
return JSONResponse(
status_code=e.status_code,
content=await authz.auth_error_content(e),
# Browser request? - return full-page HTML with metadata patched into data attrs
if "text/html" in request.headers.get("accept", ""):
return await htmlutil.patched_html_response(
request, "/int/forward/", e.status_code, mode=e.mode, **e.metadata
)
# API request - return JSON with iframe srcdoc HTML
return JSONResponse(
status_code=e.status_code, content=await authz.auth_error_content(e)
)
@app.get("/settings")
async def get_settings():
pk = global_passkey.instance
base_path = hostutil.ui_base_path()
return {
"rp_id": pk.rp_id,
"rp_name": pk.rp_name,
"ui_base_path": base_path,
"auth_host": hostutil.dedicated_auth_host(),
"auth_site_url": hostutil.auth_site_url(),
"session_cookie": AUTH_COOKIE_NAME,
}
return MsgspecResponse(
ApiSettings(
rp_id=pk.rp_id,
rp_name=pk.rp_name,
ui_base_path=base_path,
auth_host=hostutil.dedicated_auth_host(),
auth_site_url=hostutil.auth_site_url(),
session_cookie=AUTH_COOKIE_NAME,
version=__version__,
),
headers={"Access-Control-Allow-Origin": "*", "Vary": "Origin"},
)
@app.post("/user-info")
@app.get("/user-info")
async def api_user_info(
request: Request,
response: Response,
@@ -211,16 +285,23 @@ async def api_user_info(
detail="Authentication required",
mode="login",
)
ctx = db.data().session_ctx(auth, request.headers.get("host"))
ctx = session_ctx(auth, request.headers.get("host"))
if not ctx:
raise HTTPException(401, "Session expired")
raise authz.AuthException(
status_code=401,
detail="Session expired",
mode="login",
clear_session=True,
)
_set_log_extra(request, ctx.session.key)
return MsgspecResponse(
await userinfo.build_user_info(
user_uuid=ctx.user.uuid,
auth=auth,
session_record=ctx.session,
session_key=ctx.session.key,
request_host=request.headers.get("host"),
ctx=ctx,
)
)
@@ -228,6 +309,8 @@ async def api_user_info(
@app.get("/token-info")
async def token_info(credentials=Depends(bearer_auth)):
"""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
if not passphrase.is_well_formed(token):
raise HTTPException(400, "Invalid token format")
@@ -237,10 +320,13 @@ async def token_info(credentials=Depends(bearer_auth)):
raise HTTPException(401, str(e))
u = reset_token.user
return {
"token_type": reset_token.token_type,
"display_name": u.display_name,
}
return MsgspecResponse(
ApiTokenInfo(
token_type=reset_token.token_type,
display_name=u.display_name,
theme=u.theme,
)
)
@app.post("/logout")
@@ -248,11 +334,11 @@ async def api_logout(request: Request, response: Response, auth=AUTH_COOKIE):
if not auth:
return {"message": "Already logged out"}
host = request.headers.get("host")
ctx = db.data().session_ctx(auth, host)
ctx = session_ctx(auth, host)
if not ctx:
return {"message": "Already logged out"}
with suppress(Exception):
db.delete_session(auth, ctx=ctx, action="logout")
db.delete_session(ctx.session.key, ctx=ctx, action="logout")
session.clear_session_cookie(response)
return {"message": "Logged out successfully"}
@@ -261,11 +347,30 @@ async def api_logout(request: Request, response: Response, auth=AUTH_COOKIE):
async def api_set_session(
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:
raise HTTPException(401, "Session expired")
session.set_session_cookie(response, auth.credentials)
return {
"message": "Session cookie set successfully",
"user": str(ctx.user.uuid),
}
raise HTTPException(401, f"Session not found on {host}")
_set_log_extra(request, hash_secret("cookie", secret))
session.set_session_cookie(response, secret)
return {"status": "ok", "user": str(ctx.user.uuid)}
+9 -2
View File
@@ -41,7 +41,7 @@ async def auth_error_content(exc: AuthException) -> dict:
# Build hash fragment from mode and metadata
params = {"mode": exc.mode, **exc.metadata}
fragment = "&".join(f"{k}={v}" for k, v in params.items() if v is not None)
iframe_url = f"/auth/restricted/#{fragment}"
iframe_url = f"/auth/restricted/iframe#{fragment}"
return {
"detail": exc.detail,
"auth": {
@@ -80,6 +80,9 @@ async def verify(
mode="login",
clear_session=True,
)
# User's theme preference for iframe (only if explicitly set)
user_theme = ctx.user.theme if ctx.user.theme else None
# Check max_age requirement if specified
if max_age:
try:
@@ -88,6 +91,7 @@ async def verify(
status_code=401,
detail="Additional authentication required",
mode="reauth",
theme=user_theme,
)
except ValueError as e:
# Invalid max_age format - log but don't fail the request
@@ -104,7 +108,10 @@ async def verify(
ctx, perm, missing, require_all=(match == permutil.has_all)
)
raise AuthException(
status_code=403, mode="forbidden", detail="Permission required"
status_code=403,
mode="forbidden",
detail="Permission required",
theme=user_theme,
)
return ctx
+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",
)

Some files were not shown because too many files have changed in this diff Show More