Compare commits

...
131 Commits
Author SHA1 Message Date
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
LeoVasanko 2ee8ddf1d1 Log proper action for delete_session depending on what function it performs: logout, delete_session (user's own) or admin:delete_session. 2026-01-29 22:44:22 +00:00
LeoVasanko d58b3742b1 Smarter change logging on console, properly display newly created items (not available via jsondiffs). 2026-01-29 22:39:16 +00:00
LeoVasanko 5879be39a5 Better change entry logging. 2026-01-29 22:09:26 +00:00
LeoVasanko 5b3406025c Silence type checker about our unconventional imports. 2026-01-29 22:09:03 +00:00
LeoVasanko dda57ac27d Fix WS access log IP margin size change missed in earlier commit. 2026-01-29 21:39:30 +00:00
LeoVasanko 5e12dcba76 Add paskia-js to Python build; update build-frontend to use fastapi-vue utils for JS_RUNTIME that also can be set via env now. Fix paskia-js compatibility issue with deno, now works with all three runtimes. 2026-01-29 21:33:09 +00:00
LeoVasanko be7a9e7f00 Smarter access log formatting with IPv6. 2026-01-29 21:17:50 +00:00
LeoVasanko 3d2151fed7 Add support for perm=foo+bar to specify multiple scopes that are required. Multiple perm args are acceptable too. Pretty logging of permission denied errors. 2026-01-29 21:11:29 +00:00
LeoVasanko 58b56a09a4 Restrict remote auth to the existing granting user's credentials. 2026-01-29 20:24:26 +00:00
LeoVasanko 731b36b456 Cleanup auth WS code, refactor to remove duplication and pass proper context. 2026-01-29 20:15:01 +00:00
LeoVasanko 8f9cd1124c Add missing context on update_session. 2026-01-29 19:51:56 +00:00
LeoVasanko 7e49ef296a Create a stand-alone paskia npm package (paskia-js). Make the frontend use it (but from source tree to keep synced). 2026-01-29 19:46:26 +00:00
LeoVasanko af35ff3d4c Add a screenshot. 2026-01-29 19:15:17 +00:00
LeoVasanko dac1415a86 More of a normal white path color. 2026-01-29 16:57:30 +00:00
LeoVasanko 433844cf08 Refactor to separate paskia lib functionality generally useful for various apps. 2026-01-29 16:55:46 +00:00
LeoVasanko c9ea1c8948 Consistent and stronger session revalidation checks in Auth profile and Admin App. Fix missing handling of link generation network errors. 2026-01-29 16:02:29 +00:00
LeoVasanko 58f46c6abf Logging cleanup, better colors, suppress more useless messages. Enable reloading in devmode again (needs uvicorn.run to function). 2026-01-29 14:48:59 +00:00
LeoVasanko 7c4418e631 Smarter change logging on console. 2026-01-28 23:22:03 +00:00
LeoVasanko 1648c8641f Improved access logging. 2026-01-28 23:01:35 +00:00
LeoVasanko 63eb088dbd Cleanup 2026-01-28 20:31:33 +00:00
LeoVasanko e88cc004dd Implement versioning in metadata, outside of the DB itself. Cleanup to migration handling and changes. Implement new migration step using msgspec normalization. 2026-01-28 20:21:09 +00:00
LeoVasanko 76921e8b31 Add migrate:msgspec for changes in schema that don't require version bump. Rename the other migrations to migrate:sql and migrate:v{N}. 2026-01-28 19:24:15 +00:00
LeoVasanko c1b0aab296 Cleanup 2026-01-28 19:17:24 +00:00
LeoVasanko 8f89bb6d4b Modules missing git add 2026-01-28 19:04:18 +00:00
LeoVasanko d16d1ed1c2 Make database changes outside of transaction a fatal error. Fix bootstrap and migrate to work with various latest changes. 2026-01-28 19:04:00 +00:00
LeoVasanko 2cfca81672 Fix a bug with validate returning 401 when a session was refreshed. Simplify & cleanup. 2026-01-28 18:45:02 +00:00
LeoVasanko ce300ebdaf API to use msgspec structs as well. 2026-01-28 18:31:45 +00:00
LeoVasanko 7329223784 Implement full ORM. Various other cleanup. 2026-01-28 17:19:56 +00:00
LeoVasanko c8d659b5ca Move get_session_context to DB.session_ctx(). 2026-01-28 14:52:18 +00:00
143 changed files with 10869 additions and 6054 deletions
+1 -1
View File
@@ -5,7 +5,7 @@ dist/
*.lock *.lock
package-lock.json package-lock.json
paskia.sqlite paskia.sqlite
paskia.jsonl *.paskiadb
/paskia/frontend-build /paskia/frontend-build
/paskia/_version.py /paskia/_version.py
coverage-html/ coverage-html/
+182 -21
View File
@@ -1,60 +1,221 @@
# Paskia # 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. An easy to install passkey-based authentication service that protects any web application with strong passwordless login.
## What is Paskia? ## What is Paskia?
- Easy to use fully featured auth&auth system (login and permissions) - 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 * Org admins control their users and roles
* Master admin can create multiple independent orgs * Multiple independent orgs
* Master admin makes permissions available for orgs to assign * Master admin can do everything or delegate to org admins
- User Profile and Administration by API and web interface. - User Profile and Admin by API and web interface
under `/auth/` or `auth.example.com` - Implements login/reauth/forbidden flows for you
- Reset tokens and additional device linking via QR code or codewords. - Single Sign-On (SSO): Users register once and authenticate across your services
- Pure Python, FastAPI, packaged with prebuilt Vue frontend - 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 - API fetch: auth checks and login without leaving your app
- Forward-auth proxy: protect any unprotected site or service (Caddy, Nginx) - 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. 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 ## Quick Start
Install [UV](https://docs.astral.sh/uv/getting-started/installation/) and run: Install [UV](https://docs.astral.sh/uv/getting-started/installation/) and run:
```fish ```sh
uvx paskia serve --rp-id example.com 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`: For a permanent install of `paskia` CLI command, not needing `uvx`:
```fish ```sh
uv tool install paskia uv tool install paskia
``` ```
## Configuration ## 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 ```text
paskia serve [options] paskia [options]
``` ```
| Option | Description | Default | | Option | Description | Default |
|--------|-------------|---------| |--------|-------------|---------|
| Listen address | One of *host***:***port* (default all hosts, port 4401) or **unix:***path***/paskia.socket** (Unix socket) | **localhost:4401** | | -l, --listen *endpoint* | Listen address: *host*:*port*, :*port* (all interfaces), or */path.sock* | **localhost:4401** |
| --rp-id *domain* | Main/top domain | **localhost** | | --rp-id *domain* | Main/top domain for passkeys | **localhost** |
| --rp-name *"text"* | Name of your company or site | Same as rp-id | | --rp-name *"text"* | Branding name for the entire system (passkey auth, login dialog). | Same as rp-id |
| --origin *url* | Explicitly list the domain names served | **https://**_rp-id_ | | --origin *url* | Only sites listed can login (repeatable) | rp-id and all subdomains |
| --auth-host *domain* | Dedicated authentication site (e.g., **auth.example.com**) | **Unspecified:** we use **/auth/** on **every** site under rp-id.| | --auth-host *url* | Dedicated authentication site, e.g. **auth.example.com** | Use **/auth/** path on each site |
| --save | Save current options to database | (only --rp-id required on further invocations) |
To clear a stored setting, pass an empty value like `--auth-host=`. The database is stored in `{rp-id}.paskiadb` in current directory. This can be overridden by environment `PASKIA_DB` if needed.
## 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 ## Further Documentation
+33 -1
View File
@@ -15,7 +15,7 @@ For integrating Paskia with your app frontend, see [integration](Integration.md)
| Method | Path | Used for | Notes | | Method | Path | Used for | Notes |
|---:|---|---|---| |---:|---|---|---|
| GET | `/auth/api/settings` | Paskia configuration | Returns RP info + base paths + session cookie name | | GET | `/auth/api/settings` | Paskia configuration | Returns RP info + base paths + session cookie name |
| POST | `/auth/api/user-info` | Full user profile | Basic information, credentials, sessions, permissions | | GET | `/auth/api/user-info` | Full user profile | Basic information, credentials, sessions, permissions |
| POST | `/auth/api/logout` | Terminate session and delete session cookie | Signs out of the current site | | POST | `/auth/api/logout` | Terminate session and delete session cookie | Signs out of the current site |
| POST | `/auth/api/validate` | Validate and renew session cookie | Optional query: `perm=` (repeatable), `max_age=` | | POST | `/auth/api/validate` | Validate and renew session cookie | Optional query: `perm=` (repeatable), `max_age=` |
| GET | `/auth/api/forward` | Validate access (Caddy/Nginx) | 204 on success; 401/403 otherwise (HTML if requested) | | GET | `/auth/api/forward` | Validate access (Caddy/Nginx) | 204 on success; 401/403 otherwise (HTML if requested) |
@@ -40,6 +40,38 @@ Normally only used via admin panel, requires auth admin permissions and can modi
E.g. Org admin cannot see anything of the other orgs that he has no admin access to. Master admin `auth:admin` can see everything and create and manage orgs. E.g. Org admin cannot see anything of the other orgs that he has no admin access to. Master admin `auth:admin` can see everything and create and manage orgs.
| Method | Path | Used for | Notes |
|---:|---|---|---|
| GET | `/auth/api/admin/info` | Admin overview | Returns orgs, permissions, OIDC clients info |
| POST | `/auth/api/admin/permissions/` | Create permission | Body: JSON with scope, display_name, domain |
| PATCH | `/auth/api/admin/permissions/{uuid}` | Update permission | Query params: display_name, scope, domain |
| DELETE | `/auth/api/admin/permissions/{uuid}` | Delete permission | |
| POST | `/auth/api/admin/orgs/` | Create organization | Body: JSON with display_name, permissions |
| GET | `/auth/api/admin/orgs/{uuid}` | Get organization details | |
| PATCH | `/auth/api/admin/orgs/{uuid}` | Update organization | Body: JSON with display_name |
| DELETE | `/auth/api/admin/orgs/{uuid}` | Delete organization | |
| POST | `/auth/api/admin/orgs/{uuid}/users` | Create user in org | Body: JSON with display_name, role_uuid |
| POST | `/auth/api/admin/orgs/{uuid}/roles` | Create role in org | Body: JSON with display_name, permissions |
| POST | `/auth/api/admin/orgs/{uuid}/permission` | Grant permission to org | Query param: permission_uuid |
| DELETE | `/auth/api/admin/orgs/{uuid}/permission` | Revoke permission from org | Query param: permission_uuid |
| PATCH | `/auth/api/admin/roles/{uuid}` | Update role | Body: JSON with display_name |
| POST | `/auth/api/admin/roles/{uuid}/permissions/{uuid}` | Add permission to role | |
| DELETE | `/auth/api/admin/roles/{uuid}/permissions/{uuid}` | Remove permission from role | |
| DELETE | `/auth/api/admin/roles/{uuid}` | Delete role | |
| PATCH | `/auth/api/admin/users/{uuid}/role` | Update user role | Body: JSON with role_uuid |
| PATCH | `/auth/api/admin/users/{uuid}/info` | Update user info | Body: JSON with display_name |
| GET | `/auth/api/admin/users/{uuid}` | Get user details | |
| DELETE | `/auth/api/admin/users/{uuid}` | Delete user | |
| POST | `/auth/api/admin/users/{uuid}/create-link` | Create device add link | |
| DELETE | `/auth/api/admin/users/{uuid}/credentials/{uuid}` | Delete user credential | |
| DELETE | `/auth/api/admin/users/{uuid}/sessions/{key}` | Delete user session | |
| POST | `/auth/api/admin/oidc-clients/` | Create OIDC client | Body: JSON with client_name, redirect_uris |
| PATCH | `/auth/api/admin/oidc-clients/{uuid}` | Update OIDC client | Body: JSON with client_name, redirect_uris |
| PATCH | `/auth/api/admin/oidc-clients/{uuid}/reset-secret` | Reset client secret | |
| DELETE | `/auth/api/admin/oidc-clients/{uuid}` | Delete OIDC client | |
| GET | `/auth/api/admin/server-config/` | Get server config | Returns rp_name, auth_host, origins |
| PATCH | `/auth/api/admin/server-config/` | Update server config | Body: JSON with rp_name, auth_host, origins |
### WebSockets: `/auth/ws/*` ### WebSockets: `/auth/ws/*`
| Path | Used for | Notes | | Path | Used for | Notes |
+188 -33
View File
@@ -1,43 +1,198 @@
# Integrating Paskia with your App # 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) ```html
<script type="module">
```js import { apiJson, apiFetch, SessionValidator } from 'https://cdn.jsdelivr.net/npm/paskia@latest/dist/paskia.js'
// Show an authentication dialog </script>
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)
``` ```
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.

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": { "devDependencies": {
"@playwright/test": "^1.49.0", "@playwright/test": "^1.49.0",
"@simplewebauthn/browser": "^13.1.2", "@simplewebauthn/browser": "^13.1.2",
"@types/bun": "^1.3.3", "@types/node": "*",
"c8": "^10.1.3" "c8": "^10.1.3"
} }
}, },
@@ -92,11 +92,13 @@
} }
}, },
"node_modules/@playwright/test": { "node_modules/@playwright/test": {
"version": "1.57.0", "version": "1.58.2",
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.58.2.tgz",
"integrity": "sha512-akea+6bHYBBfA9uQqSYmlJXn61cTa+jbO87xVLCWbTqbWadRVmhxlXATaOjOgcBaWU4ePo0wB41KMFv3o35IXA==",
"dev": true, "dev": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"dependencies": { "dependencies": {
"playwright": "1.57.0" "playwright": "1.58.2"
}, },
"bin": { "bin": {
"playwright": "cli.js" "playwright": "cli.js"
@@ -107,17 +109,11 @@
}, },
"node_modules/@simplewebauthn/browser": { "node_modules/@simplewebauthn/browser": {
"version": "13.2.2", "version": "13.2.2",
"resolved": "https://registry.npmjs.org/@simplewebauthn/browser/-/browser-13.2.2.tgz",
"integrity": "sha512-FNW1oLQpTJyqG5kkDg5ZsotvWgmBaC6jCHR7Ej0qUNep36Wl9tj2eZu7J5rP+uhXgHaLk+QQ3lqcw2vS5MX1IA==",
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/@types/bun": {
"version": "1.3.3",
"dev": true,
"license": "MIT",
"dependencies": {
"bun-types": "1.3.3"
}
},
"node_modules/@types/istanbul-lib-coverage": { "node_modules/@types/istanbul-lib-coverage": {
"version": "2.0.6", "version": "2.0.6",
"resolved": "https://registry.npmjs.org/@types/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.6.tgz", "resolved": "https://registry.npmjs.org/@types/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.6.tgz",
@@ -126,7 +122,9 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/@types/node": { "node_modules/@types/node": {
"version": "24.10.1", "version": "25.2.3",
"resolved": "https://registry.npmjs.org/@types/node/-/node-25.2.3.tgz",
"integrity": "sha512-m0jEgYlYz+mDJZ2+F4v8D1AyQb+QzsNqRuI7xg1VQX/KlKS0qT9r1Mo16yo5F/MtifXFgaofIFsdFMox2SxIbQ==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
@@ -176,14 +174,6 @@
"balanced-match": "^1.0.0" "balanced-match": "^1.0.0"
} }
}, },
"node_modules/bun-types": {
"version": "1.3.3",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/node": "*"
}
},
"node_modules/c8": { "node_modules/c8": {
"version": "10.1.3", "version": "10.1.3",
"resolved": "https://registry.npmjs.org/c8/-/c8-10.1.3.tgz", "resolved": "https://registry.npmjs.org/c8/-/c8-10.1.3.tgz",
@@ -412,6 +402,21 @@
"url": "https://github.com/sponsors/isaacs" "url": "https://github.com/sponsors/isaacs"
} }
}, },
"node_modules/fsevents": {
"version": "2.3.2",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}
},
"node_modules/get-caller-file": { "node_modules/get-caller-file": {
"version": "2.0.5", "version": "2.0.5",
"resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz",
@@ -426,6 +431,7 @@
"version": "10.5.0", "version": "10.5.0",
"resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz", "resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz",
"integrity": "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==", "integrity": "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==",
"deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me",
"dev": true, "dev": true,
"license": "ISC", "license": "ISC",
"dependencies": { "dependencies": {
@@ -674,11 +680,13 @@
} }
}, },
"node_modules/playwright": { "node_modules/playwright": {
"version": "1.57.0", "version": "1.58.2",
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.58.2.tgz",
"integrity": "sha512-vA30H8Nvkq/cPBnNw4Q8TWz1EJyqgpuinBcHET0YVJVFldr8JDNiU9LaWAE1KqSkRYazuaBhTpB5ZzShOezQ6A==",
"dev": true, "dev": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"dependencies": { "dependencies": {
"playwright-core": "1.57.0" "playwright-core": "1.58.2"
}, },
"bin": { "bin": {
"playwright": "cli.js" "playwright": "cli.js"
@@ -691,7 +699,9 @@
} }
}, },
"node_modules/playwright-core": { "node_modules/playwright-core": {
"version": "1.57.0", "version": "1.58.2",
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.58.2.tgz",
"integrity": "sha512-yZkEtftgwS8CsfYo7nm0KE8jsvm6i/PTgVtB8DL726wNf6H2IMsDuxCpJj59KDaxCtSnrWan2AeDqM7JBaultg==",
"dev": true, "dev": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"bin": { "bin": {
@@ -712,9 +722,9 @@
} }
}, },
"node_modules/semver": { "node_modules/semver": {
"version": "7.7.3", "version": "7.7.4",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz", "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz",
"integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==", "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==",
"dev": true, "dev": true,
"license": "ISC", "license": "ISC",
"bin": { "bin": {
@@ -894,6 +904,8 @@
}, },
"node_modules/undici-types": { "node_modules/undici-types": {
"version": "7.16.0", "version": "7.16.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz",
"integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==",
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
+8 -8
View File
@@ -5,18 +5,18 @@
"description": "E2E tests for Paskia using Playwright with Virtual Authenticator", "description": "E2E tests for Paskia using Playwright with Virtual Authenticator",
"type": "module", "type": "module",
"scripts": { "scripts": {
"test": "bunx playwright test", "test": "npx playwright test",
"test:headed": "bunx playwright test --headed", "test:headed": "npx playwright test --headed",
"test:debug": "bunx playwright test --debug", "test:debug": "npx playwright test --debug",
"test:ui": "bunx playwright test --ui", "test:ui": "npx playwright test --ui",
"test:coverage": "COVERAGE=1 bunx playwright test", "test:coverage": "COVERAGE=1 npx playwright test",
"report": "bunx playwright show-report", "report": "npx playwright show-report",
"install:browsers": "bunx playwright install chromium" "install:browsers": "npx playwright install chromium"
}, },
"devDependencies": { "devDependencies": {
"@playwright/test": "^1.49.0", "@playwright/test": "^1.49.0",
"@simplewebauthn/browser": "^13.1.2", "@simplewebauthn/browser": "^13.1.2",
"@types/bun": "^1.3.3", "@types/node": "*",
"c8": "^10.1.3" "c8": "^10.1.3"
} }
} }
+1 -1
View File
@@ -4,7 +4,7 @@ import { defineConfig, devices } from '@playwright/test'
* Playwright configuration for Paskia E2E tests. * Playwright configuration for Paskia E2E tests.
* Uses Chrome's Virtual Authenticator for automated passkey testing. * Uses Chrome's Virtual Authenticator for automated passkey testing.
* *
* Run with: bun run test * Run with: npm test
*/ */
export default defineConfig({ export default defineConfig({
+5 -5
View File
@@ -148,10 +148,10 @@ test.describe('Passkey Authentication E2E', () => {
const userInfo = await getUserInfo(page, baseUrl, sessionToken) const userInfo = await getUserInfo(page, baseUrl, sessionToken)
expect(userInfo.ctx.user.uuid).toBe(userUuid) expect(userInfo.user.uuid).toBe(userUuid)
expect(userInfo.ctx.user.display_name).toBe('Admin User') expect(userInfo.user.display_name).toBe('Admin User')
expect(userInfo.credentials).toBeDefined() expect(userInfo.credentials).toBeDefined()
expect(userInfo.credentials.length).toBeGreaterThanOrEqual(1) expect(Object.keys(userInfo.credentials).length).toBeGreaterThanOrEqual(1)
// Navigate to profile and take screenshot // Navigate to profile and take screenshot
const cookieName = getSessionCookieName() const cookieName = getSessionCookieName()
@@ -169,8 +169,8 @@ test.describe('Passkey Authentication E2E', () => {
await page.screenshot({ path: 'test-results/profile-view.png' }) await page.screenshot({ path: 'test-results/profile-view.png' })
console.log('✓ Screenshot saved: test-results/profile-view.png') console.log('✓ Screenshot saved: test-results/profile-view.png')
console.log(`✓ User info retrieved: ${userInfo.ctx.user.display_name}`) console.log(`✓ User info retrieved: ${userInfo.user.display_name}`)
console.log(`✓ Credentials count: ${userInfo.credentials.length}`) console.log(`✓ Credentials count: ${Object.keys(userInfo.credentials).length}`)
}) })
test('should authenticate with existing passkey', async ({ page, virtualAuthenticator }) => { test('should authenticate with existing passkey', async ({ page, virtualAuthenticator }) => {
+34 -18
View File
@@ -10,6 +10,12 @@ import {
logout, logout,
} from './fixtures/passkey-helpers' } from './fixtures/passkey-helpers'
import type { Page, Frame } from '@playwright/test' import type { Page, Frame } from '@playwright/test'
import { readFileSync } from 'fs'
import { join, dirname } from 'path'
import { fileURLToPath } from 'url'
const __filename = fileURLToPath(import.meta.url)
const __dirname = dirname(__filename)
/** /**
* E2E tests for API mode authentication flows. * E2E tests for API mode authentication flows.
@@ -55,8 +61,18 @@ async function clearSessionCookie(page: Page): Promise<void> {
/** /**
* Set up the test page using the examples page directly. * Set up the test page using the examples page directly.
* The examples page already has iframe handling - we just add a Promise wrapper. * The examples page already has iframe handling - we just add a Promise wrapper.
* We route the paskia-js module request to serve from the local dist.
*/ */
async function setupTestHarness(page: Page): Promise<void> { async function setupTestHarness(page: Page): Promise<void> {
// Serve paskia.js from the local filesystem since the server doesn't serve /paskia-js/
const paskiaJsPath = join(__dirname, '..', '..', 'paskia-js', 'dist', 'paskia.js')
await page.route('**/paskia-js/dist/paskia.js', async route => {
const body = readFileSync(paskiaJsPath, 'utf-8')
await route.fulfill({
body,
contentType: 'application/javascript',
})
})
// Navigate to the examples page which already has the auth iframe handling // Navigate to the examples page which already has the auth iframe handling
await page.goto(`${baseUrl}/auth/examples/`) await page.goto(`${baseUrl}/auth/examples/`)
} }
@@ -143,8 +159,8 @@ async function makeApiCall(page: Page, url: string, method = 'GET'): Promise<{ s
* Wait for auth iframe to appear and return a reference to it. * Wait for auth iframe to appear and return a reference to it.
*/ */
async function waitForAuthIframe(page: Page, timeout = 5000): Promise<Frame> { async function waitForAuthIframe(page: Page, timeout = 5000): Promise<Frame> {
await page.waitForSelector('#auth-iframe', { timeout }) await page.waitForSelector('#paskia-iframe', { timeout })
const iframe = page.frameLocator('#auth-iframe') const iframe = page.frameLocator('#paskia-iframe')
// Wait for iframe content to load // Wait for iframe content to load
await iframe.locator('.view-root').waitFor({ timeout }) await iframe.locator('.view-root').waitFor({ timeout })
return page.frame({ url: /\/auth\/restricted\// })! return page.frame({ url: /\/auth\/restricted\// })!
@@ -154,14 +170,14 @@ async function waitForAuthIframe(page: Page, timeout = 5000): Promise<Frame> {
* Wait for auth iframe to disappear. * Wait for auth iframe to disappear.
*/ */
async function waitForAuthIframeHidden(page: Page, timeout = 5000): Promise<void> { async function waitForAuthIframeHidden(page: Page, timeout = 5000): Promise<void> {
await page.waitForSelector('#auth-iframe', { state: 'detached', timeout }) await page.waitForSelector('#paskia-iframe', { state: 'detached', timeout })
} }
/** /**
* Click Back button in auth iframe. * Click Back button in auth iframe.
*/ */
async function clickBackInIframe(page: Page): Promise<void> { async function clickBackInIframe(page: Page): Promise<void> {
const iframe = page.frameLocator('#auth-iframe') const iframe = page.frameLocator('#paskia-iframe')
await iframe.getByRole('button', { name: 'Back' }).click() await iframe.getByRole('button', { name: 'Back' }).click()
} }
@@ -169,7 +185,7 @@ async function clickBackInIframe(page: Page): Promise<void> {
* Click Login button in auth iframe. * Click Login button in auth iframe.
*/ */
async function clickLoginInIframe(page: Page): Promise<void> { async function clickLoginInIframe(page: Page): Promise<void> {
const iframe = page.frameLocator('#auth-iframe') const iframe = page.frameLocator('#paskia-iframe')
await iframe.getByRole('button', { name: 'Login' }).click() await iframe.getByRole('button', { name: 'Login' }).click()
} }
@@ -177,7 +193,7 @@ async function clickLoginInIframe(page: Page): Promise<void> {
* Click Verify button in auth iframe (for reauth mode). * Click Verify button in auth iframe (for reauth mode).
*/ */
async function clickVerifyInIframe(page: Page): Promise<void> { async function clickVerifyInIframe(page: Page): Promise<void> {
const iframe = page.frameLocator('#auth-iframe') const iframe = page.frameLocator('#paskia-iframe')
await iframe.getByRole('button', { name: 'Verify' }).click() await iframe.getByRole('button', { name: 'Verify' }).click()
} }
@@ -185,7 +201,7 @@ async function clickVerifyInIframe(page: Page): Promise<void> {
* Click Logout button in auth iframe (for forbidden mode). * Click Logout button in auth iframe (for forbidden mode).
*/ */
async function clickLogoutInIframe(page: Page): Promise<void> { async function clickLogoutInIframe(page: Page): Promise<void> {
const iframe = page.frameLocator('#auth-iframe') const iframe = page.frameLocator('#paskia-iframe')
await iframe.getByRole('button', { name: 'Logout' }).click() await iframe.getByRole('button', { name: 'Logout' }).click()
} }
@@ -200,11 +216,11 @@ test.describe('API Mode - 401 Login Flow', () => {
await clearSessionCookie(page) await clearSessionCookie(page)
// Make API call that triggers 401 (don't await - it blocks until iframe resolves) // Make API call that triggers 401 (don't await - it blocks until iframe resolves)
const apiCallPromise = makeApiCall(page, '/auth/api/user-info', 'POST').catch(e => e) const apiCallPromise = makeApiCall(page, '/auth/api/user-info', 'GET').catch(e => e)
console.log('✓ Auth iframe appeared on 401') console.log('✓ Auth iframe appeared on 401')
// Verify it's in login mode (not reauth) // Verify it's in login mode (not reauth)
const iframe = page.frameLocator('#auth-iframe') const iframe = page.frameLocator('#paskia-iframe')
await expect(iframe.locator('h1')).toContainText('🔐') await expect(iframe.locator('h1')).toContainText('🔐')
await expect(iframe.getByRole('button', { name: 'Login' })).toBeVisible() await expect(iframe.getByRole('button', { name: 'Login' })).toBeVisible()
@@ -252,7 +268,7 @@ test.describe('API Mode - 401 Login Flow', () => {
await setupTestHarness(page) await setupTestHarness(page)
// Make API call that triggers 401 // Make API call that triggers 401
const apiCallPromise = makeApiCall(page, '/auth/api/user-info', 'POST') const apiCallPromise = makeApiCall(page, '/auth/api/user-info', 'GET')
// Wait for auth iframe to appear // Wait for auth iframe to appear
await waitForAuthIframe(page) await waitForAuthIframe(page)
@@ -268,7 +284,7 @@ test.describe('API Mode - 401 Login Flow', () => {
// Wait for API call to complete and verify result // Wait for API call to complete and verify result
const result = await apiCallPromise const result = await apiCallPromise
expect(result.status).toBe(200) expect(result.status).toBe(200)
expect(result.data.ctx).toBeDefined() expect(result.data.user).toBeDefined()
console.log('✓ API call succeeded after authentication') console.log('✓ API call succeeded after authentication')
// Save the session for other tests // Save the session for other tests
@@ -314,7 +330,7 @@ test.describe('API Mode - 401 Reauth Flow', () => {
console.log('✓ Reauth iframe appeared (session older than max_age)') console.log('✓ Reauth iframe appeared (session older than max_age)')
// Verify it's in reauth mode // Verify it's in reauth mode
const iframe = page.frameLocator('#auth-iframe') const iframe = page.frameLocator('#paskia-iframe')
await expect(iframe.locator('h1')).toContainText('Additional Authentication') await expect(iframe.locator('h1')).toContainText('Additional Authentication')
await expect(iframe.getByRole('button', { name: 'Verify' })).toBeVisible() await expect(iframe.getByRole('button', { name: 'Verify' })).toBeVisible()
@@ -362,7 +378,7 @@ test.describe('API Mode - 401 Reauth Flow', () => {
await waitForAuthIframe(page) await waitForAuthIframe(page)
console.log('✓ Reauth iframe appeared') console.log('✓ Reauth iframe appeared')
const iframe = page.frameLocator('#auth-iframe') const iframe = page.frameLocator('#paskia-iframe')
await expect(iframe.locator('h1')).toContainText('Additional Authentication') await expect(iframe.locator('h1')).toContainText('Additional Authentication')
// Click Verify - virtual authenticator handles passkey // Click Verify - virtual authenticator handles passkey
@@ -394,7 +410,7 @@ test.describe('API Mode - 403 Forbidden Flow', () => {
const apiCallPromise = makeApiCall(page, '/auth/api/forward?perm=auth:admin', 'GET').catch(e => e) const apiCallPromise = makeApiCall(page, '/auth/api/forward?perm=auth:admin', 'GET').catch(e => e)
// Check if auth iframe appeared // Check if auth iframe appeared
const iframeAppeared = await page.waitForSelector('#auth-iframe', { timeout: 3000 }).then(() => true).catch(() => false) const iframeAppeared = await page.waitForSelector('#paskia-iframe', { timeout: 3000 }).then(() => true).catch(() => false)
if (!iframeAppeared) { if (!iframeAppeared) {
// User might already have admin permission // User might already have admin permission
@@ -410,7 +426,7 @@ test.describe('API Mode - 403 Forbidden Flow', () => {
// Wait for view to stabilize and check mode // Wait for view to stabilize and check mode
await page.waitForTimeout(500) await page.waitForTimeout(500)
const iframe = page.frameLocator('#auth-iframe') const iframe = page.frameLocator('#paskia-iframe')
const headingText = await iframe.locator('h1').textContent() const headingText = await iframe.locator('h1').textContent()
console.log(` Heading: ${headingText}`) console.log(` Heading: ${headingText}`)
@@ -459,7 +475,7 @@ test.describe('API Mode - 403 Forbidden Flow', () => {
const apiCallPromise = makeApiCall(page, '/auth/api/forward?perm=auth:admin', 'GET').catch(e => e) const apiCallPromise = makeApiCall(page, '/auth/api/forward?perm=auth:admin', 'GET').catch(e => e)
// Check if auth iframe appeared // Check if auth iframe appeared
const iframeAppeared = await page.waitForSelector('#auth-iframe', { timeout: 3000 }).then(() => true).catch(() => false) const iframeAppeared = await page.waitForSelector('#paskia-iframe', { timeout: 3000 }).then(() => true).catch(() => false)
if (!iframeAppeared) { if (!iframeAppeared) {
const result = await apiCallPromise const result = await apiCallPromise
@@ -470,7 +486,7 @@ test.describe('API Mode - 403 Forbidden Flow', () => {
} }
await waitForAuthIframe(page) await waitForAuthIframe(page)
const iframe = page.frameLocator('#auth-iframe') const iframe = page.frameLocator('#paskia-iframe')
await page.waitForTimeout(500) await page.waitForTimeout(500)
const headingText = await iframe.locator('h1').textContent() const headingText = await iframe.locator('h1').textContent()
@@ -534,7 +550,7 @@ test.describe('API Mode - Direct API Response Format', () => {
expect(data.auth).toBeDefined() expect(data.auth).toBeDefined()
expect(data.auth.iframe).toBeDefined() expect(data.auth.iframe).toBeDefined()
expect(data.auth.mode).toBe('login') expect(data.auth.mode).toBe('login')
expect(data.auth.iframe).toContain('/auth/restricted/') expect(data.auth.iframe).toContain('/auth/restricted/iframe')
console.log(`✓ 401 response includes auth.iframe: ${data.auth.iframe}`) console.log(`✓ 401 response includes auth.iframe: ${data.auth.iframe}`)
}) })
+62 -12
View File
@@ -44,7 +44,7 @@ export interface UserInfo {
sign_count: number sign_count: number
is_current_session: boolean is_current_session: boolean
}> }>
aaguid_info: Record<string, { name: string; icon_light?: string; icon_dark?: string }> aaguid_info: Record<string, { name: string; icon?: string; icon_dark?: string }>
sessions: Array<{ sessions: Array<{
id: string id: string
credential: string credential: string
@@ -193,7 +193,8 @@ export async function registerPasskey(
baseUrl: string, baseUrl: string,
options: { resetToken?: string; displayName?: string } = {} options: { resetToken?: string; displayName?: string } = {}
): Promise<RegistrationResult> { ): Promise<RegistrationResult> {
return await page.evaluate(async ({ baseUrl, resetToken, displayName }) => { // Step 1: Do WebSocket registration + exchange code in browser context
const wsResult = await page.evaluate(async ({ baseUrl, resetToken, displayName }) => {
// Build WebSocket URL with query parameters // Build WebSocket URL with query parameters
let wsUrl = `${baseUrl.replace('http', 'ws')}/auth/ws/register` let wsUrl = `${baseUrl.replace('http', 'ws')}/auth/ws/register`
const params: string[] = [] const params: string[] = []
@@ -203,6 +204,7 @@ export async function registerPasskey(
return new Promise<any>((resolve, reject) => { return new Promise<any>((resolve, reject) => {
const ws = new WebSocket(wsUrl) const ws = new WebSocket(wsUrl)
let done = false
ws.onopen = () => { ws.onopen = () => {
console.log('WebSocket connected for registration') console.log('WebSocket connected for registration')
@@ -213,15 +215,31 @@ export async function registerPasskey(
// Check for error response // Check for error response
if (data.detail) { if (data.detail) {
done = true
ws.close() ws.close()
reject(new Error(data.detail)) reject(new Error(data.detail))
return return
} }
// Check if this is the final success response // Check if this is the final success response (exchange_code flow)
if (data.session_token) { if (data.exchange_code) {
done = true
ws.close() ws.close()
resolve(data) // Exchange the code for a session cookie
try {
const resp = await fetch(`${baseUrl}/auth/api/set-session`, {
method: 'POST',
headers: { 'Authorization': `Bearer ${data.exchange_code}` },
})
if (!resp.ok) throw new Error(`Exchange failed: ${resp.status}`)
resolve({
user: data.user,
credential: data.credential,
message: data.message || 'Registration successful',
})
} catch (err: any) {
reject(new Error(`Code exchange failed: ${err.message}`))
}
return return
} }
@@ -293,12 +311,21 @@ export async function registerPasskey(
} }
ws.onclose = (event) => { ws.onclose = (event) => {
if (!event.wasClean && event.code !== 1000) { if (!done && !event.wasClean && event.code !== 1000) {
reject(new Error(`WebSocket closed unexpectedly: ${event.code}`)) reject(new Error(`WebSocket closed unexpectedly: ${event.code}`))
} }
} }
}) })
}, { baseUrl, resetToken: options.resetToken, displayName: options.displayName }) }, { baseUrl, resetToken: options.resetToken, displayName: options.displayName })
// Step 2: Extract the session token from the cookie set by the exchange
const cookies = await page.context().cookies()
const cookieName = getSessionCookieName()
const sessionCookie = cookies.find(c => c.name === cookieName)
return {
...wsResult,
session_token: sessionCookie?.value || '',
}
} }
/** /**
@@ -309,11 +336,13 @@ export async function authenticatePasskey(
page: Page, page: Page,
baseUrl: string baseUrl: string
): Promise<AuthenticationResult> { ): Promise<AuthenticationResult> {
return await page.evaluate(async ({ baseUrl }) => { // Step 1: Do WebSocket authentication + exchange code in browser context
const wsResult = await page.evaluate(async ({ baseUrl }) => {
const wsUrl = `${baseUrl.replace('http', 'ws')}/auth/ws/authenticate` const wsUrl = `${baseUrl.replace('http', 'ws')}/auth/ws/authenticate`
return new Promise<any>((resolve, reject) => { return new Promise<any>((resolve, reject) => {
const ws = new WebSocket(wsUrl) const ws = new WebSocket(wsUrl)
let done = false
ws.onopen = () => { ws.onopen = () => {
console.log('WebSocket connected for authentication') console.log('WebSocket connected for authentication')
@@ -324,15 +353,27 @@ export async function authenticatePasskey(
// Check for error response // Check for error response
if (data.detail) { if (data.detail) {
done = true
ws.close() ws.close()
reject(new Error(data.detail)) reject(new Error(data.detail))
return return
} }
// Check if this is the final success response // Check if this is the final success response (exchange_code flow)
if (data.session_token) { if (data.exchange_code) {
done = true
ws.close() ws.close()
resolve(data) // Exchange the code for a session cookie
try {
const resp = await fetch(`${baseUrl}/auth/api/set-session`, {
method: 'POST',
headers: { 'Authorization': `Bearer ${data.exchange_code}` },
})
if (!resp.ok) throw new Error(`Exchange failed: ${resp.status}`)
resolve({ user: data.user })
} catch (err: any) {
reject(new Error(`Code exchange failed: ${err.message}`))
}
return return
} }
@@ -395,12 +436,21 @@ export async function authenticatePasskey(
} }
ws.onclose = (event) => { ws.onclose = (event) => {
if (!event.wasClean && event.code !== 1000) { if (!done && !event.wasClean && event.code !== 1000) {
reject(new Error(`WebSocket closed unexpectedly: ${event.code}`)) reject(new Error(`WebSocket closed unexpectedly: ${event.code}`))
} }
} }
}) })
}, { baseUrl }) }, { baseUrl })
// Step 2: Extract the session token from the cookie set by the exchange
const cookies = await page.context().cookies()
const cookieName = getSessionCookieName()
const sessionCookie = cookies.find(c => c.name === cookieName)
return {
...wsResult,
session_token: sessionCookie?.value || '',
}
} }
/** /**
@@ -429,7 +479,7 @@ export async function getUserInfo(
sessionToken: string sessionToken: string
): Promise<UserInfo> { ): Promise<UserInfo> {
const cookieName = getSessionCookieName() const cookieName = getSessionCookieName()
const response = await page.request.post(`${baseUrl}/auth/api/user-info`, { const response = await page.request.get(`${baseUrl}/auth/api/user-info`, {
headers: { headers: {
'Cookie': `${cookieName}=${sessionToken}`, 'Cookie': `${cookieName}=${sessionToken}`,
}, },
+10 -5
View File
@@ -1,4 +1,4 @@
import { spawn } from 'child_process' import { execSync, spawn } from 'child_process'
import { join, dirname } from 'path' import { join, dirname } from 'path'
import { existsSync, mkdirSync, writeFileSync } from 'fs' import { existsSync, mkdirSync, writeFileSync } from 'fs'
import { fileURLToPath } from 'url' import { fileURLToPath } from 'url'
@@ -31,6 +31,11 @@ export default async function globalSetup() {
mkdirSync(testDataDir, { recursive: true }) mkdirSync(testDataDir, { recursive: true })
} }
// Build the package first
console.log(' Building package with uv build...')
execSync('uv build', { cwd: projectRoot, stdio: 'inherit' })
console.log(' ✅ Build complete\n')
console.log(' Starting server with in-memory database...') console.log(' Starting server with in-memory database...')
if (COLLECT_COVERAGE) { if (COLLECT_COVERAGE) {
console.log(' 📊 Coverage collection enabled for Python backend') console.log(' 📊 Coverage collection enabled for Python backend')
@@ -42,16 +47,16 @@ export default async function globalSetup() {
const serverArgs = COLLECT_COVERAGE const serverArgs = COLLECT_COVERAGE
? [ ? [
'run', 'coverage', 'run', '--parallel-mode', 'run', 'coverage', 'run', '--parallel-mode',
'-m', 'paskia.fastapi', 'localhost:4404', '-m', 'paskia', '-l', 'localhost:4404',
'--rp-id', 'localhost' '--rp-id', 'localhost'
] ]
: [ : [
'run', 'paskia', 'localhost:4404', 'run', 'paskia', '-l', 'localhost:4404',
'--rp-id', 'localhost' '--rp-id', 'localhost'
] ]
// Use a temporary jsonl file for test database // Use a fresh database file for tests
const testDbFile = join(testDataDir, 'test-db.jsonl') const testDbFile = join(testDataDir, 'test.paskiadb')
// Start the server using Node's spawn // Start the server using Node's spawn
const serverProcess = spawn('uv', serverArgs, { const serverProcess = spawn('uv', serverArgs, {
+1 -1
View File
@@ -60,7 +60,7 @@ export default async function globalTeardown() {
} }
// Clean up test database // Clean up test database
const testDbFile = join(testDataDir, 'test-db.jsonl') const testDbFile = join(testDataDir, 'test.paskiadb')
if (existsSync(testDbFile)) { if (existsSync(testDbFile)) {
console.log(' Removing test database...') console.log(' Removing test database...')
rmSync(testDbFile) rmSync(testDbFile)
+1 -1
View File
@@ -8,7 +8,7 @@
"skipLibCheck": true, "skipLibCheck": true,
"forceConsistentCasingInFileNames": true, "forceConsistentCasingInFileNames": true,
"resolveJsonModule": true, "resolveJsonModule": true,
"types": ["bun-types"] "types": ["node"]
}, },
"include": ["tests/**/*.ts", "playwright.config.ts"], "include": ["tests/**/*.ts", "playwright.config.ts"],
"exclude": ["node_modules"] "exclude": ["node_modules"]
+38 -107
View File
@@ -8,26 +8,6 @@
:root { :root {
color-scheme: light dark; /* Automatic themes by browser */ 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> </style>
</head> </head>
<body> <body>
@@ -47,7 +27,7 @@
<div class="section"> <div class="section">
<h2>API Mode (not leaving the page)</h2> <h2>API Mode (not leaving the page)</h2>
<p>For SPAs and fetch() calls - shows auth in an iframe overlay:</p> <p>For SPAs and fetch() calls - shows auth in an iframe overlay:</p>
<button onclick="apiCall('/auth/api/user-info', 'POST')">📋 Get User Info</button> <button onclick="apiCall('/auth/api/user-info', 'GET')">📋 Get User Info</button>
<button onclick="apiCall('/auth/api/forward?max_age=10s')">🔄 Reauth (max_age=10s)</button> <button onclick="apiCall('/auth/api/forward?max_age=10s')">🔄 Reauth (max_age=10s)</button>
<button onclick="apiCall('/auth/api/forward?perm=auth:admin')">🛡️ Admin Only</button> <button onclick="apiCall('/auth/api/forward?perm=auth:admin')">🛡️ Admin Only</button>
<button onclick="logout()">🚪 Logout</button> <button onclick="logout()">🚪 Logout</button>
@@ -65,99 +45,50 @@
</div> </div>
</div> </div>
<script> <script type="module">
import { apiFetch, apiJson, AuthCancelledError } from '/paskia-js/dist/paskia.js'
const output = document.getElementById('output'); 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) { function log(msg) {
output.textContent = 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. // Browser mode: open the forward endpoint directly in a new window.
// When Accept: text/html, the server redirects to the login page if needed, window.browserNav = function(url) {
// then back to the original URL after authentication.
function browserNav(url) {
log('Opening in new window...\nIf not authenticated, you\'ll see the login page.\nAfter auth, you\'ll see a 204 response (blank page = success).'); 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'); window.open(url, '_blank');
} }
+37 -114
View File
@@ -13,7 +13,8 @@
<script setup> <script setup>
import { computed, onMounted, onUnmounted, ref } from 'vue' import { computed, onMounted, onUnmounted, ref } from 'vue'
import { useAuthStore } from '@/stores/auth' import { useAuthStore } from '@/stores/auth'
import { apiJson, getAuthIframeUrl } from '@/utils/api' import { apiJson, SessionValidator } from 'paskia'
import { updateThemeFromSession } from '@/utils/theme'
import StatusMessage from '@/components/StatusMessage.vue' import StatusMessage from '@/components/StatusMessage.vue'
import ProfileView from '@/components/ProfileView.vue' import ProfileView from '@/components/ProfileView.vue'
import HostProfileView from '@/components/HostProfileView.vue' import HostProfileView from '@/components/HostProfileView.vue'
@@ -46,114 +47,50 @@ const isHostMode = computed(() => {
const configuredHost = normalizeHost(authHost) const configuredHost = normalizeHost(authHost)
return currentHost !== configuredHost return currentHost !== configuredHost
}) })
let validationTimer = null
let authIframe = null
function terminateSession() { function onSessionLost(e) {
store.userInfo = null store.userInfo = null
viewState.value = 'terminal' store.ctx = null
if (e?.name === 'AuthCancelledError') {
viewState.value = 'terminal'
} else {
store.showMessage(e?.message || 'Session lost', 'error', 5000)
viewState.value = 'terminal'
}
} }
const userUuidGetter = () => store.ctx?.user.uuid
const sessionValidator = new SessionValidator(userUuidGetter, onSessionLost)
onMounted(() => sessionValidator.start())
onUnmounted(() => sessionValidator.stop())
async function loadUserInfo() { async function loadUserInfo() {
viewState.value = 'loading'
loadingMessage.value = 'Loading...'
try { 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' }),
apiJson('/auth/api/user-info', { method: 'GET' })
])
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' viewState.value = 'profile'
startSessionValidation() } catch (e) {
return true onSessionLost(e)
} catch {
store.userInfo = null
return false
}
}
async function showAuthIframe() {
// Remove existing iframe if any
hideAuthIframe()
// Create new iframe for authentication using src URL
const url = await getAuthIframeUrl('login')
authIframe = document.createElement('iframe')
authIframe.id = 'auth-iframe'
authIframe.title = 'Authentication'
authIframe.allow = 'publickey-credentials-get; publickey-credentials-create'
authIframe.src = url
document.body.appendChild(authIframe)
loadingMessage.value = 'Authentication required...'
}
function hideAuthIframe() {
if (authIframe) {
authIframe.remove()
authIframe = null
}
}
function handleAuthMessage(event) {
const data = event.data
if (!data?.type) return
switch (data.type) {
case 'auth-success':
// Authentication successful - reload user info
hideAuthIframe()
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
hideAuthIframe()
terminateSession()
break
case 'auth-close-request':
// Legacy support - treat as back
hideAuthIframe()
break
}
}
async function validateSession() {
try {
await apiJson('/auth/api/validate', { method: 'POST' })
} catch {
stopSessionValidation()
terminateSession()
}
}
function startSessionValidation() {
// Validate session every 2 minutes
stopSessionValidation()
validationTimer = setInterval(validateSession, 2 * 60 * 1000)
}
function stopSessionValidation() {
if (validationTimer) {
clearInterval(validationTimer)
validationTimer = null
} }
} }
onMounted(async () => { onMounted(async () => {
// Listen for postMessage from auth iframe
window.addEventListener('message', handleAuthMessage)
// Load settings // Load settings
await store.loadSettings() await store.loadSettings()
@@ -167,21 +104,7 @@ onMounted(async () => {
document.title = inHostMode ? `${rpName} · Account summary` : rpName document.title = inHostMode ? `${rpName} · Account summary` : rpName
} }
// Try to load user info // Load user info (apiJson handles auth iframe if needed)
const success = await loadUserInfo() await loadUserInfo()
if (!success) {
// Need authentication - show login iframe
showAuthIframe()
}
})
onUnmounted(() => {
window.removeEventListener('message', handleAuthMessage)
stopSessionValidation()
hideAuthIframe()
}) })
</script> </script>
<style scoped>
</style>
+370 -107
View File
@@ -9,10 +9,13 @@ import AccessDenied from '@/components/AccessDenied.vue'
import AdminOverview from '@/admin/AdminOverview.vue' import AdminOverview from '@/admin/AdminOverview.vue'
import AdminOrgDetail from '@/admin/AdminOrgDetail.vue' import AdminOrgDetail from '@/admin/AdminOrgDetail.vue'
import AdminUserDetail from '@/admin/AdminUserDetail.vue' import AdminUserDetail from '@/admin/AdminUserDetail.vue'
import AdminOidcDetail from '@/admin/AdminOidcDetail.vue'
import AdminDialogs from '@/admin/AdminDialogs.vue' import AdminDialogs from '@/admin/AdminDialogs.vue'
import { useAuthStore } from '@/stores/auth' import { useAuthStore } from '@/stores/auth'
import { adminUiPath, makeUiHref } from '@/utils/settings' import { adminUiPath, makeUiHref } from '@/utils/settings'
import { apiJson } from '@/utils/api' import { apiJson, SessionValidator } from 'paskia'
import { updateThemeFromSession } from '@/utils/theme'
import { uuidv7 } from 'uuidv7'
import { getDirection } from '@/utils/keynav' import { getDirection } from '@/utils/keynav'
import { goBack } from '@/utils/helpers' import { goBack } from '@/utils/helpers'
@@ -24,9 +27,12 @@ const showBackMessage = ref(false)
const error = ref(null) const error = ref(null)
const orgs = ref([]) const orgs = ref([])
const permissions = ref([]) const permissions = ref([])
const oidcClients = ref([])
const currentOrgId = ref(null) // UUID of selected org for detail view const currentOrgId = ref(null) // UUID of selected org for detail view
const currentUserId = ref(null) // UUID for user detail view const currentUserId = ref(null) // UUID for user detail view
const currentOidcId = ref(null) // UUID for OIDC client detail view
const userDetail = ref(null) // cached user detail object const userDetail = ref(null) // cached user detail object
const editingOidcClient = ref(null) // OIDC client being edited (with local changes)
const authStore = useAuthStore() const authStore = useAuthStore()
const addingOrgForPermission = ref(null) const addingOrgForPermission = ref(null)
const PERMISSION_ID_PATTERN = '^[A-Za-z0-9:._~-]+$' const PERMISSION_ID_PATTERN = '^[A-Za-z0-9:._~-]+$'
@@ -43,6 +49,7 @@ const breadcrumbsRef = ref(null)
const adminOverviewRef = ref(null) const adminOverviewRef = ref(null)
const adminOrgDetailRef = ref(null) const adminOrgDetailRef = ref(null)
const adminUserDetailRef = ref(null) const adminUserDetailRef = ref(null)
const adminOidcDetailRef = ref(null)
// Check if any modal/dialog is open (blocks arrow key navigation) // Check if any modal/dialog is open (blocks arrow key navigation)
const hasActiveModal = computed(() => dialog.value.type !== null || showRegModal.value) const hasActiveModal = computed(() => dialog.value.type !== null || showRegModal.value)
@@ -79,11 +86,12 @@ onUnmounted(() => {
const permissionSummary = computed(() => { const permissionSummary = computed(() => {
const summary = {} const summary = {}
for (const o of orgs.value) { for (const o of orgs.value) {
const orgBase = { uuid: o.uuid, display_name: o.display_name } const orgBase = { uuid: o.uuid, display_name: o.org.display_name }
const orgPerms = new Set(o.permissions || []) // o.permissions is a dict[UUID, Permission]
const orgPermUuids = new Set(Object.keys(o.permissions || {}))
// Org-level permissions (direct) - only count if org can grant them // Org-level permissions (direct) - only count if org can grant them
for (const pid of o.permissions || []) { for (const pid of Object.keys(o.permissions || {})) {
if (!summary[pid]) summary[pid] = { orgs: [], orgSet: new Set(), userCount: 0 } if (!summary[pid]) summary[pid] = { orgs: [], orgSet: new Set(), userCount: 0 }
if (!summary[pid].orgSet.has(o.uuid)) { if (!summary[pid].orgSet.has(o.uuid)) {
summary[pid].orgs.push(orgBase) summary[pid].orgs.push(orgBase)
@@ -92,17 +100,18 @@ const permissionSummary = computed(() => {
} }
// Role-based permissions (inheritance) - only count if org can grant them // Role-based permissions (inheritance) - only count if org can grant them
for (const r of o.roles) { for (const [roleUuid, r] of Object.entries(o.roles || {})) {
for (const pid of r.permissions) { // r.permissions is dict[UUID, bool]
for (const pid of Object.keys(r.permissions || {})) {
// Only count if the org can grant this permission // Only count if the org can grant this permission
if (!orgPerms.has(pid)) continue if (!orgPermUuids.has(pid)) continue
if (!summary[pid]) summary[pid] = { orgs: [], orgSet: new Set(), userCount: 0 } if (!summary[pid]) summary[pid] = { orgs: [], orgSet: new Set(), userCount: 0 }
if (!summary[pid].orgSet.has(o.uuid)) { if (!summary[pid].orgSet.has(o.uuid)) {
summary[pid].orgs.push(orgBase) summary[pid].orgs.push(orgBase)
summary[pid].orgSet.add(o.uuid) summary[pid].orgSet.add(o.uuid)
} }
summary[pid].userCount += r.users.length summary[pid].userCount += roleUserCount(o, roleUuid)
} }
} }
} }
@@ -120,32 +129,76 @@ function parseHash() {
const h = window.location.hash || '' const h = window.location.hash || ''
currentOrgId.value = null currentOrgId.value = null
currentUserId.value = null currentUserId.value = null
currentOidcId.value = null
editingOidcClient.value = null
if (h.startsWith('#org/')) { if (h.startsWith('#org/')) {
currentOrgId.value = h.slice(5) currentOrgId.value = h.slice(5)
} else if (h.startsWith('#user/')) { } else if (h.startsWith('#user/')) {
currentUserId.value = h.slice(6) currentUserId.value = h.slice(6)
} else if (h.startsWith('#oidc:')) {
const oidcUuid = h.slice(6)
currentOidcId.value = oidcUuid
// Initialize editing client data
if (oidcUuid === 'new') {
// Generate client_id and secret for new client
const bytes = new Uint8Array(32)
crypto.getRandomValues(bytes)
const client_secret = btoa(String.fromCharCode(...bytes))
.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '')
editingOidcClient.value = {
client_id: uuidv7(),
client_secret,
isNew: true,
name: '',
redirect_uris: []
}
} else {
const client = oidcClients.value.find(c => c.uuid === oidcUuid)
if (client) {
editingOidcClient.value = {
...client,
client_id: client.uuid,
client_secret: null,
isNew: false
}
}
}
} }
} }
async function loadOrgs() { async function loadAdminData() {
const data = await apiJson('/auth/api/admin/orgs') const data = await apiJson('/auth/api/admin/info')
orgs.value = data.map(o => { // Convert dicts to arrays with uuid added
const roles = o.roles.map(r => ({ ...r, org: o.uuid, users: [] })) orgs.value = Object.entries(data.orgs).map(([uuid, o]) => ({ uuid, ...o }))
const roleMap = Object.fromEntries(roles.map(r => [r.display_name, r])) permissions.value = Object.entries(data.permissions).map(([uuid, p]) => ({ uuid, ...p }))
for (const u of o.users || []) { oidcClients.value = Object.entries(data.oidc_clients).map(([uuid, c]) => ({ uuid, ...c }))
if (roleMap[u.role]) roleMap[u.role].users.push(u)
}
return { ...o, roles }
})
} }
async function loadPermissions() { // Helper to get users for a role as sorted array of [uuid, user]
permissions.value = await apiJson('/auth/api/admin/permissions') 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() { async function loadUserInfo() {
const data = await apiJson('/auth/api/validate', { method: 'POST' }) const data = await apiJson('/auth/api/validate', { method: 'POST' })
info.value = data info.value = data
updateThemeFromSession(data.ctx)
authenticated.value = true authenticated.value = true
} }
@@ -153,17 +206,34 @@ function clearSensitiveState() {
info.value = null info.value = null
orgs.value = [] orgs.value = []
permissions.value = [] permissions.value = []
oidcClients.value = []
userDetail.value = null userDetail.value = null
editingOidcClient.value = null
authenticated.value = false authenticated.value = false
} }
function onSessionLost(e) {
clearSensitiveState()
if (e.name === 'AuthCancelledError') {
showBackMessage.value = true
} else {
error.value = e.message
}
}
const userUuidGetter = () => info.value?.ctx.user.uuid
const sessionValidator = new SessionValidator(userUuidGetter, onSessionLost)
onMounted(() => sessionValidator.start())
onUnmounted(() => sessionValidator.stop())
async function load() { async function load() {
loading.value = true loading.value = true
loadingMessage.value = 'Loading...' loadingMessage.value = 'Loading...'
error.value = null error.value = null
try { try {
// Load admin data first - apiJson will handle 401/403 with iframe authentication // Load admin data first - apiJson will handle 401/403 with iframe authentication
await Promise.all([loadOrgs(), loadPermissions()]) await loadAdminData()
// If we get here, user has admin access - now fetch user info for display // If we get here, user has admin access - now fetch user info for display
await loadUserInfo() await loadUserInfo()
@@ -171,18 +241,12 @@ async function load() {
if (!window.location.hash || window.location.hash === '#overview') { if (!window.location.hash || window.location.hash === '#overview') {
currentOrgId.value = orgs.value[0].uuid currentOrgId.value = orgs.value[0].uuid
window.location.hash = `#org/${currentOrgId.value}` window.location.hash = `#org/${currentOrgId.value}`
authStore.showMessage(`Navigating to ${orgs.value[0].display_name} Administration`, 'info', 3000)
} else { } else {
parseHash() parseHash()
} }
} else parseHash() } else parseHash()
} catch (e) { } catch (e) {
clearSensitiveState() onSessionLost(e)
if (e.name === 'AuthCancelledError') {
showBackMessage.value = true
} else {
error.value = e.message
}
} finally { } finally {
loading.value = false loading.value = false
} }
@@ -197,17 +261,17 @@ function editUserName(user) { openDialog('user-update-name', { user, name: user.
async function performOrgDeletion(orgUuid) { async function performOrgDeletion(orgUuid) {
await apiJson(`/auth/api/admin/orgs/${orgUuid}`, { method: 'DELETE' }) await apiJson(`/auth/api/admin/orgs/${orgUuid}`, { method: 'DELETE' })
await Promise.all([loadOrgs(), loadPermissions()]) await Promise.all([loadAdminData()])
} }
function deleteOrg(org) { function deleteOrg(org) {
const userCount = org.roles.reduce((acc, r) => acc + r.users.length, 0) const userCount = orgUserCount(org)
if (userCount === 0) { if (userCount === 0) {
// No users in the organization, safe to delete directly // No users in the organization, safe to delete directly
performOrgDeletion(org.uuid) performOrgDeletion(org.uuid)
.then(() => { .then(() => {
authStore.showMessage(`Organization "${org.display_name}" deleted.`, 'success', 2500) authStore.showMessage(`Organization "${org.org.display_name}" deleted.`, 'success', 2500)
}) })
.catch(e => { .catch(e => {
authStore.showMessage(e.message || 'Failed to delete organization', 'error') authStore.showMessage(e.message || 'Failed to delete organization', 'error')
@@ -216,50 +280,68 @@ function deleteOrg(org) {
} }
// Build detailed breakdown of users by role // Build detailed breakdown of users by role
const roleParts = org.roles const roleParts = Object.entries(org.roles)
.filter(r => r.users.length > 0) .map(([uuid, r]) => ({ role: r, count: roleUserCount(org, uuid) }))
.map(r => `${r.users.length} ${r.display_name}`) .filter(x => x.count > 0)
.map(x => `${x.count} ${x.role.display_name}`)
const affects = roleParts.join(', ') const affects = roleParts.join(', ')
openDialog('confirm', { message: `Delete organization "${org.display_name}", including accounts of ${affects})?`, action: async () => { openDialog('confirm', { message: `Delete organization "${org.org.display_name}", including accounts of ${affects})?`, action: async () => {
await performOrgDeletion(org.uuid) await performOrgDeletion(org.uuid)
} }) } })
} }
function createUserInRole(org, role) { openDialog('user-create', { org, role }) } function createUserInRole(org, role) { openDialog('user-create', { org, role }) }
async function moveUserToRole(org, user, targetRoleDisplayName) { function deleteUser(user, userDetail) {
if (user.role === targetRoleDisplayName) return 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 { 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', method: 'PATCH',
body: { role: targetRoleDisplayName } body: { role_uuid: targetRoleUuid }
}) })
await loadOrgs() await loadAdminData()
} catch (e) { } catch (e) {
authStore.showMessage(e.message || 'Failed to update user role') authStore.showMessage(e.message || 'Failed to update user role')
} }
} }
function onUserDragStart(e, user, org) { function moveUserToRoleFromDrag(userUuid, newRoleUuid) {
e.dataTransfer.effectAllowed = 'move' const user = selectedOrg.value?.users?.[userUuid]
e.dataTransfer.setData('text/plain', JSON.stringify({ user_uuid: user.uuid, org })) if (user) moveUserToRole(userUuid, user, newRoleUuid)
}
function onRoleDragOver(e) {
e.preventDefault()
e.dataTransfer.dropEffect = 'move'
}
function onRoleDrop(e, org, role) {
e.preventDefault()
try {
const data = JSON.parse(e.dataTransfer.getData('text/plain'))
if (data.org !== org.uuid) return // only within same org
const user = org.roles.flatMap(r => r.users).find(u => u.uuid === data.user_uuid)
if (user) moveUserToRole(org, user, role.display_name)
} catch (_) { /* ignore */ }
} }
// Role actions // Role actions
@@ -269,10 +351,10 @@ function updateRole(role) { openDialog('role-update', { role, name: role.display
function deleteRole(role) { function deleteRole(role) {
// UI only allows deleting empty roles, so no confirmation needed // 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(() => { .then(() => {
authStore.showMessage(`Role "${role.display_name}" deleted.`, 'success', 2500) authStore.showMessage(`Role "${role.display_name}" deleted.`, 'success', 2500)
loadOrgs() loadAdminData()
}) })
.catch(e => { .catch(e => {
authStore.showMessage(e.message || 'Failed to delete role', 'error') authStore.showMessage(e.message || 'Failed to delete role', 'error')
@@ -280,19 +362,22 @@ function deleteRole(role) {
} }
async function toggleRolePermission(role, pid, checked) { async function toggleRolePermission(role, pid, checked) {
// Optimistic update // Optimistic update - role.permissions is dict[UUID, bool]
const prevPermissions = [...role.permissions] const prevPermissions = { ...role.permissions }
const newPermissions = checked const newPermissions = { ...role.permissions }
? [...role.permissions, pid] if (checked) {
: role.permissions.filter(p => p !== pid) newPermissions[pid] = true
} else {
delete newPermissions[pid]
}
role.permissions = newPermissions role.permissions = newPermissions
try { try {
const method = checked ? 'POST' : 'DELETE' 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 method
}) })
await loadOrgs() await loadAdminData()
} catch (e) { } catch (e) {
authStore.showMessage(e.message || 'Failed to update role permission') authStore.showMessage(e.message || 'Failed to update role permission')
role.permissions = prevPermissions // revert role.permissions = prevPermissions // revert
@@ -303,7 +388,7 @@ async function toggleRolePermission(role, pid, checked) {
async function performPermissionDeletion(permissionUuid) { async function performPermissionDeletion(permissionUuid) {
const params = new URLSearchParams({ permission_uuid: permissionUuid }) const params = new URLSearchParams({ permission_uuid: permissionUuid })
await apiJson(`/auth/api/admin/permission?${params.toString()}`, { method: 'DELETE' }) await apiJson(`/auth/api/admin/permission?${params.toString()}`, { method: 'DELETE' })
await loadPermissions() await loadAdminData()
} }
function deletePermission(p) { function deletePermission(p) {
@@ -312,8 +397,8 @@ function deletePermission(p) {
// Count roles that have this permission // Count roles that have this permission
let roleCount = 0 let roleCount = 0
for (const org of orgs.value) { for (const org of orgs.value) {
for (const role of org.roles) { for (const role of Object.values(org.roles)) {
if (role.permissions.includes(p.uuid)) { if (p.uuid in (role.permissions || {})) {
roleCount++ roleCount++
} }
} }
@@ -341,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) const selectedOrg = computed(() => orgs.value.find(o => o.uuid === currentOrgId.value) || null)
function openOrg(o) { function openOrg(o) {
@@ -358,34 +541,41 @@ function openUser(u) {
const selectedUser = computed(() => { const selectedUser = computed(() => {
if (!currentUserId.value) return null if (!currentUserId.value) return null
for (const o of orgs.value) { for (const o of orgs.value) {
for (const r of o.roles) { const u = o.users[currentUserId.value]
const u = r.users.find(x => x.uuid === currentUserId.value) if (u) {
if (u) return { ...u, org: o.uuid, role_display_name: r.display_name } const role = o.roles[u.role]
return { ...u, uuid: currentUserId.value, org: o.uuid, role_display_name: role?.display_name }
} }
} }
return null return null
}) })
const pageHeading = computed(() => {
if (selectedUser.value) return 'Admin: User'
if (selectedOrg.value) return 'Admin: Org'
return ((authStore.settings?.rp_name) || 'Master') + ' Admin'
})
// Breadcrumb entries for admin app. // Breadcrumb entries for admin app.
const breadcrumbEntries = computed(() => { const breadcrumbEntries = computed(() => {
const entries = [ const entries = [
{ label: 'Auth', href: makeUiHref() }, { label: 'My Profile', href: makeUiHref() }
{ label: 'Admin', href: adminUiPath() }
] ]
// For org admins, combine Admin and their org
if (isOrgAdmin.value && !isMasterAdmin.value && orgs.value.length > 0) {
const org = orgs.value[0]
entries.push({ label: `Admin: ${org.org.display_name}`, href: `#org/${org.uuid}` })
} else {
entries.push({ label: 'Admin', href: adminUiPath() })
}
// Determine organization for user view if selectedOrg not explicitly chosen. // Determine organization for user view if selectedOrg not explicitly chosen.
let orgForUser = null let orgForUser = null
if (selectedUser.value) { if (selectedUser.value) {
orgForUser = orgs.value.find(o => o.uuid === selectedUser.value.org) || null orgForUser = orgs.value.find(o => o.uuid === selectedUser.value.org) || null
} }
const orgToShow = selectedOrg.value || orgForUser const orgToShow = selectedOrg.value || orgForUser
if (orgToShow) { // Add org breadcrumb only if it's not already included in the Admin entry
entries.push({ label: orgToShow.display_name, href: `#org/${orgToShow.uuid}` }) const adminOrg = (isOrgAdmin.value && !isMasterAdmin.value && orgs.value.length > 0) ? orgs.value[0] : null
if (orgToShow && (!adminOrg || orgToShow.uuid !== adminOrg.uuid)) {
entries.push({ label: orgToShow.org.display_name, href: `#org/${orgToShow.uuid}` })
}
if (currentOidcId.value) {
const label = editingOidcClient.value?.isNew ? 'New Client' : (editingOidcClient.value?.name || 'OIDC Client')
entries.push({ label, href: `#oidc:${currentOidcId.value}` })
} }
if (selectedUser.value) { if (selectedUser.value) {
entries.push({ label: selectedUser.value.display_name, href: `#user/${selectedUser.value.uuid}` }) entries.push({ label: selectedUser.value.display_name, href: `#user/${selectedUser.value.uuid}` })
@@ -396,7 +586,7 @@ const breadcrumbEntries = computed(() => {
watch(selectedUser, async (u) => { watch(selectedUser, async (u) => {
if (!u) { userDetail.value = null; return } if (!u) { userDetail.value = null; return }
try { try {
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) { } catch (e) {
userDetail.value = { error: e.message } userDetail.value = { error: e.message }
} }
@@ -408,18 +598,27 @@ function generateUserRegistrationLink(u) {
} }
async function toggleOrgPermission(org, permId, checked) { async function toggleOrgPermission(org, permId, checked) {
// Build next permission list // org.permissions is dict[UUID, Permission]
const has = org.permissions.includes(permId) const has = permId in org.permissions
if (checked && has) return if (checked && has) return
if (!checked && !has) return if (!checked && !has) return
const next = checked ? [...org.permissions, permId] : org.permissions.filter(p => p !== permId)
// Optimistic update // Optimistic update
const prev = [...org.permissions] const prev = { ...org.permissions }
org.permissions = next 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 { try {
const params = new URLSearchParams({ permission_uuid: permId }) const params = new URLSearchParams({ permission_uuid: permId })
await apiJson(`/auth/api/admin/orgs/${org.uuid}/permission?${params.toString()}`, { method: checked ? 'POST' : 'DELETE' }) await apiJson(`/auth/api/admin/orgs/${org.uuid}/permission?${params.toString()}`, { method: checked ? 'POST' : 'DELETE' })
await loadOrgs() await loadAdminData()
} catch (e) { } catch (e) {
authStore.showMessage(e.message || 'Failed to update organization permission', 'error') authStore.showMessage(e.message || 'Failed to update organization permission', 'error')
org.permissions = prev // revert org.permissions = prev // revert
@@ -529,10 +728,10 @@ function handlePanelNavigateOut(direction) {
} }
async function refreshUserDetail() { async function refreshUserDetail() {
await loadOrgs() await loadAdminData()
if (selectedUser.value) { if (selectedUser.value) {
try { try {
userDetail.value = await apiJson(`/auth/api/admin/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') } } catch (e) { authStore.showMessage(e.message || 'Failed to reload user', 'error') }
} }
} }
@@ -555,7 +754,7 @@ async function submitDialog() {
apiJson('/auth/api/admin/orgs', { method: 'POST', body: { display_name: name, permissions: [] } }) apiJson('/auth/api/admin/orgs', { method: 'POST', body: { display_name: name, permissions: [] } })
.then(() => { .then(() => {
authStore.showMessage(`Organization "${name}" created.`, 'success', 2500) authStore.showMessage(`Organization "${name}" created.`, 'success', 2500)
Promise.all([loadOrgs(), loadPermissions()]) loadAdminData()
}) })
.catch(e => { .catch(e => {
authStore.showMessage(e.message || 'Failed to create organization', 'error') authStore.showMessage(e.message || 'Failed to create organization', 'error')
@@ -569,7 +768,7 @@ async function submitDialog() {
apiJson(`/auth/api/admin/orgs/${org.uuid}`, { method: 'PATCH', body: { display_name: name } }) apiJson(`/auth/api/admin/orgs/${org.uuid}`, { method: 'PATCH', body: { display_name: name } })
.then(() => { .then(() => {
authStore.showMessage(`Organization renamed to "${name}".`, 'success', 2500) authStore.showMessage(`Organization renamed to "${name}".`, 'success', 2500)
loadOrgs() loadAdminData()
}) })
.catch(e => { .catch(e => {
authStore.showMessage(e.message || 'Failed to update organization', 'error') authStore.showMessage(e.message || 'Failed to update organization', 'error')
@@ -583,7 +782,7 @@ async function submitDialog() {
apiJson(`/auth/api/admin/orgs/${org.uuid}/roles`, { method: 'POST', body: { display_name: name, permissions: [] } }) apiJson(`/auth/api/admin/orgs/${org.uuid}/roles`, { method: 'POST', body: { display_name: name, permissions: [] } })
.then(() => { .then(() => {
authStore.showMessage(`Role "${name}" created.`, 'success', 2500) authStore.showMessage(`Role "${name}" created.`, 'success', 2500)
loadOrgs() loadAdminData()
}) })
.catch(e => { .catch(e => {
authStore.showMessage(e.message || 'Failed to create role', 'error') authStore.showMessage(e.message || 'Failed to create role', 'error')
@@ -594,7 +793,7 @@ async function submitDialog() {
// Close dialog immediately, then perform async operation // Close dialog immediately, then perform async operation
closeDialog() 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(() => { .then(() => {
authStore.showMessage(`Role renamed to "${name}".`, 'success', 2500) authStore.showMessage(`Role renamed to "${name}".`, 'success', 2500)
loadOrgs() loadOrgs()
@@ -611,7 +810,7 @@ async function submitDialog() {
apiJson(`/auth/api/admin/orgs/${org.uuid}/users`, { method: 'POST', body: { display_name: name, role: role.display_name } }) apiJson(`/auth/api/admin/orgs/${org.uuid}/users`, { method: 'POST', body: { display_name: name, role: role.display_name } })
.then(() => { .then(() => {
authStore.showMessage(`User "${name}" added to ${role.display_name} role.`, 'success', 2500) authStore.showMessage(`User "${name}" added to ${role.display_name} role.`, 'success', 2500)
loadOrgs() loadAdminData()
}) })
.catch(e => { .catch(e => {
authStore.showMessage(e.message || 'Failed to add user', 'error') authStore.showMessage(e.message || 'Failed to add user', 'error')
@@ -622,7 +821,7 @@ async function submitDialog() {
// Close dialog immediately, then perform async operation // Close dialog immediately, then perform async operation
closeDialog() 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(() => { .then(() => {
authStore.showMessage(`User renamed to "${name}".`, 'success', 2500) authStore.showMessage(`User renamed to "${name}".`, 'success', 2500)
onUserNameSaved() onUserNameSaved()
@@ -657,7 +856,7 @@ async function submitDialog() {
apiJson(`/auth/api/admin/permission?${params.toString()}`, { method: 'PATCH' }) apiJson(`/auth/api/admin/permission?${params.toString()}`, { method: 'PATCH' })
.then(() => { .then(() => {
authStore.showMessage(`Permission "${newDisplay}" updated.`, 'success', 2500) authStore.showMessage(`Permission "${newDisplay}" updated.`, 'success', 2500)
loadPermissions() loadAdminData()
}) })
.catch(e => { .catch(e => {
authStore.showMessage(e.message || 'Failed to update permission', 'error') authStore.showMessage(e.message || 'Failed to update permission', 'error')
@@ -673,12 +872,58 @@ async function submitDialog() {
apiJson('/auth/api/admin/permissions', { method: 'POST', body: { scope, display_name, domain: domain || undefined } }) apiJson('/auth/api/admin/permissions', { method: 'POST', body: { scope, display_name, domain: domain || undefined } })
.then(() => { .then(() => {
authStore.showMessage(`Permission "${display_name}" created.`, 'success', 2500) authStore.showMessage(`Permission "${display_name}" created.`, 'success', 2500)
loadPermissions() loadAdminData()
}) })
.catch(e => { .catch(e => {
authStore.showMessage(e.message || 'Failed to create permission', 'error') authStore.showMessage(e.message || 'Failed to create permission', 'error')
}) })
return // Don't call closeDialog() again return // Don't call closeDialog() again
} else if (t === 'oidc-edit') {
const { client_id, client_secret, isNew } = dialog.value.data
const name = dialog.value.data.name?.trim()
const uris = dialog.value.data.redirect_uris?.trim()
if (!name) throw new Error('Client name required')
const redirect_uris = uris ? uris.split('\n').map(u => u.trim()).filter(u => u) : []
// Close dialog immediately, then perform async operation
closeDialog()
const req = client_secret
? sha256Hex(client_secret).then(secret_hash => isNew
? apiJson('/auth/api/admin/oidc-clients', { method: 'POST', body: { client_id, secret_hash, name, redirect_uris } })
: apiJson(`/auth/api/admin/oidc-clients/${client_id}`, { method: 'PATCH', body: { name, redirect_uris, secret_hash } }))
: apiJson(`/auth/api/admin/oidc-clients/${client_id}`, { method: 'PATCH', body: { name, redirect_uris } })
req
.then(() => {
authStore.showMessage(`OIDC client "${name}" ${isNew ? 'created' : 'updated'}.`, 'success', 2500)
loadAdminData()
})
.catch(e => {
authStore.showMessage(e.message || `Failed to ${isNew ? 'create' : 'update'} OIDC client`, 'error')
})
return // Don't call closeDialog() again
} else if (t === 'server-config') {
const rp_name = dialog.value.data.rp_name?.trim() || ''
const auth_host = dialog.value.data.auth_host?.trim() || ''
// Origins are stored as-is (hostnames); backend normalizes with https://
const origins = dialog.value.data.origins
.map(o => o.trim())
.filter(o => o)
closeDialog()
apiJson('/auth/api/admin/server-config', { method: 'PATCH', body: { rp_name, auth_host, origins } })
.then(() => {
authStore.showMessage('Server configuration updated.', 'success', 2500)
// Reload settings to reflect rp_name changes
authStore.loadSettings().then(() => {
if (authStore.settings?.rp_name) document.title = authStore.settings.rp_name + ' Admin'
})
})
.catch(e => {
authStore.showMessage(e.message || 'Failed to update server configuration', 'error')
})
return // Don't call closeDialog() again
} else if (t === 'confirm') { } else if (t === 'confirm') {
const action = dialog.value.data.action const action = dialog.value.data.action
// Close dialog first, then perform action (errors shown via showMessage) // Close dialog first, then perform action (errors shown via showMessage)
@@ -718,7 +963,6 @@ async function submitDialog() {
/> />
<section v-else-if="authenticated && (isMasterAdmin || isOrgAdmin)" class="view-root view-root--wide view-admin"> <section v-else-if="authenticated && (isMasterAdmin || isOrgAdmin)" class="view-root view-root--wide view-admin">
<header class="view-header"> <header class="view-header">
<h1>{{ pageHeading }}</h1>
<Breadcrumbs ref="breadcrumbsRef" :entries="breadcrumbEntries" @keydown="handleBreadcrumbKeydown" /> <Breadcrumbs ref="breadcrumbsRef" :entries="breadcrumbEntries" @keydown="handleBreadcrumbKeydown" />
</header> </header>
@@ -726,11 +970,12 @@ async function submitDialog() {
<div class="section-body admin-section-body"> <div class="section-body admin-section-body">
<div class="admin-panels"> <div class="admin-panels">
<AdminOverview <AdminOverview
v-if="!selectedUser && !selectedOrg && (isMasterAdmin || isOrgAdmin)" v-if="!selectedUser && !selectedOrg && !currentOidcId && (isMasterAdmin || isOrgAdmin)"
ref="adminOverviewRef" ref="adminOverviewRef"
:info="info" :info="info"
:orgs="orgs" :orgs="orgs"
:permissions="permissions" :permissions="permissions"
:oidc-clients="oidcClients"
:navigation-disabled="hasActiveModal" :navigation-disabled="hasActiveModal"
:permission-summary="permissionSummary" :permission-summary="permissionSummary"
@create-org="createOrg" @create-org="createOrg"
@@ -741,6 +986,10 @@ async function submitDialog() {
@open-dialog="openDialog" @open-dialog="openDialog"
@delete-permission="deletePermission" @delete-permission="deletePermission"
@rename-permission-display="renamePermissionDisplay" @rename-permission-display="renamePermissionDisplay"
@create-oidc-client="createOidcClient"
@open-oidc-client="openOidcClient"
@delete-oidc-client="deleteOidcClient"
@open-server-config="openServerConfig"
@navigate-out="handlePanelNavigateOut" @navigate-out="handlePanelNavigateOut"
/> />
@@ -761,6 +1010,7 @@ async function submitDialog() {
@edit-user-name="editUserName" @edit-user-name="editUserName"
@close-reg-modal="showRegModal = false" @close-reg-modal="showRegModal = false"
@navigate-out="handlePanelNavigateOut" @navigate-out="handlePanelNavigateOut"
@delete-user="deleteUser(selectedUser, userDetail)"
/> />
<AdminOrgDetail <AdminOrgDetail
v-else-if="selectedOrg" v-else-if="selectedOrg"
@@ -775,10 +1025,23 @@ async function submitDialog() {
@create-user-in-role="createUserInRole" @create-user-in-role="createUserInRole"
@open-user="openUser" @open-user="openUser"
@toggle-role-permission="toggleRolePermission" @toggle-role-permission="toggleRolePermission"
@on-role-drag-over="onRoleDragOver" @move-user-to-role="moveUserToRoleFromDrag"
@navigate-out="handlePanelNavigateOut"
/>
<AdminOidcDetail
v-else-if="currentOidcId && editingOidcClient"
ref="adminOidcDetailRef"
:client="editingOidcClient"
:permissions="permissions"
:is-new="editingOidcClient.isNew"
:navigation-disabled="hasActiveModal"
@save="handleOidcSave"
@cancel="handleOidcCancel"
@delete="deleteOidcClient"
@reset-secret="resetOidcSecret"
@create-permission="createPermissionForClient"
@navigate-out="handlePanelNavigateOut" @navigate-out="handlePanelNavigateOut"
@on-role-drop="onRoleDrop"
@on-user-drag-start="onUserDragStart"
/> />
</div> </div>
@@ -792,14 +1055,14 @@ async function submitDialog() {
:settings="authStore.settings" :settings="authStore.settings"
@submit-dialog="submitDialog" @submit-dialog="submitDialog"
@close-dialog="closeDialog" @close-dialog="closeDialog"
@reset-oidc-secret="resetOidcSecret"
@create-permission-for-client="createPermissionForClient"
/> />
</div> </div>
</template> </template>
<style scoped> <style scoped>
.view-admin { padding-bottom: var(--space-3xl); } .view-admin { padding-bottom: var(--space-3xl); }
.view-header { display: flex; flex-direction: column; gap: var(--space-sm); }
.admin-section { margin-top: var(--space-xl); }
.admin-section-body { display: flex; flex-direction: column; gap: var(--space-xl); } .admin-section-body { display: flex; flex-direction: column; gap: var(--space-xl); }
.admin-panels { display: flex; flex-direction: column; gap: var(--space-xl); } .admin-panels { display: flex; flex-direction: column; gap: var(--space-xl); }
</style> </style>
+3 -1
View File
@@ -3,7 +3,9 @@
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<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> </head>
<body> <body>
<div id="admin-app"></div> <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 { createApp } from 'vue'
import { createPinia } from 'pinia' import { createPinia } from 'pinia'
+2
View File
@@ -4,6 +4,8 @@
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Auth Profile</title> <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> </head>
<body> <body>
<div id="app"></div> <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 { createApp } from 'vue'
import { createPinia } from 'pinia' import { createPinia } from 'pinia'
+25 -10
View File
@@ -2,19 +2,23 @@
<RestrictedAuth <RestrictedAuth
:mode="authMode" :mode="authMode"
:remote-auth-token="remoteAuthToken" :remote-auth-token="remoteAuthToken"
:oidc-query-string="oidcQueryString"
@authenticated="handleAuthenticated" @authenticated="handleAuthenticated"
@back="handleBack" @back="handleBack"
/> />
</template> </template>
<script setup> <script setup>
import { computed, onMounted, ref } from 'vue' import { onMounted, ref } from 'vue'
import RestrictedAuth from '@/components/RestrictedAuth.vue' import RestrictedAuth from '@/components/RestrictedAuth.vue'
// Check if this is a remote auth URL: /auth/{token} // Check if this is a remote auth URL: /auth/{token}
// The token is a 5-word passphrase like "word1.word2.word3.word4.word5" // The token is a 5-word passphrase like "word1.word2.word3.word4.word5"
const remoteAuthToken = ref(null) const remoteAuthToken = ref(null)
// For OIDC flow, pass the raw query string to preserve exact param values
const oidcQueryString = window.location.search.includes('client_id=') ? window.location.search : null
function extractRemoteToken() { function extractRemoteToken() {
const path = window.location.pathname const path = window.location.pathname
// Match /auth/{token} where token is a passphrase with dots // Match /auth/{token} where token is a passphrase with dots
@@ -30,14 +34,20 @@ function extractRemoteToken() {
return null return null
} }
// Detect mode from URL hash fragment // Parse URL hash fragment
const authMode = computed(() => { const hashParams = new URLSearchParams(window.location.hash.slice(1))
const params = new URLSearchParams(window.location.hash.slice(1))
const mode = params.get('mode') // Determine auth mode based on URL path
if (mode === 'reauth') return 'reauth' // - /auth/restricted/oidc: OIDC flow, no session dependency
if (mode === 'forbidden') return 'forbidden' // - /auth/restricted/iframe: iframe embedding, mode from hash params
return 'login' // - Other paths: forward auth, mode from hash params
}) let authMode
if (window.location.pathname === '/auth/restricted/oidc') {
authMode = 'oidc'
} else {
// Both iframe and forward auth use hash params for mode (forbidden/login/reauth)
authMode = ['reauth', 'forbidden'].includes(hashParams.get('mode')) ? hashParams.get('mode') : 'login'
}
function postToParent(message) { function postToParent(message) {
if (window.parent && window.parent !== window) { if (window.parent && window.parent !== window) {
@@ -46,10 +56,15 @@ function postToParent(message) {
} }
function handleAuthenticated(result) { function handleAuthenticated(result) {
if (result.redirect_url) {
// OIDC flow: redirect to client with auth code
window.location.href = result.redirect_url
return
}
postToParent({ postToParent({
type: 'auth-success', type: 'auth-success',
authenticated: true, authenticated: true,
sessionToken: result.session_token exchangeCode: result.exchange_code
}) })
} }
+3 -1
View File
@@ -1,8 +1,10 @@
<!DOCTYPE html> <!DOCTYPE html>
<html lang="en" style="background: transparent"> <html lang="en">
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<script>{let t=localStorage.getItem('paskia-theme');if(!t){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> </head>
<body> <body>
<div id="app"></div> <div id="app"></div>
+1 -1
View File
@@ -1,6 +1,6 @@
import './theme.js'
import { createApp } from 'vue' import { createApp } from 'vue'
import RestrictedApi from './RestrictedApi.vue' import RestrictedApi from './RestrictedApi.vue'
import '@/assets/style.css'
import { initKeyboardNavigation } from '@/utils/keynav' import { initKeyboardNavigation } from '@/utils/keynav'
createApp(RestrictedApi).mount('#app') 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 charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Access Restricted</title> <title>Access Restricted</title>
<script>(localStorage.getItem('paskia-theme')==='dark'||localStorage.getItem('paskia-theme')!=='light'&&matchMedia('(prefers-color-scheme:dark)').matches)&&document.documentElement.classList.add('dark')</script>
</head> </head>
<body> <body>
<div id="app"></div> <div id="app"></div>
+20 -19
View File
@@ -1,14 +1,14 @@
<template> <template>
<div class="app-shell"> <div class="app-shell">
<div v-if="status.show" class="global-status" style="display: block;"> <div v-if="status.show" class="global-status show">
<div :class="['status', status.type]"> <div :class="['status', status.type]">
{{ status.message }} {{ status.message }}
</div> </div>
</div> </div>
<main class="view-root"> <main class="view-root">
<div class="surface surface--tight" style="max-width: 560px; margin: 0 auto; width: 100%;"> <div class="surface surface--tight reset-container">
<header class="view-header" style="text-align: center;"> <header class="view-header center">
<h1>🔑 Registration</h1> <h1>🔑 Registration</h1>
<p class="view-lede"> <p class="view-lede">
{{ subtitleMessage }} {{ subtitleMessage }}
@@ -23,7 +23,7 @@
<section class="section-block" v-else-if="!canRegister"> <section class="section-block" v-else-if="!canRegister">
<div class="section-body center"> <div class="section-body center">
<div class="button-row center" style="justify-content: center;"> <div class="button-row button-row--center">
<button class="btn-secondary" @click="goHome">Return to sign-in</button> <button class="btn-secondary" @click="goHome">Return to sign-in</button>
</div> </div>
</div> </div>
@@ -59,7 +59,8 @@
import { computed, onMounted, reactive, ref } from 'vue' import { computed, onMounted, reactive, ref } from 'vue'
import passkey from '@/utils/passkey' import passkey from '@/utils/passkey'
import { getSettings, uiBasePath } from '@/utils/settings' import { getSettings, uiBasePath } from '@/utils/settings'
import { apiJson, ApiError, getUserFriendlyErrorMessage } from '@/utils/api' import { apiJson, ApiError, getUserFriendlyErrorMessage } from 'paskia'
import { updateThemeFromSession } from '@/utils/theme'
const status = reactive({ const status = reactive({
show: false, show: false,
@@ -80,7 +81,7 @@ const sessionDescriptor = computed(() => tokenInfo.value?.token_type || 'your en
const subtitleMessage = computed(() => { const subtitleMessage = computed(() => {
if (initializing.value) return 'Preparing your secure enrollment…' if (initializing.value) return 'Preparing your secure enrollment…'
if (!canRegister.value) return 'This authentication link is no longer valid.' if (!canRegister.value) return 'This authentication link is no longer valid.'
return `Finish up ${sessionDescriptor.value}. You may edit the name below if needed, and it will be saved to your passkey.` return `Finish up ${sessionDescriptor.value}. The name entered will be stored on your passkey and on our system.`
}) })
const basePath = computed(() => uiBasePath()) const basePath = computed(() => uiBasePath())
@@ -117,6 +118,7 @@ async function fetchTokenInfo() {
headers: { 'Authorization': `Bearer ${token.value}` }, headers: { 'Authorization': `Bearer ${token.value}` },
}) })
displayName.value = tokenInfo.value.display_name displayName.value = tokenInfo.value.display_name
if (tokenInfo.value.theme) updateThemeFromSession({ user: { theme: tokenInfo.value.theme } })
} catch (error) { } catch (error) {
console.error('Failed to load token info', error) console.error('Failed to load token info', error)
const message = error instanceof ApiError const message = error instanceof ApiError
@@ -144,7 +146,7 @@ async function registerPasskey() {
} }
try { try {
await setSessionCookie(result) await exchangeCode(result)
} catch (error) { } catch (error) {
loading.value = false loading.value = false
const message = error?.message || 'Failed to establish session' const message = error?.message || 'Failed to establish session'
@@ -156,15 +158,13 @@ async function registerPasskey() {
setTimeout(() => { loading.value = false; goHome() }, 800) setTimeout(() => { loading.value = false; goHome() }, 800)
} }
async function setSessionCookie(result) { async function exchangeCode(result) {
if (!result?.session_token) { if (!result?.exchange_code) {
throw new Error('Registration response missing session_token') throw new Error('Registration response missing exchange_code')
} }
return await apiJson('/auth/api/set-session', { return await apiJson('/auth/api/set-session', {
method: 'POST', method: 'POST',
headers: { headers: { 'Authorization': `Bearer ${result.exchange_code}` }
Authorization: `Bearer ${result.session_token}`
}
}) })
} }
@@ -203,13 +203,14 @@ onMounted(async () => {
</script> </script>
<style scoped> <style scoped>
.center { main.view-root { min-height: 100vh; align-items: center; justify-content: center; padding: 2rem 1rem; }
text-align: center; .reset-container {
} max-width: 520px;
margin: 0 auto;
.button-row.center { width: 100%;
display: flex; display: flex;
justify-content: center; flex-direction: column;
gap: 1.75rem;
} }
.section-body { .section-body {
+1
View File
@@ -4,6 +4,7 @@
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Complete Passkey Setup</title> <title>Complete Passkey Setup</title>
<script>(localStorage.getItem('paskia-theme')==='dark'||localStorage.getItem('paskia-theme')!=='light'&&matchMedia('(prefers-color-scheme:dark)').matches)&&document.documentElement.classList.add('dark')</script>
</head> </head>
<body> <body>
<div id="app"></div> <div id="app"></div>
+4 -1
View File
@@ -10,10 +10,13 @@
}, },
"dependencies": { "dependencies": {
"@simplewebauthn/browser": "^13.1.2", "@simplewebauthn/browser": "^13.1.2",
"paskia": "file:../paskia-js",
"pinia": "^3.0.3", "pinia": "^3.0.3",
"qrcode": "^1.5.4", "qrcode": "^1.5.4",
"sirv": "^3.0.2", "sirv": "^3.0.2",
"vue": "^3.5.17" "uuidv7": "^1.1.0",
"vue": "^3.5.17",
"vuedraggable": "^4.1.0"
}, },
"devDependencies": { "devDependencies": {
"@vitejs/plugin-vue": "^6.0.0", "@vitejs/plugin-vue": "^6.0.0",
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 { computed } from 'vue'
import Modal from '@/components/Modal.vue' import Modal from '@/components/Modal.vue'
import NameEditForm from '@/components/NameEditForm.vue' import NameEditForm from '@/components/NameEditForm.vue'
import { useAuthStore } from '@/stores/auth'
const props = defineProps({ const props = defineProps({
dialog: Object, dialog: Object,
@@ -9,10 +10,164 @@ const props = defineProps({
settings: Object settings: Object
}) })
const emit = defineEmits(['submitDialog', 'closeDialog']) const emit = defineEmits(['submitDialog', 'closeDialog', 'resetOidcSecret', 'createPermissionForClient'])
const NAME_EDIT_TYPES = new Set(['org-update', 'role-update', 'user-update-name']) const NAME_EDIT_TYPES = new Set(['org-update', 'role-update', 'user-update-name'])
const NO_SUBMIT_TYPES = new Set([])
const rpId = computed(() => props.settings?.rp_id || 'the configured domain') const rpId = computed(() => props.settings?.rp_id || 'the configured domain')
const discoveryUrl = computed(() => `${window.location.origin}/.well-known/openid-configuration`)
// 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> </script>
<template> <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-create'">Add User To Role</template>
<template v-else-if="dialog.type==='user-update-name'">Edit User Name</template> <template v-else-if="dialog.type==='user-update-name'">Edit User Name</template>
<template v-else-if="dialog.type==='perm-create' || dialog.type==='perm-display'">{{ dialog.type === 'perm-create' ? 'Create Permission' : 'Edit Permission' }}</template> <template v-else-if="dialog.type==='perm-create' || dialog.type==='perm-display'">{{ dialog.type === 'perm-create' ? 'Create Permission' : 'Edit Permission' }}</template>
<template v-else-if="dialog.type==='oidc-edit'">{{ dialog.data?.isNew ? 'New OIDC Client' : 'OIDC Client' }}</template>
<template v-else-if="dialog.type==='server-config'">Server Options</template>
<template v-else-if="dialog.type==='confirm'">Confirm</template> <template v-else-if="dialog.type==='confirm'">Confirm</template>
</h3> </h3>
<form @submit.prevent="$emit('submitDialog')" class="modal-form"> <form @submit.prevent="$emit('submitDialog')" class="modal-form">
@@ -76,19 +233,51 @@ const rpId = computed(() => props.settings?.rp_id || 'the configured domain')
<input ref="displayNameInput" v-model="dialog.data.display_name" required /> <input ref="displayNameInput" v-model="dialog.data.display_name" required />
</label> </label>
<label>Permission Scope <label>Permission Scope
<input v-model="dialog.data.scope" :placeholder="dialog.type === 'perm-create' ? 'yourapp:permission' : dialog.data.permission.scope" required :pattern="PERMISSION_ID_PATTERN" title="Allowed: A-Za-z0-9:._~-" data-form-type="other" /> <input v-model="dialog.data.scope" required :pattern="PERMISSION_ID_PATTERN" title="Allowed: A-Za-z0-9:._~-" data-form-type="other" />
</label> </label>
<p class="small muted">E.g. yourapp:reports. Changing the scope name may break deployed applications.</p> <p class="small muted">E.g. yourapp:reports. Changing the scope name may break deployed applications.</p>
<label>Domain Scope <label>Domain Scope
<input v-model="dialog.data.domain" placeholder="e.g. app.example.com" data-form-type="other" /> <input v-model="dialog.data.domain" data-form-type="other" />
</label> </label>
<p class="small muted">If set, this permission is effective only on the specified domain, which can be {{ rpId }} or its subdomain.</p> <p class="small muted">A domain ({{ rpId }} or subdomain) restricts this permission to that host. An OIDC client UUID sends it as a <em>groups</em> claim to that client.</p>
</template>
<template 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>
<template v-else-if="dialog.type==='confirm'"> <template v-else-if="dialog.type==='confirm'">
<p>{{ dialog.data.message }}</p> <p>{{ dialog.data.message }}</p>
</template> </template>
<div v-if="dialog.error && !NAME_EDIT_TYPES.has(dialog.type)" class="error small">{{ dialog.error }}</div> <div v-if="dialog.error && !NAME_EDIT_TYPES.has(dialog.type)" class="error small">{{ dialog.error }}</div>
<div v-if="!NAME_EDIT_TYPES.has(dialog.type)" class="modal-actions"> <div v-if="!NAME_EDIT_TYPES.has(dialog.type) && !NO_SUBMIT_TYPES.has(dialog.type)" class="modal-actions">
<button <button
type="button" type="button"
class="btn-secondary" class="btn-secondary"
@@ -100,18 +289,46 @@ const rpId = computed(() => props.settings?.rp_id || 'the configured domain')
<button <button
type="submit" type="submit"
class="btn-primary" class="btn-primary"
:disabled="dialog.busy" :disabled="dialog.busy || isValidationInvalid"
> >
{{ dialog.type==='confirm' ? 'OK' : 'Save' }} {{ dialog.type==='confirm' ? 'OK' : 'Save' }}
</button> </button>
</div> </div>
<div v-else-if="NO_SUBMIT_TYPES.has(dialog.type)" class="modal-actions">
<button
type="button"
class="btn-primary"
@click="$emit('closeDialog')"
>
Close
</button>
</div>
</form> </form>
</Modal> </Modal>
</template> </template>
<style scoped> <style scoped>
.error { color: var(--color-danger-text); }
.small { font-size: 0.9rem; }
.muted { color: var(--color-text-muted); }
.optional { font-weight: normal; color: var(--color-text-muted); font-size: 0.85em; } .optional { font-weight: normal; color: var(--color-text-muted); font-size: 0.85em; }
.oidc-divider { border: none; border-top: 1px solid var(--color-border); margin: var(--space-sm) 0; }
.oidc-dl { display: grid; grid-template-columns: auto 1fr; gap: 0.2rem 1rem; align-items: baseline; margin: 0; }
.oidc-dl dt { font-size: 0.85rem; color: var(--color-text-muted); white-space: nowrap; }
.oidc-dl dd { margin: 0; cursor: pointer; overflow: hidden; }
.oidc-dl output { font-family: var(--font-mono, monospace); font-size: 0.85rem; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; display: block; }
.oidc-reset-row { display: flex; align-items: center; gap: var(--space-sm); flex-wrap: wrap; }
.oidc-groups { cursor: default; }
.oidc-group { cursor: pointer; }
.oidc-group output { white-space: normal; word-break: break-all; }
/* Server config origins */
.origin-label { font-weight: 600; font-size: 0.95rem; margin-top: var(--space-sm); display: flex; align-items: center; gap: var(--space-sm); }
.origin-list { display: flex; flex-direction: column; gap: 0.4rem; }
.origin-row { display: flex; align-items: center; gap: var(--space-xs); }
.origin-input { flex: 1; min-width: 8rem; font-family: var(--font-mono, monospace); }
.origin-row .delete-icon { flex-shrink: 0; }
.origin-add-btn { font-size: 1.2rem; }
.input-error {
border-color: var(--color-error);
background: var(--color-error-bg, rgba(239, 68, 68, 0.05));
}
</style> </style>
+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>
+85 -61
View File
@@ -1,5 +1,6 @@
<script setup> <script setup>
import { computed, ref } from 'vue' import { computed, ref } from 'vue'
import draggable from 'vuedraggable'
import { getDirection, navigateButtonRow, focusPreferred } from '@/utils/keynav' import { getDirection, navigateButtonRow, focusPreferred } from '@/utils/keynav'
const props = defineProps({ const props = defineProps({
@@ -8,7 +9,7 @@ const props = defineProps({
navigationDisabled: { type: Boolean, default: false } navigationDisabled: { type: Boolean, default: false }
}) })
const emit = defineEmits(['updateOrg', 'createRole', 'updateRole', 'deleteRole', 'createUserInRole', 'openUser', 'toggleRolePermission', 'onRoleDragOver', 'onRoleDrop', 'onUserDragStart', 'navigateOut']) const emit = defineEmits(['updateOrg', 'createRole', 'updateRole', 'deleteRole', 'createUserInRole', 'openUser', 'toggleRolePermission', 'moveUserToRole', 'navigateOut'])
// Template refs for navigation // Template refs for navigation
const orgTitleRef = ref(null) const orgTitleRef = ref(null)
@@ -16,7 +17,8 @@ const permMatrixRef = ref(null)
const rolesGridRef = ref(null) const rolesGridRef = ref(null)
const sortedRoles = computed(() => { const sortedRoles = computed(() => {
return [...props.selectedOrg.roles].sort((a, b) => { // o.roles is dict[UUID, Role], convert to array for sorting with uuid added
return Object.entries(props.selectedOrg.roles).map(([uuid, r]) => ({ uuid, ...r })).sort((a, b) => {
const nameA = a.display_name.toLowerCase() const nameA = a.display_name.toLowerCase()
const nameB = b.display_name.toLowerCase() const nameB = b.display_name.toLowerCase()
if (nameA !== nameB) { if (nameA !== nameB) {
@@ -28,12 +30,45 @@ const sortedRoles = computed(() => {
// Get org's grantable permissions as full permission objects (with UUIDs) // Get org's grantable permissions as full permission objects (with UUIDs)
const orgPermissions = computed(() => { const orgPermissions = computed(() => {
const uuidSet = new Set(props.selectedOrg.permissions || []) // props.selectedOrg.permissions is dict[UUID, Permission]
const uuidSet = new Set(Object.keys(props.selectedOrg.permissions || {}))
return props.permissions.filter(p => uuidSet.has(p.uuid)) return props.permissions.filter(p => uuidSet.has(p.uuid))
}) })
function permissionDisplayName(scope) { // Get users for a role as sorted array of { uuid, ...user }
return props.permissions.find(p => p.scope === scope)?.display_name || scope 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) { function toggleRolePermission(role, pid, checked) {
@@ -83,7 +118,7 @@ function handleMatrixKeydown(event) {
// Calculate grid dimensions // Calculate grid dimensions
const cols = sortedRoles.value.length const cols = sortedRoles.value.length
const rows = props.selectedOrg.permissions.length const rows = Object.keys(props.selectedOrg.permissions).length
const currentRow = Math.floor(currentIndex / cols) const currentRow = Math.floor(currentIndex / cols)
const currentCol = currentIndex % cols const currentCol = currentIndex % cols
@@ -219,7 +254,7 @@ function handleRoleHeaderKeydown(event, roleIndex) {
if (checkboxes?.length) { if (checkboxes?.length) {
// Focus the checkbox in the corresponding column // Focus the checkbox in the corresponding column
const cols = sortedRoles.value.length const cols = sortedRoles.value.length
const rows = props.selectedOrg.permissions.length const rows = Object.keys(props.selectedOrg.permissions).length
const targetIndex = (rows - 1) * cols + roleIndex const targetIndex = (rows - 1) * cols + roleIndex
if (checkboxes[targetIndex]) checkboxes[targetIndex].focus() if (checkboxes[targetIndex]) checkboxes[targetIndex].focus()
else checkboxes[checkboxes.length - 1].focus() else checkboxes[checkboxes.length - 1].focus()
@@ -287,7 +322,7 @@ defineExpose({ focusFirstElement })
<template> <template>
<h2 class="org-title" ref="orgTitleRef" @keydown="handleTitleKeydown" :title="selectedOrg.uuid"> <h2 class="org-title" ref="orgTitleRef" @keydown="handleTitleKeydown" :title="selectedOrg.uuid">
<span class="org-name">{{ selectedOrg.display_name }}</span> <span class="org-name">{{ selectedOrg.org.display_name }}</span>
<button @click="$emit('updateOrg', selectedOrg)" class="icon-btn" aria-label="Rename organization" title="Rename organization"></button> <button @click="$emit('updateOrg', selectedOrg)" class="icon-btn" aria-label="Rename organization" title="Rename organization"></button>
</h2> </h2>
@@ -317,7 +352,7 @@ defineExpose({ focusFirstElement })
> >
<input <input
type="checkbox" type="checkbox"
:checked="r.permissions.includes(p.uuid)" :checked="p.uuid in (r.permissions || {})"
@change="e => toggleRolePermission(r, p.uuid, e.target.checked)" @change="e => toggleRolePermission(r, p.uuid, e.target.checked)"
/> />
</div> </div>
@@ -332,84 +367,73 @@ defineExpose({ focusFirstElement })
v-for="(r, roleIndex) in sortedRoles" v-for="(r, roleIndex) in sortedRoles"
:key="r.uuid" :key="r.uuid"
class="role-column" class="role-column"
@dragover="$emit('onRoleDragOver', $event)"
@drop="e => $emit('onRoleDrop', e, selectedOrg, r)"
> >
<div class="role-header" @keydown="e => handleRoleHeaderKeydown(e, roleIndex)"> <div class="role-header" @keydown="e => handleRoleHeaderKeydown(e, roleIndex)">
<strong class="role-name" :title="r.uuid"> <strong class="role-name" :title="r.uuid">
<span>{{ r.display_name }}</span> <span>{{ r.display_name }}</span>
<button @click="$emit('updateRole', r)" class="icon-btn" aria-label="Edit role" title="Edit role"></button> <button @click="$emit('updateRole', r)" class="icon-btn" aria-label="Edit role" title="Edit role"></button>
<button v-if="roleUserCount(r.uuid) === 0" @click="$emit('deleteRole', r)" class="icon-btn delete-icon" aria-label="Delete role" title="Delete role"></button>
</strong> </strong>
<div class="role-actions"> <div class="role-actions">
<button @click="$emit('createUserInRole', selectedOrg, r)" class="plus-btn" aria-label="Add user" title="Add user"></button> <button @click="$emit('createUserInRole', selectedOrg, r)" class="plus-btn" aria-label="Add user" title="Add user"></button>
</div> </div>
</div> </div>
<template v-if="r.users.length > 0"> <div class="user-list-wrapper">
<ul class="user-list" @keydown="handleUserListKeydown"> <draggable
<li :list="roleUsers(r.uuid)"
v-for="u in r.users.slice().sort((a, b) => { group="users"
const nameA = a.display_name.toLowerCase() item-key="uuid"
const nameB = b.display_name.toLowerCase() tag="ul"
if (nameA !== nameB) { class="user-list"
return nameA.localeCompare(nameB) @change="evt => onUserChange(evt, r.uuid)"
} @keydown="handleUserListKeydown"
return a.uuid.localeCompare(b.uuid) >
})" <template #item="{ element: u }">
:key="u.uuid" <li
class="user-chip" class="user-chip"
tabindex="0" tabindex="0"
draggable="true" @click="$emit('openUser', u)"
@dragstart="e => $emit('onUserDragStart', e, u, selectedOrg.uuid)" @keydown.enter="$emit('openUser', u)"
@click="$emit('openUser', u)" :title="u.uuid"
@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(undefined, { month: 'short', day: 'numeric', year: 'numeric' }) : '—' }}</span>
<span class="name">{{ u.display_name }}</span> </li>
<span class="meta">{{ u.last_seen ? new Date(u.last_seen).toLocaleDateString() : '—' }}</span> </template>
</li> </draggable>
</ul> <div v-if="roleUserCount(r.uuid) === 0" class="empty-role">
</template> <p class="empty-text muted">No members</p>
<div v-else class="empty-role" @keydown="e => handleEmptyRoleKeydown(e, roleIndex)"> </div>
<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> </div>
</div> </div>
</div> </div>
<p v-if="sortedRoles.length >= 2" class="roles-hint muted">Members can be drag&dropped to different roles.</p>
</template> </template>
<style scoped> <style scoped>
.card.surface { padding: var(--space-lg); } .card.surface { padding: var(--space-lg); }
.org-title { display: flex; align-items: center; gap: var(--space-sm); margin-bottom: var(--space-lg); } .org-title { display: flex; align-items: center; gap: var(--space-sm); margin-bottom: var(--space-lg); font-size: 1.65rem; }
.org-name { font-size: 1.5rem; font-weight: 600; color: var(--color-heading); } .org-name { font-weight: 600; color: var(--color-heading); }
.icon-btn { background: none; border: none; color: var(--color-text-muted); padding: 0.2rem; border-radius: var(--radius-sm); cursor: pointer; transition: background 0.2s ease, color 0.2s ease; }
.icon-btn:hover { color: var(--color-heading); background: var(--color-surface-muted); }
.matrix-wrapper { margin: var(--space-md) 0; padding: var(--space-lg); }
.matrix-scroll { overflow-x: auto; }
.matrix-hint { font-size: 0.8rem; color: var(--color-text-muted); }
.perm-matrix-grid { display: inline-grid; gap: 0.25rem; align-items: stretch; }
.perm-matrix-grid > * { padding: 0.35rem 0.45rem; font-size: 0.75rem; }
.perm-matrix-grid .grid-head { color: var(--color-text-muted); text-transform: uppercase; font-weight: 600; letter-spacing: 0.05em; }
.perm-matrix-grid .perm-head { display: flex; align-items: flex-end; justify-content: flex-start; padding: 0.35rem 0.45rem; font-size: 0.75rem; }
.perm-matrix-grid .role-head { display: flex; align-items: flex-end; justify-content: center; } .perm-matrix-grid .role-head { display: flex; align-items: flex-end; justify-content: center; }
.perm-matrix-grid .role-head span { writing-mode: vertical-rl; transform: rotate(180deg); font-size: 0.65rem; } .perm-matrix-grid .role-head span { writing-mode: vertical-rl; transform: rotate(180deg); font-size: 0.65rem; }
.perm-matrix-grid .add-role-head { cursor: pointer; } .perm-matrix-grid .add-role-head { cursor: pointer; }
.perm-name { font-weight: 600; color: var(--color-heading); padding: 0.35rem 0.45rem; font-size: 0.75rem; } .roles-grid { display: flex; flex-wrap: wrap; gap: 0; margin-top: var(--space-lg); justify-content: flex-start; align-items: stretch; }
.roles-grid { display: flex; gap: var(--space-lg); margin-top: var(--space-lg); } .role-column { flex: 0 0 240px; border-radius: var(--radius-md); padding: var(--space-md); display: flex; flex-direction: column; }
.role-column { flex: 1; min-width: 200px; border: 1px solid var(--color-border); border-radius: var(--radius-md); padding: var(--space-md); }
.role-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: var(--space-md); } .role-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: var(--space-md); }
.role-name { display: flex; align-items: center; gap: var(--space-xs); font-size: 1.1rem; color: var(--color-heading); } .role-name { display: flex; align-items: center; gap: var(--space-xs); font-size: 1.1rem; color: var(--color-heading); }
.role-actions { display: flex; gap: var(--space-xs); } .role-actions { display: flex; gap: var(--space-xs); }
.plus-btn { background: var(--color-accent-soft); color: var(--color-accent); border: none; border-radius: var(--radius-sm); padding: 0.25rem 0.45rem; font-size: 1.1rem; cursor: pointer; } .plus-btn { background: none; color: var(--color-accent); border: none; border-radius: var(--radius-sm); padding: 0.25rem 0.45rem; font-size: 1.1rem; cursor: pointer; }
.plus-btn:hover { background: rgba(37, 99, 235, 0.18); } .plus-btn:hover { background: rgba(37, 99, 235, 0.18); }
.user-list { list-style: none; padding: 0; margin: 0; display: flex; flex-direction: column; gap: var(--space-xs); } .user-list-wrapper { position: relative; flex: 1; display: flex; flex-direction: column; min-height: 5.5rem; }
.user-chip { background: var(--color-surface); border: 1px solid var(--color-border); border-radius: var(--radius-md); padding: 0.45rem 0.6rem; display: flex; justify-content: space-between; gap: var(--space-sm); cursor: grab; } .user-list { list-style: none; padding: 0; margin: 0; display: flex; flex-direction: column; gap: var(--space-xs); flex: 1; }
.user-chip { background: var(--color-accent-strong); color: var(--color-accent-contrast); border: none; border-radius: var(--radius-md); padding: 0.45rem 0.6rem; display: flex; justify-content: space-between; gap: var(--space-sm); cursor: grab; }
.user-chip:focus { outline: 2px solid var(--color-accent); outline-offset: 1px; } .user-chip:focus { outline: 2px solid var(--color-accent); outline-offset: 1px; }
.user-chip .meta { font-size: 0.7rem; color: var(--color-text-muted); } .user-chip .meta { font-size: 0.7rem; }
.empty-role { border: 1px dashed var(--color-border-strong); border-radius: var(--radius-md); padding: var(--space-sm); display: flex; flex-direction: column; gap: var(--space-xs); align-items: flex-start; } .user-chip.sortable-ghost { opacity: 0.5; }
.user-chip.sortable-chosen { box-shadow: 0 4px 12px rgba(0, 0, 0, 0.2); }
.empty-role { position: absolute; inset: 0; border: 1px dashed var(--color-border-strong); border-radius: var(--radius-md); display: flex; align-items: center; justify-content: center; pointer-events: none; }
.user-list:has(.sortable-ghost) + .empty-role { display: none; }
.empty-text { margin: 0; } .empty-text { margin: 0; }
.delete-icon { color: var(--color-danger); }
.delete-icon:hover { background: var(--color-danger-bg); color: var(--color-danger-text); }
.muted { color: var(--color-text-muted); }
@media (max-width: 720px) { @media (max-width: 720px) {
.roles-grid { flex-direction: column; } .roles-grid { flex-direction: column; }
+109 -30
View File
@@ -1,19 +1,20 @@
<script setup> <script setup>
import { computed, ref } from 'vue' import { computed, ref } from 'vue'
import { getDirection, navigateButtonRow, focusPreferred, focusAtIndex } from '@/utils/keynav' import { getDirection, navigateButtonRow, focusPreferred, focusAtIndex } from '@/utils/keynav'
import { formatDate } from '@/utils/helpers'
const props = defineProps({ const props = defineProps({
info: Object, info: Object,
orgs: Array, orgs: Array,
permissions: Array, permissions: Array,
oidcClients: Array,
permissionSummary: Object, permissionSummary: Object,
navigationDisabled: { type: Boolean, default: false } navigationDisabled: { type: Boolean, default: false }
}) })
const emit = defineEmits(['createOrg', 'openOrg', 'updateOrg', 'deleteOrg', 'toggleOrgPermission', 'openDialog', 'deletePermission', 'renamePermissionDisplay', 'navigateOut']) const emit = defineEmits(['createOrg', 'openOrg', 'updateOrg', 'deleteOrg', 'toggleOrgPermission', 'openDialog', 'deletePermission', 'renamePermissionDisplay', 'createOidcClient', 'openOidcClient', 'deleteOidcClient', 'openServerConfig', 'navigateOut'])
// Template refs for navigation // Template refs for navigation
const orgSection = ref(null)
const orgActionsRef = ref(null) const orgActionsRef = ref(null)
const orgTableRef = ref(null) const orgTableRef = ref(null)
const permMatrixRef = ref(null) const permMatrixRef = ref(null)
@@ -21,27 +22,56 @@ const permActionsRef = ref(null)
const permTableRef = ref(null) const permTableRef = ref(null)
const sortedOrgs = computed(() => [...props.orgs].sort((a,b)=> { const sortedOrgs = computed(() => [...props.orgs].sort((a,b)=> {
const nameCompare = a.display_name.localeCompare(b.display_name) const nameCompare = a.org.display_name.localeCompare(b.org.display_name)
return nameCompare !== 0 ? nameCompare : a.uuid.localeCompare(b.uuid) return nameCompare !== 0 ? nameCompare : a.uuid.localeCompare(b.uuid)
})) }))
// Map OIDC client UUIDs to display names for permission domain column
const oidcClientNames = computed(() => {
const map = {}
for (const c of props.oidcClients || []) map[c.uuid] = c.name
return map
})
function domainDisplay(domain) {
if (!domain) return '—'
return oidcClientNames.value[domain] || domain
}
// Map OIDC client UUIDs to their group permissions (sorted by scope)
const clientGroups = computed(() => {
const map = {}
for (const p of props.permissions || []) {
if (p.domain) {
if (!map[p.domain]) map[p.domain] = []
map[p.domain].push(p)
}
}
// Sort each group array by scope
for (const key in map) {
map[key].sort((a, b) => a.scope.localeCompare(b.scope))
}
return map
})
const sortedPermissions = computed(() => [...props.permissions].sort((a,b)=> a.scope.localeCompare(b.scope))) const sortedPermissions = computed(() => [...props.permissions].sort((a,b)=> a.scope.localeCompare(b.scope)))
// Derive admin status from permissions (info contains ctx from validate response) // Derive admin status from permissions (info contains ctx from validate response)
const isMasterAdmin = computed(() => props.info?.ctx.permissions.includes('auth:admin')) const isMasterAdmin = computed(() => props.info?.ctx.permissions.includes('auth:admin'))
const isOrgAdmin = computed(() => props.info?.ctx.permissions.includes('auth:org: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) { function getRoleNames(org) {
return org.roles // org.roles is dict[UUID, Role]
return Object.values(org.roles)
.slice() .slice()
.sort((a, b) => a.display_name.localeCompare(b.display_name)) .sort((a, b) => a.display_name.localeCompare(b.display_name))
.map(r => r.display_name) .map(r => r.display_name)
.join(', ') .join(', ')
} }
function orgUserCount(org) {
return Object.keys(org.users).length
}
// Table navigation for both org and permissions tables // Table navigation for both org and permissions tables
function handleTableKeydown(event, tableType) { function handleTableKeydown(event, tableType) {
if (props.navigationDisabled) return if (props.navigationDisabled) return
@@ -265,11 +295,11 @@ defineExpose({ focusFirstElement })
<tbody> <tbody>
<tr v-for="o in sortedOrgs" :key="o.uuid"> <tr v-for="o in sortedOrgs" :key="o.uuid">
<td> <td>
<a href="#org/{{o.uuid}}" @click.prevent="$emit('openOrg', o)">{{ o.display_name }}</a> <a href="#org/{{o.uuid}}" @click.prevent="$emit('openOrg', o)">{{ o.org.display_name }}</a>
<button v-if="isMasterAdmin || isOrgAdmin" @click="$emit('updateOrg', o)" class="icon-btn edit-org-btn" aria-label="Rename organization" title="Rename organization"></button> <button v-if="isMasterAdmin || isOrgAdmin" @click="$emit('updateOrg', o)" class="icon-btn edit-org-btn" aria-label="Rename organization" title="Rename organization"></button>
</td> </td>
<td class="role-names">{{ getRoleNames(o) }}</td> <td class="role-names">{{ getRoleNames(o) }}</td>
<td class="center">{{ o.roles.reduce((acc,r)=>acc + r.users.length,0) }}</td> <td class="center">{{ orgUserCount(o) }}</td>
<td v-if="isMasterAdmin" class="center"> <td v-if="isMasterAdmin" class="center">
<button @click="$emit('deleteOrg', o)" class="icon-btn delete-icon" aria-label="Delete organization" title="Delete organization"></button> <button @click="$emit('deleteOrg', o)" class="icon-btn delete-icon" aria-label="Delete organization" title="Delete organization"></button>
</td> </td>
@@ -291,9 +321,9 @@ defineExpose({ focusFirstElement })
v-for="o in sortedOrgs" v-for="o in sortedOrgs"
:key="'head-' + o.uuid" :key="'head-' + o.uuid"
class="grid-head org-head" class="grid-head org-head"
:title="o.display_name" :title="o.org.display_name"
> >
<span>{{ o.display_name }}</span> <span>{{ o.org.display_name }}</span>
</div> </div>
<template v-for="p in sortedPermissions" :key="p.uuid"> <template v-for="p in sortedPermissions" :key="p.uuid">
@@ -307,7 +337,7 @@ defineExpose({ focusFirstElement })
> >
<input <input
type="checkbox" type="checkbox"
:checked="o.permissions.includes(p.uuid)" :checked="p.uuid in o.permissions"
@change="e => $emit('toggleOrgPermission', o, p.uuid, e.target.checked)" @change="e => $emit('toggleOrgPermission', o, p.uuid, e.target.checked)"
/> />
</div> </div>
@@ -339,7 +369,7 @@ defineExpose({ focusFirstElement })
<span class="id-text">{{ p.scope }}</span> <span class="id-text">{{ p.scope }}</span>
</div> </div>
</td> </td>
<td class="perm-domain">{{ p.domain || '—' }}</td> <td class="perm-domain">{{ domainDisplay(p.domain) }}</td>
<td class="perm-members center">{{ permissionSummary[p.uuid]?.userCount || 0 }}</td> <td class="perm-members center">{{ permissionSummary[p.uuid]?.userCount || 0 }}</td>
<td class="perm-actions center"> <td class="perm-actions center">
<button @click="$emit('deletePermission', p)" class="icon-btn delete-icon" aria-label="Delete permission" title="Delete permission"></button> <button @click="$emit('deletePermission', p)" class="icon-btn delete-icon" aria-label="Delete permission" title="Delete permission"></button>
@@ -348,13 +378,67 @@ defineExpose({ focusFirstElement })
</tbody> </tbody>
</table> </table>
</div> </div>
<div v-if="isMasterAdmin" class="oidc-clients-section">
<div class="section-header">
<h2>OAuth2 / OpenID Connect</h2>
<p class="section-description">
Allow external websites and applications to securely authenticate users through this system.
The clients are remote sites or applications that we allow to use Paskia for Single Sign-On.
</p>
</div>
<div ref="oidcActionsRef">
<button @click="$emit('createOidcClient')">+ Add Site</button>
</div>
<table class="org-table" ref="oidcTableRef">
<thead>
<tr>
<th scope="col">Client</th>
<th scope="col">Groups</th>
<th scope="col" class="center">Sessions</th>
<th scope="col" class="center">Actions</th>
</tr>
</thead>
<tbody>
<tr v-if="!oidcClients || oidcClients.length === 0">
<td colspan="4" class="center muted">No OIDC clients configured</td>
</tr>
<tr v-for="client in oidcClients" :key="client.uuid">
<td class="perm-name-cell">
<div class="perm-title">
<a :href="'#oidc:' + client.uuid" @click.prevent="$emit('openOidcClient', client)">{{ client.name }}</a>
</div>
<div class="perm-id-info">
<span class="id-text">{{ client.uuid }}</span>
</div>
</td>
<td class="client-groups">
<span v-if="clientGroups[client.uuid]?.length">{{ clientGroups[client.uuid].map(g => g.scope).join(' ') }}</span>
<span v-else class="muted"></span>
</td>
<td class="center">{{ client.active_sessions || 0 }}</td>
<td class="center">
<button @click="$emit('deleteOidcClient', client)" class="icon-btn delete-icon" aria-label="Delete OIDC client" title="Delete OIDC client"></button>
</td>
</tr>
</tbody>
</table>
</div>
<div v-if="isMasterAdmin" class="server-options-section">
<div class="section-header">
<h2>Server</h2>
<p class="section-description">
Configure core server settings such as the display name, authentication host, and allowed origins.
</p>
</div>
<button @click="$emit('openServerConfig')"> Server Options</button>
</div>
</template> </template>
<style scoped> <style scoped>
.permissions-section { margin-bottom: var(--space-xl); } .permissions-section { margin-bottom: var(--space-xl); }
.permissions-section h2 { margin-bottom: var(--space-md); } .permissions-section h2 { margin-bottom: var(--space-md); }
.actions { display: flex; flex-wrap: wrap; gap: var(--space-sm); align-items: center; }
.actions button { width: auto; }
.org-table a { text-decoration: none; color: var(--color-link); } .org-table a { text-decoration: none; color: var(--color-link); }
.org-table a:hover { text-decoration: underline; } .org-table a:hover { text-decoration: underline; }
.org-table .center { width: 6rem; min-width: 6rem; } .org-table .center { width: 6rem; min-width: 6rem; }
@@ -363,24 +447,19 @@ defineExpose({ focusFirstElement })
.perm-title { font-weight: 600; color: var(--color-heading); } .perm-title { font-weight: 600; color: var(--color-heading); }
.perm-id-info { font-size: 0.8rem; color: var(--color-text-muted); display: flex; align-items: center; gap: 0.5rem; flex-wrap: wrap; } .perm-id-info { font-size: 0.8rem; color: var(--color-text-muted); display: flex; align-items: center; gap: 0.5rem; flex-wrap: wrap; }
.perm-domain { color: var(--color-text-muted); font-size: 0.9rem; } .perm-domain { color: var(--color-text-muted); font-size: 0.9rem; }
.icon-btn { background: none; border: none; color: var(--color-text-muted); padding: 0.2rem; border-radius: var(--radius-sm); cursor: pointer; transition: background 0.2s ease, color 0.2s ease; }
.icon-btn:hover { color: var(--color-heading); background: var(--color-surface-muted); }
.delete-icon { color: var(--color-danger); }
.delete-icon:hover { background: var(--color-danger-bg); color: var(--color-danger-text); }
.matrix-wrapper { margin: var(--space-md) 0; padding: var(--space-lg); }
.matrix-scroll { overflow-x: auto; }
.matrix-hint { font-size: 0.8rem; color: var(--color-text-muted); }
.perm-matrix-grid { display: inline-grid; gap: 0.25rem; align-items: stretch; }
.perm-matrix-grid > * { padding: 0.35rem 0.45rem; font-size: 0.75rem; }
.perm-matrix-grid .grid-head { color: var(--color-text-muted); text-transform: uppercase; font-weight: 600; letter-spacing: 0.05em; }
.perm-matrix-grid .perm-head { display: flex; align-items: flex-end; justify-content: flex-start; padding: 0.35rem 0.45rem; font-size: 0.75rem; }
.perm-matrix-grid .org-head { display: flex; align-items: flex-end; justify-content: center; } .perm-matrix-grid .org-head { display: flex; align-items: flex-end; justify-content: center; }
.perm-matrix-grid .org-head span { writing-mode: vertical-rl; transform: rotate(180deg); font-size: 0.65rem; } .perm-matrix-grid .org-head span { writing-mode: vertical-rl; transform: rotate(180deg); font-size: 0.65rem; }
.perm-name { font-weight: 600; color: var(--color-heading); padding: 0.35rem 0.45rem; font-size: 0.75rem; }
.display-text { margin-right: var(--space-xs); } .display-text { margin-right: var(--space-xs); }
.edit-display-btn { padding: 0.1rem 0.2rem; font-size: 0.8rem; } .edit-display-btn { padding: 0.1rem 0.2rem; font-size: 0.8rem; }
.edit-org-btn { padding: 0.1rem 0.2rem; font-size: 0.8rem; margin-left: var(--space-xs); } .edit-org-btn { padding: 0.1rem 0.2rem; font-size: 0.8rem; margin-left: var(--space-xs); }
.perm-actions { text-align: center; } .perm-actions { text-align: center; }
.center { text-align: center; }
.muted { color: var(--color-text-muted); } /* OIDC Clients Section */
.oidc-clients-section { margin-bottom: var(--space-xl); margin-top: var(--space-2xl); }
.oidc-clients-section .section-header { display: flex; flex-direction: column; gap: 0.4rem; margin-bottom: var(--space-md); }
.client-groups { font-size: 0.85rem; color: var(--color-text-muted); max-width: 200px; font-family: var(--font-mono, monospace); }
/* Server Options Section */
.server-options-section { margin-top: var(--space-2xl); }
.server-options-section .section-header { display: flex; flex-direction: column; gap: 0.4rem; margin-bottom: var(--space-md); }
</style> </style>
+46 -39
View File
@@ -5,7 +5,7 @@ import CredentialList from '@/components/CredentialList.vue'
import RegistrationLinkModal from '@/components/RegistrationLinkModal.vue' import RegistrationLinkModal from '@/components/RegistrationLinkModal.vue'
import SessionList from '@/components/SessionList.vue' import SessionList from '@/components/SessionList.vue'
import { useAuthStore } from '@/stores/auth' import { useAuthStore } from '@/stores/auth'
import { apiJson } from '@/utils/api' import { apiJson } from 'paskia'
import { getDirection, navigateButtonRow, focusPreferred, focusAtIndex } from '@/utils/keynav' import { getDirection, navigateButtonRow, focusPreferred, focusAtIndex } from '@/utils/keynav'
const props = defineProps({ const props = defineProps({
@@ -17,13 +17,18 @@ const props = defineProps({
navigationDisabled: { type: Boolean, default: false } 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 authStore = useAuthStore()
const terminatingSessions = ref({}) const terminatingSessions = ref({})
const hoveredCredentialUuid = ref(null) const hoveredCredentialUuid = ref(null)
const hoveredSession = ref(null) const hoveredSession = ref(null)
// Convert credentials dict to array with uuid attached as 'credential'
const credentials = computed(() =>
Object.entries(props.userDetail?.credentials || {}).map(([uuid, c]) => ({ ...c, credential: uuid }))
)
// Template refs for navigation // Template refs for navigation
const userInfoRef = ref(null) const userInfoRef = ref(null)
const regActionsRef = ref(null) const regActionsRef = ref(null)
@@ -45,7 +50,7 @@ function handleEditName() {
async function handleDelete(credential) { async function handleDelete(credential) {
try { 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') { if (data.status === 'ok') {
emit('onUserNameSaved') // Reuse to refresh user detail emit('onUserNameSaved') // Reuse to refresh user detail
} else { } else {
@@ -57,11 +62,11 @@ async function handleDelete(credential) {
} }
async function handleTerminateSession(session) { async function handleTerminateSession(session) {
const sessionId = session?.id const sessionKey = session?.key
if (!sessionId) return if (!sessionKey) return
terminatingSessions.value = { ...terminatingSessions.value, [sessionId]: true } terminatingSessions.value = { ...terminatingSessions.value, [sessionKey]: true }
try { try {
const data = await apiJson(`/auth/api/admin/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.status === 'ok') {
if (data.current_session_terminated) { if (data.current_session_terminated) {
sessionStorage.clear() sessionStorage.clear()
@@ -78,11 +83,15 @@ async function handleTerminateSession(session) {
authStore.showMessage(err.message || 'Failed to terminate session', 'error') authStore.showMessage(err.message || 'Failed to terminate session', 'error')
} finally { } finally {
const next = { ...terminatingSessions.value } const next = { ...terminatingSessions.value }
delete next[sessionId] delete next[sessionKey]
terminatingSessions.value = next terminatingSessions.value = next
} }
} }
async function handleDeleteUser() {
emit('deleteUser')
}
// Handle user info section keynav // Handle user info section keynav
function handleUserInfoKeydown(event) { function handleUserInfoKeydown(event) {
if (hasActiveModal.value || props.navigationDisabled) return if (hasActiveModal.value || props.navigationDisabled) return
@@ -176,31 +185,37 @@ defineExpose({ focusFirstElement })
<div ref="userInfoRef" @keydown="handleUserInfoKeydown"> <div ref="userInfoRef" @keydown="handleUserInfoKeydown">
<UserBasicInfo <UserBasicInfo
v-if="userDetail && !userDetail.error" v-if="userDetail && !userDetail.error"
:name="userDetail.display_name || selectedUser.display_name" :name="userDetail.user.display_name || selectedUser.display_name"
:visits="userDetail.visits" :visits="userDetail.user.visits"
:created-at="userDetail.created_at" :created-at="userDetail.user.created_at"
:last-seen="userDetail.last_seen" :last-seen="userDetail.user.last_seen"
:email="userDetail.user.email"
:telephone="userDetail.user.telephone"
:loading="loading" :loading="loading"
:org-display-name="userDetail.org.display_name" :org-display-name="userDetail.org.display_name"
:role-name="userDetail.role" :role-name="userDetail.role.display_name"
:update-endpoint="`/auth/api/admin/orgs/${selectedUser.org}/users/${selectedUser.uuid}/display-name`" :update-endpoint="`/auth/api/admin/users/${selectedUser.uuid}/info`"
@saved="$emit('onUserNameSaved')" @saved="$emit('onUserNameSaved')"
@edit-name="handleEditName" @edit="handleEditName"
/> >
<div class="admin-actions">
<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>
<div v-if="userDetail?.error" class="error small">{{ userDetail.error }}</div> <div v-if="userDetail?.error" class="error small">{{ userDetail.error }}</div>
<template v-if="userDetail && !userDetail.error"> <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"> <section class="section-block" data-section="registered-passkeys">
<div class="section-header"> <div class="section-header">
<h2>Registered Passkeys</h2> <h2>Registered Passkeys</h2>
@@ -208,7 +223,7 @@ defineExpose({ focusFirstElement })
<div class="section-body"> <div class="section-body">
<CredentialList <CredentialList
ref="credentialListRef" ref="credentialListRef"
:credentials="userDetail.credentials" :credentials="credentials"
:aaguid-info="userDetail.aaguid_info" :aaguid-info="userDetail.aaguid_info"
:allow-delete="true" :allow-delete="true"
:hovered-credential-uuid="hoveredCredentialUuid" :hovered-credential-uuid="hoveredCredentialUuid"
@@ -222,7 +237,7 @@ defineExpose({ focusFirstElement })
</section> </section>
<SessionList <SessionList
ref="sessionListRef" ref="sessionListRef"
:sessions="userDetail.sessions || []" :sessions="userDetail.sessions || {}"
:terminating-sessions="terminatingSessions" :terminating-sessions="terminatingSessions"
:hovered-credential-uuid="hoveredCredentialUuid" :hovered-credential-uuid="hoveredCredentialUuid"
:navigation-disabled="hasActiveModal" :navigation-disabled="hasActiveModal"
@@ -238,7 +253,7 @@ defineExpose({ focusFirstElement })
</div> </div>
<RegistrationLinkModal <RegistrationLinkModal
v-if="showRegModal" 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" :user-name="userDetail?.display_name || selectedUser.display_name"
@close="$emit('closeRegModal')" @close="$emit('closeRegModal')"
@copied="onLinkCopied" @copied="onLinkCopied"
@@ -248,14 +263,6 @@ defineExpose({ focusFirstElement })
<style scoped> <style scoped>
.user-detail { display: flex; flex-direction: column; gap: var(--space-lg); } .user-detail { display: flex; flex-direction: column; gap: var(--space-lg); }
.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; } .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> </style>
+288 -123
View File
@@ -1,68 +1,90 @@
@property --hue {
syntax: '<angle>';
inherits: true;
initial-value: 0.72turn;
}
:root { :root {
--font-sans: "Inter", "Inter var", "Segoe UI", system-ui, -apple-system, "Helvetica Neue", sans-serif; --font-sans: "Inter", "Inter var", "Segoe UI", system-ui, -apple-system, "Helvetica Neue", sans-serif;
--font-mono: "DM Mono", "JetBrains Mono", "SFMono-Regular", Menlo, Monaco, Consolas, "Liberation Mono", monospace; --font-mono: "DM Mono", "JetBrains Mono", "SFMono-Regular", Menlo, Monaco, Consolas, "Liberation Mono", monospace;
--color-canvas: #ffffff; --color-canvas: white;
--color-surface: #eff6ff; --color-surface: #def;
--color-surface-subtle: #dbeafe; --color-surface-subtle: #bcf;
--color-border: #2563eb; --color-surface-hover: oklab(0.97 -0.01 -0.02);
--color-border-strong: #1e40af; --color-dialog: oklab(0.96 -0.01 -0.03);
--color-heading: #1e3a8a; --color-border: oklab(0.82 -0.02 -0.06);
--color-text: #1e293b; --color-border-strong: oklab(0.55 -0.05 -0.14);
--color-text-muted: #475569; --color-heading: oklab(0.22 -0.03 -0.09);
--color-link: #1d4ed8; --color-text: oklab(0.2 -0.02 -0.05);
--color-link-hover: #1e40af; --color-text-muted: oklab(0.42 -0.02 -0.06);
--color-accent: #2563eb; --color-link: oklab(0.5 -0.06 -0.17);
--color-accent-strong: #1e40af; --color-link-hover: oklab(0.45 -0.06 -0.19);
--color-accent-contrast: #ffffff; --color-accent: oklab(0.55 -0.06 -0.19);
--color-success-text: #166534; --color-accent-strong: #46f;
--color-success-bg: #dcfce7; --color-accent-contrast: white;
--color-error-text: #b91c1c; --color-secondary: oklab(0.55 -0.02 -0.05);
--color-error-bg: #fee2e2; --color-secondary-strong: oklab(0.45 -0.02 -0.05);
--color-info-text: #1e40af; --color-success-text: oklab(0.4 -0.12 0.09);
--color-info-bg: #dbeafe; --color-success-bg: oklab(0.95 -0.02 0.02);
--color-danger: #dc2626; --color-error-text: oklab(0.45 0.18 0.09);
--shadow-soft: 0 10px 30px rgba(30, 64, 175, 0.15); --color-error-bg: oklab(0.95 0.03 0.01);
--radius-none: 0; --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-sm: 4px;
--radius-md: 6px; --radius-md: 6px;
--radius-lg: 10px; --radius-lg: 10px;
--space-xxs: 0.25rem;
--space-xs: 0.5rem; --space-xs: 0.5rem;
--space-sm: 0.75rem; --space-sm: 0.75rem;
--space-md: 1rem; --space-md: 1rem;
--space-lg: 1.5rem; --space-lg: 1.5rem;
--space-xl: 2.25rem; --space-xl: 2.25rem;
--space-xxl: 3.5rem; --space-3xl: 5rem;
--layout-padding: clamp(1.5rem, 3vw + 1rem, 3.25rem); --layout-padding: clamp(1.5rem, 3vw + 1rem, 3.25rem);
--transition-base: 160ms ease; --transition-base: 160ms ease;
--focus-ring: 0 0 0 2px var(--color-accent); --focus-ring: 0 0 0 2px var(--color-accent);
} }
@media (prefers-color-scheme: dark) { :root.dark {
:root { --color-canvas: oklab(0.17 -0.02 -0.05);
--color-canvas: #0f172a; --color-surface: oklab(0.22 -0.02 -0.05);
--color-surface: #141b2f; --color-surface-subtle: oklab(0.25 -0.02 -0.05);
--color-surface-subtle: #1b243b; --color-surface-hover: oklab(0.28 -0.02 -0.05);
--color-border: #25304a; --color-dialog: oklab(0.22 -0.02 -0.05);
--color-border-strong: #3d4d6b; --color-border: oklab(0.3 -0.02 -0.05);
--color-heading: #fff; --color-border-strong: oklab(0.4 -0.02 -0.05);
--color-text: #e2e8f0; --color-heading: white;
--color-text-muted: #94a3b8; --color-text: oklab(0.9 0.00 -0.01);
--color-link: #60a5fa; --color-text-muted: oklab(0.7 -0.01 -0.02);
--color-link-hover: #93c5fd; --color-link: oklab(0.7 -0.05 -0.14);
--color-accent: #60a5fa; --color-link-hover: oklab(0.8 -0.04 -0.11);
--color-accent-strong: #3b82f6; --color-accent: oklab(0.7 -0.05 -0.14);
--color-accent-contrast: #0b1120; --color-accent-strong: oklab(0.6 -0.06 -0.17);
--color-success-text: #34d399; --color-accent-contrast: oklab(0.12 -0.01 -0.03);
--color-success-bg: #1a4d2e; --color-secondary: oklab(0.6 -0.02 -0.05);
--color-error-text: #fca5a5; --color-secondary-strong: oklab(0.5 -0.02 -0.05);
--color-error-bg: #4a1f1f; --color-success-text: oklab(0.75 -0.12 0.09);
--color-info-text: #bae6fd; --color-success-bg: oklab(0.3 -0.07 0.05);
--color-info-bg: #1e3a5f; --color-error-text: oklab(0.8 0.11 0.05);
--color-danger: #f87171; --color-error-bg: oklab(0.3 0.07 0.03);
--shadow-soft: 0 0 0 #000000; --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 { body {
color-scheme: light dark;
overflow: auto; overflow: auto;
scrollbar-gutter: stable; scrollbar-gutter: stable;
height: 100%; height: 100%;
@@ -132,6 +153,7 @@ a:focus-visible {
} }
.view-root { .view-root {
position: relative;
flex: 1; flex: 1;
width: 100%; width: 100%;
display: flex; display: flex;
@@ -150,6 +172,25 @@ a:focus-visible {
max-width: 540px; max-width: 540px;
} }
.view-root--profile .view-header-wrapper {
width: 100%;
max-width: calc(1200px - 2 * var(--layout-padding));
margin: 0 auto;
position: relative;
}
.view-root--profile .theme-toggle {
position: absolute;
top: 0;
right: 0;
}
.section-block--constrained {
width: 100%;
max-width: calc(1200px - 2 * var(--layout-padding));
margin: 0 auto;
}
.view-header { .view-header {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@@ -197,6 +238,7 @@ a:focus-visible {
flex-wrap: nowrap; flex-wrap: nowrap;
gap: 0.75rem; gap: 0.75rem;
justify-content: flex-start; justify-content: flex-start;
width: 100%;
} }
.button-row button { .button-row button {
@@ -229,6 +271,7 @@ button {
gap: 0.4rem; gap: 0.4rem;
background: var(--color-surface); background: var(--color-surface);
color: var(--color-text); color: var(--color-text);
transition: box-shadow var(--transition-base), background var(--transition-base), border-color var(--transition-base);
} }
button:disabled { button:disabled {
@@ -236,49 +279,53 @@ button:disabled {
filter: opacity(0.6); filter: opacity(0.6);
} }
output[title="Click to copy"] {
cursor: pointer;
}
.btn-primary { .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); color: var(--color-accent-contrast);
border-color: var(--color-accent); 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); box-shadow: var(--shadow-soft);
} }
.btn-primary:hover:not(:disabled) {
background: var(--color-accent-strong);
border-color: var(--color-accent-strong);
}
.btn-secondary { .btn-secondary {
background: transparent; background: linear-gradient(to bottom, oklab(1 0 0 / 0.15), transparent 60%) var(--color-secondary);
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);
color: var(--color-accent-contrast); color: var(--color-accent-contrast);
border-color: transparent; border-color: transparent;
} }
.btn-danger:hover:not(:disabled) { .btn-secondary:hover:not(:disabled),
filter: brightness(0.92); .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-danger {
.btn-primary:focus-visible, background: linear-gradient(to bottom, oklab(1 0 0 / 0.15), transparent 60%) var(--color-danger);
.btn-secondary:focus-visible, color: var(--color-accent-contrast);
border-color: transparent;
}
.btn-danger:hover:not(:disabled),
.btn-danger:focus-visible { .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="text"],
input[type="search"], input[type="search"],
input[type="email"], input[type="email"],
input[type="tel"],
textarea, textarea,
select { select {
font: inherit; font: inherit;
@@ -291,6 +338,18 @@ select {
transition: border-color var(--transition-base), box-shadow var(--transition-base); transition: border-color var(--transition-base), box-shadow var(--transition-base);
} }
input:not([type]):focus,
input[type="text"]:focus,
input[type="search"]:focus,
input[type="email"]:focus,
input[type="tel"]:focus,
textarea:focus,
select:focus {
outline: none;
border-color: var(--color-accent);
box-shadow: var(--focus-ring);
}
label { label {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@@ -331,10 +390,118 @@ th {
text-align: left; text-align: left;
} }
/* Permission matrix styles */
.matrix-wrapper {
margin: var(--space-md) 0;
padding: var(--space-lg);
}
.matrix-scroll {
overflow-x: auto;
}
.matrix-hint {
font-size: 0.8rem;
color: var(--color-text-muted);
}
.perm-matrix-grid {
display: inline-grid;
gap: 0.25rem;
align-items: stretch;
}
.perm-matrix-grid > * {
padding: 0.35rem 0.45rem;
font-size: 0.75rem;
}
.perm-matrix-grid .grid-head {
color: var(--color-text-muted);
text-transform: uppercase;
font-weight: 600;
letter-spacing: 0.05em;
}
.perm-matrix-grid .perm-head {
display: flex;
align-items: flex-end;
justify-content: flex-start;
padding: 0.35rem 0.45rem;
font-size: 0.75rem;
}
.perm-matrix-grid .rotated-head {
display: flex;
align-items: flex-end;
justify-content: center;
}
.perm-matrix-grid .rotated-head span {
writing-mode: vertical-rl;
transform: rotate(180deg);
font-size: 0.65rem;
}
.perm-name {
font-weight: 600;
color: var(--color-heading);
padding: 0.35rem 0.45rem;
font-size: 0.75rem;
}
.center { .center {
text-align: center; text-align: center;
} }
/* Utility classes */
.muted {
color: var(--color-text-muted);
}
.error {
color: var(--color-danger-text);
}
.small {
font-size: 0.9rem;
}
.icon-btn {
background: none;
border: none;
color: var(--color-text-muted);
padding: 0.2rem;
border-radius: var(--radius-sm);
cursor: pointer;
transition: background 0.2s ease, color 0.2s ease;
}
.icon-btn:hover {
color: var(--color-heading);
background: var(--color-surface-subtle);
}
.delete-icon {
color: var(--color-danger);
}
.delete-icon:hover {
background: var(--color-error-bg);
color: var(--color-error-text);
}
.actions {
display: flex;
flex-wrap: wrap;
gap: var(--space-sm);
align-items: center;
}
.button-row--center {
justify-content: center;
}
.badge { .badge {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
@@ -353,13 +520,17 @@ th {
left: 0; left: 0;
right: 0; right: 0;
margin: 0 auto; margin: 0 auto;
z-index: 1200; z-index: 2000;
width: fit-content; width: fit-content;
min-width: min(520px, calc(100% - 2rem)); min-width: min(520px, calc(100% - 2rem));
max-width: calc(100% - 2rem); max-width: calc(100% - 2rem);
display: none; display: none;
} }
.global-status.show {
display: block;
}
.global-status .status { .global-status .status {
display: flex; display: flex;
align-items: center; align-items: center;
@@ -407,7 +578,7 @@ th {
.device-dialog, .device-dialog,
.modal { .modal {
background: var(--color-surface); background: var(--color-dialog);
border: 1px solid var(--color-border); border: 1px solid var(--color-border);
border-radius: var(--radius-md); border-radius: var(--radius-md);
width: min(520px, 100%); width: min(520px, 100%);
@@ -429,8 +600,7 @@ th {
.qr-code { .qr-code {
padding: 1rem; padding: 1rem;
background: #fff; background: white;
box-shadow: var(--shadow-soft);
} }
.link-container, .link-container,
@@ -487,9 +657,18 @@ th {
.record-item.is-current, .record-item.is-current,
.credential-item.current-session, .credential-item.current-session,
.session-item.is-current {
border-color: var(--color-accent);
background-color: var(--color-surface-subtle);
}
.credential-item.is-hovered, .credential-item.is-hovered,
.session-item.is-current, .session-item.is-hovered {
.session-item.is-hovered { border-color: var(--color-accent); background-color: var(--color-surface-subtle); } border-color: var(--color-accent);
background-color: var(--color-surface-subtle);
box-shadow: 0 10px 24px rgba(15, 23, 42, 0.12);
transform: translateY(-1px);
}
.credential-item.is-linked-session, .credential-item.is-linked-session,
.session-item.is-linked-credential { border-color: var(--color-accent); background-color: var(--color-surface-subtle); } .session-item.is-linked-credential { border-color: var(--color-accent); background-color: var(--color-surface-subtle); }
@@ -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 { 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; } .btn-card-delete:disabled { filter: opacity(0.4); cursor: not-allowed; }
@@ -644,9 +823,6 @@ th {
.user-info { .user-info {
display: grid; display: grid;
border-radius: var(--radius-md);
background: var(--color-surface);
padding: 1.1rem 1.25rem;
} }
.user-details { .user-details {
@@ -692,8 +868,8 @@ th {
width: 100vw; width: 100vw;
height: 100vh; height: 100vh;
background: transparent; background: transparent;
backdrop-filter: blur(.1rem) brightness(0.7); backdrop-filter: blur(.2rem) brightness(0.7);
-webkit-backdrop-filter: blur(.1rem) brightness(0.7); -webkit-backdrop-filter: blur(.2rem) brightness(0.7);
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
@@ -711,7 +887,7 @@ th {
width: 100%; width: 100%;
max-width: 480px; max-width: 480px;
padding: 2rem; padding: 2rem;
background: var(--color-surface); background: var(--color-dialog);
border-radius: var(--radius-lg); border-radius: var(--radius-lg);
box-shadow: 0 20px 60px #1e293b; box-shadow: 0 20px 60px #1e293b;
border: 1px solid var(--color-border); border: 1px solid var(--color-border);
@@ -731,45 +907,9 @@ th {
} }
} }
/* Global backdrop controlled by api.js ref-counting */
body::before {
content: '';
position: fixed;
inset: 0;
z-index: 1099;
background: transparent;
backdrop-filter: blur(0) brightness(1);
-webkit-backdrop-filter: blur(0) brightness(1);
pointer-events: none;
visibility: hidden;
transition: all 0.2s ease-out;
}
body.has-backdrop::before {
-webkit-backdrop-filter: blur(.2rem) brightness(0.5);
backdrop-filter: blur(.2rem) brightness(0.5);
visibility: visible;
}
body.has-backdrop {
overflow: auto;
}
#auth-iframe {
border: none;
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
z-index: 9999;
color-scheme: auto;
background: transparent;
}
.slot-machine { .slot-machine {
padding: 0.875rem 1rem; padding: 0.875rem 1rem;
background: var(--color-surface-hover, rgba(0, 0, 0, 0.03)); background: var(--color-surface-hover);
border: 1px solid var(--color-border); border: 1px solid var(--color-border);
border-radius: var(--radius-sm); border-radius: var(--radius-sm);
font-family: 'SF Mono', Monaco, 'Cascadia Code', 'Roboto Mono', Consolas, 'Courier New', monospace; font-family: 'SF Mono', Monaco, 'Cascadia Code', 'Roboto Mono', Consolas, 'Courier New', monospace;
@@ -790,3 +930,28 @@ body.has-backdrop {
height: 1.8em; height: 1.8em;
position: relative; position: relative;
} }
/* Spinner utilities */
.spinner {
border: 3px solid var(--color-border);
border-top-color: var(--color-primary);
border-radius: 50%;
animation: spin 1s linear infinite;
}
.spinner--sm {
width: 16px;
height: 16px;
border-width: 2px;
}
.spinner--md {
width: 40px;
height: 40px;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
+2 -2
View File
@@ -148,8 +148,8 @@ const getCredentialAuthIcon = (credential) => {
const info = props.aaguidInfo?.[credential.aaguid] const info = props.aaguidInfo?.[credential.aaguid]
if (!info) return null if (!info) return null
const isDarkMode = window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches const isDarkMode = window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches
const iconKey = isDarkMode ? 'icon_dark' : 'icon_light' // Fall back to icon if icon_dark is not available
return info[iconKey] || null return (isDarkMode && info.icon_dark) || info.icon || null
} }
</script> </script>
+9 -16
View File
@@ -1,5 +1,5 @@
<template> <template>
<section class="view-root host-view" data-view="host-profile"> <section class="view-root view-root--wide host-view" data-view="host-profile">
<header class="view-header"> <header class="view-header">
<h1>{{ headingTitle }}</h1> <h1>{{ headingTitle }}</h1>
<p class="view-lede">{{ subheading }}</p> <p class="view-lede">{{ subheading }}</p>
@@ -10,9 +10,11 @@
<UserBasicInfo <UserBasicInfo
v-if="ctx" v-if="ctx"
:name="ctx.user.display_name" :name="ctx.user.display_name"
:visits="authStore.userInfo?.visits || 0" :visits="authStore.userInfo.user.visits"
:created-at="authStore.userInfo?.created_at" :created-at="authStore.userInfo.user.created_at"
:last-seen="authStore.userInfo?.last_seen" :last-seen="authStore.userInfo.user.last_seen"
:email="ctx.user.email"
:telephone="ctx.user.telephone"
:org-display-name="orgDisplayName" :org-display-name="orgDisplayName"
:role-name="roleDisplayName" :role-name="roleDisplayName"
:can-edit="false" :can-edit="false"
@@ -78,9 +80,9 @@ const currentHost = window.location.host
const userInfoSection = ref(null) const userInfoSection = ref(null)
const buttonRow = ref(null) const buttonRow = ref(null)
const ctx = computed(() => authStore.userInfo?.ctx || null) const ctx = computed(() => authStore.userInfo || null)
const orgDisplayName = computed(() => ctx.value?.org.display_name ?? '') const orgDisplayName = computed(() => ctx.value?.org?.display_name ?? '')
const roleDisplayName = computed(() => ctx.value?.role.display_name ?? '') const roleDisplayName = computed(() => ctx.value?.role?.display_name ?? '')
const headingTitle = computed(() => { const headingTitle = computed(() => {
const service = authStore.settings?.rp_name const service = authStore.settings?.rp_name
@@ -125,12 +127,3 @@ const handleButtonRowKeydown = (event) => {
// Down does nothing (no elements below to navigate to) // Down does nothing (no elements below to navigate to)
} }
</script> </script>
<style scoped>
.host-view { padding: 3rem 1.5rem 4rem; }
.host-actions { display: flex; flex-direction: column; gap: 0.75rem; }
.host-actions .button-row { gap: 0.75rem; flex-wrap: wrap; }
.host-actions .button-row button { flex: 1 1 0; }
.note { margin: 0; color: var(--color-text-muted); }
.empty-state { margin: 0; color: var(--color-text-muted); }
</style>
+2 -7
View File
@@ -27,17 +27,12 @@ defineProps({
.loading-spinner { .loading-spinner {
width: 40px; width: 40px;
height: 40px; height: 40px;
border: 4px solid var(--color-border); border: 3px solid var(--color-border);
border-top: 4px solid var(--color-primary); border-top-color: var(--color-primary);
border-radius: 50%; border-radius: 50%;
animation: spin 1s linear infinite; animation: spin 1s linear infinite;
} }
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
.loading-container p { .loading-container p {
color: var(--color-text-muted); color: var(--color-text-muted);
margin: 0; margin: 0;
+27 -44
View File
@@ -1,12 +1,15 @@
<template> <template>
<dialog ref="dialog" @close="$emit('close')" @keydown="handleDialogKeydown"> <div class="dialog-overlay" @click="$emit('close')">
<slot /> <div ref="dialog" class="modal-panel" @keydown="handleDialogKeydown" @click.stop>
</dialog> <slot />
</div>
</div>
</template> </template>
<script setup> <script setup>
import { ref, onMounted, onUnmounted, nextTick } from 'vue' import { ref, onMounted, onUnmounted, nextTick } from 'vue'
import { navigateButtonRow, getDirection, focusPreferred, focusDialogDefault } from '@/utils/keynav' import { navigateButtonRow, getDirection, focusPreferred, focusDialogDefault } from '@/utils/keynav'
import { holdGlobalBackdrop, releaseGlobalBackdrop } from 'paskia'
const props = defineProps({ const props = defineProps({
// Optional: provide a fallback element to focus if original element is gone // Optional: provide a fallback element to focus if original element is gone
@@ -17,7 +20,7 @@ const props = defineProps({
focusSiblingSelector: { type: String, default: '' } focusSiblingSelector: { type: String, default: '' }
}) })
defineEmits(['close']) const emit = defineEmits(['close'])
// Dialog element reference // Dialog element reference
const dialog = ref(null) const dialog = ref(null)
@@ -76,6 +79,13 @@ const restoreFocus = () => {
} }
const handleDialogKeydown = (event) => { const handleDialogKeydown = (event) => {
// ESC to close (previously handled by <dialog> natively)
if (event.key === 'Escape') {
event.preventDefault()
emit('close')
return
}
const direction = getDirection(event) const direction = getDirection(event)
if (!direction) return if (!direction) return
@@ -111,11 +121,11 @@ onMounted(() => {
// Save currently focused element before modal takes focus // Save currently focused element before modal takes focus
previouslyFocusedElement.value = document.activeElement previouslyFocusedElement.value = document.activeElement
// Show the dialog as a modal holdGlobalBackdrop()
// Focus the most appropriate element
nextTick(() => { nextTick(() => {
if (dialog.value) { if (dialog.value) {
dialog.value.showModal()
// Autofocus the most appropriate element: // Autofocus the most appropriate element:
// - For form dialogs (rename, edit): focus first input and select text // - For form dialogs (rename, edit): focus first input and select text
// - For other dialogs: focus primary button (or fallback) // - For other dialogs: focus primary button (or fallback)
@@ -131,14 +141,16 @@ onMounted(() => {
}) })
onUnmounted(() => { onUnmounted(() => {
releaseGlobalBackdrop()
// Restore focus when modal closes // Restore focus when modal closes
restoreFocus() restoreFocus()
}) })
</script> </script>
<style scoped> <style scoped>
dialog { .modal-panel {
background: var(--color-surface); background: var(--color-dialog);
color: var(--color-text);
border: 1px solid var(--color-border); border: 1px solid var(--color-border);
border-radius: var(--radius-lg); border-radius: var(--radius-lg);
box-shadow: var(--shadow-xl); box-shadow: var(--shadow-xl);
@@ -147,65 +159,36 @@ dialog {
width: min(500px, 90vw); width: min(500px, 90vw);
max-height: 90vh; max-height: 90vh;
overflow-y: auto; overflow-y: auto;
position: fixed;
inset: 0;
margin: auto;
height: fit-content;
} }
dialog::backdrop { .modal-panel :deep(.modal-title),
background: transparent; .modal-panel :deep(h3) {
backdrop-filter: blur(.1rem) brightness(0.7);
-webkit-backdrop-filter: blur(.1rem) brightness(0.7);
}
dialog :deep(.modal-title),
dialog :deep(h3) {
margin: 0 0 var(--space-md); margin: 0 0 var(--space-md);
font-size: 1.25rem; font-size: 1.25rem;
font-weight: 600; font-weight: 600;
color: var(--color-heading); color: var(--color-heading);
} }
dialog :deep(form) { .modal-panel :deep(form) {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: var(--space-md); gap: var(--space-md);
} }
dialog :deep(.modal-form) { .modal-panel :deep(.modal-form) {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: var(--space-md); gap: var(--space-md);
} }
dialog :deep(.modal-form label) { .modal-panel :deep(.modal-form label) {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: var(--space-xs); gap: var(--space-xs);
font-weight: 500; font-weight: 500;
} }
dialog :deep(.modal-form input), .modal-panel :deep(.modal-actions) {
dialog :deep(.modal-form textarea) {
padding: var(--space-md);
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
background: var(--color-bg);
color: var(--color-text);
font-size: 1rem;
line-height: 1.4;
min-height: 2.5rem;
}
dialog :deep(.modal-form input:focus),
dialog :deep(.modal-form textarea:focus) {
outline: none;
border-color: var(--color-accent);
box-shadow: 0 0 0 2px #c7d2fe;
}
dialog :deep(.modal-actions) {
display: flex; display: flex;
justify-content: flex-end; justify-content: flex-end;
gap: var(--space-sm); gap: var(--space-sm);
-8
View File
@@ -84,12 +84,4 @@ function handleCancel() {
flex-direction: column; flex-direction: column;
gap: var(--space-md); gap: var(--space-md);
} }
.error {
color: var(--color-danger-text);
}
.small {
font-size: 0.9rem;
}
</style> </style>
+137 -65
View File
@@ -1,23 +1,32 @@
<template> <template>
<section class="view-root" data-view="profile"> <section class="view-root view-root--profile" data-view="profile">
<header class="view-header"> <div class="view-header-wrapper">
<h1>User Profile</h1> <div class="theme-toggle">
<Breadcrumbs ref="breadcrumbs" :entries="breadcrumbEntries" @keydown="handleBreadcrumbKeydown" /> <ThemeSelector />
<p class="view-lede">Account dashboard for managing credentials and authenticating with other devices.</p> </div>
</header> <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 <UserBasicInfo
v-if="authStore.userInfo?.ctx" v-if="authStore.userInfo?.user"
ref="userBasicInfo" ref="userBasicInfo"
:name="authStore.userInfo.ctx.user.display_name" :name="authStore.userInfo.user.display_name"
:visits="authStore.userInfo.visits" :email="authStore.userInfo.user.email"
:created-at="authStore.userInfo.created_at" :preferred_username="authStore.userInfo.user.preferred_username"
:last-seen="authStore.userInfo.last_seen" :telephone="authStore.userInfo.user.telephone"
:visits="authStore.userInfo.user.visits"
:created-at="authStore.userInfo.user.created_at"
:last-seen="authStore.userInfo.user.last_seen"
:loading="authStore.isLoading" :loading="authStore.isLoading"
update-endpoint="/auth/api/user/display-name" :org-display-name="authStore.userInfo.org.display_name"
:role-name="authStore.userInfo.role.display_name"
update-endpoint="/auth/api/user/info"
@saved="authStore.loadUserInfo()" @saved="authStore.loadUserInfo()"
@edit-name="openNameDialog" @edit="openEditDialog"
@keydown="handleUserInfoKeydown" @keydown="handleUserInfoKeydown"
> >
<div class="remote-auth-inline"> <div class="remote-auth-inline">
@@ -31,11 +40,11 @@
@device-info-visible="showDeviceInfo = $event" @device-info-visible="showDeviceInfo = $event"
/> />
</div> </div>
<p class="remote-auth-description">Provided by another device requesting remote auth.</p> <p class="remote-auth-description">Login from another device</p>
</UserBasicInfo> </UserBasicInfo>
</section> </section>
<section class="section-block"> <section :class="['section-block', { 'section-block--constrained': !useWideLayout }]">
<div class="section-header"> <div class="section-header">
<h2>Your Passkeys</h2> <h2>Your Passkeys</h2>
<p class="section-description">Ideally have at least two passkeys in case you lose one. More than one user can be registered on the same device, giving you a choice at login. <a href="https://bitwarden.com/pricing/" target="_blank" rel="noopener noreferrer">Bitwarden</a> can sync one passkey to all your devices. Other secure options include <b>local passkeys</b>, as well as hardware keys such as <a href="https://www.yubico.com" target="_blank" rel="noopener noreferrer">YubiKey</a>. Cloud sync via Google, Microsoft or iCloud is discouraged.</p> <p class="section-description">Ideally have at least two passkeys in case you lose one. More than one user can be registered on the same device, giving you a choice at login. <a href="https://bitwarden.com/pricing/" target="_blank" rel="noopener noreferrer">Bitwarden</a> can sync one passkey to all your devices. Other secure options include <b>local passkeys</b>, as well as hardware keys such as <a href="https://www.yubico.com" target="_blank" rel="noopener noreferrer">YubiKey</a>. Cloud sync via Google, Microsoft or iCloud is discouraged.</p>
@@ -43,8 +52,8 @@
<div class="section-body"> <div class="section-body">
<CredentialList <CredentialList
ref="credentialList" ref="credentialList"
:credentials="authStore.userInfo?.credentials || []" :credentials="credentials"
:aaguid-info="authStore.userInfo?.aaguid_info || {}" :aaguid-info="authStore.userInfo.aaguid_info"
:loading="authStore.isLoading" :loading="authStore.isLoading"
:hovered-credential-uuid="hoveredCredentialUuid" :hovered-credential-uuid="hoveredCredentialUuid"
:hovered-session-credential-uuid="hoveredSession?.credential" :hovered-session-credential-uuid="hoveredSession?.credential"
@@ -67,25 +76,14 @@
:terminating-sessions="terminatingSessions" :terminating-sessions="terminatingSessions"
:hovered-credential-uuid="hoveredCredentialUuid" :hovered-credential-uuid="hoveredCredentialUuid"
:navigation-disabled="hasActiveModal" :navigation-disabled="hasActiveModal"
:section-class="useWideLayout ? '' : 'section-block--constrained'"
@terminate="terminateSession" @terminate="terminateSession"
@session-hover="hoveredSession = $event" @session-hover="hoveredSession = $event"
@navigate-out="handleSessionNavigateOut" @navigate-out="handleSessionNavigateOut"
section-description="You are currently signed in to the following sessions. If you don't recognize something, consider deleting not only the session but the associated passkey you suspect is compromised, as only this terminates all linked sessions and prevents logging in again." section-description="You are currently signed in to the following sessions. If you don't recognize something, consider deleting not only the session but the associated passkey you suspect is compromised, as only this terminates all linked sessions and prevents logging in again."
/> />
<Modal v-if="showNameDialog" @close="showNameDialog = false"> <section :class="['section-block', { 'section-block--constrained': !useWideLayout }]">
<h3>Edit Display Name</h3>
<form @submit.prevent="saveName" class="modal-form">
<NameEditForm
label="Display Name"
v-model="newName"
:busy="saving"
@cancel="showNameDialog = false"
/>
</form>
</Modal>
<section class="section-block">
<div class="button-row" ref="logoutButtons"> <div class="button-row" ref="logoutButtons">
<button <button
type="button" type="button"
@@ -101,9 +99,38 @@
<button @click="logoutEverywhere" class="btn-danger" :disabled="authStore.isLoading" @keydown="handleLogoutButtonKeydown">All</button> <button @click="logoutEverywhere" class="btn-danger" :disabled="authStore.isLoading" @keydown="handleLogoutButtonKeydown">All</button>
</template> </template>
</div> </div>
<p class="logout-note" v-if="!hasMultipleSessions"><strong>Logout</strong> from {{ currentSessionHost }}.</p> <div class="logout-footer">
<p class="logout-note" v-else><strong>Logout</strong> this session on {{ currentSessionHost }}, or <strong>All</strong> sessions across all sites and devices for {{ rpName }}. You'll need to log in again with your passkey afterwards.</p> <p class="logout-note" v-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> </section>
<Modal v-if="showEditDialog" @close="showEditDialog = false">
<h3>Edit Profile</h3>
<form @submit.prevent="saveProfile" class="modal-form">
<div class="profile-edit-form">
<label for="edit-display-name">Display Name
<input id="edit-display-name" type="text" v-model="editName" :disabled="saving" required />
</label>
<label for="edit-email">Email
<input id="edit-email" type="email" v-model="editEmail" :disabled="saving" />
</label>
<label for="edit-username">Preferred Username
<input id="edit-username" type="text" v-model="editUsername" :disabled="saving" placeholder="username" />
</label>
<label for="edit-telephone">Telephone
<input id="edit-telephone" type="tel" v-model="editTelephone" :disabled="saving" />
</label>
<div v-if="editError" class="error small">{{ editError }}</div>
<div class="modal-actions">
<button type="button" class="btn-secondary" @click="showEditDialog = false" :disabled="saving">Cancel</button>
<button type="submit" class="btn-primary" :disabled="saving" data-nav-primary>Save</button>
</div>
</div>
</form>
</Modal>
<RegistrationLinkModal <RegistrationLinkModal
v-if="showRegLink" v-if="showRegLink"
endpoint="/auth/api/user/create-link" endpoint="/auth/api/user/create-link"
@@ -117,9 +144,9 @@
import { ref, onMounted, onUnmounted, computed, watch } from 'vue' import { ref, onMounted, onUnmounted, computed, watch } from 'vue'
import Breadcrumbs from '@/components/Breadcrumbs.vue' import Breadcrumbs from '@/components/Breadcrumbs.vue'
import CredentialList from '@/components/CredentialList.vue' import CredentialList from '@/components/CredentialList.vue'
import ThemeSelector from '@/components/ThemeSelector.vue'
import UserBasicInfo from '@/components/UserBasicInfo.vue' import UserBasicInfo from '@/components/UserBasicInfo.vue'
import Modal from '@/components/Modal.vue' import Modal from '@/components/Modal.vue'
import NameEditForm from '@/components/NameEditForm.vue'
import SessionList from '@/components/SessionList.vue' import SessionList from '@/components/SessionList.vue'
import RegistrationLinkModal from '@/components/RegistrationLinkModal.vue' import RegistrationLinkModal from '@/components/RegistrationLinkModal.vue'
import RemoteAuthPermit from '@/components/RemoteAuthPermit.vue' import RemoteAuthPermit from '@/components/RemoteAuthPermit.vue'
@@ -127,15 +154,19 @@ import { useAuthStore } from '@/stores/auth'
import { adminUiPath, makeUiHref } from '@/utils/settings' import { adminUiPath, makeUiHref } from '@/utils/settings'
import passkey from '@/utils/passkey' import passkey from '@/utils/passkey'
import { goBack } from '@/utils/helpers' import { goBack } from '@/utils/helpers'
import { apiJson } from '@/utils/api' import { apiJson } from 'paskia'
import { navigateButtonRow, focusPreferred, focusAtIndex, getDirection } from '@/utils/keynav' import { navigateButtonRow, focusPreferred, focusAtIndex, getDirection } from '@/utils/keynav'
const authStore = useAuthStore() const authStore = useAuthStore()
const updateInterval = ref(null) const updateInterval = ref(null)
const showNameDialog = ref(false) const showEditDialog = ref(false)
const showRegLink = ref(false) const showRegLink = ref(false)
const newName = ref('') const editName = ref('')
const editEmail = ref('')
const editUsername = ref('')
const editTelephone = ref('')
const saving = ref(false) const saving = ref(false)
const editError = ref('')
const hoveredCredentialUuid = ref(null) const hoveredCredentialUuid = ref(null)
const hoveredSession = ref(null) const hoveredSession = ref(null)
const showDeviceInfo = ref(false) const showDeviceInfo = ref(false)
@@ -149,9 +180,17 @@ const userBasicInfo = ref(null)
const userInfoSection = ref(null) const userInfoSection = ref(null)
// Check if any modal/dialog is open (blocks arrow key navigation) // Check if any modal/dialog is open (blocks arrow key navigation)
const hasActiveModal = computed(() => showNameDialog.value || showRegLink.value) const hasActiveModal = computed(() => showEditDialog.value || showRegLink.value)
watch(showNameDialog, (newVal) => { if (newVal) newName.value = authStore.userInfo?.ctx.user.display_name ?? '' }) watch(showEditDialog, (open) => {
if (!open) return
const user = authStore.userInfo.user
editName.value = user.display_name ?? ''
editEmail.value = user.email ?? ''
editUsername.value = user.preferred_username ?? ''
editTelephone.value = user.telephone ?? ''
editError.value = ''
})
onMounted(() => { onMounted(() => {
updateInterval.value = setInterval(() => { if (authStore.userInfo) authStore.userInfo = { ...authStore.userInfo } }, 60000) updateInterval.value = setInterval(() => { if (authStore.userInfo) authStore.userInfo = { ...authStore.userInfo } }, 60000)
@@ -301,59 +340,92 @@ const handleDelete = async (credential) => {
} }
const rpName = computed(() => authStore.settings?.rp_name || 'this service') const rpName = computed(() => authStore.settings?.rp_name || 'this service')
const sessions = computed(() => authStore.userInfo?.sessions || []) const paskiaVersion = computed(() => authStore.settings?.version || '')
const sessions = computed(() => authStore.userInfo.sessions)
const currentSessionHost = computed(() => { const currentSessionHost = computed(() => {
const currentSession = sessions.value.find(session => session.is_current) const currentSession = Object.values(sessions.value).find(session => session.is_current)
return currentSession?.host || 'this host' return currentSession?.host || 'this host'
}) })
const terminatingSessions = ref({}) const terminatingSessions = ref({})
const terminateSession = async (session) => { const terminateSession = async (session) => {
const sessionId = session?.id const sessionKey = session?.key
if (!sessionId) return if (!sessionKey) return
terminatingSessions.value = { ...terminatingSessions.value, [sessionId]: true } terminatingSessions.value = { ...terminatingSessions.value, [sessionKey]: true }
try { await authStore.terminateSession(sessionId) } try { await authStore.terminateSession(sessionKey) }
catch (error) { authStore.showMessage(error.message || 'Failed to terminate session', 'error', 5000) } catch (error) { authStore.showMessage(error.message || 'Failed to terminate session', 'error', 5000) }
finally { finally {
const next = { ...terminatingSessions.value } const next = { ...terminatingSessions.value }
delete next[sessionId] delete next[sessionKey]
terminatingSessions.value = next terminatingSessions.value = next
} }
} }
const logoutEverywhere = async () => { await authStore.logoutEverywhere() } const logoutEverywhere = async () => { await authStore.logoutEverywhere() }
const logout = async () => { await authStore.logout() } const logout = async () => { await authStore.logout() }
const openNameDialog = () => { newName.value = authStore.userInfo?.ctx.user.display_name ?? ''; showNameDialog.value = true } const openEditDialog = () => { showEditDialog.value = true }
const isAdmin = computed(() => { const isAdmin = computed(() => {
const perms = authStore.userInfo?.ctx.permissions const perms = Object.values(authStore.userInfo.permissions).map(p => p.scope)
return perms.includes('auth:admin') || perms.includes('auth:org:admin') return perms.includes('auth:admin') || perms.includes('auth:org:admin')
}) })
const hasMultipleSessions = computed(() => sessions.value.length > 1) const hasMultipleSessions = computed(() => Object.keys(sessions.value).length > 1)
const breadcrumbEntries = computed(() => { const entries = [{ label: 'Auth', href: makeUiHref() }]; if (isAdmin.value) entries.push({ label: 'Admin', href: adminUiPath() }); return entries }) const credentials = computed(() =>
Object.entries(authStore.userInfo.credentials).map(([uuid, c]) => ({ ...c, credential: uuid }))
)
const useWideLayout = computed(() => {
// Check if any single site has more than 8 sessions
const groups = {}
for (const session of Object.values(sessions.value)) {
const host = session.host || ''
if (!groups[host]) groups[host] = []
groups[host].push(session)
}
const hasLargeSessionGroup = Object.values(groups).some(group => group.length > 8)
const saveName = async () => { // Check if passkeys exceed 8
const name = newName.value.trim() const hasManyCredentials = credentials.value.length > 8
if (!name) { authStore.showMessage('Name cannot be empty', 'error'); return }
return hasLargeSessionGroup || hasManyCredentials
})
const breadcrumbEntries = computed(() => { const entries = [{ label: 'My Profile', href: makeUiHref() }]; if (isAdmin.value) entries.push({ label: 'Admin', href: adminUiPath() }); return entries })
const saveProfile = async () => {
const name = editName.value.trim()
if (!name) { editError.value = 'Name cannot be empty'; return }
const user = authStore.userInfo.user
const emailVal = editEmail.value.trim() || null
const usernameVal = editUsername.value.trim() || null
const telephoneVal = editTelephone.value.trim() || null
try { try {
editError.value = ''
saving.value = true saving.value = true
await apiJson('/auth/api/user/display-name', { method: 'PATCH', body: { display_name: name } }) const body = {}
showNameDialog.value = false if (name !== user.display_name) body.display_name = name
await authStore.loadUserInfo() if (emailVal !== (user.email || null)) body.email = emailVal
authStore.showMessage('Name updated successfully!', 'success', 3000) if (usernameVal !== (user.preferred_username || null)) body.preferred_username = usernameVal
} catch (e) { authStore.showMessage(e.message || 'Failed to update name', 'error') } if (telephoneVal !== (user.telephone || null)) body.telephone = telephoneVal
finally { saving.value = false } if (Object.keys(body).length) {
await apiJson('/auth/api/user/info', { method: 'PATCH', body })
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> </script>
<style scoped> <style scoped>
.view-lede { margin: 0; color: var(--color-text-muted); font-size: 1rem; }
.section-header { display: flex; flex-direction: column; gap: 0.4rem; } .section-header { display: flex; flex-direction: column; gap: 0.4rem; }
.empty-state { margin: 0; color: var(--color-text-muted); text-align: center; padding: 1rem 0; } .empty-state { margin: 0; color: var(--color-text-muted); text-align: center; padding: 1rem 0; }
.logout-note { margin: 0.75rem 0 0; color: var(--color-text-muted); font-size: 0.875rem; } .logout-note { margin: 0; color: var(--color-text-muted); }
.logout-footer { display: flex; justify-content: space-between; align-items: baseline; gap: 1rem; margin-top: 0.75rem; }
.paskia-version { color: var(--color-text-muted); text-decoration: none; white-space: nowrap; font-weight: 700; }
.paskia-version:hover { color: var(--color-link-hover); }
.remote-auth-inline { display: flex; flex-direction: column; gap: 0.5rem; } .remote-auth-inline { display: flex; flex-direction: column; gap: 0.5rem; }
.remote-auth-label { display: block; margin: 0; font-size: 0.875rem; color: var(--color-text-muted); font-weight: 500; } .remote-auth-label { display: block; margin: 0; font-size: 0.875rem; color: var(--color-text-muted); font-weight: 500; }
.remote-auth-description { .remote-auth-description { font-size: 0.75rem; color: var(--color-text-muted); }
font-size: 0.75rem; .theme-toggle { position: absolute; top: var(--layout-padding); right: var(--layout-padding); }
color: var(--color-text-muted); .profile-edit-form { display: flex; flex-direction: column; gap: var(--space-md); }
}
</style> </style>
@@ -1,16 +1,17 @@
<template> <template>
<dialog ref="dialog" @close="$emit('close')" @keydown="handleDialogKeydown"> <div v-if="linkUrl" class="dialog-overlay" @click="$emit('close')">
<div class="device-dialog" role="dialog" aria-modal="true" aria-labelledby="regTitle"> <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"> <div class="reg-header-row">
<h2 id="regTitle" class="reg-title"> <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> </h2>
<button class="icon-btn" @click="$emit('close')" aria-label="Close" tabindex="-1"></button> <button class="icon-btn" @click="$emit('close')" aria-label="Close" tabindex="-1"></button>
</div> </div>
<div class="device-link-section"> <div class="device-link-section">
<p class="reg-help"> <p class="reg-help">
Scan this QR code on the new device, or copy the link and open it there. {{ helpText }}
</p> </p>
<QRCodeDisplay <QRCodeDisplay
@@ -28,16 +29,18 @@
<div class="reg-actions" ref="actionsRow" @keydown="handleActionsKeydown"> <div class="reg-actions" ref="actionsRow" @keydown="handleActionsKeydown">
<button class="btn-secondary" @click="$emit('close')">Close</button> <button class="btn-secondary" @click="$emit('close')">Close</button>
</div> </div>
</div>
</div> </div>
</dialog> </div>
</template> </template>
<script setup> <script setup>
import { ref, onMounted, onUnmounted, nextTick } from 'vue' import { ref, computed, onMounted, onUnmounted, nextTick } from 'vue'
import QRCodeDisplay from '@/components/QRCodeDisplay.vue' import QRCodeDisplay from '@/components/QRCodeDisplay.vue'
import { apiJson } from '@/utils/api' import { apiJson, AuthCancelledError, holdGlobalBackdrop, releaseGlobalBackdrop } from 'paskia'
import { formatDate } from '@/utils/helpers' import { formatDate } from '@/utils/helpers'
import { getDirection } from '@/utils/keynav' import { getDirection } from '@/utils/keynav'
import { useAuthStore } from '@/stores/auth'
const props = defineProps({ const props = defineProps({
endpoint: { type: String, required: true }, endpoint: { type: String, required: true },
@@ -46,34 +49,50 @@ const props = defineProps({
const emit = defineEmits(['close', 'copied']) const emit = defineEmits(['close', 'copied'])
const authStore = useAuthStore()
const dialog = ref(null) const dialog = ref(null)
const linkUrl = ref(null) const linkUrl = ref(null)
const expiresAt = ref(null) const expiresAt = ref(null)
const tokenType = ref(null)
const actionsRow = ref(null) const actionsRow = ref(null)
// Store the element that had focus before modal opened // Store the element that had focus before modal opened
const previouslyFocusedElement = ref(null) 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() { async function generateLink() {
try { try {
const data = await apiJson(props.endpoint, { method: 'POST' }) const data = await apiJson(props.endpoint, { method: 'POST' })
if (data.url) { if (data.url) {
linkUrl.value = data.url linkUrl.value = data.url
expiresAt.value = data.expires ? new Date(data.expires) : null 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() await nextTick()
if (dialog.value) { const actions = actionsRow.value
dialog.value.showModal() const target = actions?.querySelector('.btn-primary') || actions?.querySelector('button')
target?.focus()
// 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()
}
} else { } else {
emit('close') emit('close')
} }
} catch { } catch (e) {
if (!(e instanceof AuthCancelledError)) {
authStore.showMessage(e.message || 'Failed to generate link', 'error')
}
emit('close') emit('close')
} }
} }
@@ -83,7 +102,12 @@ function onCopied() {
} }
const handleDialogKeydown = (event) => { const handleDialogKeydown = (event) => {
// ESC is handled automatically by <dialog> // ESC to close
if (event.key === 'Escape') {
event.preventDefault()
emit('close')
return
}
// Handle other key navigation // Handle other key navigation
const direction = getDirection(event) const direction = getDirection(event)
if (!direction) return if (!direction) return
@@ -129,6 +153,7 @@ onMounted(() => {
}) })
onUnmounted(() => { onUnmounted(() => {
if (linkUrl.value) releaseGlobalBackdrop()
// Restore focus when modal closes // Restore focus when modal closes
const prev = previouslyFocusedElement.value const prev = previouslyFocusedElement.value
if (prev && document.body.contains(prev) && !prev.disabled) { if (prev && document.body.contains(prev) && !prev.disabled) {
@@ -138,23 +163,6 @@ onUnmounted(() => {
</script> </script>
<style scoped> <style scoped>
dialog {
border: none;
background: transparent;
padding: 0;
max-width: none;
width: fit-content;
height: fit-content;
position: fixed;
inset: 0;
margin: auto;
}
dialog::backdrop {
-webkit-backdrop-filter: blur(.2rem) brightness(0.5);
backdrop-filter: blur(.2rem) brightness(0.5);
}
.icon-btn { background: none; border: none; cursor: pointer; font-size: 1rem; opacity: .6; } .icon-btn { background: none; border: none; cursor: pointer; font-size: 1rem; opacity: .6; }
.icon-btn:hover { opacity: 1; } .icon-btn:hover { opacity: 1; }
.reg-header-row { display: flex; justify-content: space-between; align-items: center; gap: .75rem; margin-bottom: .75rem; } .reg-header-row { display: flex; justify-content: space-between; align-items: center; gap: .75rem; margin-bottom: .75rem; }
+14 -12
View File
@@ -53,17 +53,16 @@
<!-- Device info display (shown when 3 words match a request) --> <!-- Device info display (shown when 3 words match a request) -->
<div v-else-if="deviceInfo" class="device-info"> <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-permit-text">Permit {{ deviceInfo.action === 'register' ? 'registration' : 'login' }} to <strong>{{ deviceInfo.host }}</strong></p>
<p class="device-meta">{{ deviceInfo.user_agent_pretty }}</p> <p class="device-meta">{{ deviceInfo.user_agent_pretty || '—' }}</p>
<p v-if="error" class="error-message" style="margin-top: 0.5rem;">{{ error }}</p> <p v-if="error" class="error-message">{{ error }}</p>
<div class="button-row" style="margin-top: 0.75rem; display: flex; gap: 0.5rem;"> <div class="button-row device-actions">
<button <button
type="button" type="button"
class="btn-secondary" class="btn-secondary"
:disabled="loading" :disabled="loading"
@click="deny" @click="deny"
style="flex: 1;"
> >
Deny Deny
</button> </button>
@@ -72,7 +71,6 @@
type="submit" type="submit"
:disabled="loading" :disabled="loading"
class="btn-primary" class="btn-primary"
style="flex: 1;"
> >
{{ loading ? 'Authenticating…' : 'Authorize' }} {{ loading ? 'Authenticating…' : 'Authorize' }}
</button> </button>
@@ -773,7 +771,6 @@ defineExpose({ reset, deny, code, handleInput, loading, error })
.input-wrapper { .input-wrapper {
position: relative; position: relative;
display: flex; display: flex;
width: 280px;
max-width: 100%; max-width: 100%;
} }
@@ -921,10 +918,6 @@ defineExpose({ reset, deny, code, handleInput, loading, error })
animation: spin 0.8s linear infinite; animation: spin 0.8s linear infinite;
} }
@keyframes spin {
to { transform: rotate(360deg); }
}
.device-info { .device-info {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@@ -945,9 +938,18 @@ defineExpose({ reset, deny, code, handleInput, loading, error })
} }
.error-message { .error-message {
margin: 0; margin: 0.5rem 0 0;
font-size: 0.875rem; font-size: 0.875rem;
color: var(--color-error, #ef4444); color: var(--color-error, #ef4444);
margin-bottom: 1rem; }
.device-actions {
margin-top: 0.75rem;
display: flex;
gap: 0.5rem;
}
.device-actions button {
flex: 1;
} }
</style> </style>
@@ -8,7 +8,7 @@
<!-- Error state --> <!-- Error state -->
<div v-else-if="error" class="error-section"> <div v-else-if="error" class="error-section">
<p class="error-message">{{ error }}</p> <p class="error-message">{{ error }}</p>
<button class="btn-primary" @click="retry" style="margin-top: 0.75rem;">Try Again</button> <button class="btn-primary" @click="retry">Try Again</button>
</div> </div>
<!-- Connecting phase --> <!-- Connecting phase -->
@@ -163,6 +163,9 @@ async function startRemoteAuth() {
// PoW challenge // PoW challenge
const powChallenge = await ws.receive_json() const powChallenge = await ws.receive_json()
if (powChallenge.status) {
throw new Error(powChallenge.detail || `Failed to connect: ${powChallenge.status}`)
}
if (powChallenge.pow) { if (powChallenge.pow) {
const challenge = b64dec(powChallenge.pow.challenge) const challenge = b64dec(powChallenge.pow.challenge)
const nonces = await solvePoW(challenge, powChallenge.pow.work) const nonces = await solvePoW(challenge, powChallenge.pow.work)
@@ -196,7 +199,7 @@ async function startRemoteAuth() {
} else if (msg.status === 'authenticated') { } else if (msg.status === 'authenticated') {
// Success // Success
completed.value = true completed.value = true
emit('authenticated', { session_token: msg.session_token }) emit('authenticated', { exchange_code: msg.exchange_code })
break break
} else if (msg.status === 'denied') { } else if (msg.status === 'denied') {
// Explicitly denied by the authenticating device // Explicitly denied by the authenticating device
@@ -293,10 +296,6 @@ defineExpose({ retry, cancel })
animation: spin 0.8s linear infinite; animation: spin 0.8s linear infinite;
} }
@keyframes spin {
to { transform: rotate(360deg); }
}
.auth-display { .auth-display {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@@ -499,6 +498,10 @@ defineExpose({ retry, cancel })
color: var(--color-error, #ef4444); color: var(--color-error, #ef4444);
} }
.error-section button {
margin-top: 0.75rem;
}
/* Responsive adjustments */ /* Responsive adjustments */
@media (max-width: 640px) { @media (max-width: 640px) {
.auth-content { .auth-content {
+30 -14
View File
@@ -1,6 +1,6 @@
<template> <template>
<div class="app-shell"> <div class="app-shell">
<div v-if="status.show" class="global-status" style="display: block;"> <div v-if="status.show" class="global-status show">
<div :class="['status', status.type]"> <div :class="['status', status.type]">
{{ status.message }} {{ status.message }}
</div> </div>
@@ -18,7 +18,7 @@
<div class="section-body center"> <div class="section-body center">
<!-- Local passkey authentication view --> <!-- Local passkey authentication view -->
<div v-if="authView === 'local'" class="auth-view"> <div v-if="authView === 'local'" class="auth-view">
<div class="button-row center" ref="buttonRow"> <div class="button-row button-row--center" ref="buttonRow">
<slot name="actions" <slot name="actions"
:loading="loading" :loading="loading"
:can-authenticate="canAuthenticate" :can-authenticate="canAuthenticate"
@@ -58,15 +58,20 @@
import { computed, nextTick, onMounted, onUnmounted, reactive, ref, watch } from 'vue' import { computed, nextTick, onMounted, onUnmounted, reactive, ref, watch } from 'vue'
import passkey from '@/utils/passkey' import passkey from '@/utils/passkey'
import { getSettings, uiBasePath } from '@/utils/settings' import { getSettings, uiBasePath } from '@/utils/settings'
import { fetchJson, getUserFriendlyErrorMessage } from '@/utils/api' import { fetchJson, getUserFriendlyErrorMessage } from 'paskia'
import RemoteAuthRequest from '@/components/RemoteAuthRequest.vue' import RemoteAuthRequest from '@/components/RemoteAuthRequest.vue'
import { focusDialogButton } from '@/utils/keynav' import { focusDialogButton } from '@/utils/keynav'
import { updateThemeFromSession } from '@/utils/theme'
const props = defineProps({ const props = defineProps({
mode: { mode: {
type: String, type: String,
default: 'login', default: 'login',
validator: (value) => ['login', 'reauth', 'forbidden'].includes(value) validator: (value) => ['login', 'reauth', 'forbidden', 'oidc'].includes(value)
},
oidcQueryString: {
type: String,
default: null
} }
}) })
@@ -143,6 +148,7 @@ async function fetchSettings() {
async function validateSession() { async function validateSession() {
try { try {
session.value = await fetchJson('/auth/api/validate', { method: 'POST' }) session.value = await fetchJson('/auth/api/validate', { method: 'POST' })
updateThemeFromSession(session.value?.ctx)
if (isAuthenticated.value && props.mode !== 'reauth') { if (isAuthenticated.value && props.mode !== 'reauth') {
currentView.value = 'forbidden' currentView.value = 'forbidden'
emit('forbidden', session.value) emit('forbidden', session.value)
@@ -163,7 +169,7 @@ async function authenticateUser() {
loading.value = true loading.value = true
showMessage('Starting authentication…', 'info') showMessage('Starting authentication…', 'info')
let result let result
try { result = await passkey.authenticate() } catch (error) { try { result = await passkey.authenticate(props.oidcQueryString) } catch (error) {
loading.value = false loading.value = false
const message = error?.message || 'Passkey authentication cancelled' const message = error?.message || 'Passkey authentication cancelled'
const cancelled = message === 'Passkey authentication cancelled' const cancelled = message === 'Passkey authentication cancelled'
@@ -171,7 +177,13 @@ async function authenticateUser() {
emit('auth-error', { message, cancelled }) emit('auth-error', { message, cancelled })
return return
} }
try { await setSessionCookie(result) } catch (error) { // OIDC flow: no session cookie, just emit the redirect_url
if (result.redirect_url) {
loading.value = false
emit('authenticated', result)
return
}
try { await exchangeCode(result) } catch (error) {
loading.value = false loading.value = false
const message = error?.message || 'Failed to establish session' const message = error?.message || 'Failed to establish session'
showMessage(message, 'error', 4000) showMessage(message, 'error', 4000)
@@ -202,13 +214,13 @@ function openProfile() {
if (profileWindow) profileWindow.focus() if (profileWindow) profileWindow.focus()
} }
async function setSessionCookie(result) { async function exchangeCode(result) {
if (!result?.session_token) { if (!result?.exchange_code) {
console.error('setSessionCookie called with missing session_token:', result) console.error('exchangeCode called with missing exchange_code:', result)
throw new Error('Authentication response missing session_token') throw new Error('Authentication response missing exchange_code')
} }
return await fetchJson('/auth/api/set-session', { return await fetchJson('/auth/api/set-session', {
method: 'POST', headers: { Authorization: `Bearer ${result.session_token}` } method: 'POST', headers: { 'Authorization': `Bearer ${result.exchange_code}` }
}) })
} }
@@ -223,7 +235,7 @@ function switchToLocal() {
async function handleRemoteAuthenticated(result) { async function handleRemoteAuthenticated(result) {
showMessage('Authenticated from another device!', 'success', 2000) showMessage('Authenticated from another device!', 'success', 2000)
try { try {
await setSessionCookie(result) await exchangeCode(result)
} catch (error) { } catch (error) {
const message = error?.message || 'Failed to establish session' const message = error?.message || 'Failed to establish session'
showMessage(message, 'error', 4000) showMessage(message, 'error', 4000)
@@ -265,7 +277,12 @@ watch(initializing, (newVal) => {
onMounted(async () => { onMounted(async () => {
await fetchSettings() await fetchSettings()
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 initializing.value = false
// Add click handler for inline links // Add click handler for inline links
@@ -284,7 +301,6 @@ defineExpose({
</script> </script>
<style scoped> <style scoped>
.button-row.center { display: flex; justify-content: center; gap: 0.75rem; flex-wrap: wrap; }
.user-line { margin: 0.5rem 0 0; font-weight: 500; color: var(--color-text); } .user-line { margin: 0.5rem 0 0; font-weight: 500; color: var(--color-text); }
main.view-root { min-height: 100vh; align-items: center; justify-content: center; padding: 2rem 1rem; } main.view-root { min-height: 100vh; align-items: center; justify-content: center; padding: 2rem 1rem; }
.surface.surface--tight { .surface.surface--tight {
+34 -34
View File
@@ -1,25 +1,26 @@
<template> <template>
<section class="section-block" data-component="session-list-section"> <section :class="['section-block', sectionClass]" data-component="session-list-section">
<div class="section-header"> <div class="section-header">
<h2>Active Sessions</h2> <h2>Active Sessions</h2>
<p class="section-description">{{ sectionDescription }}</p> <p class="section-description">{{ sectionDescription }}</p>
</div> </div>
<div class="section-body"> <div class="section-body">
<div> <div>
<template v-if="Array.isArray(sessions) && sessions.length"> <template v-if="sessionsArray.length">
<div v-for="(group, host) in groupedSessions" :key="host" class="session-group" tabindex="0" @keydown="handleGroupKeydown($event, host)"> <div v-for="(group, key) in groupedSessions" :key="key" class="session-group" tabindex="0" @keydown="handleGroupKeydown($event, key)">
<span :class="['session-group-host', { 'is-current-site': group.isCurrentSite }]"> <span :class="['session-group-host', { 'is-current-site': group.isCurrentSite }]">
<span class="session-group-icon">🌐</span> <span class="session-group-icon">{{ group.isOIDC ? '🪪' : '🌐' }}</span>
<a v-if="host" :href="hostUrl(host)" tabindex="-1" target="_blank" rel="noopener noreferrer">{{ host }}</a> <template v-if="group.isOIDC">{{ group.displayName }}</template>
<a v-else-if="key" :href="hostUrl(key)" tabindex="-1" target="_blank" rel="noopener noreferrer">{{ key }}</a>
<template v-else>Unbound host</template> <template v-else>Unbound host</template>
</span> </span>
<div class="session-list"> <div class="session-list">
<div <div
v-for="session in group.sessions" v-for="session in group.sessions"
:key="session.id" :key="session.key"
:class="['session-item', { :class="['session-item', {
'is-current': session.is_current && !hoveredIp && !hoveredCredentialUuid, 'is-current': session.is_current && !hoveredIp && !hoveredCredentialUuid,
'is-hovered': hoveredSession?.id === session.id, 'is-hovered': hoveredSession?.key === session.key,
'is-linked-credential': hoveredCredentialUuid === session.credential 'is-linked-credential': hoveredCredentialUuid === session.credential
}]" }]"
tabindex="-1" tabindex="-1"
@@ -30,17 +31,17 @@
@keydown="handleItemKeydown($event, session)" @keydown="handleItemKeydown($event, session)"
> >
<div class="item-top"> <div class="item-top">
<h4 class="item-title">{{ session.user_agent }}</h4> <h4 class="item-title">{{ session.user_agent || '—' }}</h4>
<div class="item-actions"> <div class="item-actions">
<span v-if="session.is_current && !hoveredIp && !hoveredCredentialUuid" class="badge badge-current">Current</span> <span v-if="session.is_current && !hoveredIp && !hoveredCredentialUuid" class="badge badge-current">Current</span>
<span v-else-if="hoveredSession?.id === session.id" class="badge badge-current">Selected</span> <span v-else-if="hoveredSession?.key === session.key" class="badge badge-current">Selected</span>
<span v-else-if="hoveredCredentialUuid === session.credential" class="badge badge-current">Linked</span> <span v-else-if="hoveredCredentialUuid === session.credential" class="badge badge-current">Linked</span>
<span v-else-if="!hoveredCredentialUuid && isSameHost(session.ip)" class="badge">Same IP</span> <span v-else-if="!hoveredCredentialUuid && isSameHost(session.ip)" class="badge">Same IP</span>
<button <button
@click="$emit('terminate', session)" @click="$emit('terminate', session)"
class="btn-card-delete" class="btn-card-delete"
:disabled="isTerminating(session.id)" :disabled="isTerminating(session.key)"
:title="isTerminating(session.id) ? 'Terminating...' : 'Terminate session'" :title="isTerminating(session.key) ? 'Terminating...' : 'Terminate session'"
tabindex="-1" tabindex="-1"
></button> ></button>
</div> </div>
@@ -69,12 +70,13 @@ import { hostIP } from '@/utils/helpers'
import { navigateGrid, handleDeleteKey, handleEscape, getDirection } from '@/utils/keynav' import { navigateGrid, handleDeleteKey, handleEscape, getDirection } from '@/utils/keynav'
const props = defineProps({ const props = defineProps({
sessions: { type: Array, default: () => [] }, sessions: { type: Object, default: () => ({}) },
emptyMessage: { type: String, default: 'You currently have no other active sessions.' }, emptyMessage: { type: String, default: 'You currently have no other active sessions.' },
sectionDescription: { type: String, default: "Review where you're signed in and end any sessions you no longer recognize." }, sectionDescription: { type: String, default: "Review where you're signed in and end any sessions you no longer recognize." },
terminatingSessions: { type: Object, default: () => ({}) }, terminatingSessions: { type: Object, default: () => ({}) },
hoveredCredentialUuid: { type: String, default: null }, hoveredCredentialUuid: { type: String, default: null },
navigationDisabled: { type: Boolean, default: false }, navigationDisabled: { type: Boolean, default: false },
sectionClass: { type: String, default: '' },
}) })
const emit = defineEmits(['terminate', 'sessionHover', 'navigate-out']) const emit = defineEmits(['terminate', 'sessionHover', 'navigate-out'])
@@ -106,7 +108,7 @@ const handleCardClick = (event) => {
} }
} }
const isTerminating = (sessionId) => !!props.terminatingSessions[sessionId] const isTerminating = (sessionKey) => !!props.terminatingSessions[sessionKey]
const handleGroupKeydown = (event, host) => { const handleGroupKeydown = (event, host) => {
const group = event.currentTarget const group = event.currentTarget
@@ -149,7 +151,7 @@ const handleGroupKeydown = (event, host) => {
const handleItemKeydown = (event, session) => { const handleItemKeydown = (event, session) => {
// Handle delete (always allowed even with modal) // Handle delete (always allowed even with modal)
handleDeleteKey(event, () => { handleDeleteKey(event, () => {
if (!isTerminating(session.id)) emit('terminate', session) if (!isTerminating(session.key)) emit('terminate', session)
}) })
if (event.defaultPrevented) return if (event.defaultPrevented) return
@@ -208,9 +210,14 @@ const copyIp = async (ip) => {
const displayIp = ip => hostIP(ip) ?? ip const displayIp = ip => hostIP(ip) ?? ip
// Convert sessions dict to array with key attached
const sessionsArray = computed(() =>
Object.entries(props.sessions || {}).map(([key, session]) => ({ ...session, key }))
)
const currentHostIP = computed(() => { const currentHostIP = computed(() => {
if (hoveredIp.value) return hostIP(hoveredIp.value) if (hoveredIp.value) return hostIP(hoveredIp.value)
const current = props.sessions.find(s => s.is_current) const current = sessionsArray.value.find(s => s.is_current)
return current ? hostIP(current.ip) : null return current ? hostIP(current.ip) : null
}) })
@@ -218,27 +225,20 @@ const isSameHost = ip => currentHostIP.value && hostIP(ip) === currentHostIP.val
const groupedSessions = computed(() => { const groupedSessions = computed(() => {
const groups = {} const groups = {}
for (const session of props.sessions) { for (const session of sessionsArray.value) {
const host = session.host || '' const groupKey = session.client || session.host || ''
if (!groups[host]) { if (!groups[groupKey]) {
groups[host] = { sessions: [], isCurrentSite: false } groups[groupKey] = { sessions: [], isCurrentSite: false, isOIDC: !!session.client, displayName: session.client_name || groupKey }
}
groups[host].sessions.push(session)
if (session.is_current_host) {
groups[host].isCurrentSite = true
} }
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 groupKey in groups) groups[groupKey].sessions.sort((a, b) => new Date(b.last_renewed) - new Date(a.last_renewed))
for (const host in groups) {
groups[host].sessions.sort((a, b) => new Date(b.last_renewed) - new Date(a.last_renewed))
}
// Sort groups by host name (natural sort)
const collator = new Intl.Collator(undefined, { numeric: true, sensitivity: 'base' }) const collator = new Intl.Collator(undefined, { numeric: true, sensitivity: 'base' })
const sortedHosts = Object.keys(groups).sort(collator.compare) const sorted = Object.entries(groups).sort(([, a], [, b]) => {
const sortedGroups = {} if (a.isOIDC !== b.isOIDC) return a.isOIDC ? 1 : -1
for (const host of sortedHosts) { return collator.compare(a.displayName, b.displayName) || collator.compare(a.sessions[0]?.client || '', b.sessions[0]?.client || '')
sortedGroups[host] = groups[host] })
} return Object.fromEntries(sorted)
return sortedGroups
}) })
</script> </script>
+1 -1
View File
@@ -1,5 +1,5 @@
<template> <template>
<div v-if="authStore.status.show" class="global-status" style="display: block;"> <div v-if="authStore.status.show" class="global-status show">
<div :class="['status', authStore.status.type]"> <div :class="['status', authStore.status.type]">
{{ authStore.status.message }} {{ authStore.status.message }}
</div> </div>
+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>
+76 -57
View File
@@ -1,23 +1,38 @@
<template> <template>
<div v-if="userLoaded" class="user-info" :class="{ 'has-extra': $slots.default }"> <div v-if="userLoaded" class="user-info" :class="{ 'has-extra': $slots.default }">
<h3 class="user-name-heading"> <div class="user-info-content">
<span class="icon">👤</span> <div class="user-picture">
<span class="user-name-row"> <span>👤</span>
<span class="display-name" :title="name">{{ name }}</span> </div>
<button v-if="canEdit && updateEndpoint" class="mini-btn" @click="emit('editName')" title="Edit name"></button> <h3 class="user-name-heading">
</span> <span class="user-name-row">
</h3> <span class="display-name" :title="name">{{ name }}</span>
<div v-if="orgDisplayName || roleName" class="org-role-sub"> <button v-if="canEdit && updateEndpoint" class="mini-btn" @click="emit('edit')" title="Edit profile"></button>
<div class="org-line" v-if="orgDisplayName">{{ orgDisplayName }}</div> </span>
<div class="role-line" v-if="roleName">{{ roleName }}</div> </h3>
</div> <div v-if="orgDisplayName || roleName" class="org-role-sub">
<div class="user-details"> <div class="org-line" v-if="orgDisplayName">{{ orgDisplayName }}</div>
<span class="date-label"><strong>Visits:</strong></span> <div class="role-line" v-if="roleName">{{ roleName }}</div>
<span class="date-value">{{ visits || 0 }}</span> </div>
<span class="date-label"><strong>Registered:</strong></span> <div class="info-fields-block">
<span class="date-value">{{ formatDate(createdAt) }}</span> <div v-if="preferred_username" class="contact-item">🆔 {{ preferred_username }}</div>
<span class="date-label"><strong>Last seen:</strong></span> <a v-if="email" :href="`mailto:${email}`" class="contact-link"> {{ email }}</a>
<span class="date-value">{{ formatDate(lastSeen) }}</span> <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>
<div v-if="$slots.default" class="user-info-extra"> <div v-if="$slots.default" class="user-info-extra">
<slot></slot> <slot></slot>
@@ -26,12 +41,15 @@
</template> </template>
<script setup> <script setup>
import { ref, computed, watch } from 'vue' import { computed } from 'vue'
import { useAuthStore } from '@/stores/auth' import { useAuthStore } from '@/stores/auth'
import { formatDate } from '@/utils/helpers' import { formatDate } from '@/utils/helpers'
const props = defineProps({ const props = defineProps({
name: { type: String, required: true }, name: { type: String, required: true },
email: { type: String, default: null },
preferred_username: { type: String, default: null },
telephone: { type: String, default: null },
visits: { type: [Number, String], default: 0 }, visits: { type: [Number, String], default: 0 },
createdAt: { type: [String, Number, Date], default: null }, createdAt: { type: [String, Number, Date], default: null },
lastSeen: { type: [String, Number, Date], default: null }, lastSeen: { type: [String, Number, Date], default: null },
@@ -42,7 +60,7 @@ const props = defineProps({
roleName: { type: String, default: '' } roleName: { type: String, default: '' }
}) })
const emit = defineEmits(['saved', 'editName']) const emit = defineEmits(['saved', 'edit'])
const authStore = useAuthStore() const authStore = useAuthStore()
const userLoaded = computed(() => !!props.name) const userLoaded = computed(() => !!props.name)
@@ -50,56 +68,57 @@ const userLoaded = computed(() => !!props.name)
<style scoped> <style scoped>
.user-info.has-extra { .user-info.has-extra {
grid-template-columns: auto 1fr 2fr; grid-template-columns: minmax(0, 1fr) 14rem;
grid-template-areas: grid-template-areas:
"heading heading extra" "content extra";
"org org extra" gap: 1.5rem;
"label1 value1 extra"
"label2 value2 extra"
"label3 value3 extra";
} }
.user-info:not(.has-extra) { .user-info:not(.has-extra) {
grid-template-columns: auto 1fr; grid-template-columns: minmax(0, 1fr);
grid-template-areas: grid-template-areas:
"heading heading" "content";
"org org"
"label1 value1"
"label2 value2"
"label3 value3";
} }
@media (max-width: 720px) { @media (max-width: 720px) {
.user-info.has-extra { .user-info.has-extra {
grid-template-columns: auto 1fr; grid-template-columns: 1fr;
grid-template-areas: grid-template-areas:
"heading heading" "content"
"org org" "extra";
"label1 value1"
"label2 value2"
"label3 value3"
"extra extra";
} }
} }
.user-name-heading { grid-area: heading; display: flex; align-items: center; flex-wrap: wrap; margin: 0 0 0.25rem 0; } .user-info-content {
.org-role-sub { grid-area: org; display:flex; flex-direction:column; margin: -0.15rem 0 0.25rem; } grid-area: content;
.org-line { font-size: .7rem; font-weight:600; line-height:1.1; color: var(--color-text-muted); text-transform: uppercase; letter-spacing: 0.05em; } display: grid;
.role-line { font-size:.65rem; color: var(--color-text-muted); line-height:1.1; } grid-template-columns: auto minmax(0, 1fr) minmax(0, 1fr);
.info-label:nth-of-type(1) { grid-area: label1; } grid-template-areas:
.info-value:nth-of-type(2) { grid-area: value1; } "picture heading fields"
.info-label:nth-of-type(3) { grid-area: label2; } "picture org fields"
.info-value:nth-of-type(4) { grid-area: value2; } ". info info";
.info-label:nth-of-type(5) { grid-area: label3; } gap: 0 1rem;
.info-value:nth-of-type(6) { grid-area: value3; } min-width: 0;
.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; } .user-picture { grid-area: picture; display: flex; align-items: flex-start; font-size: 2em; line-height: 1; }
.display-name { font-weight: 600; font-size: 1.05em; line-height: 1.2; max-width: 14ch; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .user-name-heading { grid-area: heading; display: flex; align-items: center; flex-wrap: wrap; margin: 0 0 0.25rem 0; min-width: 0; }
.name-input { width: auto; flex: 1 1 140px; min-width: 120px; padding: 6px 8px; font-size: 0.9em; border: 1px solid var(--color-border-strong); border-radius: 6px; background: var(--color-surface); color: var(--color-text); } .org-role-sub { grid-area: org; display: flex; flex-direction: column; min-width: 0; }
.user-name-heading .name-input { width: auto; } .org-line { font-size: .7rem; font-weight: 600; line-height: 1.1; color: var(--color-text-muted); text-transform: uppercase; letter-spacing: 0.05em; }
.name-input:focus { outline: none; border-color: var(--color-accent); box-shadow: var(--focus-ring); } .role-line { font-size: .65rem; color: var(--color-text-muted); line-height: 1.1; }
.mini-btn { width: auto; padding: 4px 6px; margin: 0; font-size: 0.75em; line-height: 1; cursor: pointer; } .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:hover:not(:disabled) { background: var(--color-accent-soft); color: var(--color-accent); }
.mini-btn:active:not(:disabled) { transform: translateY(1px); } .mini-btn:active:not(:disabled) { transform: translateY(1px); }
.mini-btn:disabled { opacity: 0.5; cursor: not-allowed; } .mini-btn:disabled { opacity: 0.5; cursor: not-allowed; }
+7 -4
View File
@@ -1,12 +1,14 @@
import { defineStore } from 'pinia' import { defineStore } from 'pinia'
import { register, authenticate } from '@/utils/passkey' import { register, authenticate } from '@/utils/passkey'
import { getSettings } from '@/utils/settings' import { getSettings } from '@/utils/settings'
import { apiJson } from '@/utils/api' import { apiJson } from 'paskia'
import { updateThemeFromSession } from '@/utils/theme'
export const useAuthStore = defineStore('auth', { export const useAuthStore = defineStore('auth', {
state: () => ({ state: () => ({
// Auth State // Auth State
userInfo: null, // Contains the full user info response: {user, credentials, aaguid_info} userInfo: null, // Contains the full user info response: {user, credentials, aaguid_info}
ctx: null, // Session context from validate
isLoading: false, isLoading: false,
// Settings // Settings
@@ -85,7 +87,8 @@ export const useAuthStore = defineStore('auth', {
}, },
async loadUserInfo() { async loadUserInfo() {
try { try {
this.userInfo = await apiJson('/auth/api/user-info', { method: 'POST' }) this.userInfo = await apiJson('/auth/api/user-info', { method: 'GET' })
updateThemeFromSession(this.userInfo)
console.log('User info loaded:', this.userInfo) console.log('User info loaded:', this.userInfo)
} catch (error) { } catch (error) {
// Suppress toast for 401/403 errors - the auth iframe will handle these // Suppress toast for 401/403 errors - the auth iframe will handle these
@@ -101,9 +104,9 @@ export const useAuthStore = defineStore('auth', {
await apiJson(`/auth/api/user/credential/${uuid}`, { method: 'DELETE' }) await apiJson(`/auth/api/user/credential/${uuid}`, { method: 'DELETE' })
await this.loadUserInfo() await this.loadUserInfo()
}, },
async terminateSession(sessionId) { async terminateSession(sessionKey) {
try { try {
const payload = await apiJson(`/auth/api/user/session/${sessionId}`, { method: 'DELETE' }) const payload = await apiJson(`/auth/api/user/session/${sessionKey}`, { method: 'DELETE' })
if (payload?.current_session_terminated) { if (payload?.current_session_terminated) {
sessionStorage.clear() sessionStorage.clear()
location.reload() location.reload()
-405
View File
@@ -1,405 +0,0 @@
/**
* API fetch wrapper that handles authentication errors with iframe-based re-authentication.
*
* When a 401 or 403 response is received with an `auth` object containing `iframe` URL,
* this wrapper shows an authentication iframe and retries the original request after
* successful authentication.
*/
/** Default timeout for API requests in milliseconds */
const DEFAULT_TIMEOUT_MS = 1000
/**
* Custom error class for API errors with full response context.
*/
export class ApiError extends Error {
constructor(url, response, data) {
super(data?.detail || `Request failed: ${response.status}`)
this.name = 'ApiError'
this.url = url
this.status = response.status
this.statusText = response.statusText
this.data = data
}
}
/**
* Custom error class for network/timeout errors.
*/
export class NetworkError extends Error {
constructor(message, originalError = null) {
super(message)
this.name = 'NetworkError'
this.originalError = originalError
}
}
/**
* Error thrown when user cancels authentication.
*/
export class AuthCancelledError extends Error {
constructor() {
super('Authentication cancelled')
this.name = 'AuthCancelledError'
}
}
let authIframe = null
let authPromise = null
let authResolve = null
let authReject = null
// Global backdrop ref-count (works independently of Pinia store)
let backdropHolders = 0
/**
* Hold global backdrop (increment ref-count).
* Multiple callers can hold the backdrop; it only hides when all release.
*/
export function holdGlobalBackdrop() {
backdropHolders++
document.body.classList.add('has-backdrop')
}
/**
* Release global backdrop (decrement ref-count).
* Backdrop hides only when ref-count reaches zero.
*/
export function releaseGlobalBackdrop() {
backdropHolders = Math.max(0, backdropHolders - 1)
if (backdropHolders === 0) {
document.body.classList.remove('has-backdrop')
}
}
// 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')
}
/**
* Check if an auth iframe is already open (from any source).
* @returns {boolean}
*/
export function isAuthIframeOpen() {
return !!document.getElementById('auth-iframe')
}
/**
* Show the authentication iframe and return a promise that resolves on success.
* If an auth iframe is already open (from any source), hooks into its completion.
* Uses global backdrop system to avoid flicker between auth and caller's UI.
* @param {string} iframeUrl - The URL for the iframe src
* @returns {Promise<void>}
* @throws {AuthCancelledError} - If authentication is cancelled by user
*/
export function showAuthIframe(iframeUrl) {
// If we already have a promise (from us), return it
if (authPromise) return authPromise
// If there's already an iframe in the DOM (from App.vue or elsewhere),
// create a promise that hooks into the message handler
if (document.getElementById('auth-iframe')) {
authPromise = new Promise((resolve, reject) => {
authResolve = resolve
authReject = reject
})
return authPromise
}
authPromise = new Promise((resolve, reject) => {
authResolve = resolve
authReject = reject
})
// Remove existing iframe if any
hideAuthIframe()
// Hold global backdrop for auth iframe
holdGlobalBackdrop()
// Create new iframe for authentication using src URL
authIframe = document.createElement('iframe')
authIframe.id = 'auth-iframe'
authIframe.title = 'Authentication'
authIframe.allow = 'publickey-credentials-get; publickey-credentials-create'
authIframe.src = iframeUrl
document.body.appendChild(authIframe)
return authPromise
}
function hideAuthIframe() {
if (authIframe) {
authIframe.remove()
authIframe = null
releaseGlobalBackdrop()
}
}
function handleAuthMessage(event) {
const data = event.data
if (!data?.type) return
switch (data.type) {
case 'auth-success':
hideAuthIframe()
if (authResolve) {
authResolve()
authPromise = null
authResolve = null
authReject = null
}
break
case 'auth-back':
case 'auth-close-request':
hideAuthIframe()
if (authReject) {
authReject(new AuthCancelledError())
authPromise = null
authResolve = null
authReject = null
}
break
case 'auth-error':
// Keep iframe open for retry, but if cancelled, treat as back
if (data.cancelled && authReject) {
hideAuthIframe()
authReject(new AuthCancelledError())
authPromise = null
authResolve = null
authReject = null
}
break
}
}
// Install global message listener
if (typeof window !== 'undefined') {
window.addEventListener('message', handleAuthMessage)
}
/**
* Fetch wrapper that handles auth errors with iframe-based re-authentication.
* Loops until successful or user cancels authentication.
*
* @param {string|URL} url - The URL to fetch
* @param {RequestInit} [options] - Fetch options
* @param {number} [options.timeout] - Timeout in ms (default: 10000, use 0 to disable)
* @returns {Promise<Response>} - The fetch response
* @throws {AuthCancelledError} - If authentication is cancelled by user
* @throws {NetworkError} - If network error or timeout occurs
*/
export async function apiFetch(url, options = {}) {
const { timeout = DEFAULT_TIMEOUT_MS, ...fetchOptions } = options
// Ensure credentials are included for cookie-based auth
fetchOptions.credentials = fetchOptions.credentials || 'include'
while (true) {
let response
try {
response = await fetch(url, {...fetchOptions, signal: timeout && AbortSignal.timeout(timeout)})
} catch (error) {
// Handle network errors and timeouts
if (error.name === 'TimeoutError') {
throw new NetworkError('Request timed out', error)
}
if (error.name === 'AbortError') {
// Re-throw abort errors as-is (user-initiated cancellation)
throw error
}
if (error.name === 'TypeError' && error.message === 'Failed to fetch') {
throw new NetworkError('Unable to connect to server', error)
}
throw new NetworkError(error.message || 'Network error', error)
}
// Check for auth errors (401/403)
if (response.status === 401 || response.status === 403) {
// Try to parse the response to get the iframe URL
let authInfo = null
try {
const data = await response.clone().json()
authInfo = data.auth
} catch {
// If we can't parse JSON, no iframe available
}
// Authenticate via iframe (only in top-level window)
if (authInfo?.iframe && window === window.top) {
// Show auth iframe (or wait for existing one) and retry on success
// showAuthIframe returns existing promise if iframe is already open
await showAuthIframe(authInfo.iframe)
continue // Retry the original request
}
}
return response
}
}
/**
* Convenience method for JSON API calls.
* Automatically sets Accept and Content-Type headers.
* Returns parsed JSON directly if response is ok, throws ApiError otherwise.
*
* @param {string|URL} url - The URL to fetch
* @param {RequestInit} [options] - Fetch options
* @returns {Promise<any>} - Parsed JSON response
* @throws {ApiError} - If response is not ok
* @throws {NetworkError} - If network error or timeout occurs
* @throws {AuthCancelledError} - If authentication is cancelled by user
*/
export async function apiJson(url, options = {}) {
const fetchOptions = { ...options }
// Set default headers, allowing caller overrides
fetchOptions.headers = {
'Accept': 'application/json',
...fetchOptions.headers,
}
// Set Content-Type for requests with JSON body
if (fetchOptions.body && typeof fetchOptions.body === 'object' && !(fetchOptions.body instanceof FormData)) {
fetchOptions.headers = {
'Content-Type': 'application/json',
...fetchOptions.headers,
}
fetchOptions.body = JSON.stringify(fetchOptions.body)
}
const response = await apiFetch(url, fetchOptions)
const data = await response.json()
if (!response.ok) {
throw new ApiError(url, response, data)
}
return data
}
/**
* Simple JSON fetch without auto-auth iframe handling.
* Use this in contexts where showing an auth iframe would be inappropriate
* (e.g., inside the auth iframe itself).
*
* @param {string|URL} url - The URL to fetch
* @param {RequestInit} [options] - Fetch options
* @returns {Promise<any>} - Parsed JSON response
* @throws {ApiError} - If response is not ok
*/
export async function fetchJson(url, options = {}) {
const fetchOptions = {
...options,
headers: {
'Accept': 'application/json',
...options.headers,
},
}
const response = await fetch(url, fetchOptions)
const data = await response.json()
if (!response.ok) {
throw new ApiError(url, response, data)
}
return data
}
/**
* Convert an error to a user-friendly message.
* @param {Error} error - The error to convert
* @returns {string} - User-friendly error message
*/
export function getUserFriendlyErrorMessage(error) {
if (error instanceof NetworkError) {
return error.message
}
if (error instanceof ApiError) {
return error.message
}
if (error.name === 'TimeoutError') {
return 'Request timed out'
}
if (error.name === 'TypeError' && error.message === 'Failed to fetch') {
return 'Unable to connect to server'
}
return error.message || 'An error occurred'
}
/**
* Check if an error should show a toast to the user.
* @param {Error} error - The error to check
* @returns {boolean} - Whether to show a toast
*/
export function shouldShowErrorToast(error) {
// Don't show toast for user cancellations
if (error instanceof AuthCancelledError) return false
if (error.name === 'AbortError') return false
// Don't show toast for 401/403 errors - the auth iframe will handle these
if (error instanceof ApiError && (error.status === 401 || error.status === 403)) return false
return true
}
/**
* Create an API caller with error handling (toast + console.error).
* Wraps apiJson calls with consistent error handling for apps.
*
* @param {Function} showMessage - Function to show toast messages: (message, type, duration) => void
* @returns {Function} - Wrapped apiJson that handles errors
*/
export function createApiCaller(showMessage) {
/**
* @param {string|URL} url - The URL to fetch
* @param {RequestInit} [options] - Fetch options
* @returns {Promise<any>} - Parsed JSON response, or undefined on error
*/
return async function apiCall(url, options = {}) {
try {
return await apiJson(url, options)
} catch (error) {
if (!shouldShowErrorToast(error)) {
throw error
}
// Log full error details
console.error(`API error for ${url}:`, error instanceof ApiError ? { status: error.status, statusText: error.statusText, data: error.data } : error)
// Show user-friendly toast
showMessage(getUserFriendlyErrorMessage(error), 'error', 4000)
throw error
}
}
}
export default apiFetch
+8 -3
View File
@@ -1,7 +1,7 @@
import { startRegistration, startAuthentication } from '@simplewebauthn/browser' import { startRegistration, startAuthentication } from '@simplewebauthn/browser'
import aWebSocket from '@/utils/awaitable-websocket' import aWebSocket from '@/utils/awaitable-websocket'
import { getSettings } from '@/utils/settings' import { getSettings } from '@/utils/settings'
import { showAuthIframe } from '@/utils/api' import { showAuthIframe } from 'paskia'
// Generic path normalizer: if an auth_host is configured and differs from current // Generic path normalizer: if an auth_host is configured and differs from current
// host, return absolute URL (scheme derived by aWebSocket). Otherwise, keep as-is. // host, return absolute URL (scheme derived by aWebSocket). Otherwise, keep as-is.
@@ -54,8 +54,13 @@ export async function register(resetToken = null, displayName = null, onstartreg
} }
} }
export async function authenticate() { export async function authenticate(queryString = null) {
const ws = await aWebSocket(await makeUrl('/auth/ws/authenticate')) // Build URL, optionally appending raw query string (e.g. for OIDC params)
let url = await makeUrl('/auth/ws/authenticate')
if (queryString) {
url += queryString.startsWith('?') ? queryString : `?${queryString}`
}
const ws = await aWebSocket(url)
try { try {
let res = await ws.receive_json() let res = await ws.receive_json()
if (res.status >= 400) throw new Error(res.detail || `Authentication failed: ${res.status}`) if (res.status >= 400) throw new Error(res.detail || `Authentication failed: ${res.status}`)
+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 * FastAPI-Vue Vite Plugin
* auto-upgrade@fastapi-vue-setup -- remove this if you edit the plugin
* *
* Configures Vite for FastAPI backend integration: * Configures Vite for FastAPI backend integration:
* - Proxies /api/* requests to the FastAPI backend * - Proxies /api/* requests to the FastAPI backend
* - Builds to the Python module's frontend-build directory * - Builds to the Python module's frontend-build directory
* *
* Environment variables (with defaults): * Options:
* FASTAPI_VUE_BACKEND_URL=http://localhost:5180 - Backend API URL for proxying * 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"] } = {}) { export default function fastapiVue({ paths = ["/api"] } = {}) {
const backendUrl = process.env.PASKIA_BACKEND_URL || "http://localhost:4402"
// Build proxy configuration for each path // Build proxy configuration for each path
const proxy = {} const proxy = {}
for (const path of paths) { for (const path of paths) {
@@ -23,7 +24,7 @@ export default function fastapiVue({ paths = ["/api"] } = {}) {
} }
return { return {
name: "fastapi-vite", name: "vite-plugin-fastapi-paskia",
config: () => ({ config: () => ({
server: { proxy }, server: { proxy },
build: { build: {
+16 -1
View File
@@ -16,6 +16,7 @@ export default defineConfig(({ command }) => ({
fastapiVue({ paths: [ fastapiVue({ paths: [
"/auth/api", "/auth/api",
"/auth/ws", "/auth/ws",
"/.well-known/openid-configuration",
// Passphrase links: /auth/word1.word2.word3.word4.word5 // Passphrase links: /auth/word1.word2.word3.word4.word5
"^/auth/[a-z]+\\.[a-z]+\\.[a-z]+\\.[a-z]+\\.[a-z]+$", "^/auth/[a-z]+\\.[a-z]+\\.[a-z]+\\.[a-z]+\\.[a-z]+$",
// Passphrase links: /word1.word2.word3.word4.word5 // Passphrase links: /word1.word2.word3.word4.word5
@@ -46,6 +47,18 @@ export default defineConfig(({ command }) => ({
}) })
} }
}, },
{
name: 'restricted-endpoints-rewrite',
configureServer(server) {
server.middlewares.use((req, _res, next) => {
// Rewrite /auth/restricted/iframe and /auth/restricted/oidc to /auth/restricted/
if (req.url === '/auth/restricted/iframe' || req.url === '/auth/restricted/oidc') {
req.url = '/auth/restricted/'
}
next()
})
}
},
{ {
name: 'serve-examples', name: 'serve-examples',
configureServer(server) { configureServer(server) {
@@ -90,7 +103,9 @@ export default defineConfig(({ command }) => ({
} }
].filter(Boolean), ].filter(Boolean),
resolve: { resolve: {
alias: { '@': fileURLToPath(new URL('./src', import.meta.url)) } alias: {
'@': fileURLToPath(new URL('./src', import.meta.url)),
}
}, },
base: '/', base: '/',
server: { server: {
+86
View File
@@ -0,0 +1,86 @@
# OIDC Provider Implementation
OpenID Connect 1.0 provider enabling third-party apps to authenticate users via passkey. Also supports native cookie-based authentication.
## Data Models
**User** — Added: `email`, `preferred_username`
**Session** — Added: `client_uuid` (None = native, set = OIDC)
- `key: bytes` — hashed DB key, never stored raw
- `secret``hash_secret("session", secret)` → DB lookup
- OIDC `sid``base64url.encode(hash_secret("oidc", session.key))`
**OIDClient**`uuid, client_secret_hash, name, redirect_uris`
## Auth Codes (In-Memory Only)
60-second lifetime, auto-cleaned:
```python
from paskia.authcode import AuthCode, OIDC, codes
class AuthCode(msgspec.Struct):
session_key: str # Session DB key
created: datetime
oidc: OIDC | None # Only for OIDC mode
class OIDC(msgspec.Struct):
redirect_uri, scope, nonce, code_challenge, code_challenge_method: str
```
Usage: `code = authcode.store(AuthCode(...))` → later `codes.pop(code, None)`
## Authorization Flows
### OIDC (Authorization Code)
1. `GET /auth/restricted/oidc?client_id=UUID&redirect_uri=...&scope=openid&nonce=...&code_challenge=...`
2. Frontend → WebSocket: `/auth/ws/authenticate?client_id=...&redirect_uri=...&...`
3. Validate client/redirect_uri, authenticate via passkey
4. `db.oidc_login()``(secret, session_key)`
5. Create `AuthCode(session_key, oidc=OIDC(...))` → code
6. Return: `{"redirect_url": "{redirect_uri}?code={code}&state={state}"}`
7. Client exchanges code at `/auth/oidc/token` with `code_verifier` (PKCE S256)
**Token:** `access_token, id_token, refresh_token={secret}, expires_in=3600`
**ID token:** `sub, sid (base64url), name, preferred_username, email, groups`
### Native (Cookie)
1. WebSocket: `/auth/ws/authenticate` (no OIDC params)
2. Authenticate via passkey
3. `db.login()` → secret
4. Create `AuthCode(session_key=secret, oidc=None)` → exchange_code
5. Return: `{"user": "UUID", "exchange_code": "..."}`
6. `POST /auth/api/exchange` with code → sets cookie
## Refresh & Logout
**Refresh:** `POST /auth/oidc/token` with `grant_type=refresh_token&refresh_token={secret}&client_id=...&client_secret=...`
- Looks up session, validates client match
- Extends expiry +24h (sliding window)
- Returns new tokens with same `sid`
**Back-channel logout:** `POST /auth/oidc/backchannel-logout` with `logout_token={jwt}`
- Verify signature, extract `sid` or `sub`
- Delete matched sessions
- Return 200 OK
Discovery: `backchannel_logout_supported: true`
## Endpoints
- `GET /.well-known/openid-configuration` — Discovery
- `GET /auth/oidc/keys` — Keys (EdDSA)
- `POST /auth/oidc/token` — Exchange/refresh
- `GET /auth/oidc/userinfo` — User (bearer token)
- `POST /auth/oidc/backchannel-logout` — Logout
- `POST /auth/api/exchange` — Native auth code → cookie
## Files
**Created:** [paskia/authcode.py](paskia/authcode.py), [paskia/util/crypto.py](paskia/util/crypto.py), [paskia/fastapi/oid.py](paskia/fastapi/oid.py)
**Modified:** [paskia/db/structs.py](paskia/db/structs.py), [paskia/db/operations.py](paskia/db/operations.py), [paskia/fastapi/ws.py](paskia/fastapi/ws.py), [paskia/fastapi/api.py](paskia/fastapi/api.py), [paskia/globals.py](paskia/globals.py), [paskia/fastapi/mainapp.py](paskia/fastapi/mainapp.py)
+30
View File
@@ -0,0 +1,30 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
.DS_Store
dist
dist-ssr
coverage
*.local
/cypress/videos/
/cypress/screenshots/
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
*.tsbuildinfo
+131
View File
@@ -0,0 +1,131 @@
# Paskia
![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.
## Installation
### NPM
No framework dependencies. Works with any framework (Vue, React, Svelte, etc.) or vanilla JS. Typescript typing included.
```sh
npm install paskia
```
```js
import { ... } from 'paskia'
```
### Plain JavaScript
Fetch the module directly from a CDN, or [download](https://cdn.jsdelivr.net/npm/paskia@latest/dist/paskia.js) first and host yourself. No Node needed.
```html
<script type="module">
import { ... } from 'https://cdn.jsdelivr.net/npm/paskia@latest/dist/paskia.js'
</script>
```
## Features
### Session Validation
Refresh session and track its validity with automatic polling. Pauses on lack of user activity to avoid useless traffic and to allow session expiry even when the page is left open but idle. This monitors that the same account stays logged in but doesn't do any permission checks.
```js
import { SessionValidator } from 'paskia'
const validator = new SessionValidator(
() => currentUser?.uuid, // getter for current user ID that we track
(error) => handleSessionLost(error) // callback when session is lost
)
validator.start() // call at your app startup/login
validator.stop() // stop the system (optional)
```
### API Fetch Utilities
Enhanced fetch functions with automatic error handling and authentication retry:
```js
import { apiJson, apiFetch } from 'paskia'
// JSON API calls with automatic auth handling
const data = await apiJson('/api/endpoint', { method: 'POST', body: { key: 'value' } })
// Raw fetch with auth handling
const response = await apiFetch('/api/endpoint')
```
When a 401/403 response includes an auth iframe URL, the request automatically pauses, displays the authentication UI, and retries upon success. In case this is not needed, use standard `fetch` or our `fetchJson`.
The JSON variants set headers automatically, with body and response in JSON.
### 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/iframe#mode=login`, `mode=reauth` or `mode=forbidden` to trigger the UX flow you need.
```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) {
await showAuthIframe(data.auth.iframe) // Raises AuthCancelledError if the user cancels
}
}
```
This resolves after the user authenticates (possibly with another account than previously), and you should usually retry the original API request. Note that successful authentication doesn't guarantee that the user still has rights to what originally failed.
### Shared Blur Backdrop
The authentication dialog displays with a blur backdrop (z-index 1099). The auth iframe uses z-index 9999. Your app dialogs should use z-index 11009998 to appear above the backdrop but below authentication.
The backdrop is also reusable/refcounted, so you can keep consistent visuals for your own dialogs:
```js
import { holdGlobalBackdrop, releaseGlobalBackdrop } from 'paskia'
holdGlobalBackdrop()
try {
await your.own.dialog()
} finally {
releaseGlobalBackdrop()
}
```
The backdrop only disappears after all holders have released it.
## Error Handling
### AuthCancelledError (apiFetch, apiJson, showAuthIframe)
If the user clicks Back in the authentication dialog, refusing to authenticate, `AuthCancelledError` is risen (as a response to postMessage from the iframe). The dialog closes as expected and it is up to the app how to continue from there.
- Do nothing if the app can continue despite the failed operation (no UI notification needed)
- Display a simple Access Denied page with suggestion/button to reload the page to try again
Do not retry automatically.
### UI feedback
A set of small utilities are available for determining whether the user needs a notification and to format the error message.
```js
import { getUserFriendlyErrorMessage, shouldShowErrorToast } from 'paskia'
try {
await apiJson('/api/action')
} catch (e) {
if (shouldShowErrorToast(e)) {
your.message.display(getUserFriendlyErrorMessage(e))
}
}
```
+41
View File
@@ -0,0 +1,41 @@
{
"name": "paskia",
"version": "1.1.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",
"exports": {
".": {
"types": "./dist/paskia.d.ts",
"import": "./dist/paskia.js"
}
},
"files": [
"dist"
],
"scripts": {
"build": "vite build",
"prepublishOnly": "npm run build"
},
"devDependencies": {
"typescript": "~5.8.0",
"vite": "^7.3.1",
"vite-plugin-dts": "^4.5.4"
},
"keywords": [
"auth",
"authentication",
"paskia",
"passkey",
"webauthn"
]
}
+146
View File
@@ -0,0 +1,146 @@
import { showAuthIframe, AuthCancelledError } from './overlay'
export { AuthCancelledError }
const DEFAULT_TIMEOUT_MS = 1000
export interface ApiFetchOptions extends RequestInit {
timeout?: number
}
export interface FetchJsonOptions extends Omit<RequestInit, 'body'> {
timeout?: number
body?: BodyInit | Record<string, unknown> | null
}
export class ApiError extends Error {
readonly url: string
readonly status: number
readonly statusText: string
readonly data: unknown
constructor(url: string, response: Response, data: unknown) {
super((data as { detail?: string })?.detail || `Request failed: ${response.status}`)
this.name = 'ApiError'
this.url = url
this.status = response.status
this.statusText = response.statusText
this.data = data
}
}
export class NetworkError extends Error {
readonly originalError: Error | null
constructor(message: string, originalError: Error | null = null) {
super(message)
this.name = 'NetworkError'
this.originalError = originalError
}
}
export async function apiFetch(url: string, options: ApiFetchOptions = {}): Promise<Response> {
const { timeout = DEFAULT_TIMEOUT_MS, ...fetchOptions } = options
fetchOptions.credentials = fetchOptions.credentials || 'include'
while (true) {
let response: Response
try {
response = await fetch(url, {...fetchOptions, signal: timeout ? AbortSignal.timeout(timeout) : undefined})
} catch (error) {
const err = error as Error
if (err.name === 'TimeoutError') {
throw new NetworkError('Request timed out', err)
}
if (err.name === 'AbortError') {
throw error
}
if (err.name === 'TypeError' && err.message === 'Failed to fetch') {
throw new NetworkError('Unable to connect to server', err)
}
throw new NetworkError(err.message || 'Network error', err)
}
if (response.status === 401 || response.status === 403) {
let data: { auth?: { iframe?: string } } | null = null
try {
data = await response.clone().json()
} catch {}
if (data?.auth?.iframe && window === window.top) {
await showAuthIframe(data.auth.iframe)
continue // Retry the original request after successful auth
}
}
return response
}
}
type FetchFn = (url: string, options?: RequestInit) => Promise<Response>
export async function apiJson<T = unknown>(url: string, options: FetchJsonOptions = {}): Promise<T> {
return fetchJson<T>(url, options, apiFetch)
}
export async function fetchJson<T = unknown>(url: string, options: FetchJsonOptions = {}, fetchFn: FetchFn = fetch): Promise<T> {
const headers: Record<string, string> = {
'Accept': 'application/json',
...(options.headers as Record<string, string>),
}
let body: BodyInit | undefined
if (options.body && typeof options.body === 'object' && !(options.body instanceof FormData)) {
headers['Content-Type'] = 'application/json'
body = JSON.stringify(options.body)
} else {
body = options.body as BodyInit
}
const opt: RequestInit = { ...options, headers, body }
const response = await fetchFn(url, opt)
const data = await response.json() as T
if (!response.ok) {
throw new ApiError(url, response, data)
}
return data
}
export function getUserFriendlyErrorMessage(error: Error): string {
if (error instanceof NetworkError) return error.message
if (error instanceof ApiError) return error.message
if (error.name === 'TimeoutError') return 'Request timed out'
if (error.name === 'TypeError' && error.message === 'Failed to fetch') {
return 'Unable to connect to server'
}
return error.message || 'An error occurred'
}
export function shouldShowErrorToast(error: Error): boolean {
if (error instanceof AuthCancelledError) return false
if (error.name === 'AbortError') return false
if (error instanceof ApiError && (error.status === 401 || error.status === 403)) return false
return true
}
type ShowMessageFn = (message: string, type: string, duration: number) => void
export function createApiCaller(showMessage: ShowMessageFn) {
return async function apiCall<T = unknown>(url: string, options: FetchJsonOptions = {}): Promise<T> {
try {
return await apiJson<T>(url, options)
} catch (error) {
if (!shouldShowErrorToast(error as Error)) {
throw error
}
const err = error as Error
console.error(`API error for ${url}:`, err instanceof ApiError ? { status: err.status, statusText: err.statusText, data: err.data } : err)
showMessage(getUserFriendlyErrorMessage(err), 'error', 4000)
throw error
}
}
}
export default apiFetch
+23
View File
@@ -0,0 +1,23 @@
export {
ApiError,
NetworkError,
AuthCancelledError,
apiFetch,
apiJson,
fetchJson,
getUserFriendlyErrorMessage,
shouldShowErrorToast,
createApiCaller,
} from './fetch'
export type { ApiFetchOptions, FetchJsonOptions } from './fetch'
export {
holdGlobalBackdrop,
releaseGlobalBackdrop,
isAuthIframeOpen,
hideAuthIframe,
showAuthIframe,
} from './overlay'
export { SessionValidator } from './validate'
+167
View File
@@ -0,0 +1,167 @@
const AUTH_IFRAME_ID = 'paskia-iframe'
const STYLES_ID = 'paskia-dialog'
const STYLES_TEXT = `\
body::before {
content: '';
position: fixed;
inset: 0;
z-index: 1099;
background: transparent;
backdrop-filter: blur(0) brightness(1);
-webkit-backdrop-filter: blur(0) brightness(1);
pointer-events: none;
visibility: hidden;
transition: all 0.2s ease-out;
}
body.paskia-backdrop::before {
backdrop-filter: blur(.2rem) brightness(0.7);
-webkit-backdrop-filter: blur(.2rem) brightness(0.7);
visibility: visible;
}
body.paskia-backdrop {
overflow: auto;
}
#${AUTH_IFRAME_ID} {
border: none;
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
z-index: 9999;
color-scheme: auto;
background: transparent;
}
`
let authIframe: HTMLIFrameElement | null = null
let authPromise: Promise<void> | null = null
let authResolve: (() => void) | null = null
let authReject: ((error: Error) => void) | null = null
let messageListenerInstalled = false
let backdropHolders = 0
function injectStyles(): void {
if (document.getElementById(STYLES_ID)) return
const style = document.createElement('style')
style.id = STYLES_ID
style.textContent = STYLES_TEXT
document.head.insertBefore(style, document.head.firstChild)
}
export class AuthCancelledError extends Error {
constructor() {
super('Authentication cancelled')
this.name = 'AuthCancelledError'
}
}
export function holdGlobalBackdrop(): void {
injectStyles()
backdropHolders++
document.body.classList.add('paskia-backdrop')
}
export function releaseGlobalBackdrop(): void {
backdropHolders = Math.max(0, backdropHolders - 1)
if (backdropHolders === 0) {
document.body.classList.remove('paskia-backdrop')
}
}
export function isAuthIframeOpen(): boolean {
return !!document.getElementById(AUTH_IFRAME_ID)
}
export function hideAuthIframe(): void {
if (authIframe) {
authIframe.remove()
authIframe = null
releaseGlobalBackdrop()
}
}
function handleAuthMessage(event: MessageEvent): void {
const data = event.data as { type?: string }
if (!data?.type) return
switch (data.type) {
case 'auth-success':
hideAuthIframe()
if (authResolve) {
authResolve()
authPromise = null
authResolve = null
authReject = null
}
break
case 'auth-back':
hideAuthIframe()
if (authReject) {
authReject(new AuthCancelledError())
authPromise = null
authResolve = null
authReject = null
}
break
}
}
function ensureMessageListener(): void {
if (messageListenerInstalled) return
if (typeof window !== 'undefined') {
window.addEventListener('message', handleAuthMessage)
messageListenerInstalled = true
}
}
export function showAuthIframe(iframeUrl: string, title = 'Authentication'): Promise<void> {
injectStyles()
ensureMessageListener()
if (authPromise) return authPromise
if (document.getElementById(AUTH_IFRAME_ID)) {
authPromise = new Promise((resolve, reject) => {
authResolve = resolve
authReject = reject
})
return authPromise
}
authPromise = new Promise((resolve, reject) => {
authResolve = resolve
authReject = reject
})
hideAuthIframe()
holdGlobalBackdrop()
authIframe = document.createElement('iframe')
authIframe.id = AUTH_IFRAME_ID
authIframe.title = title
authIframe.src = iframeUrl
document.body.appendChild(authIframe)
return authPromise
}
export function createAuthIframe(iframeUrl: string, title = 'Authentication'): HTMLIFrameElement {
injectStyles()
const existing = document.getElementById(AUTH_IFRAME_ID)
if (existing) existing.remove()
const iframe = document.createElement('iframe')
iframe.id = AUTH_IFRAME_ID
iframe.title = title
iframe.src = iframeUrl
document.body.appendChild(iframe)
return iframe
}
export function removeAuthIframe(): void {
const iframe = document.getElementById(AUTH_IFRAME_ID)
if (iframe) iframe.remove()
}
+66
View File
@@ -0,0 +1,66 @@
import { apiJson } from './fetch'
const POLL_INTERVAL = 60 * 1000
const IDLE_TIMEOUT = 5 * 60 * 1000
export class SessionValidator {
private userUuidGetter: () => string | undefined
private onSessionLost: (error: Error) => void
private pollTimer: ReturnType<typeof setInterval> | null = null
private idleTimer: ReturnType<typeof setTimeout> | null = null
private active = false
constructor(userUuidGetter: () => string | undefined, onSessionLost: (error: Error) => void) {
this.userUuidGetter = userUuidGetter
this.onSessionLost = onSessionLost
this.resetIdleTimer = this.resetIdleTimer.bind(this)
}
resetIdleTimer(): void {
if (this.idleTimer) clearTimeout(this.idleTimer)
if (!this.active) this.startPolling()
this.idleTimer = setTimeout(() => this.stopPolling(), IDLE_TIMEOUT)
}
async validate(): Promise<void> {
try {
const data = await apiJson<{ ctx?: { user?: { uuid?: string } } }>('/auth/api/validate', { method: 'POST' })
const newUuid = data.ctx?.user?.uuid
if (newUuid !== this.userUuidGetter()) {
window.location.reload()
}
} catch (error) {
if ((error as Error).name !== 'NetworkError') {
this.stopPolling()
this.onSessionLost(error as Error)
}
}
}
startPolling(): void {
if (this.active) return
this.active = true
this.pollTimer = setInterval(() => this.validate(), POLL_INTERVAL)
}
stopPolling(): void {
this.active = false
if (this.pollTimer) {
clearInterval(this.pollTimer)
this.pollTimer = null
}
}
start(): void {
window.addEventListener('pointermove', this.resetIdleTimer)
window.addEventListener('pointerdown', this.resetIdleTimer)
this.resetIdleTimer()
}
stop(): void {
window.removeEventListener('pointermove', this.resetIdleTimer)
window.removeEventListener('pointerdown', this.resetIdleTimer)
if (this.idleTimer) clearTimeout(this.idleTimer)
this.stopPolling()
}
}
+15
View File
@@ -0,0 +1,15 @@
{
"compilerOptions": {
"target": "ES2020",
"module": "ESNext",
"moduleResolution": "bundler",
"strict": true,
"declaration": true,
"declarationDir": "./dist",
"outDir": "./dist",
"rootDir": "./src",
"lib": ["ES2020", "DOM"],
"skipLibCheck": true
},
"include": ["src/**/*.ts"]
}
+17
View File
@@ -0,0 +1,17 @@
import { defineConfig } from 'vite'
import { resolve } from 'node:path'
import { fileURLToPath } from 'node:url'
import dts from 'vite-plugin-dts'
const __dirname = fileURLToPath(new URL('.', import.meta.url))
export default defineConfig({
plugins: [dts({ rollupTypes: true })],
build: {
lib: {
entry: resolve(__dirname, 'src/index.ts'),
fileName: 'paskia',
formats: ['es'],
},
},
})
+147
View File
@@ -0,0 +1,147 @@
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.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 = os.environ.get("PASKIA_DB", f"{args.rp_id}.paskiadb")
try:
config = load_readonly(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()
+17 -4
View File
@@ -10,6 +10,7 @@ This module provides functionality to:
import json import json
from collections.abc import Iterable from collections.abc import Iterable
from importlib.resources import files from importlib.resources import files
from uuid import UUID
__ALL__ = ["AAGUID", "filter"] __ALL__ = ["AAGUID", "filter"]
@@ -18,15 +19,27 @@ AAGUID_FILE = files("paskia") / "aaguid" / "combined_aaguid.json"
AAGUID: dict[str, dict] = json.loads(AAGUID_FILE.read_text(encoding="utf-8")) AAGUID: dict[str, dict] = json.loads(AAGUID_FILE.read_text(encoding="utf-8"))
def filter(aaguids: Iterable[str]) -> dict[str, dict]: def filter(aaguids: Iterable[UUID]) -> dict[str, dict]:
""" """
Get AAGUID information only for the provided set of AAGUIDs. Get AAGUID information only for the provided set of AAGUIDs.
Args: Args:
aaguids: Set of AAGUID strings that the user has credentials for aaguids: Iterable of AAGUIDs (UUIDs) that the user has credentials for
Returns: Returns:
Dictionary mapping AAGUID to authenticator information for only Dictionary mapping AAGUID string to authenticator information for only
the AAGUIDs that the user has and that we have data for the AAGUIDs that the user has and that we have data for
""" """
return {aaguid: AAGUID[aaguid] for aaguid in aaguids if aaguid 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)
+11 -20
View File
@@ -8,12 +8,13 @@ independent of any web framework:
- Credential management - Credential management
""" """
from datetime import datetime, timezone from datetime import UTC, datetime
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
from uuid import UUID from uuid import UUID
from paskia import db from paskia import db
from paskia.config import RESET_LIFETIME, SESSION_LIFETIME from paskia.config import RESET_LIFETIME, SESSION_LIFETIME
from paskia.db.structs import ResetToken
from paskia.util import hostutil from paskia.util import hostutil
if TYPE_CHECKING: if TYPE_CHECKING:
@@ -22,41 +23,31 @@ if TYPE_CHECKING:
EXPIRES = SESSION_LIFETIME EXPIRES = SESSION_LIFETIME
def session_ctx(auth: str, host: str | None = None):
"""Get session context with normalized host."""
return db.data().session_ctx(auth, hostutil.normalize_host(host))
def expires() -> datetime: def expires() -> datetime:
return datetime.now(timezone.utc) + EXPIRES return datetime.now(UTC) + EXPIRES
def reset_expires() -> datetime: def reset_expires() -> datetime:
return datetime.now(timezone.utc) + RESET_LIFETIME return datetime.now(UTC) + RESET_LIFETIME
def get_reset(token: str) -> "ResetToken": def get_reset(token: str) -> "ResetToken":
"""Validate a credential reset token.""" """Validate a credential reset token."""
record = db.get_reset_token(token) record = ResetToken.by_passphrase(token)
if record: if record:
return record return record
raise ValueError("This authentication link is no longer valid.") raise ValueError("This authentication link is no longer valid.")
def refresh_session_token(token: str, *, ip: str, user_agent: str):
"""Refresh a session extending its expiry."""
session_record = db.data().sessions.get(token)
if not session_record:
raise ValueError("Session not found or expired")
updated = db.update_session(
token,
ip=ip,
user_agent=user_agent,
expiry=expires(),
)
if not updated:
raise ValueError("Session not found or expired")
def delete_credential(credential_uuid: UUID, auth: str, host: str | None = None): def delete_credential(credential_uuid: UUID, auth: str, host: str | None = None):
"""Delete a specific credential for the current user.""" """Delete a specific credential for the current user."""
ctx = db.get_session_context(auth, hostutil.normalize_host(host)) ctx = session_ctx(auth, host)
if not ctx: if not ctx:
raise ValueError("Session expired") raise ValueError("Session expired")
db.delete_credential(credential_uuid, ctx.user.uuid) db.delete_credential(credential_uuid, ctx.user.uuid)
+36 -37
View File
@@ -6,42 +6,45 @@ including creating default admin user, organization, permissions, and
generating a reset link for initial admin setup. generating a reset link for initial admin setup.
""" """
import asyncio
import logging import logging
from paskia import authsession, db, globals from paskia import authsession, db
from paskia.util import hostutil, passphrase from paskia.db.structs import Config
from paskia.util import hostutil
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
# Shared log message template for admin reset links # Shared log message template for admin reset links
ADMIN_RESET_MESSAGE = """\ ADMIN_RESET_MESSAGE = """
%s
👤 Admin %s 👤 Admin %s
- Use this link to register a Passkey for the admin user! - 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.""" """Log a reset link message and return the URL."""
reset_link = hostutil.reset_link_url(passphrase) 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 return reset_link
async def bootstrap_system() -> None: async def bootstrap_system(config: Config | None = None) -> None:
""" """
Bootstrap the entire system with default data. Bootstrap the entire system with default data.
Uses db.bootstrap() which performs all operations in a single transaction. Uses db.bootstrap() which performs all operations in a single transaction.
The transaction log will show a single "bootstrap" action with all changes. The transaction log will show a single "bootstrap" action with all changes.
Args:
config: Configuration to store (rp_id, rp_name, origins, etc.)
""" """
# Call the single-transaction bootstrap function # Call the single-transaction bootstrap function
reset_passphrase = db.bootstrap() reset_passphrase = db.bootstrap(config=config)
# Log the reset link (this is separate from the transaction log) # Log the reset link (this is separate from the transaction log)
_log_reset_link("✅ Bootstrap completed!", reset_passphrase) _log_reset_link(reset_passphrase, "✅ Bootstrap completed!")
async def check_admin_credentials() -> bool: async def check_admin_credentials() -> bool:
@@ -52,17 +55,24 @@ async def check_admin_credentials() -> bool:
bool: True if a reset link was created, False if admin already has credentials bool: True if a reset link was created, False if admin already has credentials
""" """
try: try:
# Get permission organizations to find admin users # Find the auth:admin permission
p = next( p = next(
(p for p in db.data().permissions.values() if p.scope == "auth:admin"), None (p for p in db.data().permissions.values() if p.scope == "auth:admin"), None
) )
if not p or not p.orgs: if not p:
return False return False
# Get users from the first organization with admin permission perm_uuid = p.uuid
first_org_uuid = next(iter(p.orgs))
org_users = db.get_organization_users(first_org_uuid) # Find all roles that have the auth:admin permission
admin_users = [user for user, role in org_users if role == "Administration"] admin_roles = [
r for r in db.data().roles.values() if perm_uuid in r.permissions
]
# Collect all users from those roles
admin_users = []
for role in admin_roles:
admin_users.extend(role.users)
if not admin_users: if not admin_users:
return False return False
@@ -70,18 +80,17 @@ async def check_admin_credentials() -> bool:
# Check first admin user for credentials # Check first admin user for credentials
admin_user = admin_users[0] admin_user = admin_users[0]
if not db.get_user_credential_ids(admin_user.uuid): if not admin_user.credential_ids:
# Admin exists but has no credentials, create reset link # Admin exists but has no credentials, create reset link
logger.info("⚠️ Admin user has no credentials!")
token = passphrase.generate()
expiry = authsession.reset_expires() expiry = authsession.reset_expires()
db.create_reset_token( token = db.create_reset_token(
user_uuid=admin_user.uuid, user_uuid=admin_user.uuid,
passphrase=token,
expiry=expiry, expiry=expiry,
token_type="admin registration", token_type="admin registration",
) )
_log_reset_link("⚠️ Admin user has no credentials!", token) _log_reset_link(token)
return True return True
return False return False
@@ -90,10 +99,13 @@ async def check_admin_credentials() -> bool:
return False return False
async def bootstrap_if_needed() -> bool: async def bootstrap_if_needed(config: Config | None = None) -> bool:
""" """
Check if system needs bootstrapping and perform it if necessary. Check if system needs bootstrapping and perform it if necessary.
Args:
config: Configuration to store during bootstrap (rp_id, rp_name, origins, etc.)
Returns: Returns:
bool: True if bootstrapping was performed, False if system was already set up bool: True if bootstrapping was performed, False if system was already set up
""" """
@@ -106,18 +118,5 @@ async def bootstrap_if_needed() -> bool:
# No admin permission found, need to bootstrap # No admin permission found, need to bootstrap
# Bootstrap creates the admin user AND the reset link, so no need to check credentials after # Bootstrap creates the admin user AND the reset link, so no need to check credentials after
await bootstrap_system() await bootstrap_system(config=config)
return True return True
# CLI interface
async def main():
"""Main CLI entry point for bootstrapping."""
# Configure logging for CLI usage
logging.basicConfig(level=logging.INFO, format="%(message)s", force=True)
await globals.init()
if __name__ == "__main__":
asyncio.run(main())
-17
View File
@@ -1,4 +1,3 @@
from dataclasses import dataclass
from datetime import timedelta from datetime import timedelta
# Shared configuration constants for session management. # Shared configuration constants for session management.
@@ -6,19 +5,3 @@ SESSION_LIFETIME = timedelta(hours=24)
# Lifetime for reset links created by admins # Lifetime for reset links created by admins
RESET_LIFETIME = timedelta(days=14) RESET_LIFETIME = timedelta(days=14)
@dataclass
class PaskiaConfig:
"""Runtime configuration for the Paskia authentication server."""
rp_id: str
rp_name: str | None
origins: list[str] | None
auth_host: str | None
site_url: str # Base URL without trailing path (e.g. https://example.com)
site_path: str # Path to auth UI: "/" if auth_host, else "/auth/"
# Listen address (one of host:port or uds)
host: str | None = None
port: int | None = None
uds: str | None = None
+28 -28
View File
@@ -1,8 +1,8 @@
""" """
Database module for WebAuthn passkey authentication. Database module for WebAuthn passkey authentication.
Read: Access data() directly, use build_* to convert to public structs. Read: Access data() directly for structs.
CTX: get_session_context(key) returns SessionContext with effective permissions. CTX: data().session_ctx(key) returns SessionContext with effective permissions.
Write: Functions validate and commit, or raise ValueError. Write: Functions validate and commit, or raise ValueError.
Usage: Usage:
@@ -10,10 +10,9 @@ Usage:
# Read (after init) # Read (after init)
user_data = db.data().users[user_uuid] user_data = db.data().users[user_uuid]
user = db.build_user(user_uuid)
# Context # Context
ctx = db.get_session_context(session_key) ctx = db.data().session_ctx(session_key)
# Write # Write
db.create_user(user) db.create_user(user)
@@ -26,20 +25,22 @@ from paskia.db.background import (
stop_background, stop_background,
stop_cleanup, stop_cleanup,
) )
from paskia.db.bootstrap import bootstrap
from paskia.db.jsonl import load_readonly
from paskia.db.lifecycle import cleanup_expired, init
from paskia.db.operations import ( from paskia.db.operations import (
add_permission_to_org, add_permission_to_org,
add_permission_to_role, add_permission_to_role,
bootstrap,
cleanup_expired,
create_credential, create_credential,
create_credential_session, create_credential_session,
create_oid_client,
create_org, create_org,
create_permission, create_permission,
create_reset_token, create_reset_token,
create_role, create_role,
create_session,
create_user, create_user,
delete_credential, delete_credential,
delete_oid_client,
delete_org, delete_org,
delete_permission, delete_permission,
delete_reset_token, delete_reset_token,
@@ -47,27 +48,28 @@ from paskia.db.operations import (
delete_session, delete_session,
delete_sessions_for_user, delete_sessions_for_user,
delete_user, delete_user,
get_organization_users, is_username_taken,
get_reset_token,
get_session_context,
get_user_credential_ids,
get_user_organization,
init,
login, login,
oidc_login,
remove_permission_from_org, remove_permission_from_org,
remove_permission_from_role, remove_permission_from_role,
reset_oid_client_secret,
set_session_host, set_session_host,
update_config,
update_credential_sign_count, update_credential_sign_count,
update_oid_client,
update_org_name, update_org_name,
update_permission, update_permission,
update_role_name, update_role_name,
update_session, update_session,
update_user_display_name, update_user_display_name,
update_user_info,
update_user_role, update_user_role,
update_user_role_in_organization,
) )
from paskia.db.structs import ( from paskia.db.structs import (
DB, DB,
Client,
Config,
Credential, Credential,
Org, Org,
Permission, Permission,
@@ -86,8 +88,10 @@ def data() -> DB:
__all__ = [ __all__ = [
# Types # Types
"Config",
"Credential", "Credential",
"DB", "DB",
"Client",
"Org", "Org",
"Permission", "Permission",
"ResetToken", "ResetToken",
@@ -98,24 +102,13 @@ __all__ = [
# Instance # Instance
"data", "data",
"init", "init",
"load_readonly",
# Background # Background
"start_background", "start_background",
"stop_background", "stop_background",
"start_cleanup", "start_cleanup",
"stop_cleanup", "stop_cleanup",
# Builders
"build_credential",
"build_permission",
"build_reset_token",
"build_role",
"build_session",
"build_user",
# Read ops # Read ops
"get_organization_users",
"get_reset_token",
"get_session_context",
"get_user_credential_ids",
"get_user_organization",
# Write ops # Write ops
"add_permission_to_org", "add_permission_to_org",
"add_permission_to_role", "add_permission_to_role",
@@ -127,7 +120,6 @@ __all__ = [
"create_permission", "create_permission",
"create_reset_token", "create_reset_token",
"create_role", "create_role",
"create_session",
"create_user", "create_user",
"delete_credential", "delete_credential",
"delete_org", "delete_org",
@@ -138,15 +130,23 @@ __all__ = [
"delete_sessions_for_user", "delete_sessions_for_user",
"delete_user", "delete_user",
"login", "login",
"oidc_login",
"remove_permission_from_org", "remove_permission_from_org",
"remove_permission_from_role", "remove_permission_from_role",
"set_session_host", "set_session_host",
"update_config",
"update_credential_sign_count", "update_credential_sign_count",
"update_org_name", "update_org_name",
"update_permission", "update_permission",
"update_role_name", "update_role_name",
"update_session", "update_session",
"update_user_display_name", "update_user_display_name",
"update_user_info",
"update_user_role", "update_user_role",
"update_user_role_in_organization", "is_username_taken",
# OIDC
"create_oid_client",
"update_oid_client",
"reset_oid_client_secret",
"delete_oid_client",
] ]
+19 -16
View File
@@ -6,9 +6,10 @@ Periodically flushes pending changes to disk and cleans up expired items.
import asyncio import asyncio
import logging import logging
from datetime import datetime, timezone from datetime import UTC, datetime
from paskia.db.operations import _store, cleanup_expired import paskia.db.operations as _ops
from paskia.db.lifecycle import cleanup_expired
FLUSH_INTERVAL = 0.1 # Flush to disk FLUSH_INTERVAL = 0.1 # Flush to disk
CLEANUP_INTERVAL = 1 # Expired item cleanup CLEANUP_INTERVAL = 1 # Expired item cleanup
@@ -20,11 +21,11 @@ _background_task: asyncio.Task | None = None
async def flush() -> None: async def flush() -> None:
"""Write all pending database changes to disk.""" """Write all pending database changes to disk."""
store = _ops._db._store
if _store is None: if store is None:
_logger.warning("flush() called but _store is None") _logger.warning("flush() called but _store is None")
return return
await _store.flush() await store.flush()
async def _background_loop(): async def _background_loop():
@@ -33,7 +34,7 @@ async def _background_loop():
cleanup_expired() cleanup_expired()
await flush() await flush()
last_cleanup = datetime.now(timezone.utc) last_cleanup = datetime.now(UTC)
while True: while True:
try: try:
@@ -42,11 +43,15 @@ async def _background_loop():
await flush() await flush()
# Run cleanup periodically # Run cleanup periodically
now = datetime.now(timezone.utc) now = datetime.now(UTC)
if (now - last_cleanup).total_seconds() >= CLEANUP_INTERVAL: if (now - last_cleanup).total_seconds() >= CLEANUP_INTERVAL:
cleanup_expired() cleanup_expired()
await flush() # Flush cleanup changes await flush() # Flush cleanup changes
last_cleanup = now last_cleanup = now
# Conditionally write a snapshot to speed up future startups
if _ops._db._store is not None:
_ops._db._store.maybe_snapshot()
except asyncio.CancelledError: except asyncio.CancelledError:
# Final flush before exit # Final flush before exit
await flush() await flush()
@@ -74,25 +79,22 @@ async def start_background():
_logger.debug("Background task in different event loop, restarting") _logger.debug("Background task in different event loop, restarting")
_background_task = None _background_task = None
else: else:
# Task is running in the same event loop - this is an error # Task is already running in same loop - idempotent, just return
raise RuntimeError( # This happens with dual IPv4+IPv6 endpoints sharing the same process
"Background task is already running. " _logger.debug(
"start_background() must not be called multiple times in the same event loop." "Background task already running in same loop, skipping"
) )
except RuntimeError: return
raise # Re-raise RuntimeError from above
except Exception as e: except Exception as e:
_logger.debug("Error checking background task loop: %s, restarting", e) _logger.debug("Error checking background task loop: %s, restarting", e)
_background_task = None _background_task = None
if _background_task is None: if _background_task is None:
_background_task = asyncio.create_task(_background_loop()) _background_task = asyncio.create_task(_background_loop())
else:
_logger.debug("Background task already running: %s", _background_task)
async def stop_background(): async def stop_background():
"""Stop the background task and flush any pending changes.""" """Stop the background task, flush pending changes, and release the file lock."""
global _background_task global _background_task
if _background_task: if _background_task:
_background_task.cancel() _background_task.cancel()
@@ -101,6 +103,7 @@ async def stop_background():
except asyncio.CancelledError: except asyncio.CancelledError:
pass pass
_background_task = None _background_task = None
_ops._db._store.close()
# Aliases for backwards compatibility # Aliases for backwards compatibility
+126
View File
@@ -0,0 +1,126 @@
"""
Bootstrap operations for initial system setup.
"""
from datetime import UTC, datetime
import uuid7
import paskia.db.operations as _ops
from paskia.authsession import reset_expires
from paskia.db.structs import Config, Org, Permission, ResetToken, Role, User
from paskia.util.crypto import secret_key
def bootstrap(
org_name: str = "Organization",
admin_name: str = "Admin",
reset_passphrase: str | None = None,
reset_expiry: datetime | None = None,
config: Config | None = None,
) -> str:
"""Bootstrap the entire system in a single transaction.
Creates:
- auth:admin permission (Master Admin)
- auth:org:admin permission (Org Admin)
- Organization with Administration role
- Admin user with Administration role
- Reset token for admin registration
- Config (if provided)
This is the only way to create a new database file.
All data is created atomically - if any step fails, nothing is written.
Args:
org_name: Display name for the organization (default: "Organization")
admin_name: Display name for the admin user (default: "Admin")
reset_passphrase: Passphrase for the reset token (generated if not provided)
reset_expiry: Expiry datetime for the reset token (default: 14 days)
config: Configuration to store (rp_id, rp_name, origins, etc.)
Returns:
The reset passphrase for admin registration.
"""
# Check if system is already bootstrapped
for p in _ops._db.permissions.values():
if p.scope == "auth:admin":
raise ValueError(
"System already bootstrapped (auth:admin permission exists)"
)
# Generate UUIDs upfront
now = datetime.now(UTC)
perm_admin_uuid = uuid7.create(now)
perm_org_admin_uuid = uuid7.create(now)
org_uuid = uuid7.create(now)
role_uuid = uuid7.create(now)
user_uuid = uuid7.create(now)
# Set reset token expiry (passphrase generated by ResetToken.create)
if reset_expiry is None:
reset_expiry = reset_expires()
with _ops._db.transaction("bootstrap"):
# Create auth:admin permission
perm_admin = Permission(
scope="auth:admin",
display_name="Master Admin",
orgs={org_uuid: True}, # Grant to org
)
perm_admin.uuid = perm_admin_uuid
perm_admin.store()
# Create auth:org:admin permission
perm_org_admin = Permission(
scope="auth:org:admin",
display_name="Org Admin",
orgs={org_uuid: True}, # Grant to org
)
perm_org_admin.uuid = perm_org_admin_uuid
perm_org_admin.store()
# Create organization
new_org = Org.create(display_name=org_name)
new_org.uuid = org_uuid
new_org.store()
# Create Administration role with both permissions
admin_role = Role(
org_uuid=org_uuid,
display_name="Administration",
permissions={perm_admin_uuid: True, perm_org_admin_uuid: True},
)
admin_role.uuid = role_uuid
admin_role.store()
# Create admin user
admin_user = User(
display_name=admin_name,
role_uuid=role_uuid,
created_at=now,
last_seen=None,
visits=0,
theme="",
)
admin_user.uuid = user_uuid
admin_user.store()
# Create reset token
reset_token, reset_passphrase = ResetToken.create(
user=user_uuid,
expiry=reset_expiry,
token_type="admin bootstrap",
passphrase=reset_passphrase,
)
reset_token.store()
# Set config if provided
if config is not None:
_ops._db.config = config
# Generate OIDC signing key
_ops._db.oidc.key = secret_key()
return reset_passphrase
+247
View File
@@ -0,0 +1,247 @@
"""Cross-platform locked file for the database (no separate .lock files).
Unix: open() + fcntl.flock (advisory, cooperative among processes that flock).
Windows: CreateFileW with FILE_SHARE_READ (OS-enforced, allows readers, blocks writers).
A single file descriptor is opened once for both reading and writing.
The lock is acquired atomically (on Windows) or immediately after open (on Unix),
and the same descriptor is used for the lifetime of the process: first to read
the existing content, then to append new writes.
"""
import logging
import os
import sys
from pathlib import Path
_logger = logging.getLogger(__name__)
def _fatal(msg: str) -> None:
"""Log a fatal error and exit immediately, bypassing exception handlers."""
_logger.critical(msg)
os._exit(1)
if sys.platform == "win32":
import ctypes
from ctypes import wintypes
_kernel32 = ctypes.WinDLL("kernel32", use_last_error=True)
_GENERIC_READ = 0x80000000
_GENERIC_WRITE = 0x40000000
_FILE_SHARE_READ = 0x00000001
_OPEN_EXISTING = 3
_OPEN_ALWAYS = 4
_FILE_ATTRIBUTE_NORMAL = 0x80
_FILE_BEGIN = 0
_FILE_END = 2
_ERROR_SHARING_VIOLATION = 32
_INVALID_FILE_SIZE = 0xFFFFFFFF
_kernel32.CreateFileW.restype = wintypes.HANDLE
_kernel32.CreateFileW.argtypes = [
wintypes.LPCWSTR,
wintypes.DWORD,
wintypes.DWORD,
ctypes.c_void_p,
wintypes.DWORD,
wintypes.DWORD,
wintypes.HANDLE,
]
_kernel32.ReadFile.restype = wintypes.BOOL
_kernel32.ReadFile.argtypes = [
wintypes.HANDLE,
ctypes.c_void_p,
wintypes.DWORD,
ctypes.POINTER(wintypes.DWORD),
ctypes.c_void_p,
]
_kernel32.WriteFile.restype = wintypes.BOOL
_kernel32.WriteFile.argtypes = [
wintypes.HANDLE,
ctypes.c_void_p,
wintypes.DWORD,
ctypes.POINTER(wintypes.DWORD),
ctypes.c_void_p,
]
_kernel32.GetFileSize.restype = wintypes.DWORD
_kernel32.GetFileSize.argtypes = [
wintypes.HANDLE,
ctypes.POINTER(wintypes.DWORD),
]
_kernel32.SetFilePointer.restype = wintypes.DWORD
_kernel32.SetFilePointer.argtypes = [
wintypes.HANDLE,
wintypes.LONG,
ctypes.POINTER(wintypes.LONG),
wintypes.DWORD,
]
_kernel32.CloseHandle.restype = wintypes.BOOL
_kernel32.CloseHandle.argtypes = [wintypes.HANDLE]
def _is_invalid_handle(handle) -> bool:
return ctypes.c_void_p(handle).value == ctypes.c_void_p(-1).value
else:
import fcntl
class LockedFile:
"""A file opened with an exclusive write lock.
Usage::
f = LockedFile()
f.open(path) # open + lock (read+write)
content = f.read() # read entire content
f.write(data) # append data (seeks to end first)
f.close() # release lock + close fd
Unix: fcntl.flock (advisory) — read-only callers that don't flock are unaffected.
Windows: CreateFileW with FILE_SHARE_READ — OS blocks other writers.
"""
def __init__(self) -> None:
self._fd: int | None = None # Unix fd or Windows HANDLE
def open(self, path: Path, *, create: bool = False) -> None:
"""Open *path* for read+write with an exclusive lock.
Args:
path: File to open and lock.
create: If True, create the file if it doesn't exist (bootstrap).
Raises:
SystemExit: If the file is locked by another process or not found.
"""
if self._fd is not None:
return # Already open (idempotent)
if sys.platform == "win32":
self._open_win32(path, create)
else:
self._open_unix(path, create)
def open_and_read(self, path: Path) -> bytes:
"""Open *path* with exclusive lock and read all content.
Combined operation for efficient use with asyncio.to_thread().
"""
self.open(path)
return self.read()
def read(self) -> bytes:
"""Read the entire file content from the beginning."""
if self._fd is None:
raise RuntimeError("LockedFile.read() called on a closed file")
if sys.platform == "win32":
return self._read_win32()
else:
return self._read_unix()
def write(self, data: bytes) -> None:
"""Append *data* to the end of the file."""
if self._fd is None:
raise RuntimeError("LockedFile.write() called on a closed file")
if sys.platform == "win32":
self._write_win32(data)
else:
self._write_unix(data)
def close(self) -> None:
"""Release the lock and close the file."""
if self._fd is None:
return
if sys.platform == "win32":
_kernel32.CloseHandle(self._fd)
else:
os.close(self._fd)
self._fd = None
@property
def is_open(self) -> bool:
return self._fd is not None
# -- Unix ----------------------------------------------------------------
def _open_unix(self, path: Path, create: bool) -> None:
flags = os.O_RDWR | (os.O_CREAT if create else 0)
try:
fd = os.open(path, flags, 0o666)
except FileNotFoundError:
_fatal(f"Database file not found: {path.resolve()}")
try:
fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
except OSError:
os.close(fd)
_fatal(f"🛑 {path.resolve()}: database already locked by another instance")
self._fd = fd
def _read_unix(self) -> bytes:
os.lseek(self._fd, 0, os.SEEK_SET)
chunks = []
while True:
chunk = os.read(self._fd, 1 << 20) # 1 MiB
if not chunk:
break
chunks.append(chunk)
return b"".join(chunks)
def _write_unix(self, data: bytes) -> None:
os.lseek(self._fd, 0, os.SEEK_END)
os.write(self._fd, data)
# -- Windows -------------------------------------------------------------
def _open_win32(self, path: Path, create: bool) -> None:
disposition = _OPEN_ALWAYS if create else _OPEN_EXISTING
handle = _kernel32.CreateFileW(
str(path),
_GENERIC_READ | _GENERIC_WRITE,
_FILE_SHARE_READ,
None,
disposition,
_FILE_ATTRIBUTE_NORMAL,
None,
)
if _is_invalid_handle(handle):
err = ctypes.get_last_error()
if err == _ERROR_SHARING_VIOLATION:
_fatal(
f"🛑 {path.resolve()}: database already locked by another instance"
)
_fatal(f"Failed to open database {path.resolve()}: Windows error {err}")
self._fd = handle
def _read_win32(self) -> bytes:
_kernel32.SetFilePointer(self._fd, 0, None, _FILE_BEGIN)
size = _kernel32.GetFileSize(self._fd, None)
if size == _INVALID_FILE_SIZE:
raise OSError(
f"GetFileSize failed: Windows error {ctypes.get_last_error()}"
)
if size == 0:
return b""
buf = ctypes.create_string_buffer(size)
bytes_read = wintypes.DWORD()
ok = _kernel32.ReadFile(self._fd, buf, size, ctypes.byref(bytes_read), None)
if not ok:
raise OSError(f"ReadFile failed: Windows error {ctypes.get_last_error()}")
return buf.raw[: bytes_read.value]
def _write_win32(self, data: bytes) -> None:
_kernel32.SetFilePointer(self._fd, 0, None, _FILE_END)
written = wintypes.DWORD()
ok = _kernel32.WriteFile(
self._fd,
data,
len(data),
ctypes.byref(written),
None,
)
if not ok:
raise OSError(f"WriteFile failed: Windows error {ctypes.get_last_error()}")
+248 -190
View File
@@ -2,230 +2,243 @@
JSONL persistence layer for the database. JSONL persistence layer for the database.
""" """
from __future__ import annotations import asyncio
import copy import copy
import json
import logging import logging
import sys import os
import signal
from collections import deque from collections import deque
from contextlib import contextmanager from contextlib import contextmanager
from datetime import datetime, timezone from datetime import UTC, datetime
from pathlib import Path from pathlib import Path
from typing import Any from typing import Any
from uuid import UUID from uuid import UUID
import aiofiles
import jsondiff import jsondiff
import msgspec import msgspec
from paskia.db.migrations import apply_migrations from paskia.db.filelock import LockedFile
from paskia.db.structs import DB, SessionContext from paskia.db.logging import log_change
from paskia.db.migrations import (
DBVER,
MigrationCtx,
apply_all_migrations,
apply_migrations_readonly,
)
from paskia.db.snapshot import SnapshotState
from paskia.db.structs import DB, Config, SessionContext
_logger = logging.getLogger(__name__) _logger = logging.getLogger(__name__)
# Default database path
DB_PATH_DEFAULT = "paskia.jsonl" class ReplayResult(msgspec.Struct, frozen=False):
"""Return value of _replay_from_data"""
state: dict = {}
v: int = 0
ts: datetime | None = None
snapts: datetime | None = None
changes: int = 0
class _ChangeRecord(msgspec.Struct, omit_defaults=True): class DatabaseError(ValueError):
"""A single change record in the JSONL file.""" """Exception raised for database loading errors."""
ts: datetime
a: str # action - describes the operation (e.g., "migrate", "login", "create_user")
u: str | None = None # user UUID who performed the action (None for system)
diff: dict = {}
# msgspec encoder for change records def _replay_from_data(data: bytes, db_path: str) -> ReplayResult:
_change_encoder = msgspec.json.Encoder() """Replay database state from file data, using the last snapshot if available."""
resolved_path = str(Path(db_path).resolve())
result = ReplayResult()
# Find and apply the last snapshot
snap, start_offset = SnapshotState.load(data)
if snap:
result.state = snap.state
result.v = snap.v
result.snapts = snap.ts
# Replay change records after the snapshot
lines = data[start_offset:].split(b"\n")
for raw in lines:
line = raw.strip()
if not line:
continue
try:
change = msgspec.json.decode(line, type=ChangeRecord)
except msgspec.DecodeError as e:
raise DatabaseError(
f"{resolved_path}: {e}\n{line.decode(errors='replace')}"
)
result.state = jsondiff.patch(result.state, change.diff, marshal=True)
result.v = change.v
result.ts = change.ts
result.changes += 1
return result
async def load_jsonl(db_path: Path) -> dict: def load_readonly(db_path: str, *, rp_id: str = "localhost") -> DB:
"""Load data from disk by applying change log. """Replay JSONL and apply migrations to produce a DB, without writing anything.
Replays all changes from JSONL file using plain dicts (to handle This is suitable for reading settings before the server starts.
schema evolution). Migrations are applied in-memory only; nothing is queued or flushed.
Args:
db_path: Path to the JSONL database file
Returns:
The final state after applying all changes
Raises:
ValueError: If file doesn't exist or cannot be loaded
""" """
if not db_path.exists(): path = Path(db_path)
raise ValueError(f"Database file not found: {db_path}") if not path.exists():
data_dict: dict = {} return DB(config=Config(rp_id=rp_id))
try: try:
# Read entire file at once and split into lines content = path.read_bytes()
async with aiofiles.open(db_path, "rb") as f: r = _replay_from_data(content, str(path.resolve()))
content = await f.read() data_dict = r.state
for line_num, line in enumerate(content.split(b"\n"), 1): version = r.v
line = line.strip()
if not line: if not data_dict:
continue return DB(config=Config(rp_id=rp_id))
try:
change = msgspec.json.decode(line) # Apply migrations in-memory (no persistence)
# Apply the diff to current state (marshal=True for $-prefixed keys) apply_migrations_readonly(data_dict, version, MigrationCtx(rp_id=rp_id))
data_dict = jsondiff.patch(data_dict, change["diff"], marshal=True)
except Exception as e: # Decode to msgspec struct
raise ValueError(f"Error parsing line {line_num}: {e}") try:
except (OSError, ValueError, msgspec.DecodeError) as e: return msgspec.json.decode(msgspec.json.encode(data_dict), type=DB)
raise ValueError(f"Failed to load database: {e}") except msgspec.ValidationError as e:
return data_dict raise DatabaseError(f"{path.resolve()}: {e}") from None
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}")
class ChangeRecord(msgspec.Struct, omit_defaults=True, kw_only=True):
ts: datetime = msgspec.field(default_factory=lambda: datetime.now(UTC))
a: str = "" # action (e.g., "migrate", "login", "create_user")
v: int = 0 # schema version after this change
u: str | None = None # user UUID who performed the action (None for system)
diff: dict
def compute_diff(previous: dict, current: dict) -> dict | None: def compute_diff(previous: dict, current: dict) -> dict | None:
"""Compute JSON diff between two states. return jsondiff.diff(previous, current, marshal=True) or None
Args:
previous: Previous state (JSON-compatible dict)
current: Current state (JSON-compatible dict)
Returns:
The diff, or None if no changes
"""
diff = jsondiff.diff(previous, current, marshal=True)
return diff if diff else None
def create_change_record(
action: str, diff: dict, user: str | None = None
) -> _ChangeRecord:
"""Create a change record for persistence."""
return _ChangeRecord(
ts=datetime.now(timezone.utc),
a=action,
u=user,
diff=diff,
)
# Actions that are allowed to create a new database file # Actions that are allowed to create a new database file
_BOOTSTRAP_ACTIONS = frozenset({"bootstrap", "migrate"}) _BOOTSTRAP_ACTIONS = frozenset({"bootstrap"})
async def flush_changes(
db_path: Path,
pending_changes: deque[_ChangeRecord],
) -> bool:
"""Write all pending changes to disk.
Args:
db_path: Path to the JSONL database file
pending_changes: Queue of pending change records (will be cleared on success)
Returns:
True if flush succeeded, False otherwise
"""
if not pending_changes:
return True
if not db_path.exists():
first_action = pending_changes[0].a
if first_action not in _BOOTSTRAP_ACTIONS:
_logger.error(
"Refusing to create database file with action '%s' - "
"only bootstrap or migrate can create a new database",
first_action,
)
pending_changes.clear()
return False
changes_to_write = list(pending_changes)
pending_changes.clear()
try:
lines = [_change_encoder.encode(change) for change in changes_to_write]
if not lines:
return True
async with aiofiles.open(db_path, "ab") as f:
await f.write(b"\n".join(lines) + b"\n")
return True
except OSError:
_logger.exception("Failed to flush database changes")
# Re-queue the changes on failure
for change in reversed(changes_to_write):
pending_changes.appendleft(change)
return False
class JsonlStore: class JsonlStore:
"""JSONL persistence layer for a DB instance.""" """JSONL persistence layer for a DB instance."""
def __init__(self, db: DB, db_path: str = DB_PATH_DEFAULT): def __init__(self, db: DB, db_path: str):
self.db: DB = db self.db: DB = db
self.db_path = Path(db_path) self.db_path = Path(db_path)
self._previous_builtins: dict[str, Any] = {} self._file = LockedFile()
self._pending_changes: deque[_ChangeRecord] = deque() self._flush_failed = False
self._statedict: dict[str, Any] = {}
self._pending_changes: deque[ChangeRecord] = deque()
self._current_action: str = "system" self._current_action: str = "system"
self._current_user: str | None = None self._current_user: str | None = None
self._in_transaction: bool = False self._in_transaction: bool = False
self._transaction_snapshot: dict[str, Any] | None = None self._transaction_snapshot: dict[str, Any] | None = None
self._v: int = DBVER # Schema version for new databases
self._snapshot = SnapshotState()
async def load(self, db_path: str | None = None) -> None: async def load(
self, db_path: str | None = None, *, rp_id: str = "localhost"
) -> None:
"""Load data from JSONL change log.""" """Load data from JSONL change log."""
if db_path is not None: if db_path is not None:
self.db_path = Path(db_path) self.db_path = Path(db_path)
self._rp_id = rp_id
if not self.db_path.exists():
return
# Open with exclusive write lock and read contents — single threadpool call
content = await asyncio.to_thread(self._file.open_and_read, self.db_path)
# Replay change log to reconstruct state (snapshot-accelerated)
try: try:
data_dict = await load_jsonl(self.db_path) r = _replay_from_data(content, str(self.db_path.resolve()))
if data_dict: statedict = r.state
# Preserve original state before migrations (deep copy for nested dicts) self._v = r.v
original_dict = copy.deepcopy(data_dict) self._snapshot.ts = r.snapts
self._snapshot.changes = r.changes
except (OSError, ValueError, msgspec.DecodeError, DatabaseError) as e:
raise SystemExit(f"{e}")
except Exception as e:
_logger.exception("Unexpected error loading database")
raise SystemExit(f"{e}")
# Apply schema migrations (modifies data_dict in place) if not statedict:
migrated = apply_migrations(data_dict) return
decoder = msgspec.json.Decoder(DB) # Set previous state for diffing (will be updated by _queue_change)
self.db = decoder.decode(msgspec.json.encode(data_dict)) self._statedict = copy.deepcopy(statedict)
self.db._store = self
# Update previous state to migrated data FIRST (to avoid transaction hardening reset) # Callback to persist each migration
self._previous_builtins = data_dict async def persist_migration(
action: str, new_version: int, current: dict
) -> None:
self._v = new_version
self._queue_change(action, new_version, current)
# Persist migration by manually computing and queueing the diff # Apply schema migrations one at a time
if migrated: await apply_all_migrations(
diff = compute_diff(original_dict, data_dict) statedict,
if diff: self._v,
self._pending_changes.append( persist_migration,
create_change_record("migrate", diff, user=None) MigrationCtx(rp_id=rp_id),
) )
_logger.info("Queued migration changes for persistence")
await self.flush()
except ValueError:
if self.db_path.exists():
raise
def _queue_change(self) -> None: # Decode to msgspec struct
current = msgspec.to_builtins(self.db) decoder = msgspec.json.Decoder(DB)
diff = compute_diff(self._previous_builtins, current) self.db = decoder.decode(msgspec.json.encode(statedict))
if diff: self.db._store = self
self._pending_changes.append(
create_change_record(self._current_action, diff, self._current_user) # 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._v, normalized_dict)
def _queue_change(
self, action: str, version: int, current: dict, user: str | None = None
) -> None:
"""Queue a change record and log it.
Args:
action: The action name for the change record
version: The schema version for the change record
current: The current state as a plain dict
user: Optional user UUID who performed the action
"""
diff = compute_diff(self._statedict, current)
if not diff:
return
self._pending_changes.append(
ChangeRecord(
a=action,
v=version,
u=user,
diff=diff,
) )
self._previous_builtins = current )
# Log the change with user display name if available
user_display = None
if self._current_user:
try:
user_uuid = UUID(self._current_user)
if user_uuid in self.db.users:
user_display = self.db.users[user_uuid].display_name
except (ValueError, KeyError):
user_display = self._current_user
diff_json = json.dumps(diff, default=str) # Log the change with user display name if available
if user_display: user_display = None
print( if user:
f"{self._current_action} by {user_display}: {diff_json}", try:
file=sys.stderr, user_uuid = UUID(user)
) if user_uuid in self.db.users:
else: user_display = self.db.users[user_uuid].display_name
print(f"{self._current_action}: {diff_json}", file=sys.stderr) except (ValueError, KeyError):
user_display = user
log_change(action, diff, user_display, self._statedict, self.db)
self._statedict = copy.deepcopy(current)
@contextmanager @contextmanager
def transaction( def transaction(
@@ -247,20 +260,20 @@ class JsonlStore:
# Check for out-of-transaction modifications # Check for out-of-transaction modifications
current_state = msgspec.to_builtins(self.db) current_state = msgspec.to_builtins(self.db)
if current_state != self._previous_builtins: if current_state != self._statedict:
diff = compute_diff(self._previous_builtins, current_state) # Allow bootstrap to create a new database from empty state
diff_json = json.dumps(diff, default=str, indent=2) is_bootstrap = action in _BOOTSTRAP_ACTIONS
_logger.error( if is_bootstrap and not self._statedict:
"Database state modified outside of transaction! " pass # Expected: creating database from scratch
"This indicates a bug where DB changes occurred without a transaction wrapper. " else:
"Resetting to last known state from JSONL file.\n" diff = compute_diff(self._statedict, current_state)
f"Changes detected:\n{diff_json}" diff_json = msgspec.json.encode(diff).decode()
) _logger.critical(
# Hard reset to last known good state "Database state modified outside of transaction! "
decoder = msgspec.json.Decoder(DB) "This indicates a bug where DB changes occurred without a transaction wrapper.\n"
self.db = decoder.decode(msgspec.json.encode(self._previous_builtins)) f"Changes detected:\n{diff_json}"
self.db._store = self )
current_state = self._previous_builtins.copy() raise SystemExit(1)
old_action = self._current_action old_action = self._current_action
old_user = self._current_user old_user = self._current_user
@@ -272,7 +285,10 @@ class JsonlStore:
try: try:
yield yield
self._queue_change() current = msgspec.to_builtins(self.db)
self._queue_change(
self._current_action, self._v, current, self._current_user
)
except Exception: except Exception:
# Rollback on error: restore from snapshot # Rollback on error: restore from snapshot
_logger.warning("Transaction '%s' failed, rolling back changes", action) _logger.warning("Transaction '%s' failed, rolling back changes", action)
@@ -289,6 +305,48 @@ class JsonlStore:
self._in_transaction = False self._in_transaction = False
self._transaction_snapshot = None self._transaction_snapshot = None
async def flush(self) -> bool: async def flush(self) -> None:
"""Write all pending changes to disk.""" """Write all pending changes to disk.
return await flush_changes(self.db_path, self._pending_changes)
On failure, logs an error and sends SIGTERM to trigger graceful shutdown.
"""
if self._flush_failed or not self._pending_changes:
return
if not self._file.is_open:
first_action = self._pending_changes[0].a
if first_action not in _BOOTSTRAP_ACTIONS:
_logger.error(
"Refusing to create database file with action '%s' - "
"only bootstrap can create a new database",
first_action,
)
self._flush_failed = True
os.kill(os.getpid(), signal.SIGTERM)
return
# Bootstrap: create and open the file with lock
await asyncio.to_thread(self._file.open, self.db_path, create=True)
changes_to_write = list(self._pending_changes)
try:
lines = [msgspec.json.encode(change) for change in changes_to_write]
if not lines:
self._pending_changes.clear()
return
await asyncio.to_thread(self._file.write, b"\n".join(lines) + b"\n")
self._snapshot.record_lines(len(lines))
self._pending_changes.clear()
except OSError as e:
_logger.error("Failed to flush database: %s", e)
self._flush_failed = True
os.kill(os.getpid(), signal.SIGTERM)
def maybe_snapshot(self) -> None:
"""Write a snapshot if conditions are met."""
self._snapshot.maybe_write(self._file, self._v, self._statedict)
def close(self) -> None:
"""Release the file lock and close the file."""
self._file.close()
+47
View File
@@ -0,0 +1,47 @@
"""
Database lifecycle: initialization and maintenance.
"""
import logging
import os
from datetime import UTC, datetime
import paskia.db.operations as _ops
from paskia import oidc_notify
from paskia.authsession import EXPIRES
from paskia.db.jsonl import JsonlStore
_logger = logging.getLogger(__name__)
async def init(rp_id: str, *args, **kwargs):
"""Load database from JSONL file."""
if _ops._db._store:
_logger.debug("Database already initialized, skipping reload")
return
db_path = os.environ.get("PASKIA_DB", f"{rp_id}.paskiadb")
store = JsonlStore(_ops._db, db_path)
await store.load(db_path, rp_id=rp_id)
_ops._db = store.db
_ops._db._store = store
# Request a snapshot after successful startup
store._snapshot.request_force()
def cleanup_expired() -> int:
"""Remove expired sessions and reset tokens. Returns count removed."""
now = datetime.now(UTC)
count = 0
limit = now - EXPIRES
expired_sessions = [k for k, s in _ops._db.sessions.items() if s.validated < limit]
if expired_sessions:
oidc_notify.schedule_notifications(expired_sessions)
with _ops._db.transaction("expiry"):
for k in expired_sessions:
del _ops._db.sessions[k]
count += 1
expired_tokens = [k for k, t in _ops._db.reset_tokens.items() if t.expiry < now]
for k in expired_tokens:
del _ops._db.reset_tokens[k]
count += 1
return count
+466
View File
@@ -0,0 +1,466 @@
"""
Database change logging with pretty-printed diffs.
Provides a logger for JSONL database changes that formats diffs
in a human-readable path.notation style with color coding.
UUIDs are replaced with display names where available, or the full UUID string
for types without display names.
"""
import logging
import re
import sys
from typing import TYPE_CHECKING, Any
from uuid import UUID
if TYPE_CHECKING:
from paskia.db.structs import DB
logger = logging.getLogger("paskia.db")
# UUID regex pattern (8-4-4-4-12 hex format)
_UUID_PATTERN = re.compile(
r"^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$"
)
# Pattern to match control characters and bidirectional overrides
_UNSAFE_CHARS = re.compile(
r"[\x00-\x1f\x7f-\x9f" # C0 and C1 control characters
r"\u200e\u200f" # LRM, RLM
r"\u202a-\u202e" # LRE, RLE, PDF, LRO, RLO
r"\u2066-\u2069" # LRI, RLI, FSI, PDI
r"]"
)
# ANSI color codes (matching FastAPI logging style)
_RESET = "\033[0m"
_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 _is_uuid(value: str) -> bool:
"""Check if a string is a UUID."""
return bool(_UUID_PATTERN.match(value))
class UuidResolver:
"""Resolve UUIDs to display names or short suffixes.
Uses the previous state for lookups to show the name before any changes.
"""
def __init__(self, db: "DB | None" = None, previous: dict | None = None):
self._db = db
self._previous = previous
def resolve(self, uuid_str: str) -> str:
"""Resolve a UUID to its display name or the full UUID string."""
display = self._get_display_name(uuid_str)
if display:
return display
return uuid_str
def _get_display_name(self, uuid_str: str) -> str | None:
"""Look up display name for a UUID.
First checks the previous state (to show names before changes),
then falls back to the current database.
"""
# Try previous state first (for showing name before a change)
name = self._lookup_in_previous(uuid_str)
if name:
return name
# Fall back to current database
return self._lookup_in_db(uuid_str)
def _lookup_in_previous(self, uuid_str: str) -> str | None:
"""Look up display name in the previous state dict."""
if not self._previous:
return None
# Check users
if "users" in self._previous and uuid_str in self._previous["users"]:
user_data = self._previous["users"][uuid_str]
if isinstance(user_data, dict) and "display_name" in user_data:
return user_data["display_name"]
# Check orgs
if "orgs" in self._previous and uuid_str in self._previous["orgs"]:
org_data = self._previous["orgs"][uuid_str]
if isinstance(org_data, dict) and "display_name" in org_data:
return org_data["display_name"]
# Check roles
if "roles" in self._previous and uuid_str in self._previous["roles"]:
role_data = self._previous["roles"][uuid_str]
if isinstance(role_data, dict) and "display_name" in role_data:
return role_data["display_name"]
# Check permissions
if (
"permissions" in self._previous
and uuid_str in self._previous["permissions"]
):
perm_data = self._previous["permissions"][uuid_str]
if isinstance(perm_data, dict) and "display_name" in perm_data:
return perm_data["display_name"]
return None
def _lookup_in_db(self, uuid_str: str) -> str | None:
"""Look up display name in the current database."""
if not self._db:
return None
try:
uuid_obj = UUID(uuid_str)
except ValueError:
return None
# Check users
if uuid_obj in self._db.users:
return self._db.users[uuid_obj].display_name
# Check orgs
if uuid_obj in self._db.orgs:
return self._db.orgs[uuid_obj].display_name
# Check roles
if uuid_obj in self._db.roles:
return self._db.roles[uuid_obj].display_name
# Check permissions
if uuid_obj in self._db.permissions:
return self._db.permissions[uuid_obj].display_name
return None
def _format_value(
value: Any,
max_len: int = 60,
resolver: UuidResolver | None = None,
) -> str:
"""Format a value for display, truncating if needed.
If resolver is provided, UUIDs are replaced with display names or short suffixes.
"""
if value is None:
return "null"
if isinstance(value, bool):
return "true" if value else "false"
if isinstance(value, (int, float)):
return str(value)
if isinstance(value, str):
# Check if it's a UUID and resolve to display name
if resolver and _is_uuid(value):
return resolver.resolve(value)
# Filter out control characters and bidirectional overrides
value = _UNSAFE_CHARS.sub("", value)
# Truncate long strings
if len(value) > max_len:
return value[: max_len - 3] + "..."
return value
if isinstance(value, dict):
if not value:
return "{}"
# Check if all values are True - render as set-like {key1, key2}
all_true = all(v is True for v in value.values())
parts = []
for k, v in value.items():
# Replace UUID keys with display names
key_display = resolver.resolve(k) if resolver and _is_uuid(k) else k
if all_true:
parts.append(key_display)
else:
val_display = _format_value(v, max_len=30, resolver=resolver)
parts.append(f"{key_display}: {val_display}")
return "{" + ", ".join(parts) + "}"
if isinstance(value, list):
if not value:
return "[]"
parts = [_format_value(v, max_len=30, resolver=resolver) for v in value]
return "[" + ", ".join(parts) + "]"
# Fallback for other types
text = str(value)
if len(text) > max_len:
text = text[: max_len - 3] + "..."
return text
def _format_path(path: list[str], resolver: UuidResolver | None = None) -> str:
"""Format a path as dot notation with prefix in dark grey, final in default.
If resolver is provided, UUIDs in the path are replaced with display names.
"""
if not path:
return ""
# Replace UUIDs in path with display names
if resolver:
path = [resolver.resolve(p) if _is_uuid(p) else p for p in path]
if len(path) == 1:
return f"{_PATH_FINAL}{path[0]}{_RESET}"
prefix = ".".join(path[:-1])
final = path[-1]
return f"{_PATH_PREFIX}{prefix}.{_RESET}{_PATH_FINAL}{final}{_RESET}"
def _get_nested(data: dict | None, path: list[str]) -> Any:
"""Get a nested value from a dict by path, or None if not found."""
if data is None:
return None
current = data
for key in path:
if not isinstance(current, dict) or key not in current:
return None
current = current[key]
return current
def _collect_changes(
diff: dict,
path: list[str],
changes: list[tuple[str, list[str], Any]],
previous: dict | None,
) -> None:
"""
Recursively collect changes from a diff into a flat list.
Each change is a tuple of (change_type, path, new_value).
change_type is one of: 'add', 'update', 'delete'
"""
if not isinstance(diff, dict):
# Leaf value - check if it existed before
existed = _get_nested(previous, path) is not None
changes.append(("update" if existed else "add", path, diff))
return
for key, value in diff.items():
if key == "$delete":
# $delete contains a list of keys to delete
if isinstance(value, list):
for deleted_key in value:
changes.append(("delete", path + [str(deleted_key)], None))
else:
changes.append(("delete", path + [str(value)], None))
elif key == "$replace":
# $replace replaces the entire collection at this path
# We need to track what was added and what was deleted
old_collection = _get_nested(previous, path)
old_keys = (
set(old_collection.keys())
if isinstance(old_collection, dict)
else set()
)
new_keys = set(value.keys()) if isinstance(value, dict) else set()
# Items that existed before but not in new = deleted
for deleted_key in old_keys - new_keys:
changes.append(("delete", path + [str(deleted_key)], None))
# Items in new collection
if isinstance(value, dict):
for rkey, rval in value.items():
existed = rkey in old_keys
changes.append(
("update" if existed else "add", path + [str(rkey)], rval)
)
elif value or not old_keys:
# Non-dict replacement or empty replacement with nothing before
changes.append(
("update" if old_collection is not None else "add", path, value)
)
elif key.startswith("$"):
# Other special operations (future-proofing)
changes.append(("add", path, {key: value}))
else:
# Regular nested key - check if this item existed before
new_path = path + [str(key)]
existed = _get_nested(previous, new_path) is not None
if existed:
# Item exists - recurse to show specific field changes
_collect_changes(value, new_path, changes, previous)
else:
# New item - record as add with full value, don't recurse
changes.append(("add", new_path, value))
def _format_change_lines(
change_type: str,
path: list[str],
value: Any,
resolver: UuidResolver | None = None,
) -> list[str]:
"""Format a single change as one or more lines.
If resolver is provided, UUIDs are replaced with display names.
"""
# Helper to format a value, checking for censored paths
def fmt_value(v: Any, child_path: list[str]) -> str:
if child_path[-2:] == ["oidc", "key"]:
return f"{_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 len(formatted_path) == 1:
return [f" {_DELETE}{formatted_path[0]}{_RESET}"]
prefix = ".".join(formatted_path[:-1])
final = formatted_path[-1]
return [f" {_PATH_PREFIX}{prefix}.{_RESET}{_DELETE}{final}{_RESET}"]
if change_type == "add":
# New item being created - only final element in green
# For dict values, show children on separate indented lines
if isinstance(value, dict) and value:
lines = []
# First line: path with green final element and grey =
if len(formatted_path) == 1:
lines.append(f" {_ADD}{formatted_path[0]}{_RESET} {_SEP}={_RESET}")
else:
prefix = ".".join(formatted_path[:-1])
final = formatted_path[-1]
lines.append(
f" {_PATH_PREFIX}{prefix}.{_RESET}{_ADD}{final}{_RESET} {_SEP}={_RESET}"
)
# Child lines: indented key: value, with aligned values
# Format keys (may contain UUIDs)
formatted_items = []
for k, v in value.items():
k_display = resolver.resolve(k) if resolver and _is_uuid(k) else k
v_str = fmt_value(v, path + [k])
formatted_items.append((k_display, v_str))
max_key_len = max(len(k) for k, _ in formatted_items)
field_width = max(max_key_len, 12) # minimum 12 chars
for k_display, v_str in formatted_items:
padding = " " * (field_width - len(k_display))
lines.append(f" {k_display}{_SEP}:{_RESET}{padding} {v_str}")
return lines
else:
value_str = fmt_value(value, path)
if len(formatted_path) == 1:
return [
f" {_ADD}{formatted_path[0]}{_RESET} {_SEP}={_RESET} {value_str}"
]
prefix = ".".join(formatted_path[:-1])
final = formatted_path[-1]
return [
f" {_PATH_PREFIX}{prefix}.{_RESET}{_ADD}{final}{_RESET} {_SEP}={_RESET} {value_str}"
]
# update: Existing item being updated - normal path colors
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, db: "DB | None" = None
) -> list[str]:
"""
Format a JSON diff as human-readable lines.
Args:
diff: The JSON diff dict
previous: The previous state dict (for determining add vs update)
db: Optional database for looking up display names
Returns a list of formatted lines (without newlines).
UUIDs are replaced with display names (using previous state for lookups).
"""
changes: list[tuple[str, list[str], Any]] = []
_collect_changes(diff, [], changes, previous)
if not changes:
return []
# Create resolver for UUID replacement (uses previous state for lookups)
resolver = UuidResolver(db, previous)
# Format each change
lines = []
for change_type, path, value in changes:
lines.extend(_format_change_lines(change_type, path, value, resolver))
return lines
def format_action_header(action: str, user_display: str | None = None) -> str:
"""Format the action header line."""
action_str = f"{_ACTION}{action}{_RESET}"
if user_display:
user_str = f"{_USER}{user_display}{_RESET}"
return f"{action_str} by {user_str}"
return action_str
def log_change(
action: str,
diff: dict,
user_display: str | None = None,
previous: dict | None = None,
db: "DB | None" = None,
) -> None:
"""
Log a database change with pretty-printed diff.
UUIDs are replaced with display names for readability. For types without
display names, the full UUID string is used.
Args:
action: The action name (e.g., "login", "admin:delete_user")
diff: The JSON diff dict
user_display: Optional display name of the user who performed the action
previous: The previous state dict (for determining add vs update)
db: Optional database for looking up display names
"""
header = format_action_header(action, user_display)
diff_lines = format_diff(diff, previous, db)
if not diff_lines:
logger.info(header)
return
if len(diff_lines) == 1:
# Single change - combine on one line
logger.info(f"{header}{diff_lines[0]}")
else:
# Multiple changes - header on its own line, then changes
logger.info(header)
for line in diff_lines:
logger.info(line)
def configure_db_logging() -> None:
"""Configure the database logger to output to stderr without prefix."""
handler = logging.StreamHandler(sys.stderr)
handler.setFormatter(logging.Formatter("%(message)s"))
logger.addHandler(handler)
logger.setLevel(logging.INFO)
logger.propagate = False
+72 -19
View File
@@ -5,30 +5,83 @@ Migrations are applied during database load based on the version field.
Each migration should be idempotent and only run when needed. Each migration should be idempotent and only run when needed.
""" """
import logging import base64
from collections.abc import Awaitable, Callable
_logger = logging.getLogger(__name__) import msgspec
from paskia.util.crypto import secret_key
def apply_migrations(data_dict: dict) -> bool: class MigrationCtx(msgspec.Struct):
"""Apply any pending schema migrations to the database dictionary. """Context passed to each migration function."""
Args: rp_id: str
data_dict: The raw database dictionary loaded from JSONL
Returns:
True if any migrations were applied, False otherwise 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)
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}
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]
migrations = sorted(
[f for n, f in globals().items() if n.startswith("migrate_v")],
key=lambda f: int(f.__name__.removeprefix("migrate_v")),
)
DBVER = len(migrations) # Used by bootstrap to set initial version
def apply_migrations_readonly(
data_dict: dict,
current_version: int,
ctx: MigrationCtx,
) -> int:
"""Apply migration functions in-place without persistence.
Returns the new version after all migrations.
""" """
db_version = data_dict.get("v", 0) while current_version < DBVER:
migrated = False migrations[current_version](data_dict, ctx)
current_version += 1
return current_version
if db_version == 0:
# Migration v0 -> v1: Remove created_at from orgs (field removed from schema)
if "orgs" in data_dict:
for org_data in data_dict["orgs"].values():
org_data.pop("created_at", None)
data_dict["v"] = 1
migrated = True
_logger.info("Applied schema migration: v0 -> v1 (removed org.created_at)")
return migrated async def apply_all_migrations(
data_dict: dict,
current_version: int,
persist: Callable[[str, int, dict], Awaitable[None]],
ctx: MigrationCtx,
) -> None:
while current_version < DBVER:
migrations[current_version](data_dict, ctx)
current_version += 1
await persist(f"migrate:v{current_version}", current_version, data_dict)
+328 -466
View File
File diff suppressed because it is too large Load Diff
+88
View File
@@ -0,0 +1,88 @@
"""
Snapshot handling for JSONL database persistence.
"""
import logging
from datetime import UTC, datetime
from typing import Any
import msgspec
_logger = logging.getLogger(__name__)
LINEPREFIX = b"SNAPSHOT "
MINDIFFS = 100
class Snapshot(msgspec.Struct):
"""Snapshot data structure for database persistence."""
ts: datetime
v: int
state: dict[str, Any]
class SnapshotState:
"""Tracks snapshot timing and line counts for a database file."""
def __init__(self) -> None:
self.ts: datetime | None = None
self.changes: int = 0
self._force_pending: bool = False
def request_force(self) -> None:
"""Request a forced snapshot on the next maybe_write call."""
self._force_pending = True
def record_lines(self, count: int) -> None:
self.changes += count
def maybe_write(self, file, version: int, state: dict) -> None:
"""Write a snapshot if conditions are met (enough changes, and Sunday UTC or forced)."""
if self.changes < MINDIFFS:
return
force = self._force_pending
now = datetime.now(UTC)
if not force and now.weekday() != 6: # 6 = Sunday
return
sunday_midnight = now.replace(hour=0, minute=0, second=0, microsecond=0)
if not force and self.ts is not None and self.ts >= sunday_midnight:
return
if not file.is_open:
return
try:
self._write(file, version, state, now)
self._force_pending = False
except Exception as exc:
_logger.error("snapshot: failed to write snapshot: %r", exc)
def _write(self, file, version: int, state: dict, now: datetime) -> None:
"""Write a snapshot and update internal state."""
data = msgspec.json.encode(Snapshot(ts=now, v=version, state=state))
file.write(LINEPREFIX + data + b"\n")
self.changes = 0
self.ts = now
@staticmethod
def load(data: bytes) -> tuple[Snapshot | None, int]:
"""Find and parse the last snapshot in file data.
Returns (snapshot, replay_offset) where replay_offset is the byte
position to start replaying change records from. If no valid snapshot
is found, returns (None, 0).
"""
marker = b"\n" + LINEPREFIX
pos = data.rfind(marker)
if pos != -1:
pos += 1 # skip the newline
elif data.startswith(LINEPREFIX):
pos = 0
else:
return None, 0
end = data.find(b"\n", pos)
if end == -1:
raise ValueError("Incomplete snapshot line at end of file")
snap = msgspec.json.decode(data[pos + len(LINEPREFIX) : end], type=Snapshot)
return snap, end + 1
+520 -73
View File
@@ -1,9 +1,17 @@
from datetime import datetime, timezone from __future__ import annotations
import hashlib
import secrets
from datetime import UTC, datetime
from uuid import UUID from uuid import UUID
import msgspec import msgspec
import uuid7 import uuid7
from paskia import db
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 # Sentinel for uuid fields before they are set by create() or DB post init
_UUID_UNSET = UUID(int=0) _UUID_UNSET = UUID(int=0)
@@ -22,113 +30,256 @@ class Permission(msgspec.Struct, dict=True, omit_defaults=True):
orgs: dict[UUID, bool] = {} # org_uuid -> True (which orgs can grant this) orgs: dict[UUID, bool] = {} # org_uuid -> True (which orgs can grant this)
def __post_init__(self): def __post_init__(self):
self.uuid: UUID = _UUID_UNSET # Convenience field, not serialized if not hasattr(self, "uuid"):
self.uuid: UUID = _UUID_UNSET
@property @property
def org_set(self) -> set[UUID]: def org_set(self) -> set[UUID]:
"""Get orgs that can grant this permission as a set.""" """Get orgs that can grant this permission as a set."""
return set(self.orgs.keys()) return set(self.orgs.keys())
@property
def orgs_list(self) -> list[Org]:
"""Get list of Org objects that can grant this permission."""
return [
db.data().orgs[org_uuid]
for org_uuid in self.orgs.keys()
if org_uuid in db.data().orgs
]
def store(self) -> None:
"""Store this permission in the database. Must be called inside a transaction."""
db.data().permissions[self.uuid] = self
def delete(self) -> None:
"""Delete this permission and remove it from all roles.
Must be called inside a transaction.
"""
_data = db.data()
for role in _data.roles.values():
role.permissions.pop(self.uuid, None)
del _data.permissions[self.uuid]
@classmethod @classmethod
def create( def create(
cls, cls,
scope: str, scope: str,
display_name: str, display_name: str,
domain: str | None = None, domain: str | None = None,
) -> "Permission": created_at: datetime | None = None,
) -> Permission:
"""Create a new Permission with auto-generated uuid7.""" """Create a new Permission with auto-generated uuid7."""
now = created_at or datetime.now(UTC)
perm = cls( perm = cls(
scope=scope, scope=scope,
display_name=display_name, display_name=display_name,
domain=domain, domain=domain,
) )
perm.uuid = uuid7.create() perm.uuid = uuid7.create(now)
return perm return perm
class Role(msgspec.Struct, dict=True, omit_defaults=True):
"""Role data structure.
Mutable fields: display_name, permissions
Immutable fields: org (set at creation, never modified)
uuid is generated at creation.
"""
org: UUID
display_name: str
permissions: dict[UUID, bool] = {} # permission_uuid -> True
def __post_init__(self):
self.uuid: UUID = _UUID_UNSET # Convenience field, not serialized
@property
def permission_set(self) -> set[UUID]:
"""Get permissions as a set of UUIDs."""
return set(self.permissions.keys())
@classmethod
def create(
cls,
org: UUID,
display_name: str,
permissions: set[UUID] | None = None,
) -> "Role":
"""Create a new Role with auto-generated uuid7."""
role = cls(
org=org,
display_name=display_name,
permissions={p: True for p in (permissions or set())},
)
role.uuid = uuid7.create()
return role
class Org(msgspec.Struct, dict=True): class Org(msgspec.Struct, dict=True):
"""Organization data structure.""" """Organization data structure."""
display_name: str display_name: str
def __post_init__(self): def __post_init__(self):
self.uuid: UUID = _UUID_UNSET # Convenience field, not serialized if not hasattr(self, "uuid"):
self.uuid: UUID = _UUID_UNSET
@property
def roles(self) -> list[Role]:
"""Get all roles that belong to this organization."""
return [r for r in db.data().roles.values() if r.org_uuid == self.uuid]
@property
def permissions(self) -> list[Permission]:
"""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 @classmethod
def create(cls, display_name: str) -> "Org": def create(cls, display_name: str, created_at: datetime | None = None) -> Org:
"""Create a new Org with auto-generated uuid7.""" """Create a new Org with auto-generated uuid7."""
now = created_at or datetime.now(UTC)
org = cls(display_name=display_name) org = cls(display_name=display_name)
org.uuid = uuid7.create() org.uuid = uuid7.create(now)
return org return org
class User(msgspec.Struct, dict=True): class Role(msgspec.Struct, dict=True, omit_defaults=True):
"""Role data structure.
Mutable fields: display_name, permissions
Immutable fields: org_uuid (set at creation, never modified)
uuid is generated at creation.
"""
org_uuid: UUID = msgspec.field(name="org")
display_name: str
permissions: dict[UUID, bool] = {} # permission_uuid -> True
def __post_init__(self):
if not hasattr(self, "uuid"):
self.uuid: UUID = _UUID_UNSET
@property
def permission_set(self) -> set[UUID]:
"""Get permissions as a set of UUIDs."""
return set(self.permissions.keys())
@property
def permissions_list(self) -> list[Permission]:
"""Get list of Permission objects for this role."""
return [
db.data().permissions[perm_uuid]
for perm_uuid in self.permissions.keys()
if perm_uuid in db.data().permissions
]
@property
def org(self) -> Org:
"""Get the organization object this role belongs to."""
return db.data().orgs[self.org_uuid]
@property
def users(self) -> list[User]:
"""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(now)
return role
class User(msgspec.Struct, dict=True, omit_defaults=True, kw_only=True):
"""User data structure. """User data structure.
Mutable fields: display_name, role, 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) Immutable fields: created_at (set at creation, never modified)
uuid is derived from created_at using uuid7. uuid is derived from created_at using uuid7.
""" """
display_name: str display_name: str
role: UUID role_uuid: UUID = msgspec.field(name="role")
created_at: datetime created_at: datetime
visits: int
last_seen: datetime | None = None last_seen: datetime | None = None
visits: int = 0 theme: str = ""
email: str | None = None # OIDC email claim
preferred_username: str | None = None # OIDC preferred_username claim
telephone: str | None = None # Telephone number
def __post_init__(self): def __post_init__(self):
self.uuid: UUID = _UUID_UNSET # Convenience field, not serialized if not hasattr(self, "uuid"):
self.uuid: UUID = _UUID_UNSET
@property
def role(self) -> Role:
"""Get the role object this user has."""
return db.data().roles[self.role_uuid]
@property
def org(self) -> Org:
"""Get the organization this user belongs to (via role)."""
return self.role.org
@property
def credentials(self) -> list[Credential]:
"""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."""
return [s for s in db.data().sessions.values() if s.user_uuid == self.uuid]
@property
def reset_tokens(self) -> list[ResetToken]:
"""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 @classmethod
def create( def create(
cls, cls,
display_name: str, display_name: str,
role: UUID, role: UUID | Role,
created_at: datetime | None = None, created_at: datetime | None = None,
) -> "User": ) -> User:
"""Create a new User with auto-generated uuid7.""" """Create a new User with auto-generated uuid7."""
role_uuid = role if isinstance(role, UUID) else role.uuid
user = cls( user = cls(
display_name=display_name, display_name=display_name,
role=role, role_uuid=role_uuid,
created_at=created_at or datetime.now(timezone.utc), created_at=created_at or datetime.now(UTC),
last_seen=None,
visits=0,
theme="",
) )
user.uuid = uuid7.create(user.created_at) user.uuid = uuid7.create(user.created_at)
return user return user
@@ -143,7 +294,7 @@ class Credential(msgspec.Struct, dict=True):
""" """
credential_id: bytes # Long binary ID from the authenticator credential_id: bytes # Long binary ID from the authenticator
user: UUID user_uuid: UUID = msgspec.field(name="user")
aaguid: UUID aaguid: UUID
public_key: bytes public_key: bytes
sign_count: int sign_count: int
@@ -152,23 +303,51 @@ class Credential(msgspec.Struct, dict=True):
last_verified: datetime | None = None last_verified: datetime | None = None
def __post_init__(self): def __post_init__(self):
self.uuid: UUID = _UUID_UNSET # Convenience field, not serialized if not hasattr(self, "uuid"):
self.uuid: UUID = _UUID_UNSET
@property
def user(self) -> User:
"""Get the User object for this credential."""
return db.data().users[self.user_uuid]
@property
def sessions(self) -> list[Session]:
"""Get all sessions using this credential."""
return [
s for s in db.data().sessions.values() if s.credential_uuid == self.uuid
]
def store(self) -> None:
"""Store this credential in the database. Must be called inside a transaction."""
db.data().credentials[self.uuid] = self
def delete(self) -> None:
"""Delete this credential and all its sessions.
Must be called inside a transaction.
"""
_data = db.data()
for sess in self.sessions:
del _data.sessions[sess.key]
del _data.credentials[self.uuid]
@classmethod @classmethod
def create( def create(
cls, cls,
credential_id: bytes, credential_id: bytes,
user: UUID, user: UUID | User,
aaguid: UUID, aaguid: UUID,
public_key: bytes, public_key: bytes,
sign_count: int, sign_count: int,
created_at: datetime | None = None, created_at: datetime | None = None,
) -> "Credential": ) -> Credential:
"""Create a new Credential with auto-generated uuid7.""" """Create a new Credential with auto-generated uuid7."""
now = created_at or datetime.now(timezone.utc) user_uuid = user if isinstance(user, UUID) else user.uuid
now = created_at or datetime.now(UTC)
cred = cls( cred = cls(
credential_id=credential_id, credential_id=credential_id,
user=user, user_uuid=user_uuid,
aaguid=aaguid, aaguid=aaguid,
public_key=public_key, public_key=public_key,
sign_count=sign_count, sign_count=sign_count,
@@ -180,32 +359,102 @@ class Credential(msgspec.Struct, dict=True):
return cred return cred
class Session(msgspec.Struct, dict=True): class Session(msgspec.Struct, dict=True, omit_defaults=True):
"""Session data structure. """Session data structure.
Mutable fields: expiry (updated on session refresh) Mutable fields: validated (updated on session refresh)
Immutable fields: user, credential, host, ip, user_agent Immutable fields: user_uuid, credential_uuid, host, ip, user_agent, client_uuid
key is stored in the dict key, not in the struct. key is the hashed db_key, stored in the dict key, not in the struct.
If client_uuid is set, this is an OIDC session.
Security: The database stores only derived keys, never the raw secret.
A database leak does not expose working session credentials.
""" """
user: UUID user_uuid: UUID = msgspec.field(name="user")
credential: UUID credential_uuid: UUID = msgspec.field(name="credential")
host: str host: str
ip: str ip: str
user_agent: str user_agent: str
expiry: datetime validated: datetime
client_uuid: UUID | None = msgspec.field(name="client", default=None)
def __post_init__(self): def __post_init__(self):
self.key: str = "" # Convenience field, not serialized if not hasattr(self, "key"):
self.key: str = ""
@property
def user(self) -> User:
"""Get the User object for this session."""
return db.data().users[self.user_uuid]
@property
def credential(self) -> Credential:
"""Get the Credential object for this session."""
return db.data().credentials[self.credential_uuid]
def metadata(self) -> dict: def metadata(self) -> dict:
"""Return session metadata for backwards compatibility.""" """Return session metadata for backwards compatibility."""
return { return {
"ip": self.ip, "ip": self.ip,
"user_agent": self.user_agent, "user_agent": self.user_agent,
"expiry": self.expiry.isoformat(), "validated": self.validated.isoformat(),
} }
def store(self, last_seen: datetime) -> None:
"""Store this session in the database and record a visit.
Updates user.last_seen and user.visits. Must be called inside
a database transaction.
"""
_data = db.data()
_data.sessions[self.key] = self
_data.users[self.user_uuid].last_seen = last_seen
_data.users[self.user_uuid].visits += 1
def delete(self) -> None:
"""Delete this session from the database. Must be called inside a transaction."""
del db.data().sessions[self.key]
@classmethod
def create(
cls,
user: UUID | User,
credential: UUID | Credential,
key: str,
host: str,
ip: str,
user_agent: str,
validated: datetime,
client: UUID | None = None,
) -> Session:
"""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,
validated=validated,
client_uuid=client,
)
session.key = key
return session
class ResetToken(msgspec.Struct, dict=True): class ResetToken(msgspec.Struct, dict=True):
"""Reset/device-addition token data structure. """Reset/device-addition token data structure.
@@ -214,12 +463,127 @@ class ResetToken(msgspec.Struct, dict=True):
key is stored in the dict key, not in the struct. key is stored in the dict key, not in the struct.
""" """
user: UUID user_uuid: UUID = msgspec.field(name="user")
expiry: datetime expiry: datetime
token_type: str token_type: str
def __post_init__(self): def __post_init__(self):
self.key: bytes = b"" # Convenience field, not serialized if not hasattr(self, "key"):
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): class SessionContext(msgspec.Struct):
@@ -231,6 +595,21 @@ class SessionContext(msgspec.Struct):
permissions: list[Permission] = [] 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 # Database storage structure
# ------------------------------------------------------------------------- # -------------------------------------------------------------------------
@@ -239,14 +618,16 @@ class SessionContext(msgspec.Struct):
class DB(msgspec.Struct, dict=True, omit_defaults=False): class DB(msgspec.Struct, dict=True, omit_defaults=False):
"""In-memory database. Access fields directly for reads.""" """In-memory database. Access fields directly for reads."""
config: Config
permissions: dict[UUID, Permission] = {} permissions: dict[UUID, Permission] = {}
orgs: dict[UUID, Org] = {} orgs: dict[UUID, Org] = {}
roles: dict[UUID, Role] = {} roles: dict[UUID, Role] = {}
users: dict[UUID, User] = {} users: dict[UUID, User] = {}
credentials: dict[UUID, Credential] = {} credentials: dict[UUID, Credential] = {}
sessions: dict[str, Session] = {} sessions: dict[str, Session] = {}
reset_tokens: dict[bytes, ResetToken] = {} reset_tokens: dict[str, ResetToken] = {}
v: int = 0 # OIDC provider data
oidc: OIDC = msgspec.field(default_factory=lambda: OIDC())
def __post_init__(self): def __post_init__(self):
# Store reference for persistence (not serialized) # Store reference for persistence (not serialized)
@@ -266,7 +647,73 @@ class DB(msgspec.Struct, dict=True, omit_defaults=False):
session.key = key session.key = key
for key, token in self.reset_tokens.items(): for key, token in self.reset_tokens.items():
token.key = key token.key = key
# OIDC
for uuid, client in self.oidc.clients.items():
client.uuid = uuid
def transaction(self, action, ctx=None, *, user=None): def transaction(self, action, ctx=None, *, user=None):
"""Wrap writes in transaction. Delegates to JsonlStore.""" """Wrap writes in transaction. Delegates to JsonlStore."""
return self._store.transaction(action, ctx, user=user) return self._store.transaction(action, ctx, user=user)
def session_ctx(
self, session_secret: str, host: str | None = None
) -> SessionContext | None:
"""Get full session context with effective permissions.
Args:
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[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)
normalized_input = host
if s.host != normalized_input:
# Session bound to different host
return None
try:
user = s.user
role = user.role
org = role.org
credential = s.credential
except KeyError:
return None
# 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}
effective_perms = []
for perm_uuid in role.permission_set:
if perm_uuid not in org_perm_uuids:
continue
try:
p = self.permissions[perm_uuid]
except KeyError:
continue
# 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)
return SessionContext(
session=s,
user=user,
org=org,
role=role,
credential=credential,
permissions=effective_perms,
)
-235
View File
@@ -1,235 +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 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 app as fastapi_app
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": "info",
}
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"]
# Suppress uvicorn startup messages in dev mode
run_kwargs["log_level"] = "warning"
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=fastapi_app, **run_kwargs, **ep)).serve()
)
else:
server = Server(Config(app=fastapi_app, **run_kwargs, **endpoints[0]))
await server.serve()
try:
asyncio.run(async_main())
except KeyboardInterrupt:
pass
if __name__ == "__main__":
main()
File diff suppressed because it is too large Load Diff
+3
View File
@@ -0,0 +1,3 @@
from paskia.fastapi.admin.adminapp import app
__all__ = ["app"]
+116
View File
@@ -0,0 +1,116 @@
from uuid import UUID
from fastapi import FastAPI, Request
from paskia import db
from paskia.fastapi import authz
from paskia.fastapi.admin import (
oidc_clients,
orgs,
permissions,
roles,
server_config,
users,
)
from paskia.fastapi.admin.errors import install_error_handlers
from paskia.fastapi.front import frontend
from paskia.fastapi.response import MsgspecResponse
from paskia.fastapi.session import AUTH_COOKIE
from paskia.util import (
permutil,
vitedev,
)
from paskia.util.apistructs import (
ApiAdminInfo,
ApiOidcClient,
ApiOrg,
ApiOrgResponse,
ApiPermission,
)
app = FastAPI(docs_url=None, redoc_url=None, openapi_url=None)
install_error_handlers(app)
app.mount("/oidc-clients", oidc_clients.app)
app.mount("/orgs", orgs.app)
app.mount("/roles", roles.app)
app.mount("/users", users.app)
app.mount("/permissions", permissions.app)
app.mount("/server-config", server_config.app)
def master_admin(ctx) -> bool:
return any(p.scope == "auth:admin" for p in ctx.permissions)
def org_admin(ctx, org_uuid: UUID) -> bool:
return ctx.org.uuid == org_uuid and any(
p.scope == "auth:org:admin" for p in ctx.permissions
)
def can_manage_org(ctx, org_uuid: UUID) -> bool:
return master_admin(ctx) or org_admin(ctx, org_uuid)
@app.get("/")
async def adminapp(request: Request, auth=AUTH_COOKIE):
return await vitedev.handle(request, frontend, "/auth/admin/")
@app.get("/info")
async def admin_info(request: Request, auth=AUTH_COOKIE):
ctx = await authz.verify(
auth,
["auth:admin", "auth:org:admin"],
match=permutil.has_any,
host=request.headers.get("host"),
)
# Orgs
orgs = list(db.data().orgs.values())
if not master_admin(ctx):
# Org admins can only see their own organization
orgs = [o for o in orgs if o.uuid == ctx.org.uuid]
def org_to_dict(o):
roles = o.roles
return ApiOrgResponse(
org=ApiOrg.from_db(o),
permissions={p.uuid: p for p in o.permissions},
roles={r.uuid: r for r in roles},
users={u.uuid: u for r in roles for u in r.users},
)
orgs_dict = {o.uuid: org_to_dict(o) for o in orgs}
# Permissions
perms = db.data().permissions.values() if master_admin(ctx) else ctx.org.permissions
perms_dict = {p.uuid: ApiPermission.from_db(p) for p in perms}
# OIDC Clients (master admin only)
oidc_clients_dict = {}
if master_admin(ctx):
clients = sorted(db.data().oidc.clients.values(), key=lambda c: c.uuid)
sessions = db.data().sessions
# Count active sessions per client
client_session_counts = {}
for session in sessions.values():
if session.client_uuid:
client_session_counts[session.client_uuid] = (
client_session_counts.get(session.client_uuid, 0) + 1
)
oidc_clients_dict = {
client.uuid: ApiOidcClient.from_db(
client, client_session_counts.get(client.uuid, 0)
)
for client in clients
}
return MsgspecResponse(
ApiAdminInfo(
orgs=orgs_dict,
permissions=perms_dict,
oidc_clients=oidc_clients_dict,
)
)
+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 hostutil, permutil
from paskia.util.apistructs import (
ApiAaguidInfo,
ApiCreateLinkResponse,
ApiOrg,
ApiRole,
ApiUser,
ApiUserDetail,
ApiUserSession,
)
app = FastAPI(docs_url=None, redoc_url=None, openapi_url=None)
install_error_handlers(app)
def master_admin(ctx) -> bool:
return any(p.scope == "auth:admin" for p in ctx.permissions)
def org_admin(ctx, org_uuid: UUID) -> bool:
return ctx.org.uuid == org_uuid and any(
p.scope == "auth:org:admin" for p in ctx.permissions
)
def can_manage_org(ctx, org_uuid: UUID) -> bool:
return master_admin(ctx) or org_admin(ctx, org_uuid)
@app.patch("/{user_uuid}/role")
async def admin_update_user_role(
user_uuid: UUID,
request: Request,
payload: dict = Body(...),
auth=AUTH_COOKIE,
):
try:
user = db.data().users[user_uuid]
except KeyError:
raise HTTPException(status_code=404, detail="User not found")
ctx = await authz.verify(
auth,
["auth:admin", "auth:org:admin"],
match=permutil.has_any,
host=request.headers.get("host"),
)
if not can_manage_org(ctx, user.org.uuid):
raise authz.AuthException(
status_code=403, detail="Insufficient permissions", mode="forbidden"
)
role_uuid_str = payload.get("role_uuid")
if not role_uuid_str:
raise ValueError("role_uuid is required")
try:
new_role_uuid = UUID(role_uuid_str)
except (ValueError, TypeError):
raise ValueError("Invalid role UUID")
new_role = db.data().roles.get(new_role_uuid)
if not new_role or new_role.org_uuid != user.org.uuid:
raise ValueError("Role not found in organization")
# Sanity check: prevent admin from removing their own access
if ctx.user.uuid == user_uuid:
# Check if any permission in the new role is an admin permission
has_admin_access = False
for perm_uuid in new_role.permissions:
perm = db.data().permissions.get(perm_uuid)
if perm and perm.scope in ["auth:admin", "auth:org:admin"]:
has_admin_access = True
break
if not has_admin_access:
raise ValueError(
"Cannot change your own role to one without admin permissions"
)
db.update_user_role(user_uuid, new_role_uuid, ctx=ctx)
return {"status": "ok"}
@app.post("/{user_uuid}/create-link")
async def admin_create_user_registration_link(
user_uuid: UUID,
request: Request,
auth=AUTH_COOKIE,
):
try:
user = db.data().users[user_uuid]
except KeyError:
raise HTTPException(status_code=404, detail="User not found")
ctx = await authz.verify(
auth,
["auth:admin", "auth:org:admin"],
match=permutil.has_any,
host=request.headers.get("host"),
max_age="5m",
)
if not can_manage_org(ctx, user.org.uuid):
raise authz.AuthException(
status_code=403, detail="Insufficient permissions", mode="forbidden"
)
# Check if user has existing credentials
has_credentials = db.data().users[user_uuid].credential_ids
token_type = "user registration" if not has_credentials else "account recovery"
expiry = reset_expires()
token = db.create_reset_token(
user_uuid=user_uuid,
expiry=expiry,
token_type=token_type,
ctx=ctx,
)
url = hostutil.reset_link_url(token)
return MsgspecResponse(
ApiCreateLinkResponse(
url=url,
expires=expiry,
token_type=token_type,
)
)
@app.get("/{user_uuid}")
async def admin_get_user_detail(
user_uuid: UUID,
request: Request,
auth=AUTH_COOKIE,
):
try:
user = db.data().users[user_uuid]
except KeyError:
raise HTTPException(status_code=404, detail="User not found")
ctx = await authz.verify(
auth,
["auth:admin", "auth:org:admin"],
match=permutil.has_any,
host=request.headers.get("host"),
)
if not can_manage_org(ctx, user.org.uuid):
raise authz.AuthException(
status_code=403, detail="Insufficient permissions", mode="forbidden"
)
normalized_host = hostutil.normalize_host(request.headers.get("host"))
sessions = {
s.key: ApiUserSession.from_db(
s,
current_key=ctx.session.key,
normalized_host=normalized_host,
)
for s in user.sessions
}
return MsgspecResponse(
ApiUserDetail(
user=ApiUser.from_db(user),
credentials={c.uuid: c for c in user.credentials},
aaguid_info={
k: ApiAaguidInfo(**v)
for k, v in aaguid_mod.filter(
c.aaguid for c in user.credentials
).items()
},
sessions=sessions,
org=ApiOrg.from_db(user.org),
role=ApiRole.from_db(user.role),
)
)
@app.patch("/{user_uuid}/info")
async def admin_update_user_info(
user_uuid: UUID,
request: Request,
payload: dict = Body(...),
auth=AUTH_COOKIE,
):
"""Update user profile info (display_name, email, preferred_username, telephone).
Pass only the fields you want to update. Use null to clear optional fields.
"""
try:
user = db.data().users[user_uuid]
except KeyError:
raise HTTPException(status_code=404, detail="User not found")
ctx = await authz.verify(
auth,
["auth:admin", "auth:org:admin"],
match=permutil.has_any,
host=request.headers.get("host"),
)
if not can_manage_org(ctx, user.org.uuid):
raise authz.AuthException(
status_code=403, detail="Insufficient permissions", mode="forbidden"
)
kwargs = {}
if "display_name" in payload:
name = (payload["display_name"] or "").strip()
if not name:
raise HTTPException(status_code=400, detail="display_name cannot be empty")
if len(name) > 64:
raise HTTPException(status_code=400, detail="display_name too long")
kwargs["display_name"] = name
if "email" in payload:
kwargs["email"] = payload["email"]
if "preferred_username" in payload:
kwargs["preferred_username"] = payload["preferred_username"]
if "telephone" in payload:
kwargs["telephone"] = payload["telephone"]
if not kwargs:
raise HTTPException(status_code=400, detail="No fields to update")
db.update_user_info(user_uuid, **kwargs, ctx=ctx)
return {"status": "ok"}
@app.delete("/{user_uuid}")
async def admin_delete_user(
user_uuid: UUID,
request: Request,
auth=AUTH_COOKIE,
):
"""Delete a user and all their credentials/sessions."""
try:
user = db.data().users[user_uuid]
except KeyError:
raise HTTPException(status_code=404, detail="User not found")
ctx = await authz.verify(
auth,
["auth:admin", "auth:org:admin"],
match=permutil.has_any,
host=request.headers.get("host"),
max_age="5m",
)
if not can_manage_org(ctx, user.org.uuid):
raise authz.AuthException(
status_code=403, detail="Insufficient permissions", mode="forbidden"
)
# Prevent admin from deleting themselves
if ctx.user.uuid == user_uuid:
raise ValueError("Cannot delete your own account")
db.delete_user(user_uuid, ctx=ctx)
return {"status": "ok"}
@app.delete("/{user_uuid}/credentials/{credential_uuid}")
async def admin_delete_user_credential(
user_uuid: UUID,
credential_uuid: UUID,
request: Request,
auth=AUTH_COOKIE,
):
try:
user = db.data().users[user_uuid]
except KeyError:
raise HTTPException(status_code=404, detail="User not found")
ctx = await authz.verify(
auth,
["auth:admin", "auth:org:admin"],
match=permutil.has_any,
host=request.headers.get("host"),
max_age="5m",
)
if not can_manage_org(ctx, user.org.uuid):
raise authz.AuthException(
status_code=403, detail="Insufficient permissions", mode="forbidden"
)
db.delete_credential(credential_uuid, user_uuid, ctx=ctx)
return {"status": "ok"}
@app.delete("/{user_uuid}/sessions/{session_id}")
async def admin_delete_user_session(
user_uuid: UUID,
session_id: str,
request: Request,
auth=AUTH_COOKIE,
):
try:
user = db.data().users[user_uuid]
except KeyError:
raise HTTPException(status_code=404, detail="User not found")
ctx = await authz.verify(
auth,
["auth:admin", "auth:org:admin"],
match=permutil.has_any,
host=request.headers.get("host"),
)
if not can_manage_org(ctx, user.org.uuid):
raise authz.AuthException(
status_code=403, detail="Insufficient permissions", mode="forbidden"
)
session_key = session_id
target_session = db.data().sessions.get(session_key)
if not target_session or target_session.user_uuid != user_uuid:
raise HTTPException(status_code=404, detail="Session not found")
db.delete_session(session_key, ctx=ctx, action="admin:delete_session")
# Check if admin terminated their own session
current_terminated = session_key == ctx.session.key
return {"status": "ok", "current_session_terminated": current_terminated}
+178 -96
View File
@@ -1,6 +1,7 @@
import logging import logging
from contextlib import suppress from contextlib import suppress
from datetime import datetime, timedelta, timezone from datetime import UTC, datetime, timedelta
from uuid import UUID
from fastapi import ( from fastapi import (
Depends, Depends,
@@ -13,18 +14,26 @@ from fastapi import (
from fastapi.responses import JSONResponse from fastapi.responses import JSONResponse
from fastapi.security import HTTPBearer from fastapi.security import HTTPBearer
from paskia import db from paskia import authcode, db
from paskia.authsession import ( from paskia._version import __version__
EXPIRES, from paskia.authsession import EXPIRES, get_reset, session_ctx
get_reset,
refresh_session_token,
)
from paskia.fastapi import authz, session, user from paskia.fastapi import authz, session, user
from paskia.fastapi.session import AUTH_COOKIE, AUTH_COOKIE_NAME from paskia.fastapi.response import MsgspecResponse
from paskia.fastapi.session import AUTH_COOKIE, AUTH_COOKIE_NAME, get_client_ip
from paskia.globals import passkey as global_passkey from paskia.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,
)
bearer_auth = HTTPBearer(auto_error=True) bearer_auth = HTTPBearer(auto_error=False)
app = FastAPI(docs_url=None, redoc_url=None, openapi_url=None) app = FastAPI(docs_url=None, redoc_url=None, openapi_url=None)
@@ -81,7 +90,7 @@ async def validate_token(
try: try:
ctx = await authz.verify( ctx = await authz.verify(
auth, auth,
perm, " ".join(perm).split(),
host=request.headers.get("host"), host=request.headers.get("host"),
max_age=max_age, max_age=max_age,
) )
@@ -90,44 +99,78 @@ async def validate_token(
raise raise
renewed = False renewed = False
if auth: if auth:
consumed = EXPIRES - (ctx.session.expiry - datetime.now(timezone.utc)) consumed = datetime.now(UTC) - ctx.session.validated
if not timedelta(0) < consumed < _REFRESH_INTERVAL: if not timedelta(0) < consumed < _REFRESH_INTERVAL:
try: db.update_session(
refresh_session_token( ctx.session.key,
auth, ip=get_client_ip(request),
ip=request.client.host if request.client else "", user_agent=request.headers.get("user-agent") or "",
user_agent=request.headers.get("user-agent") or "", validated=datetime.now(UTC),
) ctx=ctx,
session.set_session_cookie(response, auth) )
renewed = True session.set_session_cookie(response, auth)
except ValueError: renewed = True
# Session disappeared, e.g. due to concurrent logout; global handler will clear return MsgspecResponse(
raise authz.AuthException( ApiValidateResponse(
status_code=401, detail="Session expired", mode="login" valid=True,
) renewed=renewed,
return { ctx=userinfo.build_session_context(ctx),
"valid": True, )
"renewed": renewed, )
"ctx": userinfo.format_session_context(ctx),
}
@app.get("/token-info") @app.get("/check")
async def token_info(credentials=Depends(bearer_auth)): async def check_user(
"""Get reset/device-add token info. Pass token via Bearer header.""" request: Request,
token = credentials.credentials user_uuid: UUID = Query(..., alias="user"),
if not passphrase.is_well_formed(token): perm: list[str] = Query([]),
raise HTTPException(400, "Invalid token format") ):
"""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: try:
reset_token = get_reset(token) u = data.users[user_uuid]
except ValueError as e: role = u.role
raise HTTPException(401, str(e)) org = role.org
except KeyError:
raise HTTPException(status_code=404, detail="User not found")
u = db.data().users.get(reset_token.user) host = hostutil.normalize_host(request.headers.get("host"))
return { org_perm_uuids = {p.uuid for p in org.permissions}
"token_type": reset_token.token_type,
"display_name": u.display_name, 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") @app.get("/forward")
@@ -150,11 +193,14 @@ async def forward_authentication(
- If Accept header contains "text/html": HTML page for authentication - If Accept header contains "text/html": HTML page for authentication
with data attributes for mode and other metadata. with data attributes for mode and other metadata.
- Otherwise: JSON response with error details and an `iframe` field - Otherwise: JSON response with error details and an `iframe` field
pointing to /auth/restricted/?mode=... for iframe-based authentication. pointing to /auth/restricted/iframe#mode=... for iframe-based authentication.
""" """
try: try:
ctx = await authz.verify( ctx = await authz.verify(
auth, perm, host=request.headers.get("host"), max_age=max_age auth,
" ".join(perm).split(),
host=request.headers.get("host"),
max_age=max_age,
) )
# Build permission scopes for Remote-Groups header # Build permission scopes for Remote-Groups header
role_permissions = ( role_permissions = (
@@ -170,13 +216,7 @@ async def forward_authentication(
"Remote-Role": str(ctx.role.uuid), "Remote-Role": str(ctx.role.uuid),
"Remote-Role-Name": ctx.role.display_name, "Remote-Role-Name": ctx.role.display_name,
"Remote-Session-Expires": ( "Remote-Session-Expires": (
ctx.session.expiry.astimezone(timezone.utc) (ctx.session.validated + EXPIRES).isoformat().replace("+00:00", "Z")
.isoformat()
.replace("+00:00", "Z")
if ctx.session.expiry.tzinfo
else ctx.session.expiry.replace(tzinfo=timezone.utc)
.isoformat()
.replace("+00:00", "Z")
), ),
"Remote-Credential": str(ctx.session.credential), "Remote-Credential": str(ctx.session.credential),
} }
@@ -185,42 +225,36 @@ async def forward_authentication(
# Clear cookie only if session is invalid (not for reauth) # Clear cookie only if session is invalid (not for reauth)
if e.clear_session: if e.clear_session:
session.clear_session_cookie(response) session.clear_session_cookie(response)
# Browser request? - return full-page HTML with metadata patched into data attrs
# Check Accept header to decide response format if "text/html" in request.headers.get("accept", ""):
accept = request.headers.get("accept", "") return await htmlutil.patched_html_response(
wants_html = "text/html" in accept request, "/int/forward/", e.status_code, mode=e.mode, **e.metadata
if wants_html:
# Browser request - return full-page HTML with metadata
data_attrs = {"mode": e.mode, **e.metadata}
html = (await vitedev.read("/int/forward/index.html"))[0]
html = htmlutil.patch_html_data_attrs(html, **data_attrs)
return Response(
html, status_code=e.status_code, media_type="text/html; charset=UTF-8"
)
else:
# API request - return JSON with iframe srcdoc HTML
return JSONResponse(
status_code=e.status_code,
content=await authz.auth_error_content(e),
) )
# API request - return JSON with iframe srcdoc HTML
return JSONResponse(
status_code=e.status_code, content=await authz.auth_error_content(e)
)
@app.get("/settings") @app.get("/settings")
async def get_settings(): async def get_settings():
pk = global_passkey.instance pk = global_passkey.instance
base_path = hostutil.ui_base_path() base_path = hostutil.ui_base_path()
return { return MsgspecResponse(
"rp_id": pk.rp_id, ApiSettings(
"rp_name": pk.rp_name, rp_id=pk.rp_id,
"ui_base_path": base_path, rp_name=pk.rp_name,
"auth_host": hostutil.dedicated_auth_host(), ui_base_path=base_path,
"auth_site_url": hostutil.auth_site_url(), auth_host=hostutil.dedicated_auth_host(),
"session_cookie": AUTH_COOKIE_NAME, auth_site_url=hostutil.auth_site_url(),
} session_cookie=AUTH_COOKIE_NAME,
version=__version__,
),
headers={"Access-Control-Allow-Origin": "*", "Vary": "Origin"},
)
@app.post("/user-info") @app.get("/user-info")
async def api_user_info( async def api_user_info(
request: Request, request: Request,
response: Response, response: Response,
@@ -233,15 +267,45 @@ async def api_user_info(
detail="Authentication required", detail="Authentication required",
mode="login", mode="login",
) )
ctx = db.get_session_context(auth, request.headers.get("host")) ctx = session_ctx(auth, request.headers.get("host"))
if not ctx: if not ctx:
raise HTTPException(401, "Session expired") raise authz.AuthException(
status_code=401,
detail="Session expired",
mode="login",
clear_session=True,
)
return await userinfo.format_user_info( return MsgspecResponse(
user_uuid=ctx.user.uuid, await userinfo.build_user_info(
auth=auth, user_uuid=ctx.user.uuid,
session_record=ctx.session, session_key=ctx.session.key,
request_host=request.headers.get("host"), request_host=request.headers.get("host"),
ctx=ctx,
)
)
@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")
try:
reset_token = get_reset(token)
except ValueError as e:
raise HTTPException(401, str(e))
u = reset_token.user
return MsgspecResponse(
ApiTokenInfo(
token_type=reset_token.token_type,
display_name=u.display_name,
theme=u.theme,
)
) )
@@ -250,11 +314,11 @@ async def api_logout(request: Request, response: Response, auth=AUTH_COOKIE):
if not auth: if not auth:
return {"message": "Already logged out"} return {"message": "Already logged out"}
host = request.headers.get("host") host = request.headers.get("host")
ctx = db.get_session_context(auth, host) ctx = session_ctx(auth, host)
if not ctx: if not ctx:
return {"message": "Already logged out"} return {"message": "Already logged out"}
with suppress(Exception): with suppress(Exception):
db.delete_session(auth, ctx=ctx) db.delete_session(ctx.session.key, ctx=ctx, action="logout")
session.clear_session_cookie(response) session.clear_session_cookie(response)
return {"message": "Logged out successfully"} return {"message": "Logged out successfully"}
@@ -263,11 +327,29 @@ async def api_logout(request: Request, response: Response, auth=AUTH_COOKIE):
async def api_set_session( async def api_set_session(
request: Request, response: Response, auth=Depends(bearer_auth) request: Request, response: Response, auth=Depends(bearer_auth)
): ):
ctx = db.get_session_context(auth.credentials, request.headers.get("host")) """Exchange an auth code for setting the session cookie.
Called by frontend after WebSocket authentication.
The code is ephemeral (60s TTL) and can only be used once.
"""
if not auth or not auth.credentials:
raise HTTPException(400, "Bearer token required")
# Verify host is provided
host = hostutil.normalize_host(request.headers.get("host", ""))
if not host:
raise HTTPException(400, "Host header required")
a = authcode.consume_cookie(auth.credentials)
if not a:
raise HTTPException(401, "Code expired or already used")
secret = a.session_key
# Verify the session exists
ctx = session_ctx(secret, host)
if not ctx: if not ctx:
raise HTTPException(401, "Session expired") raise HTTPException(401, f"Session not found on {host}")
session.set_session_cookie(response, auth.credentials)
return { session.set_session_cookie(response, secret)
"message": "Session cookie set successfully", return {"status": "ok", "user": str(ctx.user.uuid)}
"user": str(ctx.user.uuid),
}
+12 -10
View File
@@ -2,6 +2,7 @@ import logging
from fastapi import HTTPException from fastapi import HTTPException
from paskia.fastapi.logging import log_permission_denied
from paskia.util import permutil, sessionutil from paskia.util import permutil, sessionutil
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -40,7 +41,7 @@ async def auth_error_content(exc: AuthException) -> dict:
# Build hash fragment from mode and metadata # Build hash fragment from mode and metadata
params = {"mode": exc.mode, **exc.metadata} params = {"mode": exc.mode, **exc.metadata}
fragment = "&".join(f"{k}={v}" for k, v in params.items() if v is not None) fragment = "&".join(f"{k}={v}" for k, v in params.items() if v is not None)
iframe_url = f"/auth/restricted/#{fragment}" iframe_url = f"/auth/restricted/iframe#{fragment}"
return { return {
"detail": exc.detail, "detail": exc.detail,
"auth": { "auth": {
@@ -79,6 +80,9 @@ async def verify(
mode="login", mode="login",
clear_session=True, 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 # Check max_age requirement if specified
if max_age: if max_age:
try: try:
@@ -87,29 +91,27 @@ async def verify(
status_code=401, status_code=401,
detail="Additional authentication required", detail="Additional authentication required",
mode="reauth", mode="reauth",
theme=user_theme,
) )
except ValueError as e: except ValueError as e:
# Invalid max_age format - log but don't fail the request # Invalid max_age format - log but don't fail the request
logger.warning(f"Invalid max_age format '{max_age}': {e}") logger.warning(f"Invalid max_age format '{max_age}': {e}")
if not match(ctx, perm): if not match(ctx, perm):
# Determine which permissions are missing for clearer diagnostics
effective_scopes = ( effective_scopes = (
{p.scope for p in (ctx.permissions or [])} {p.scope for p in (ctx.permissions or [])}
if ctx.permissions if ctx.permissions
else set(ctx.role.permissions or []) else set(ctx.role.permissions or [])
) )
missing = sorted(set(perm) - effective_scopes) missing = sorted(set(perm) - effective_scopes)
logger.warning( log_permission_denied(
"Permission denied: user=%s role=%s missing=%s required=%s granted=%s", # noqa: E501 ctx, perm, missing, require_all=(match == permutil.has_all)
getattr(ctx.user, "uuid", "?"),
getattr(ctx.role, "display_name", "?"),
missing,
perm,
list(effective_scopes),
) )
raise AuthException( raise AuthException(
status_code=403, mode="forbidden", detail="Permission required" status_code=403,
mode="forbidden",
detail="Permission required",
theme=user_theme,
) )
return ctx 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